알림 발송을 마이크로서비스로 분리하기
마냑은 사용자가 설정을 넣으면 AI가 스토리를 만들고 그 스토리 속 인물과 채팅하는 서비스다. 백엔드는 Kotlin과 Spring Boot로 만든 모놀리스 하나다. 마이크로서비스와 메시지 큐를 공부하려고 이 서버에서 알림 발송만 떼어내기로 했다. 패키지가 열둘인데 왜 하필 알림인지, 무엇을 보고 골랐는지 정리해 둔다.
재미있는 건 한 달 전에 같은 검토를 하고 정반대 결론을 냈다는 점이다. 그때는 “하필 알림이 최악의 분해 후보”라고 적어 두고 검토를 접었다. 한 달 뒤 같은 모듈을 첫 번째로 골랐다. 뒤집힌 건 알림 모듈이 아니라 “분리한다”는 말의 정의였다.
환경
이 글에서 사용한 환경은 Kotlin 2.2.21, Spring Boot 4.0.6, Java 21이다. 서버는 ECS Fargate 태스크 한 대로 돌고 백엔드 인원은 혼자다. 코드 집계와 운영 로그 조회는 2026년 9월 21일 기준이다.
한 달 전에는 알림이 최악의 후보였다
8월 27일에 알림 기능을 넣으면서 마이크로서비스 전환을 처음 검토했다. 결론은 보류였고 근거는 규모였다.
당시 서버는 180파일 17,158줄이었고 운영 태스크는 한 대, 백엔드는 혼자였다. 이미 서버, AI, 웹, 안드로이드, 테라폼 다섯 레포로 나뉘어 있어서 분산 비용은 치르는 중이었다. 세 레포 동반 배포와 반쪽 배포 장애도 이미 겪었다.
서버에서 먼저 나눌 만한 부분은 벌써 나뉘어 있었다. 부하 성격이 다른 AI 추론은 별도 레포와 컨테이너로 진작 떨어져 있고 서버에 남은 건 CRUD다. CRUD는 쪼개서 좋아지지 않는다.
보류 판단을 적으면서 알림을 콕 집어 “최악의 분해 후보”라고 썼다. 이유가 넷이었다.
- 알림은 세 도메인을 읽는다. 회원에서 수신 동의를, 서비스 내부 재화인 이프의 원장에서 당일 출석 여부를, 스토리 제작 요청에서 완료 훅을 가져온다.
- 떼어내면 지금 한 트랜잭션이라 공짜인 중복 발송 방지를 직접 만들어야 한다.
- 이프의 내부 구현이 API 모양으로 밖에 드러난다.
- 탈퇴할 때 토큰과 동의 기록을 지우는 일이 분산 트랜잭션이 된다.
읽고 보면 다 맞는 말이다. 그런데 이 넷은 전부 알림 서비스가 자기 데이터베이스를 두고 독립한다는 전제 위에 있다. 그 전제를 적어 두지 않은 게 한 달 뒤에 판단이 뒤집힌 이유다.
알림 모듈의 의존성 확인하기
9월에 알림, 검색, 결제 기능이 차례로 들어오면서 서버는 282파일 25,752줄이 됐다. 한 달이 안 되는 사이에 파일이 100개 늘었다.
세 기능 모두 외부 시스템을 부르지만 첫 분리 대상으로는 알림만 남겼다. 검색은 story와 서로 의존하고 있어 먼저 순환을 끊어야 하고, 관심사였던 색인 실패 복구는 서비스를 떼지 않아도 만들 수 있다. 결제는 웹훅을 요청 스레드에서 처리하는 문제가 있지만 그로블의 웹훅 재전송과 주기 대사라는 복구 경로가 이미 있다. 반면 알림은 FCM 전송이 실패하면 재시도할 방법이 없다.
알림이 실제로 얼마나 독립돼 있는지 확인하려고 패키지 사이 import를 세었다.
상위 결과는 이렇다.
31 story -> global
24 story -> image
21 chat -> global
20 chat -> story
13 user -> global
13 credit -> global
13 chat -> credit
10 user -> auth
10 push -> auth
10 auth -> global
10 auth -> credit
9 search -> story
9 global -> auth
8 push -> global
7 story -> search
6 story -> credit
6 invite -> credit
global은 공통 계층이라 다들 부른다. 도메인만 놓고 보면 story가 허브고 chat이 story를 20번 부른다. 채팅 턴마다 스토리 스냅샷을 읽으니 당연한 숫자다.
숫자에서는 들어오는 의존 수와 양방향 순환 여부를 봤다. 대부분이 어딘가와 서로를 부른다.
순환이 아예 없는 모듈은 셋이다. feedback, invite, push.
feedback은 10파일이고 하는 일이 피드백 저장과 슬랙 웹훅 하나다. invite는 3파일이고 초대 코드 검증이다. 떼어 봐야 배울 것도 얻을 것도 없다.
고립도만 보면 feedback이 1등인데 그걸 고르지 않았다는 게 중요하다. 고립돼 있다는 사실만으로 분리할 이유가 생기지는 않는다.
push로 들어오는 의존은 딱 하나다. 탈퇴 처리가 기기 토큰을 지우려고 부른다.
user/service/UserWithdrawalService.kt:7:import com.knk.manyak.push.repository.DevicePushTokenRepository
나가는 의존 10건도 뜯어보면 종류가 셋이다.
6 import com.knk.manyak.auth.repository.UserRepository
3 import com.knk.manyak.auth.entity.UserStatus
1 import com.knk.manyak.auth.entity.User
회원을 조회하고 상태를 확인하는 게 전부다. 한 달 전에 “세 도메인을 읽는다”고 적은 것과 숫자가 다른데, 그건 출석 대상을 뽑는 쿼리가 회원 저장소 안에 네이티브 SQL로 들어 있어서다. 이프 원장을 읽는 건 맞지만 알림에서 직접 읽는 구조는 아니다.
알림 실패는 로그만 남는다
스토리 완성 푸시는 커밋 뒤에 별도 스레드에서 FCM을 부른다. FCM 호출이 실패해도 스토리 제작까지 실패로 돌릴 수는 없으므로 예외를 잡아 경고만 남긴다. 문제는 그다음이다. 실패한 발송을 저장하지 않고 재시도하는 경로도 없어 로그를 놓치면 그대로 유실된다.
운영에서 알림 실패가 있었는지 CloudWatch Logs Insights로 확인했다.
fields @timestamp, @message
| filter @message like /"level":"(INFO|WARN|ERROR)"/
and @message not like /RequestCorrelationFilter/
and @message like /푸시|FCM|발송/
| parse @message /"message":"(?<msg>[^"]*)"/
| stats count(*) as n, min(@timestamp) as first, max(@timestamp) as last by msg
| sort n desc
RequestCorrelationFilter를 빼는 게 핵심이다. 추적 헤더 누락 경고가 수천 건이라 안 빼면 나머지가 안 보인다.
30일치 결과에서 발송 실패 경고는 0건이었다. 대신 다른 게 하나 걸렸다.
FCM 서비스 계정이 설정되지 않아 푸시 발송을 비활성화합니다.
레벨이 INFO다. 시크릿이 빠진 채 배포되면 푸시가 조용히 0건이 된다. 실패가 안 보이는 실패다.
바뀐 건 분리의 범위였다
한 달 사이에 알림 모듈은 거의 그대로다. 바뀐 건 “떼어낸다”가 무엇을 뜻하는지였다.
8월의 전제는 알림 서비스가 자기 데이터베이스를 갖는 것이었다. 그러면 동의와 토큰을 복제해야 하고, 복제가 늦으면 수신을 끈 사람에게 광고가 나간다. 탈퇴 정리는 두 데이터베이스에 걸친다. 최악이 맞다.
9월의 전제는 발송 실행만 옮기는 것이다. 토큰 테이블과 동의 컬럼은 서버가 그대로 소유하고, 알림 서비스는 보내기 직전에 서버 API를 불러 “이 사람에게 지금 보내도 되나, 토큰은 뭔가”만 묻는다. 복제가 없으니 복제 지연도 없고 탈퇴 정리는 여전히 서버의 한 트랜잭션이다.
같은 모듈인데 어디까지 넘기느냐에 따라 최악이 최선이 됐다. 분해 후보를 고를 때 모듈 이름만 놓고 따지면 안 되고 책임 경계를 어디에 그을지까지 같이 정해야 한다는 걸 이번에 배웠다.
발송 실행만 분리하기
알림 서비스는 FCM을 부르는 일만 한다. 회원 상태와 수신 동의 판정, 기기 토큰 보관은 서버에 남는다. 알림 서비스가 갖는 상태는 나중에 큐를 붙일 때 필요한 중복 처리 기록 하나뿐이다.
서버 쪽에는 내부 API가 하나 생긴다. 지금 이 판정이 세 군데로 흩어져 있다는 것도 조사하면서 알았다. 발송기는 회원이 활성인지만 보고, 서비스 알림 동의는 스토리 완성 리스너가 따로 보고, 광고 동의와 야간 시간 판정은 출석과 프로모션 서비스가 각자 본다. 이걸 API 하나로 모은다.
이 API가 돌려주는 값이 앞으로 알림 서비스가 회원에 대해 알게 될 전부다. 이 경계를 제대로 그리면 분리가 쉬워진다.
지금 분리해서 잃는 것
솔직히 지금 당장 얻는 건 별로 없다. 운영 태스크가 한 대라 독립 스케일이 필요한 상황도 아니고, 컨테이너가 하나 늘면 월 비용도 는다. 서버가 죽으면 자격 조회를 못 하니 알림도 못 나간다. 프로모션처럼 수천 명에게 보낼 때는 HTTP 호출도 수천 번이다.
그래서 당장은 제품 개선보다 학습에 초점을 맞췄다. 실제로 규모가 필요해지는 조건은 따로 적어 뒀다. 백엔드가 셋 이상이 되어 배포 충돌이 생기거나, 한 도메인만 부하가 튀거나, 발송이 수만 건 단위가 되는 때다. 지금은 셋 다 아니다.
정리
첫 분리 대상은 순환 없이 떨어져 있고 외부 시스템을 부르며 실패가 조용히 유실되는 모듈이어야 했다. 세 조건을 다 만족하는 게 알림 하나였다.
다음은 내부 API를 만드는 차례다. 흩어진 발송 자격 판정 세 개를 엔드포인트 하나로 모으고 컨테이너를 나누기 전에 서버 안에서 경계를 먼저 만든다. 코드를 옮기지 않고도 분리가 되는지 안 되는지는 여기서 거의 결정된다.