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

Hwasoo Cho
당근 테크 블로그
6 min readJul 17, 2020

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

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

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

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

푸시 서비스 구조

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

Server (Express.js)

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

서버 요청 수와 응답속도

Task Queue (Bull.js)

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

‘최소 한번 전달’은 queue에 있는 job이 꼭 한번은 실행됨을 보장한다는 의미입니다. 워커가 job을 할당받았는데 일정 시간 (timeout) 동안 정상적으로 return 하지 못하면 또 다른 워커가 같은 job을 할당받게 됩니다 (한번은 성공해야 하기 때문이죠). 이때 이전에 실행된 job이 사실 끝났었고 어떠한 이유로 return을 실패한 거라면 job이 중복으로 실행된 경우가 생길 수 있지만 ‘최소 한번 전달’은 보장합니다. ‘최소 한번 전달’의 반대로 ‘최대 한번 전달’ (at-most-once delivery)도 있습니다.

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

(왼쪽) 카카오 푸시 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를 사용했습니다.

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

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

--

--