Socket IO 기반 알람 전송 시스템
Socket IO 기반 알람 전송 시스템
회사에서 숏폼 플랫폼을 제작하고 있으라고 하여 근 한달 동안 숏폼 플랫폼의 기본 틀을 제작하였다. 그 이후 Notification/Direct Message 기능에 대한 요구 사항이 있었고 기존의 틀에서 확장을 해야하는 상황이었다.
전체적인 시스템은 아래와 같이 구성되어 있었다.
- Spring Boot 로 작성된 메인 API 서버가 있다.
- API 서버 이용 시에는 최초로 모바일 어플리케이션에서 Auth0 로 로그인 한 후 발급 받은 Auth0 Access Token 을 통해 숏폼 서비스 이용 권한(서비스 JWT Token) 을 발급 받는다.
- 이후 발급 받은 JWT Token을 이용하여 서비스를 이용한다.
여기서 실시간 Notification 과 추후 Direct Message 개발을 위한 확장을 고려해 Socket IO를 이용한 개발기록을 작성한다.
알람 전송 플로우
알람 전송을 위해서 우선은 어떤 이벤트에 대해 Notification을 발송할 것인가 에 대한 질문이 필요했다. 상세한 설명 없이 일단 만들고 있으라고 해서 사실상 인스타그램의 릴스, 유튜브 쇼츠 등과 같은 서비스를 리버싱하여 개발을 하는 상태였고 우선적으로 5가지 이벤트에 대해 Notification을 발송하기로 했다.
- 자신을 팔로우하는 새로운 팔로워가 생겼을 때
- 자신의 피드에 댓글이 달렸을 때
- 자신의 댓글에 대댓글이 생겼을 때
- 자신의 피드에 좋아요가 생겼을 때
- 자신의 댓글에 좋아요가 생겼을 때
고려사항
여기서 설계에 들어가기 전에 생각을 해볼 필요가 있었다.
- Notification 서비스의 장애로 인해 숏폼 플랫폼의 핵심 API 서버가 장애가 생겨서는 안된다.
- Notification 의 전달 방식은 사용자가 앱을 이용중인 상태라면 실시간으로 Notificaiton이 전송되어야한다.
- 사용자가 현재 앱을 이용중인 상태가 아니라면 FCM 을 통해 메시지가 전송되어야 한다.
- 실시간 전송 서비스를 제공하는 서비스 역시 클러스터로 동작하며, 한명의 사용자가 여러 Socket Connection을 유지하고 있을 수 있다.
- 웹 개발 한정이 아니기 때문에 모바일 어플리케이션 개발 측에서 현재 사용하고 있는 기술 스택을 이용할 필요가 있다.
이미 개발하고 있던 서비스는 PostgreSQL을 사용중인 상태였다. Noitification 서비스의 경우 핵심 도메인에는 속하지 않는다. 하지만 Notification이 발생하는 경우는 핵심 도메인의 기능 이용 중 발생한 이벤트로 인해 트리거 되어야 한다. 이벤트까지 모두 저장할 필요는 없지만, 사용자에게 어떤 방식이든 발송된 Notificaiton 메시지는 저장되어야 한다.
그렇다면 핵심 기능을 제공하는 데이터베이스에 Notification 이벤트를 저장하는건 좋은 생각은 아니다. 이벤트는 많은 수가 발생할 것이고, 인덱스로 인한 핵심 도메인 API들의 성능 하락이 분명 발생할 것이다. 여기서 데이터베이스 분리를 고려하였다.
또한 2-5번에 의해 최종적으로 실시간 알람 전송에는 Socket IO를 이용하는 것이 좋겠다는 의견을 제시하였다. 첫번째로 Socket.IO를 기존 프로젝트에서 사용했기 때문에 웹 프론트, iOS, AOS 개발자 모두 사용법에 큰 비용이 들지 않다는 점이 5번 조건을 만족 하였으며, 차차 살펴보겠지만 3,4번 조건을 만족시킬 수 있는 피처를 Socket.IO 에서 제공하기 때문이다.
Socket.IO의 선정 이유
가장 큰 문제는 4번에 해당했다. 다음과 같은 시나리오를 생각해보자.
- 사용자 A가 아이폰으로 앱을 실행한다. 이 때 Socket.io Instance 1번에 접속한다.
- 사용자 A가 아이패드로도 앱을 실행한다. 이 때 Socket.io Instance 2번에 접속한다.
- 사용자 A가 안드로이드로도 앱을 실행한다. 이 때 Socket.io Instance 3번에 접속한다.
한명의 사용자는 이벤트가 발생하면 여러 기기에 동시에 메시지를 받아야 한다. 다행히도 Socket.io에서는 이러한 상황을 쉽게 관리해줄 수 있는 Room을 제공한다.
이 기능을 이용하여 요구 사항을 달성하기 위하여 아래와 같이 플로우를 설계하였다.
- 각 사용자는 Socket.io 서버(이하 서버)로 연결 시 JWT 토큰을 제공해야한다.
- 서버는 이를 디코딩하여 MemberId를 얻을 수 있다.
- 서버는 2에서 획득한 notification/{MemberId}를 Room ID로 Room을 생성하고 해당 소켓을 Room에 Join 시킨다.
위 예에서 room:memberAId 에 해당하는 룸에는 총 3개의 Connection이 존재하게 될 것이다. 이후 io.to(roomId).emit("notification", message) 등의 형식으로 메시지를 전송한다면 연결된 사용자 Connection들에게 실시간 메시지를 전달할 수 있다. 여기서 message를 전송하기 전 Room의 Connection이 존재하지 않는다면( = 룸이 없다) FCM 메시지 전달을 위한 이벤트 발행을 하고 해당 이벤트를 API 서버 등에서 처리할 수 있었다.
여기서 끝은 아니다. Socket.IO 인스턴스들이 여러대라는 점을 고려해야한다. 단일 인스턴스에서 io.to(roomId).emit(eventName, message) 를 사용하면 당연히 모든 커넥션이 그 인스턴스에 있기 때문에 메시지 전송이 가능하지만 멀티 인스턴스 환경에서는 그렇지 않다. 모든 커넥션이 분산되어 있기 때문이다.
이러한 분산 커넥션에 대해 메시지를 전송할 수 있게 해주는 Feature 역시 Socket.io 에서 제공한다. RedisAdapter 는 멀티 인스턴스로 구성 시 아래와 같은 상황에서 사용한다.
- 특정 Room에 속한 모든 사용자들에게 메시지를 전송할 필요가 있을 때
- Broadcast 메시지를 전송할 필요가 있을 때
여러 인스턴스에 분산되어 있는 커넥션들에게 메시지를 동시에 전송하고 싶을 때 사용하는 것이다. Socket.IO는 이 기능 구현에 Redis Pub/Sub을 사용하였다.
클러스터를 구성하는 Socket.io 인스턴스 중 하나에서 io.to(roomId).emit(eventName,message) 형식으로 메시지를 전송하면 아래와 같이 수행된다.
- 해당 인스턴스에 roomId에 조인되어 있는 모든 Connection에 메시지를 전송한다.
- Redis 에 이벤트를 Publish 한다.
- 다른 인스턴스들은 RedisAdapter를 통해 이벤트를 구독 중이고, 이벤트가 발생하면 동일 roomId에 현재 자신이 가지고 있는 Connection으로 메시지를 전송한다.
보내야하는 메시지가 N개 있고, room에 커넥션이 M개 있다면 각 인스턴스는 O(NM)의 시간복잡도로 메시지 전송을 수행하게 된다.
마지막으로 고려해야할 사항이 있었다. room으로 메시지를 전송하는 것은 알겠는데, 현재 room에 사용자가 있는지 없는지는 어떻게 체크할 수 있는가? 간단하게 아래 코드로 확인할 수 있다.
const roomName = `notificaiton/${memberId}`
// room에 사용자가 존재하면 인스턴스가 반환되고 없다면 undefined가 반환
if(io.sockets.adapter.rooms[`notification/${memberId}`]) {
io.to(roomName).emit(message)
}else {
// FCM 전송을 위한 로직
}
도메인 분리에 따른 이벤트 정보 전달
기존 시스템도 사실 모놀리식 구조는 아니었다. 인증 도메인은 Auth0 를 사용하고 있었고 영상 업로드부터 HLS 스트리밍을 위해 m3u8으로 변환하는 파이프라인은 S3, Lambda, Elemental Media Conveter가 사용되었다. Notification 서비스를 분리하기로 생각한 시점에서 핵심 도메인에서 발생한 이벤트를 서브 도메인으로 전달해야하는 문제가 생긴다. 이런 부분을 위해서 존재하는 좋은 제품들은 많다. Message Queue라고 불리는 녀석들이다.
Kafka, AWS SQS 두가지를 고려했었다. 당시 상황에선 Kafka가 가장 좋은 선택지였으나, 회사 AWS 계정의 크레딧 문제가 있었다.
내가 생각한 Notification 이벤트가 요구하는 메시지큐의 스펙은 다음과 같았다.
- 순서가 유지될 것
- Notificaiton Event를 다중 인스턴스가 처리할 수 있을 것
- 메시지를 소비하는 컨슈머를 특정할 수 있을 것
- 처리량이 높아야한다
이 부분에서 SQS가 걸리는 부분이 많았다. 1번 조건을 달성하려면 FIFO 큐를 이용해야 하는데, FIFO 큐는 TPS 300으로 제한 사항이 있다. 당장은 무리없는 수치라고 생각하긴 했지만 CPO님이 말한 MAU라면 못버틸 수치다. 또한 3번은 MessageGroupId 를 통해 어떻게든 만족을 시킬 수 있다고 판단되어 일단은 SQS로 진행하게 되었다.
설계
위 고려 사항을 통해 아래와 같은 시나리오를 구성했다.
- 클라이언트에서 서버로(Socket.IO) 연결을 수립한다.
- 서버는 JWT Token을 디코드하고 파싱하여 사용자 ID를 확보한다.
- notification/{사용자ID} Room에 연결된 소켓을 조인시킨다.
- API 서버에서 사용자에 대한 Notification이 필요한 이벤트가 발생하면 SQS의 notification-create-event queue로 이벤트를 발행한다.
- Socket 서버들은 모두 큐를 구독하고 있고 이벤트를 수신(소비)한다. 사용자에게 보낼 메시지를 구성한다.
- Socket을 통한 전송이 성공했다면 해당 메시지를 데이터베이스에 영속화 하고 조회할 수 있게 한다.
- room이 존재하지 않거나, 전송 시점에 사용자 커넥션에 문제가 생겨 전송하지 못한 경우 FCM 메시지 전송 이벤트를 발생시킨다.
여기까지는 단순하게 핵심 도메인에서 발생한 이벤트를 어떻게 Notification 쪽으로 전달할 것인가에 대한 내용을 간략하게 적은 플로우이다. 아직은 문제가 많은 상태이지만 일단 이것을 기반으로 설계를 짜보았다.

그림 하단의 MongoDB 는 실제 보낸 Notification 메시지를 저장하고 조회 시 사용하는 데이터베이스이다.
중복 메시지를 어떻게 해결할 것인가?
예를 들어보면 사용자 A가 새로운 피드 F 를 올렸다고 가정해보자. 사용자 B를 포함한 다수의 유저가 피드 F에 대해 좋아요(L), 댓글(R) 등을 달았다고 가정해보자. 이 때 이벤트 수는 L.size() + R.size() 가 될 것이다. 이를 모두 Notification으로 전송하면 사용자 경험이 결코 좋지 못할 것이다.
이에 아래와 같은 추가적인 요구사항이 발생하였다.
- 많은 이벤트가 발생하는 유저에 대해서는 알람을 모아 보내야할 필요가 있다.
- 동일 리소스 대상에 대한 알람은 1회만 처리한다.
결국 API 서버가 SQS를 발행한 이벤트를 곧바로 Socket 서버에서 소비하는 것이 아닌 중간 다리가 필요한 상황이 되었다. 이에 한 notification-create-event가 실제 전송 메시지로 변환되는데 다음과 같은 로직이 추가되었다.
- API 서버에서 이벤트 발행 시, 사용자 ID, 리소스 ID를 포함시킨다. 또한 사용자ID 기반으로 MessageGroupId를 선정한다.
- notification-create-event를 소비하는 cronjob을 생성한다.
Notification을 어떻게 그룹화 할 것인지는 정책에 따라 달라지겠지만 여기선 간단하게 예를 들어본다. 사용자 A가 신규 피드 F를 올렸는데, 거기에 다른 사용자들의 댓글이 달렸다.
// notification-create-event
[
{
"targetResource": {
"type": "feed",
"id": <feedFId>
},
"agentUser": {
"memberId" : <Turing's ID>,
"nickname" : "Turing"
},
"targetUser" : : <memberAId>
},
{
...
"agentUser": {
"memberId" : <Baker's ID>,
"nickname" : "Baker"
},
...
}
]
이런식으로 이벤트들이 발행될 것이고 한 사용자의 이벤트는 특정 MessageGroupId로 가기 때문에 순차적인 처리 그리고 단일 인스턴스에 의한 처리를 보장할 수 있으며 중복 메시지 송신 문제를 해결할 수 있다.
그리고 이를 소비하는 cronjob들의 로직은 다음과 같다.
- cronjob 인스턴트들은 각자의 MessageGroupId의 이벤트만 처리한다.
- cronjob 인스턴트들이 기간 내 들어온 이벤트들을 사용자-리소스별로 집계해 notification-send-event를 발행한다.
메시지를 집계하여 사용자 ID 그리고 리소스 ID(특정 피드, 특정 댓글 등등)를 그룹화하여 메시지를 생성하게 된다. 위에 든 예시에서 사용자에게 피드 F에 대한 좋아요, 댓글을 따로 보낼 필요가 없다. 그냥 합쳐서 회원님의 피드에 <님을> 포함한 n명이 응답했어요. 정도로 퉁쳐서 보내면 될 것이다. 그러면 집계를 다음과 같은 형식으로 만들어야 할 것이다.
// notification-send-event
{
"receiver" : <memberAId>,
"targetResource": "feed/feedId",
"message" : "<args1> 님을 포함한 <args2>명의 사용자들이 당신의 게시글에 응답했어요!",
"args" : [
{
"memberId": <Baker's Id>,
"nickname": "Baker"
},
17
],
"createdAt": <생성 시각>
}
이제 Notification 메시지까지 생성되었고, 메시지의 실제 전송은 소켓 서버 인스턴스에서 담당한다. 우선 메시지를 notification-send-event를 발행하기 전에, 해당 메시지를 영속화 해야한다. 실제 메시지가 Push Notification 또는 Socket.IO로 발송 두가지 모두 실패하더라도 조회는 할 수 있어야 하기 때문이다.
영속화 된 형태는 아래와 같을 것이다.
{
"_id" : <hashed_id>,
"receiver" : <memberAId>,
"targetResource": "feed/feedId",
"message" : "<args1> 님을 포함한 <args2>명의 사용자들이 당신의 게시글에 응답했어요!",
"args" : [
{
"memberId": <Baker's Id>,
"nickname": "Baker"
},
17
],
"createdAt": <생성 시각 타임스탬프>
}
이제 notification-send-event를 발행한다.
전송 서버 인스턴트들은 notification-send-event를 구독하고, 이벤트가 도달하면 다음 로직을 수행한다. - 이벤트에 memberId와 targetResource를 가지고 이미 전송된 이력이 있는 대상인지 확인한다.(MongoDB 사용) - 해당 리소스들에 대해 Notification 이 조회된다면 메시지를 폐기한다. - 사용자가 room에 속해있는지 확인한다. - 사용자가 room에 존재한다면 실시간 전송을 수행한다. - 사용자가 room에 존재하지 않거나, 실시간 전송중 문제가 발생한 경우 FCM 메시지 발송을 시도한다.
위 방식을 적용하여 알림 서비스를 구현했다.
퇴사 후 1년이 지난 시점에서 회고
Hash 방식을 이용하여 중복 메시지 전송 문제를 해결하려 시도한 흔적을 보면서 멀리도 돌아갔구나 라는 생각이 많이 든다. 2024년 7월 사이드 프로젝트로 SNS 개발을 하려 디자인 중에 이 글을 다시 살펴보면서 아쉬운 점이 많이 든다.
우선 해시 기반의 방식을 통해 발행된 notification-create-event를 처리할 필요가 없다. notification-create-event를 받아. 수신 대상 사용자 ID와 리소스 ID를 포함하여 이벤트를 저장하고 있는데 이러한 이벤트는 갱신 유실등의 문제가 발생할 여지가 없다. 그냥 리스트에 계속 추가하는 형식이다.
오히려 저렇게 구성을 한다면, 수백만 팔로워를 가진 사용자가 게시글을 올렸을 때 폭발적인 이벤트가 발생한다면 단일 인스턴스에 부하가 쏠리는 현상이 발생할 것이다.
이런식으로 해결하면 좀 더 깔끔할 것 같다.
MongoDB의 복합 인덱스 사용
// NotificationEvent 테이블
{
"_id": "알림 ID",
"userId": <Member A Id>,
"resourceId": "feed/<feedId>",
"eventType" : "feed-like",
"messageTemplate" : [
"$1 님을 비롯한 $2명의 사용자가 당신의 게시글에 좋아요를 눌렀습니다.",
"당신이 올린 게시물을 $1 님이 좋아합니다."
]
"isSent": "PENDING",
... 기타 필드들
}
대충 이런식으로 notification-create-event 소비 프로세스에서 MongoDB로 저장한다. 이 때 복합 유니크 인덱스를 지정해둔다
db.notifications.createIndex(
{ userId: 1, resourceId: 1, eventType: 1 },
{ unique: true }
);
뭐 이런식으로 복합 인덱스를 설정하고 실제 전송될 메시지를 구성할 때는 이 도큐먼트를 이용하도록 하면 같은 사용자와 같은 리소스에 대한 이벤트는 하나의 문서만 발생하기 때문에 중복 발송에 대한 방지가 가능하다. 그러면 메시지 템플릿의 첫 부분처럼 여러명이 같은 이벤트를 발생시켰고, 그것을 집계한 메시지를 생성해야할 때는 어떻게 해야할까? 추가로 테이블을 구성하면 된다.
복합 유니크 인덱스를 생성해두었기 때문에, userId, resourceId, eventType을 통해 검색하면 알림 ID를 알 수 있다. 그러면 해당 이벤트를 발생시킨 다른 사용자들의 정보를 추가로 저장하는 테이블을 만들어 저장해두면 된다.
// FeedLikeEvent
{
"_id": "NotificationEventAgent Id",
"notificationId": <알림 ID 레퍼런스>,
"agentId" : <이벤트를 발생시킨 사용자 ID>,
"likedAt": "시간 정보"
}
이렇게 저장해두고 이후에 Spring Batch이나 Spring Schedule을 이용해서 분산 처리를 하면 된다. 이 때, FCM 전송이나 Socket.IO를 통한 메시지 전송이 반드시 한 인스턴스에서만 처리하도록 하면 된다.
Spring Scheduling을 이용한다고 가정하고, 서비스 환경을 아래와 같이 가정해보자.
- 알림 전송이 필요한 알림 ID를 조회하는 Cronjob Process가 두대 있다.
- 필요한 알림 ID를 받아 메시지를 작성하고, FCM에 요청하는 프로세스는 4대가 있다.
이런 상황이라면 다음과 같은 방식으로 구현할 수 있을 것이다.
- 두대의 알림 전송 ID 조회 프로세스는 Redis Lock을 이용하여 알림 전송 테이블의 접근을 한대만 할 수 있게한다.
- 락을 획득하지 못한 프로세스는 해당 주기의 작업을 포기하고, 락을 획득한 프로세스만 작업을 시작한다.
- 아직 알람 발송 처리를 하지 않은 알림 ID를 리스트로 조회해온다.
- Kafka를 통해 message-send-event 를 발행한다.
- 4대의 FCM 전송 서비스는 Kafka message-send-event를 구독하여 알림 ID를 획득한다.
- 각 서비스는 획득한 알림 ID에 해당하는 notification을 몽고 DB에서 조회하고, 메시지 타입을 확인한다.
- 필요한 테이블에서 추가로 이벤트 개수를 전부 조회한다.
- 메시지를 생성하고 FCM을 통해 발송한다.
위와 같은 과정으로 중복 발송 문제를 해결할 수 있다. 개발 당시에 참 스트레스를 많이 받고 있어서 저렇게 비효율적으로 개발했구나... 싶기도 한데 지금보니 안타까운 수준이다. 정작 위 프로세스 유사한 방식을 이미 정확한 조회수 집계를 위해 테스트를 했었던 것으로 기억하는데 왜 저렇게 구성을 한 것인지 이해가 안가네...