뉴스레터 서비스
📌 소개
newsletter-service
itjustbong • Updated Jan 31, 2023
벨로그 글을 뉴스레터로 받아보면 어떨까요?
해당 프로젝트의 시작은 사실 GDSC SSU 웹사이트에 뉴스레터 기능이 들어가면 좋겠다라는 생각과 함께 시작되었습니다.그렇지만 GDSC SSU 사이트는 개발 중이라서 제가 뉴스레터 서비스를 제작할 수는 없었습니다...ㅎ
그래서 프로젝트의 다른 방향을 고민해보다가,문득 뉴스레터 기능을 제공하지 않는 서비스에도 크롤링이나 기타 방법을 통해서 뉴스레터 서비스를 제공해보면 어떨까라는 생각이 들었습니다.
(학과 공지사항 등과 같이 PUSH 기능을 제공하지 않는 서비스를,뉴스레터로 받아보는 것도 좋을 것 같기도 했구요!)
그 첫번째 서비스 대상은 제가 가장 애용하는 벨로그로 선택하였습니다.
📌 목표 및 요구사항
프로젝트의 가장 큰 목적은 "확장성" 있는 뉴스레터여야 했습니다.
왜냐하면 이 프로젝트를 진행하는 시점에는 벨로그만 뉴스레터로 제공할 것이지만,GDSC SSU 사이트가 완성되면 해당 기능을 어렵지 않게 추가할 수 있어야했고, 더 나아가서는 숭실대 학교 및 학과 공지사항 등도 뉴스레터를 붙일 수 있어야 했기 때문입니다.
간단하게 요구사항을 정리해보면,
1. 뉴스레터의 소스가 될 수 있는 데이터를 가져와야 합니다. (크롤링, RSS 등)
2. 뉴스레터의 발송할 수 있는 수단이 있어야 합니다.(이메일, 문자, FCM 등)
3. 뉴스레터를 받을 사용자들의 정보를 저장할 수 있어야 합니다. (파베, Supabase 등)
4. 뉴스레터를 신청할 클라이언트가 필요합니다. (웹 사이트)
5. 뉴스레터를 주기적으로 발송할 수 있는 서버가 필요합니다. (GCP, AWS, 깃허브 액션 등)
위의 요구사항에서 확인 할 수 있듯이 소스를 가져오는 로직도 다양할 것이고,
뉴스레터를 보내는 수단도 다양할 수 있습니다.
이런 이유로, 각자의 역할을 나타낼 수 있는 인터페이스도 설정해두면 좋겠다고 생각하였습니다.
우선 저는 최대한 빠르고 단순하게 만들 수 있는 수단들을 정했습니다.
(왜냐면, 할 일이 많은데 갑자기 시작해버린 프로젝트이기 때문이죠...)
📌 기술 스택
그래서 저는
1. 벨로그의 데이터를 긁어오는 것은 크롤링으로 진행 (cheerio)
2. 발송하는 수단으로는 메일 활용 (nodemailer)
3. 사용자들의 정보를 저장하는 DB는 supabase
4. 클라이언트는 입력 폼 3개를 받는 순수 HTML
5. cron이 가능한 서버는 깃허브 액션을 이용하기로 하였습니다.
supabase를 선택한 이유는,sql기반의 파이어스토어(?)라고 볼 수 있는 서비스였고,다양한 서비스를 제공해주고 있었습니다. (graphQL 등)
깃허브 액션 을 선택한 이유는,사실 깃허브 액션이 cron이 가능한지는 모르고 있었습니다.
어떤 서버를 선택할까를 고민하다가 무료로 사용할 수 있었고,무엇보다 깃허브 액션을 한번 잘 사용해보며 공부를 해보고 싶었습니다!
위의 사항을 토대로 프로젝트의 기술 스택을 정리해보면 사진과 같습니다.
📌 프로젝트 구현
npm과 타입스크립트 초기 세팅을 통해 기초적인 개발 환경만 구축 한 이후, 바로 개발을 시작하였습니다.
여기저기서 배운 지식을 적용해보려고 하다보니,여기 저기 부족한 부분이 많이 있습니다. 만약 코드의 개선점에 대해 피드백을 주신다면 감사드리겠습니다!
📒 데이터 수집 장치
가장 먼저 구현한 것은 뉴스레터의 소스가 될 수 있는 데이터를 가져오는 녀석이었습니다.위에서 언급한 바와 같이,모든 개발 과정에서 가장 필요한 것은,확장성과 다양성(?)이었습니다.
어떤 데이터든, 어떤 수집 방법이든 모든 것을 염두해 두는 것이 좋을 것 같았습니다.
그래서 해당 장치는 다음과 같은 기능을 제공하면 좋겠다고 생각하였습니다.
1. 데이터 소스(벨로그, 학과 공지사항 등)에 접근하여, 데이터의 원본 파일을 가져옵니다.
2. 가져온 데이터 소스를 기반으로, 원하는 데이터 형태를 가진 자료 형태로 가공합니다.
위 요구사항을 인터페이스 형태로 표현한다면 하기와 같을 것 입니다.
export interface iCrawler { fetchSrc(): Promise<this | undefined>; parseSrc(): ParsedSrcType[]; }
우선 저는
iCrawler
를 구현하면서, 벨로그 글을 크롤링하는 코드를 작성하고자 하였습니다.
구현하는 방법은 많이 있겠지만, 앵귤러를 공부하면서 많이 접하게 된 객체지향 적인 코드를 짜보고자 하였습니다.(하지만,,, 이렇게 짜는게 OOP스러운 것인지는 잘 모르겠습니다... 오브젝트 책 읽고 다시 리팩토링을 해봐야겠습니다.)파싱된 정보를 물고 있는 모델 객체를 만들어 관리를 할지 고민하였지만,그 정도의 필요성은 느끼지 못하였습니다.그래서 그냥
VelogCrawler
로 이름 붙은 녀석이 파싱된 데이터를 가지고 있도록 하였습니다.또한 해당 객체를 싱글톤 객체로 구현하였는데, 발송 수단이 여러개가 도입된다면,굳이 똑같은 요청(fetchSrc)을 보낼 필요는 없을 것이라 생각하였습니다.
또한 깃허브 액션에 의해 서버가 프로그램이 스케쥴링된 시간에 켜졌다가 꺼질 것이기 때문에,
크게 메모리의 누수 등의 문제가 발생할 여지가 없을 것이라 생각하였습니다.
코드에는 특별하게 공유할만한 부분은 없지만,궁금하신 분들께서는 itjustbong/newsletter-service에서 확인할 수 있습니다.
📒 템플릿 빌더
해당 빌더는 위의
데이터 수집 장치
가 어떤 형태에 맞춰서 데이터를 파싱해 오면,
해당 데이터를 기반으로 뉴스레터에 실어서 보낼 HTML을 만들어주면 됩니다.하지만, 몇 가지 문제가 있었습니다.
우선 벨로그 뉴스레터는 메일로만 발송할 예정인데, 메일 서비스를 제공해주는 업체에 따라,Style 속성이나 기타 몇몇 속성이 제대로 작동하지 않고, 더욱이 js파일은 작동시킬 수 없었습니다.
그래서 Style 속성도 전부 inline 형태로 작성을 하기로 하였고,최대한 단순하게 발송하고자 하였습니다.
어떻게 HTML을 생성할까 고민하다가,리스트가 여러개 생성되어 어느 노드에 붙어야 했습니다.
DOM객체를 생성(createEelement 등)하는 방법등이 있었겠지만, 그냥 문자열만 치환해주면 간단하겠다라는 생각으로 구현을 하였습니다.
그래서 큰 틀의 HTML 템플릿은 다음과 같습니다.
const newsLetterHTML = ` <!DOCTYPE html> <html lang="ko"> <body><news-contents /><body>`;
이후 파싱된 데이터를 가지고 와서 HTML 형태에 맞춰 문자열을 만들고, 해당 문자열을
<news-contents />
과 바꿔치도록 구현하였습니다.const buildVelogTemplate = (parsedHTML: ParsedSrcType[]) => { const newsList = parsedHTML .map(post => `<div> ~ </div>`) .join(''); const templatedView = newsLetterHTML.replace('<news-contents />', newsList); return templatedView; }
📒 사용자 정보 관리 DB
해당 장치에게 필요한 것은,
1. 클라이언트에서 사용자가 추가되었을 경우,
2. 서버에서 스케줄에 의해 뉴스레터가 발송될 때, 등록된 서비스 별 조회 요청
위 요구사항을 interface로 표현해보면 하기와 같이 표현해볼 수 있을 것입니다.
export interface iDatabase { getAllUser(service: SERVICE): any; addUser(name: string, contact: string, service: SERVICE): any; }
또한 Supabase 를 연결해야하는데,왜 파이어베이스의 대체 할 수 있다고 말하는 지 알 것 같았습니다.
매우 사용법이 쉬웠고,파이어스토어가 제공하지 않는 여러 기능들(graph QL 등)을 제공해주었습니다.
아무쪼록, 위 인터페이스를 Supabase를 활용하는 구현체를 제작해야 합니다.일반 ORM과 데이터에 접근하는 방식은 크게 다르지 않았습니다.
getAllUser에서는 서비스의 타입(벨로그, 학교 공지사항 등)을 전달받아,DB에서 조회하도록 구현하였습니다.
async getAllUser(service: SERVICE) { const allUser = await this.supabase .from('뉴스레터 테이블') .select('*') .eq('서비스', service) .eq('상태', 1); return allUser.data; }
addUser 에서도 마찬가지로,사용자의 정보와 서비스 타입을 전달 받아 DB에 저장하도록 구현하였습니다.
📒 뉴스레터 발송 장치
해당 발송 장치는 템플릿, 뉴스레터 수신자들의 정보를 전달받아 수단에 맞게 발송만 해주면 됩니다.
그래서 실제로도 구현은 단순하게 이루어질 것입니다.
export interface iSender { send(option: Option): void; }
구현체 또한 nodemailer를 활용하기 때문에,nodemailer에 맞는 세팅을 해주었습니다.
다만, 일반적으로 많이 활용하는 Gmail을 활용하지 않았고,커스텀 도메인에 대해서 무료 메일 서비스를 제공하는 다음 메일을 활용하였습니다.
const transporter = nodemailer.createTransport({ host: 'smtp.daum.net', port: 465, secure: true, auth: { user: this._senderID, pass: this._senderPW, }, });
또한 처음에 테스트를 진행하면서,친구들의 정보를 받았을 때,받는 사람끼리의 이메일 정보가 모두 노출되는 문제가 있었습니다.
해당 문제는 createTransport에 전달하는 option 중,to 로 받는 사람을 전달하지 않고,bcc 옵션으로 수신자 이메일을 전달하면,비밀 참조로 메일을 발송할 수 있었습니다.
📒 서비스 root 구현
서비스는 다양한 정보를 다양한 매체를 통해 발송할 수 있으면 좋겠다라는 목적을 가지고 만들었습니다.
그런 목적을 달성하기 위해, 위에서 구현했던 데이터 수집기, 정보 저장, 발송 장치를 가져가다 쓰는 메인 녀석이 필요합니다.
sendNewsLetterForVelog( VelogCrawler.getInstance(), Superbase.getInstance(), NodeMailer.getInstance() );
위와 같이 구현이 되어 있는데,추후에 몇몇 기능이 추가된다면 다음과 같이 만들 수 있지 않을까라는 생각입니다.
sendNewsLetterForVelog( VelogCrawler.getInstance(), Superbase.getInstance(), NodeMailer.getInstance() ); sendNewsLetterForSSU( SSUCrawler.getInstance(), Superbase.getInstance(), NodeMailer.getInstance() ); sendNewsLetterForGeek( GeekCrawler.getInstance(), Firestore.getInstance(), SMSSender.getInstance() );
📒 클라이언트 제작 및 배포
클라이언트에서는 사용자의 정보를 받고,해당 사용자가 어떤 뉴스레터를 받고 싶어 하는지에 대한 정보를 기록하는 역할을 해주면 됩니다.
supabase
클라이언트를 직접 연결하여,바로 db에 정보를 저장할 수 있도록 하였습니다.const result = await supabase.from('뉴스레터 테이블').insert([ { 서비스명: service, user: { name, email }, }, ]);
위의 코드처럼 너무 어렵지 않게 row를 추가할 수 있어 매우 편리했습니다.
참고로 아직 CRUD 조차 다 구현하지 않아,한번 구독 메일을 등록해두면,구독을 취소하는 방법이 클라이언트 단에는 없습니다..ㅎㅎ
또한 클라이언트를 배포하기 위하여 깃허브를 활용하고자 하였습니다.해당 과정은 어렵지않게 레포의 세팅을 통해서도 진행할 수 있습니다
하지만 위의 사진과 같이 단순하게 깃허브 페이지 기능을 사용하는데 있어서,마음에 좀 걸리는 부분이 있었습니다.
supabase의 키가 깃허브에 그대로 올라가야하는데,해당 부분을 그대로 올리기에는 좀 찔리는 부분이 있어서,다른 방법을 찾아보기로 했습니다.
하루 반나절을 구글님을 둘러본 결과...마땅한 해결책은 보이지 않았고,차선책 정도로 깃허브 레포에서만 해당 코드를 숨기고,깃허브 액션을 통해서 gh-pages를 배포하는 과정에서,secrets키를 활용하여 env 파일을 생성하기로 하였습니다.
- name: Generate Environment Variables File for Production run: | echo "export const SUPABASE_KEY='$SUPABASE_KEY'" >> env.js echo "export const SUPABASE_URL='$SUPABASE_URL'" >> env.js env: SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }} SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
그래서 위 코드는 client 브런치에 업데이트가 발생하면,위 코드를 포함하여 깃허브 액션을 수행하게 되고,최신 프론트가 반영됩니다.
위의 방법이 절대 완벽하지 않은 것 같아,난독화도 진행해봤지만,프레임워크 없이 개발된 웹에서,코드 자체가 많지 않다보니,시크릿 키가 어렵지 않게 사람이 알아볼 수 있을 정도였습니다.
혹시, 해당 방법에 대해서 아시는 분이 있다면 댓글 부탁드리겠습니다!
📒 Cron 세팅
깃허브 액션이 해당 기능을 제공해주고 있는지 몰랐습니다.생각보다 너무 쉽고 간편하게 원하는 동작을 무료로 할 수 있었습니다.(물론, 초기에 많은 삽질 덕에 push를 많이 했네요..ㅎ)
우선 깃허브 액션이 제공하는 스케줄링(Cron)은 완벽하지는 않은 것 같습니다.11시에 작동되길 원하지만,항상 +50분 이내에 실행이 되는 모습을 확인 할 수 있었습니다.
하지만, 무료에다가 뉴스레터 서비스가 굳이 정각에 실행될 이유는 없으니...
cron 을 세팅하기 위해서 관련 규약(?)을 확인해야 하는데,crontab.guru 에서 사용자가 원하는 스케줄을 만들 수 있습니다.
참고 사항으로는, UTC 기준이기 때문에, 한국보다 9시간 차이를 두고 계산하여야 합니다.
on: schedule: - cron: '0 2 * * *'
또한 처음에 제가 잘 못 세팅했던 부분이,매일 11시에 뉴스레터를 발송한다는 생각으로,
'* 2 * * *'
을 세팅하여,뉴스레터가 2~3번 발송되는 문제가 있었습니다.📌 결과물
위의 사진과 같이 클라이언트에서 정보를 입력 받고,깃허브 액션에 의해 돌아가는 스케줄러가 구독한 사용자의 정보를 토대로,뉴스레터를 발송하는 동작을 확인할 수 있습니다!
해당 프로젝트를 진행하면서,깃허브 액션을 CI이외의 용도로 처음 사용해보았고, 오랜만에 모던 프론트엔드 프레임워크없이 진행한 프로젝트라 이것저것 재미있는 요소들이 많이 있었습니다.
물론 어중간한게 알고 있는 지식들을 적용해보느라 코드나 구조가 깔끔하지 못하나 싶지만,NFF 스터디 앵귤러를 개발하면서 접한 OOP 개념을 다시한번 고민해 볼 수 있어서 좋은 프로젝트 였습니다.
감사합니다 👻