Situation
OTL의 기능이 점점 정상화되면서 새로운 기능을 만들고자 하였습니다. 그 첫 번째가 바로 백엔드 자체 알림 서버를 구축하는 것입니다.
기존에도 Firebase를 통해 일괄적으로 알림을 보내고 있었다고 들었으나, 조금 더 사용자 맞춤형으로 알림을 보내고자, 알림 서버를 따로 구축하게 되었습니다.
Task
정책에 따른 Table 설계
우선적으로 알림을 보내기 위해서는 ‘유저 동의’와 유저의 기기를 식별할 수 있는 FCM 토큰이 필요했습니다.
알림은 정책적으로 세 개의 알림이 있는데, ‘정보성’, ‘광고성’, ‘야간 광고성’ 입니다. 모든 알림은 이 세 개 안에서 분류가 됩니다.
만약 이 중 한 가지의 알림을 동의하지 않는 경우에는 해당 정보에 속하는 모든 알림이 보내지지 않아야합니다.
반대로 이 세 가지 부류 아래에 있는 알림의 경우, 해당 알림을 개별적으로 켜거나 끄거나 할 수 있어야합니다.
따라서 위와 같이 agreement에도 agreement_status를 넣고, session_userprofile_notification에도 is_active를 넣었습니다.
또한 notification을 언제 보냈는지 확인하기 위해 notification_history를 만들어서 관리하였습니다.
MQ를 활용한 FCM 발송
원래의 구현은 백엔드 서버에서 바로 FCM으로 메시지를 보내는 것이었습니다. 하지만 FCM 호출은 FCM에서 메시지가 전송될 때까지 기다리는 방식인 것으로 알기에 node.js event-loop에 불필요하게 쌓여있을 수 있다는 점, 그리고 클라이언트와의 response time이 늦어질 수 있다는 점 때문에 Message Queue를 도입하기로 결정하였습니다. 이에 스팍스 내 타 서비스에 문의한 결과 FCM을 사용한 채팅 알림의 경우 아래와 같이 700ms 까지 응답이 미루어질 수 있다는 점을 확인하였습니다.
특히 스팍스 안에서 사용자도 가장 많을 뿐만 아니라, 저희가 기획한 알림은 특정한 시간대(eg. 수업 시작, 수강 신청 빈자리 알림) 등이기 때문에 이벤트 루프에 불필요한 promise가 많이 쌓일 수도 있다는 우려가 있어 적극적으로 MQ 도입을 검토하게 되었습니다.
MessageQueue로써는 가장 범용적인 RabbitMQ를 사용하고자 했습니다. Redis의 pub-sub 또한 고려하였으나, broadcast만 된다는 단점으로 인하여 rabbitmq로 진행하였습니다. NestJS에서 RabbitMQ를 쓰기 위해서는 microservice 옵션을 켰어야했습니다. 하지만 해당 기능이 MQ 전체를 추상화한 것이기 때문에, RabbitMQ를 세세하게 다루지는 못하였고, 이에 다음 라이브러리를 사용하고자 하였습니다.
리팩토링
간단한 테스트를 마친 후에는 리팩토링을 진행해야했습니다. 제가 진행해야했던 중요한 리팩토링은 RabbitMQ 모듈을 libs로 빼서 공통 모듈로 만드는 것, 그리고 hexagonal architecture에 맞게 rabbitmq 모듈과 다른 클라이언트들은 인터페이스로 느슨한 결합을 이루는 것입니다.
Action
작업물은 다음 깃헙에서 보실 수 있습니다.
먼저 MQ를 다룰 줄 몰랐기에 RabbitMQ에 관해 공부부터 진행하였습니다. 다음 문서를 참고하였습니다.
RabbitMQ에는 다음과 같은 개념이 있습니다.
•
Exchange
◦
publisher 입장에서 어느 곳에다가 메시지를 발행해야하는지를 의미합니다. exchange라는 이름 그대로 교환기를 의미합니다.
◦
즉, publisher가 발행한 메시지를 어느 queue에다가 보낼지를 결정하는 곳입니다.
•
Queue
◦
실제로 메시지가 쌓이고, consumer에게 처리되는 부분입니다.
◦
모든 consumer는 메시지에 대해서 경합합니다. 즉, 하나의 메시지는 하나의 컨슈머에 의해 소비됩니다.
◦
queue에는 바인딩 키를 부여할 수 있습니다. (바인딩 키, 라우팅 키, 정책) 이 세 가지의 조합으로 exchange는 어느 큐에 메시지를 보낼지 결정합니다.
•
RoutingKey
◦
메시지에 지정하는 키입니다. 이 키를 기반으로 exchange는 어느 큐로 보내질 것인지 결정하게 됩니다.
◦
routing-key와 큐를 매핑하는 정책으로는 다음과 같은 것들이 있습니다.
◦
Direct: 정확하게 라우팅 키가 일치되어야합니다.
◦
Fanout: 라우팅 키를 무시하고 모든 큐에 메시지를 전달합니다.
◦
topic: 라우팅 키의 패턴을 기반으로 일치하는 바인딩 키에 메시지를 보냅니다.
이 구조가 좋은 점은 제가 보았을 때 다음과 같습니다.
우선 publisher는 어느 큐에다가 메시지를 보낼 것인지, 명확하게 알 필요가 없습니다. exchange에다가 보내면 알아서 라우팅 키와 바인딩 키를 기반으로 보내주기 때문입니다. 또한 consumer가 다시 다른 queue에 메시지를 발송함으로써 순차적으로 작업을 이어나갈 수 있습니다. 명확하게 exchange가 하는 역할은 아닐 수 있지만, exchange가 있기에 topic 정책이 존재하고, 이로 인해 하나의 작업은 하나의 exchange에, 세부적인 작업은 queue에 매핑되는 개념적 구조가 완성됩니다.
저희는 도커 기반으로 배포를 할 것이기 때문에 볼륨 마운트 후 durable 옵션을 켰습니다. 또한 메시지 발송에 실패했을 경우를 대비하여 dead-letter-exchange를 활성화하였습니다.
현재 알림 관련 exchange와 queue 구성은 다음과 같습니다.
{
url: process.env.RABBITMQ_URL,
user: process.env.RABBITMQ_USER,
password: process.env.RABBITMQ_PASSWORD,
queueName: Object.values(QueueNames),
exchangeConfig: {
exchanges: [
{
name: 'notifications', // notification exchange
type: 'x-delayed-message',
createExchangeIfNotExists: true,
options: {
arguments: {
'x-delayed-type': 'direct',
},
},
},
{
name: 'notifications.dlx', // notification dlx
type: 'x-delayed-message',
createExchangeIfNotExists: true,
options: {
arguments: {
'x-delayed-type': 'direct',
},
},
},
],
},
queueConfig: {
NOTI_FCM: {
exchange: 'notifications',
routingKey: 'notifications.fcm', // fcm을 통한 알림 발송을 처리하는 큐, 메시지를 분석해서 정보성/광고성/야간광고성 전담 큐로 전달
queue: 'notifications.fcm.queue',
createQueueIfNotExists: true,
queueOptions: {
deadLetterExchange: 'notifications.dlx',
},
},
NOTI_INFO_FCM: {
exchange: 'notifications',
routingKey: 'notifications.info.fcm', // 정보성 알림 처리 큐
queue: 'notifications.info.fcm.queue',
createQueueIfNotExists: true,
queueOptions: {
deadLetterExchange: 'notifications.dlx',
},
},
NOTI_AD_FCM: {
exchange: 'notifications',
routingKey: 'notifications.ad.fcm', // 광고서 알림 처리 큐
queue: 'notifications.ad.fcm.queue',
createQueueIfNotExists: true,
queueOptions: {
deadLetterExchange: 'notifications.dlx',
},
},
NOTI_NIGHT_AD_FCM: {
exchange: 'notifications',
routingKey: 'notifications.night-ad.fcm', // 야간 광고성 알림 처리 큐
queue: 'notifications.night-ad.fcm.queue',
createQueueIfNotExists: true,
queueOptions: {
deadLetterExchange: 'notifications.dlx',
},
},
NOTI_EMAIL: {
exchange: 'notifications',
routingKey: 'notifications.email', // 이메일 발송 처리 큐
queue: 'notifications.email.queue',
createQueueIfNotExists: true,
queueOptions: {
deadLetterExchange: 'notifications.dlx',
},
},
NOTI_FCM_DLQ: {
exchange: 'notifications.dlx',
routingKey: 'notifications.fcm', // fcm관련 알림의 dlq
queue: 'notifications.fcm.dlq',
createQueueIfNotExists: true,
},
NOTI_INFO_FCM_DLQ: {
exchange: 'notifications.dlx',
routingKey: 'notifications.info.fcm',
queue: 'notifications.info.fcm.dlq',
createQueueIfNotExists: true,
},
NOTI_AD_FCM_DLQ: {
exchange: 'notifications.dlx',
routingKey: 'notifications.ad.fcm',
queue: 'notifications.ad.fcm.dlq',
createQueueIfNotExists: true,
},
NOTI_NIGHT_AD_FCM_DLQ: {
exchange: 'notifications.dlx',
routingKey: 'notifications.night-ad.fcm',
queue: 'notifications.night-ad.fcm.dlq',
createQueueIfNotExists: true,
queueOptions: {
deadLetterExchange: 'notifications.dlx',
},
},
NOTI_EMAIL_DLQ: {
exchange: 'notifications',
routingKey: 'notifications.email',
queue: 'notifications.email.queue',
createQueueIfNotExists: true,
queueOptions: {
deadLetterExchange: 'notifications.dlx',
},
},
},
}
TypeScript
복사
현재 구현 필요 사항 중 가장 중요한 것은 밤 시간대에 알림을 켜지 않은 사용자에게는 밤 시간대에 알림을 보내지 않는 것입니다. 하지만 단순하게 보내지 않는 것이 아닌, 그 다음 날 아침에라도 보낼 수 있으면 좋겠다는 생각을 하였습니다. 예를 들어 광고/이벤트 문구 같이 최대한 빨리 보낼수록 좋은 알림의 경우, 밤에 보낼 수 있는 사람은 밤에 보내고 아닌 사람은 아침에 보내는 식입니다.
이를 구현하기 위해서는 총 2가지의 방법이 있었습니다. Deferred Message와 같이 Night-Ad를 못 보낸 사용자들에 한해서 아침에 보낼 수 있는 큐를 따로 만들어놓는 것입니다. 두 번째 방법은 DB나 Redis와 같은 곳에 저장후, 매초마다 돌면서 예약한 시간이 되었는지 검색하여 보내는 것입니다.
우선 첫 번째 방법은 FCM 발송 테스트 시에 에러를 잡던 중, 사용 불가하다고 판단하였습니다. 저는 Deferred-Message 같은 곳에 메시지를 따로 모은 후, 이걸 처리하는 핸들러를 만들 생각이었습니다. 예를 들어 처리하려는 시간이 도래하기 전이라면 의도적으로 Nack을 보내서 다시 메시지큐에 들어가게 하는 식입니다. 하지만 컨슈머가 존재한다면 메시지 큐는 계속 메시지를 소비하도록 비우기 때문에, 사실상 공회전 하고 있는 것이나 마찬가지였습니다.
현재 단계에서는 MQ를 클러스터화할 것이 아닌 단일 인스턴스를 사용할 예정이기에 이런 식으로 RabbitMQ의 성능을 낭비하게 되면, 정상적으로 소비되어야 할 큐에 어떤 영향이 갈 지 알 수 없었기에 진행하기에 리스크가 있었습니다. 인스턴스 측면에서도 CPU를 그저 공회전 시키는 것이므로 좋지 않습니다.
이에 다른 스토리지에 저장한 후, 스케쥴러로 돌면서 검사하는 것으로 방향을 순회하였습니다. 이제 어떤 스토리지에다가 저장할지 결정했습니다. 큰 선택지로는 DB 또는 Redis가 있었습니다. 이 중 저는 Redis를 선택하였습니다. DB 또한 훌륭한 선택지이지만 매번 DB의 특정 테이블을 계속 scan한다는 것이 read-replica가 존재하지 않는 현재로써는 좋은 선택지라고 할 수 없었습니다.
이에 redis의 durable 옵션을 견고하게 다져서 사용하기로 하였습니다. 우선 동작 방식은 redis의 zadd를 이용해 set에 추가하고, zscorerange를 이용해 정렬한 뒤, 검사하여 시간이 되면 발송하는 방식입니다.
제일 걱정이 되었던 것은 durable 여부입니다. redis 도커가 어떤 이유로든 내려가게 된다면 메시지를 전부 소실하기 때문에 이를 지켜줄 무언가가 필요했습니다. 찾아보니 다음과 같이 bin.log 같이 히스토리를 남기는 AOF와 백업인 RDB가 존재했습니다.
이를 활용하여 현재 redis.conf는 다음처럼 작성되었습니다.
# ─────────── Redis Authentication ───────────
requirepass ${REDIS_SCHEDULER_PASSWORD}
# ─────────── AOF Persistence ───────────
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
# ─────────── RDB Snapshot Intervals ───────────
save 900 1
save 300 10
save 60 10000
Plain Text
복사
이를 통해 1) DB에 무리를 주지 않으면서 2)인메모리로 빠른 정렬/검색 3)복구 4) 검사 주기 조절(현재 1초)을 통한 최적화 를 고려한 구현을 해보게 되었습니다. 저희는 추후 Admin를 개설하여 Firebase홈페이지처럼 알림을 등록하고 예약할 수 있게끔 할 생각입니다. 이럴 경우, 저희가 자체적으로 알림을 예약하게 될텐데, 이럴 경우에도 효과적으로 사용할 수 있을 것 같습니다.
리팩토링
golevelup 패키지는 rabbitMQ의 다양한 옵션을 사용할 수 있었지만, 추상화가 덜 되어있었습니다. 특히 RabbitMQ 이외의 MQ를 사용하거나 교체하는 경우를 고려하여 추상화가 한 번 더 필요했습니다.
이미 OTL에는 Hexagonal Architecture가 적용이 되어있는 모듈이 몇 개 있었으므로 (알림 관련 약관 동의, device 관련), notification 또한 Hexagonal architecture로 리팩토링을 진행하였습니다.
이에 기존에는 amqpConnection을 직접 inject 받는 구조에서 다음과 같이 interface를 inject 받는 구조로 변경되었습니다.
@Injectable()
export class NotificationPrivateService extends NotificationPublicService implements NotificationInPort {
constructor(
@Inject(NOTIFICATION_REPOSITORY)
protected readonly notificationRepository: NotificationRepository,
@Inject(AGREEMENT_REPOSITORY)
protected readonly agreementRepository: AgreementRepository,
@Inject(NOTIFICATION_MQ)
protected readonly notificationMq: NotificationMq,
) {
super(notificationRepository, agreementRepository, notificationMq)
}
...
}
TypeScript
복사
여기서 NotificationMq는 다음과 같은 interface입니다.
export const NOTIFICATION_MQ = Symbol('NOTIFICATION_MQ')
export interface NotificationMq {
publishNotification(request: FCMNotificationRequest): Promise<boolean>
}
TypeScript
복사
Result
아래처럼 성공적으로 알림을 보낼 수 있었습니다.
이제 본격적으로 아래와 같은 알림을 구현해야합니다.
알림 목록
이에 더해서 MessageQueue를 좀 더 세밀하게 세팅해야할 필요를 느꼈습니다. DB의 Index처럼 다양한 설정을 할 수 있는 것으로 보이기에 좀 더 깊이 탐구하여 실제 production에 내놓을 수 있을 정도의 구축을 하는 것이 목표입니다.
이와 함께 k6로 로드 테스트 또한 진행해보고자 합니다.
이 과정에서 MessageQueue, Publisher, Consumer에 대한 monitoring을 진행하여 안정성을 확보하고자 합니다.