당근마켓의 푸시알림을 지탱하고 있는 Node.js 서비스

Hwasoo Cho
Jul 17 · 6 min read
Image for post
Image for post

푸시알림은 당근마켓 서비스에서 채팅, ‘키워드 알림’, ‘금주의 인기매물’과 같은 여러 기능에 사용되고 있습니다. 기존에 당근마켓 메인 Ruby on Rails 서버에 구현됐었던 푸시알림 서비스를 트래픽 증가에 따라 마이크로 서비스로 분리하는 작업이 필요했습니다. 현재 초당 1500 요청을 누락 없이 지원하는 푸시 서비스를 Node.js, TypeScript로 개발한 후기를 공유하려고 합니다.

새로 개발되는 푸시 서비스의 역할은 다음과 같았습니다.

  • 푸시 토큰 관리: 토큰 등록, 중복 토큰 제거, 만료된 토큰 삭제.
  • FCM, Kakao 푸시 서버 요청 장애 시 알아서 failover 및 retry.
  • 당근마켓 트래픽 증가에 따른 손쉬운 horizontal scale-up.

당근마켓은 중고거래가 실시간 채팅으로 이루어지기 때문에 푸시 서비스에 아무리 많은 트래픽이 발생해도 안정적으로 지원해야 한다는 점이 가장 중요했습니다. 각 구성요소에 일시 장애가 나더라도 받은 요청은 모두 수행할 수 있는 다음과 같은 구조를 채택하게 되었습니다.

Image for post
Image for post
푸시 서비스 구조

서버와 워커를 분리하고 푸시 서비스 내에서 발생하는 모든 작업은 Task Queue를 통해 관리하도록 구상했습니다. 각 구성요소에 대해 살펴보겠습니다.

Server (Express.js)

서버의 역할은 초당 1500 요청이 넘을 수 있는 트래픽을 우선 받아내는 것이기 때문에 정말 간단한 요청 파라미터 validation만 수행하고 바로 Job을 생성하여 Task Queue로 넘깁니다. 무난한 Express.js을 사용해서 구현했고 validation은 TypeScript와 시너지가 좋은 class-validator을 사용했습니다. 사실 서버와 워커는 같은 인스턴스로 운용할 수 있었지만 무거운 워커 작업에 의해 요청을 못 받아내는 경우를 줄이기 위해 따로 운용하도록 결정했습니다. 5개의 인스턴스로 운영되고 있는 서버는 평균 초당 1000 요청에 5ms 응답속도를 가지고 있습니다.

Image for post
Image for post
서버 요청 수와 응답속도

Task Queue (Bull.js)

Bull.js는 Node.js 생태계에서 많이 사용되는 queue system 구현체 중 하나입니다. Backend는 Redis를 쓰고 있고 queue 전략 중 ‘최소 한번 전달’ (at-least-once delivery)을 채택하고 있습니다. Retry, priority, concurrency 등 많은 기능을 지원하고 API도 직관적이었습니다. 한 가지 아쉬웠던 점은 공식 UI 대시보드가 없다는 점이었는데 bull-board로 처리 중인 작업 개수 정도는 확인이 가능했습니다.

푸시 서비스에서 발생하는 모든 작업은 Task Queue로 관리됩니다. 이를테면 사용자에게 푸시를 전송하려면 1. 유저 아이디 값으로 토큰 정보를 조회하는 Job을 만들고 이 작업이 끝나면 2. FCM이나 Kakao 푸시 서버로 전송하는 Job을 만듭니다. 이렇게 모든 작업을 Job 단위로 분리하여 각 Job의 평균 처리 시간을 수집하여 성능 모니터링을 하고 horizontal scaling에 대한 수요를 빠르게 파악할 수 있었습니다. 또한 각 외부 dependency (DynamoDB, FCM, Kakao)별로 Job을 분산했기 때문에 외부 dependency의 장애에 대한 파악이 쉬웠습니다.

Image for post
Image for post
Image for post
Image for post
(왼쪽) 카카오 푸시 Job 처리 시간 모니터링. (오른쪽) DynamoDB에서 푸시 토큰 조회 Job 처리 시간 모니터링.

Workers (Node.js)

푸시 서비스의 코어 로직은 워커들이 수행합니다. 워커 코드는 다음과 같은 클래스들로 구성되어있습니다.

  • ApnsConverterService: APNS 토큰을 FCM으로 변환해줍니다.
  • FcmService: FCM으로 푸시 요청을 전송합니다.
  • KakaoPushService: Kakao 푸시로 푸시 요청을 전송합니다.
  • PushDispatcherService: 비즈니스 로직에 따라 FCM, Kakao 둘 중 하나를 선택합니다.
  • TokenEntitiesService: DynamoDB에 토큰을 저장하거나 쿼리하여 가져옵니다.

이 모든 클래스들을 typedi라는 dependency injection 솔루션을 사용해서 운용했습니다. Dependency injection을 사용한 이유는 서로 의존성으로 묶여있는 로직들을 비교적 쉽게 테스트하기 위함이었습니다. 현재 푸시 서비스의 test coverage는 70%을 유지하고 있고 토큰을 실수로 삭제하거나 만료된 토큰을 잘 못 처리하는 등의 로직 에러에 대한 우려를 최대한 줄일 수 있었습니다. 테스트는 Jest를 사용해서 작성했고 DynamoDB를 따로 mocking하지 않고 테스트 환경의 docker-compose를 사용했습니다.

Image for post
Image for post

당근마켓의 푸시 서비스를 Node.js, TypeScript로 구성한 내용에 대해 살펴보았습니다. 다음 글에는 코드 레벨로 들어가서 푸시 서비스를 대규모 트래픽 환경에서 안정화하기까지 필요했던 TypeScript 프로그래밍 패턴과 에러 핸들링, 그리고 로깅 방식에 대해 살펴보겠습니다.

당근마켓은 현재 Node.js, TypeScript 개발자분들을 모시고 있습니다. 저희와 함께 당근마켓을 더 좋은 서비스로 만들고 싶은 분은 채용 공고를 확인해주세요.

당근마켓 팀블로그

당근마켓은 동네 이웃 간의 연결을 도와 따뜻하고 활발한 교류가 있는 지역 사회를 꿈꾸고 있어요.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store