알림 시스템: 설계와 고쳐 온 것들

outbox에서 FCM까지, 그리고 그 사이에서 실제로 부러졌던 지점들

MSG-178~181 MSG-313~315 전부 머지 완료 · 대상: 백엔드 팀원 · 2026-08-05

0. 한눈에

FillMap 백엔드에 푸시 알림을 붙이는 작업은 티켓 7개로 나뉘어 진행됐다. 토큰 수집(MSG-178), 발송 파이프라인(MSG-179), 수신 설정(MSG-180), 트리거 연동(MSG-181)이 뼈대를 세웠고, 영상 준비 완료(MSG-313), 뱃지 임박(MSG-314), 주간 요약(MSG-315)이 알림 종류를 늘렸다.

5종알림 카테고리
5건마이그레이션 (V21·22·24·25·26)
16R교차 리뷰 라운드
16건리뷰가 잡은 결함
1,127전체 테스트 (907에서)
93.1%커버리지 (변경분 100%)
이 문서가 다루는 것 1장은 지금 돌고 있는 구조다. 2장은 그 구조에 도달하기까지 부러졌던 지점을 증상, 원인, 해결로 적은 기록이다. 24건 중 13건은 자동 리뷰가 머지 전에 잡았고(Codex 교차 리뷰 12건, PR 리뷰 봇 1건), 나머지는 코드나 인프라 실측, 아니면 실제로 무언가 깨진 뒤에 드러났다.

1. 설계

1.1 왜 outbox인가

가장 단순한 구현은 업로드가 끝나는 자리에서 FCM을 바로 호출하는 것이다. 이걸 안 쓴 이유는 이중 쓰기 문제 때문이다. DB 커밋과 외부 호출은 하나의 트랜잭션으로 묶이지 않아서, 커밋은 됐는데 발송 직전에 프로세스가 죽으면 알림이 사라지고, 발송은 됐는데 커밋이 롤백되면 있지도 않은 사건의 알림이 나간다.

그래서 알림 요청을 비즈니스 트랜잭션과 같은 커밋으로 DB 테이블에 먼저 적는다. NotificationCommandService.record()가 전파 REQUIRED라 호출자의 트랜잭션에 그대로 참여한다. 커밋되면 알림은 반드시 남고, 롤백되면 알림도 없던 일이 된다. 발송은 그 뒤에 별도 릴레이가 맡는다.

void record(Long userId, NotificationCategory category, String eventKey, String title, String body);
// 반드시 호출자의 비즈니스 트랜잭션 안에서 호출할 것. 같은 커밋이어야 원자성이 성립한다.

전달 보장 수준은 at-least-once와 멱등 dedupe다. 분산 환경에서 exactly-once는 현실적이지 않아서 추구하지 않고, 대신 중복이 와도 한 건으로 접히게 만들었다.

1.2 파이프라인 5단계

비즈니스 트랜잭션 업로드 · 뱃지 · 배치 notifications outbox 겸 발송 기록 Kafka 파티션 1 · 보존 7일 컨슈머 필터 4단 · 재시도 FCM 500 단위 청크 같은 커밋 릴레이 5초 배치 100건 리스너 1 멀티캐스트 PUBLISHED가 30분 넘게 남아 있으면 PENDING으로 되돌려 재발행 설정 off → SKIPPED · 상한 초과 → SKIPPED 토큰 없음 → SKIPPED · 재시도 소진 → DEAD 무효 토큰은 push_tokens에서 즉시 삭제 전달 보장: at-least-once + (user_id, event_key) 멱등 dedupe

릴레이가 Kafka로 보내는 메시지는 outbox 행의 id 문자열 하나뿐이다. 컨슈머가 그 id로 DB를 다시 읽으므로 페이로드 스키마가 진화할 일이 없고, 읽는 김에 멱등 검사도 자연스럽게 된다.

1.3 상태 머신

PENDING ──릴레이 발행──▶ PUBLISHED ──컨슈머──▶ SENT     발송 성공, sent_at 기록
                                       ├──▶ SKIPPED  설정 off · 상한 초과 · 토큰 없음
                                       └──▶ DEAD     재시도 상한 소진, last_error에 원인

PUBLISHED가 따로 있는 이유는 이게 없으면 릴레이가 컨슈머 종결 전까지 매 주기 같은 행을 다시 발행하기 때문이다. 컨슈머가 밀리는 순간 중복이 폭증한다. 대신 발행 성공과 상태 갱신 사이에 크래시가 나면 1회 재발행이 가능한데, 이건 컨슈머 멱등이 흡수한다.

죽은 편지함을 별도 토픽으로 만들지 않았다. outbox 행 자체가 DLQ다. 상태가 DEAD인 행을 세면 그게 곧 실패 목록이고, 운영 가시성도 한 문장으로 끝난다.

SELECT status, count(*) FROM notifications GROUP BY status;
-- 성공률 = SENT / (SENT + DEAD)

1.4 멱등 2중 방어

같은 사건이 거듭 기록되거나 거듭 소비되는 경로를 기록 시점과 소비 시점 양쪽에서 접는다.

  1. 기록 시: INSERT ... ON CONFLICT (user_id, event_key) DO NOTHING. 트리거가 같은 사건을 중복 기록해도 DB가 1행으로 접는다.
  2. 소비 시: 행을 읽어 이미 종결 상태(SENT, SKIPPED, DEAD)면 즉시 ack하고 끝낸다. 리밸런싱이나 오프셋 커밋 전 크래시로 인한 재전달이 재발송으로 이어지지 않는다.
이 둘이 중복 발송을 없애지는 않는다. 컨슈머는 FCM 호출이 끝난 뒤 별도 트랜잭션으로 markSent를 찍는다. 그 사이에 프로세스가 죽으면 행은 종결 전 상태로 남는다. 릴레이가 발행 직후 찍은 PUBLISHED가 보통이고, 발행과 markPublished 사이에 컨슈머가 먼저 집었다면 PENDING이다. 2번 방어는 이 둘을 모두 진행 대상으로 보므로, 오프셋도 커밋되지 않은 그 메시지가 재전달될 때 그대로 통과해 같은 알림이 한 번 더 나간다. 전달 보장이 at-least-once인 이유가 이것이고, 없앨 방법은 발송과 상태 기록을 한 트랜잭션에 묶는 것인데 외부 HTTP 호출이라 불가능하다. 창을 좁히려면 발송 직전에 SENDING 같은 중간 상태를 찍어야 하는데, 그건 쓰기가 한 번 더 늘고 그 상태에 멈춘 행을 되살리는 스윕이 또 필요해서 MVP 규모에는 과하다고 봤다.

이 설계의 진짜 이득은 재시도 코드를 따로 안 써도 된다는 점이다. 스케줄러를 하루에 네 번 발화시키거나 배치를 30분 뒤에 한 번 더 돌리면, 이미 기록된 대상은 ON CONFLICT가 0행으로 흡수하므로 재발화가 곧 재시도가 된다.

이벤트 키는 사건의 자연 식별자를 쓴다. 종류마다 "같은 사건"의 정의가 다르다.

알림event_key이 키가 강제하는 것
뱃지 획득BADGE:{badgeId}뱃지는 생애 1회 지급이라 날짜가 필요 없다
뱃지 임박BADGE_NEAR:{badgeId}같은 뱃지 임박은 생애 1회. 롤백 후 재진입도 흡수
핫구역 진입HOTZONE:{KST일자}:{gridId}같은 날 이탈 후 재진입은 1건, 다른 날은 새 사건
스트릭 리마인드REMIND:{KST일자}20시부터 23시까지 네 번 발화해도 하루 1건
영상 준비 완료VIDEO:{videoId}:{원본키 꼬리 45자}처리 시도 단위. 교체 업로드는 키가 달라져 새 알림
주간 요약WEEKLY:{ISO 주 기반 연도}-W{주차}19시와 19시 30분 발화가 같은 키

1.5 알림 5종

카테고리발화 방식발화 지점전송률 상한
BADGE 동기, 업로드 트랜잭션 안 BadgeAwardServiceImpl.award() 한 곳. 축이 늘어도 배선이 자동 커버된다 획득은 무제한, 임박은 하루 1건
HOTZONE 10분 주기 스냅숏 비교 HotZoneEntryDetector. Redis SET과 현재 집합의 차집합이 신규 진입 하루 1건
REMIND cron 20~23시 매시 StreakRemindScheduler. last_recorded_date = 어제 한 조건 하루 1건
VIDEO 상태 전이 시점 VideoStatusWriter의 종결 전이 4개. 완료 2, 실패 2 없음
WEEKLY cron 일요일 19시, 19시 30분 WeeklySummaryScheduler. 대상 판정과 집계가 한 쿼리 없음

전송률 상한 판정은 컨슈머의 switch 하나에 모여 있고 이 switch가 exhaustive다. 카테고리를 추가하면 컴파일이 깨지므로, 상한 정책을 안 정한 채 새 알림이 발송 경로로 흘러드는 일이 구조적으로 막힌다. VIDEO와 WEEKLY를 추가할 때 실제로 이 컴파일 에러가 먼저 났다.

1.6 게이트와 격리

발송 관련 빈은 전부 fillmap.notification.enabled 게이트 안에 있고 기본값이 off다. 로컬과 CI는 Kafka 없이 그대로 green이고, 컨텍스트 테스트가 "비활성이면 릴레이와 컨슈머 빈이 없다"를 실증한다. 반대로 record()와 설정 서비스는 게이트 밖 상시 빈이다. 기록은 DB insert뿐이라 무해하고, 발송이 꺼져 있어도 이력은 쌓이며, 켜는 순간 릴레이가 밀린 PENDING을 발행한다.

dev와 prod 격리는 토픽 prefix와 컨슈머 그룹으로 한다. dev.notification.sendprod.notification.send, 그리고 fillmap-notification-dev-prod다. postgres와 redis는 컨테이너를 두 벌 띄워 물리 격리했는데 Kafka만 논리 격리인 이유는 2장 D3에 있다.

2. 결함 기록

아래는 시간순이 아니라 고장 성격별로 묶었다. 각 항목의 출처 표시는 Codex가 자동 교차 리뷰, 리뷰 봇이 PR 리뷰 워크플로, 실측이 코드나 인프라를 직접 확인하다 발견한 것, 사고가 실제로 무언가 깨진 뒤 알게 된 것이다. P1은 반드시 고쳐야 할 것, P2와 P3은 그보다 낮은 등급이다.

A. 유실과 정지 (파이프라인이 멈추는 것들)

A1. Kafka 볼륨을 붙였는데 데이터가 안 남는다 Codex 1R P1

증상
compose에 kafkadata:/var/lib/kafka/data 볼륨을 마운트했는데 컨테이너를 재기동하면 토픽과 미소비 메시지가 사라진다.
원인
apache/kafka 이미지의 기본 로그 디렉터리는 /tmp/kraft-combined-logs다. 볼륨은 아무도 안 쓰는 경로에 붙어 있었고 실제 데이터는 컨테이너 임시 계층에 쌓이고 있었다.
해결
KAFKA_LOG_DIRS: /var/lib/kafka/data를 명시했다. 이미지 기본값을 확인하지 않고 관례적인 경로를 가정한 것이 문제였다.

A2. 브로커가 죽으면 스케줄러 스레드가 통째로 잠긴다 Codex 1R P1

증상
Kafka가 내려간 동안 릴레이뿐 아니라 무관한 배치(스테일 토큰 정리, AI 블러 폴러)까지 멈춘다.
원인
릴레이가 배치 100건을 돌며 send().get()으로 동기 확인을 한다. 브로커가 없으면 건당 delivery timeout 기본 2분을 기다린다. 100건이면 최대 200분인데, 스프링 기본 TaskScheduler는 단일 스레드라 그 시간 동안 다른 스케줄 작업이 전부 대기한다.
해결
첫 발행이 실패하면 경고 로그만 남기고 남은 배치를 즉시 중단한다. 나머지 행은 PENDING으로 남아 다음 5초 주기가 자연스럽게 재시도한다.

A3. FCM 멀티캐스트의 토큰 500개 하드 리밋 Codex 1R P2

증상
디바이스 토큰이 많은 사용자에게 보낼 때 요청 자체가 거부되어, 재시도를 다 소진하고 DEAD가 된다.
원인
sendEachForMulticast는 토큰 500개가 상한이고 초과하면 요청을 받지 않는다. push_tokens에는 사용자당 등록 개수 상한이 없어서 이론상 언제든 넘을 수 있다. 초안에는 "사용자당 토큰 수 대비 1콜로 충분"이라고 적혀 있었다.
해결
500개 단위로 잘라 호출하고 SendResult를 합산한다.

A4. 컨슈머가 그룹에서 축출되어 같은 알림을 다시 처리한다 Codex 2R P1

증상
FCM이 느린 구간에서 리밸런스가 일어나고, 오프셋을 커밋하지 못한 메시지가 재처리된다.
원인
spring-kafka 4.1.0 소스를 직접 확인한 결과, 2-인자 DefaultErrorHandler의 기본 백오프 핸들러는 파티션을 pause하지 않고 리스너 스레드를 sleep시킨다. 백오프만으로는 max.poll.interval.ms 기본 300초에 못 미치지만, 기본 max.poll.records 500건에 FCM 지연이 겹치면 넘어간다.
해결
max.poll.interval.ms=600000, max.poll.records=10을 명시했다. 워스트 케이스를 계산해 10건 × 40초 + 최대 백오프 128초 = 528초로 600초 안에 든다는 것까지 검증했다.

A5. 깨진 메시지 하나가 파티션을 4분씩 막는다 Codex 2R P2

증상
숫자가 아닌 페이로드가 토픽에 들어오면 뒤따르는 정상 알림이 전부 지연된다.
원인
NumberFormatException은 몇 번을 재시도해도 결과가 같은 결정적 실패인데, 기본 에러 핸들러가 이걸 일반 실패와 똑같이 취급해 8회 백오프를 다 돈다. 파티션이 1개라 그동안 큐 전체가 정지한다.
해결
addNotRetryableExceptions에 등록해 백오프 없이 즉시 recoverer로 보낸다. recoverer는 id를 파싱할 수 없으므로 error 로그를 남기고 폐기한다.

A6. PUBLISHED로 영원히 남는 행 Codex 3R P2

증상
"유실 0"을 성공 기준으로 잡았는데, 컨슈머가 오래 죽어 있으면 그 기준이 조용히 깨진다.
원인
릴레이는 발행 직후 상태를 PUBLISHED로 바꾼다. 컨슈머가 브로커 보존기간 7일보다 오래 다운되면 메시지 자체가 사라지고, 그 행은 아무도 다시 건드리지 않아 영구히 PUBLISHED에 멈춘다. PENDING만 감시하는 설계에는 이 구멍이 안 보인다.
해결
릴레이 사이클 첫머리에 30분 넘은 PUBLISHED를 PENDING으로 되돌리는 스윕을 넣었다. 재발행 중복은 컨슈머 멱등이 흡수한다. 토픽을 무한 보존하는 대안은 EC2 디스크 상한 때문에 기각했다.

A7. 그 복구 장치가 스스로 폭주할 뻔했다 Codex 4R P1

증상
A6의 스윕을 created_at 기준으로 짰는데, 게이트를 켜는 순간 같은 행이 5초마다 리셋되고 재발행되는 무한 루프가 된다.
원인
게이트가 off인 동안 쌓인 백로그는 "기록된 지 30분이 지난 뒤에야 처음 발행되는" 행이다. created_at 기준이면 발행하자마자 stale로 오판된다. 게다가 릴레이가 id 오름차순으로 읽으므로 가장 오래된 배치가 영구히 앞자리를 차지해 뒤의 알림은 영영 못 나간다.
해결
판정 기준을 published_at으로 바꾸고, 발행 시각을 statement_timestamp() AT TIME ZONE 'UTC'로 명시 기록했다. 스윕용 partial index도 같이 넣어 풀스캔을 막았다. 정상 상태에선 PUBLISHED 행이 거의 0건이라 인덱스가 매우 가볍다.

A8. FCM 타임아웃이 무한이라 재시도 경로가 무력화된다 Codex 5R P1

증상
FCM이 응답을 멈추면 재시도도 DEAD 격리도 일어나지 않고 그냥 멈춘다.
원인
firebase-admin의 기본 connect와 read 타임아웃이 0, 즉 무한이다. 리스너 스레드가 하나뿐이라 그 스레드가 영구 블록되면 공들여 만든 백오프와 DEAD 격리가 전부 도달 불가능해진다.
해결
connect 10초, read 30초로 유한화했다. 이 값이 poll 예산 안에 드는지도 계산으로 확인했다 (A4의 528초). 참고로 Codex가 제시한 615초 산식은 백오프를 이중 계상한 것이라 반박했고, 근거를 대고 값을 유지했다.

A9. 리마인드 20시 단발은 실패하면 그날이 끝이다 Codex 1R P2

증상
스트릭 리마인드는 자정에 끊기기 전에 도착해야 의미가 있는데, 20시 배치가 부분 실패하면 그날은 아무도 다시 시도하지 않는다.
원인
cron이 0 0 20 * * * 단발이었다. 시간에 민감한 알림에 재실행 주체를 안 둔 설계다.
해결
cron을 0 0 20-23 * * *로 바꿔 네 번 발화시키고, 루프에 per-recipient try-catch를 넣었다. 재시도 코드는 한 줄도 안 늘었다. 이미 기록된 대상은 event_key 멱등이 흡수하므로 재발화가 곧 재시도가 된다. 추가 비용은 1행짜리 테이블 풀스캔 3회뿐이다.

A10. 이벤트 키가 길어서 영상이 영원히 처리 중에 머문다 Codex 1R P3

증상
영상 준비 완료 알림을 붙였더니, 특정 영상이 READY로도 FAILED로도 넘어가지 않고 계속 처리 중 상태로 남는다.
원인
키를 VIDEO:{videoId}:{원본 파일명}으로 잡았는데, 확정 업로드의 실제 파일명은 UUID 하나가 아니라 {pendingUuid}-{attemptUuid}.{확장자} 연결이라 77자다. 최대 103자가 되어 event_key VARCHAR(100)을 넘고, insert 실패가 같은 트랜잭션의 상태 전이까지 롤백시킨다. 알림 하나 실패가 영상 파이프라인을 망가뜨린 셈이다.
해결
파일명 꼬리 45자만 쓴다. 키가 최대 71자로 항상 한계 안이면서 attemptUuid 36자와 확장자는 온전히 남아 시도 정체성이 보존된다. 잘리는 앞부분은 videoId가 이미 식별한다. "마지막 하이픈 뒤"로 자르는 방식은 UUID 내부 하이픈에 걸려 쓰지 않았다.

B. 시간대와 정합

이 그룹은 한 뿌리에서 나왔다 PostgreSQL은 timestamptz 값을 timestamp 컬럼에 넣을 때 접속 세션의 타임존으로 벽시계를 환산한다. JDBC 세션은 JVM 기본 타임존을 따르므로, 같은 코드가 UTC 서버와 KST 서버에서 9시간 다른 값을 저장한다. 이 프로젝트는 과거 MSG-222에서 같은 함정을 한 번 밟은 적이 있다.

B1. 하루 상한 판정이 환경마다 9시간씩 어긋난다 실측

증상
뱃지 임박의 "하루 1건" 판정이 notifications.created_at을 읽는데, KST로 도는 배포에서만 경계가 밀린다.
원인
created_at이 DDL의 DEFAULT CURRENT_TIMESTAMP로 채워지고 있었다. 같은 테이블의 sent_atpublished_at은 이 함정을 알고 명시 UTC로 기록하고 있었는데 created_at만 빠져 있었다.
해결
insert 문에 created_at을 명시하고 statement_timestamp() AT TIME ZONE 'UTC'를 넣었다. 판정 대상 행이 전부 이 수정 이후에 쓰이므로 자기일관이고, created_at을 조건으로 읽는 다른 코드가 없어 부작용도 없었다.

B2. 이번엔 반대로, UTC로 바인딩하면 틀린다 실측

증상
주간 요약의 집계 창을 레포 관례대로 UTC로 바인딩하려다, 그러면 KST 배포에서 창이 9시간 어긋난다는 걸 발견했다.
원인
집계 재료인 두 컬럼은 UTC가 아니다. videos.created_at은 Hibernate @CreationTimestamp가 JVM 기본 존 벽시계로 쓰고, user_grids.first_collected_atnow()가 세션 타임존 캐스트를 거쳐 역시 JVM 기본 존으로 저장된다. B1이 UTC로 고친 것은 notifications.created_at 하나뿐이라 테이블마다 관례가 다르다.
해결
KST 주 시작을 절대 시각으로 잡은 뒤 ZoneId.systemDefault()로 환산해 바인딩한다. UTC로 도는 배포에서는 이 환산이 항등이다. 원본 컬럼의 쓰기 경로를 UTC로 통일하는 근본 수정은 기존 행이 어느 존으로 쓰였는지 행마다 달라 소급 해석이 불가능해서 범위 밖으로 뒀고, 남은 천장에 적었다.

B3. 연말에 같은 주가 두 번 발송될 뻔했다 실측

증상
주간 요약 키를 달력 연도로 만들면 연말 경계에서 같은 주가 두 개의 키로 쪼개진다.
원인
2026-12-28과 2027-01-01은 ISO 기준 같은 주인데 달력 연도가 다르다. 키가 갈리면 멱등이 성립하지 않아 한 주에 두 번 발송된다.
해결
IsoFields.WEEK_BASED_YEARWEEK_OF_WEEK_BASED_YEAR를 쓴다. 두 날짜 모두 2026-W53이 된다. 주차는 두 자리로 제로패딩한다.

C. 동시성과 잠금

C1. 조회로 확인하고 쓰면 상한이 뚫린다 리뷰 봇 P2

증상
뱃지 임박의 "하루 1건"을 조회 후 기록으로 구현했더니, 같은 사용자의 동시 업로드 둘이 둘 다 조회를 통과해 2건이 남을 수 있다.
원인
select-then-act다. 그리고 더 근본적으로, 이 기능은 제약이 두 축이다. "같은 뱃지 생애 1회"와 "하루 전체 1건"인데, notifications의 유니크 제약은 (user_id, event_key) 하나뿐이라 키 하나로 두 축을 동시에 표현할 수 없다.
해결
축을 나눴다. 생애 축은 BADGE_NEAR:{badgeId} 키가 DB 제약으로 강제하고, 하루 축은 사용자 단위 advisory 트랜잭션 잠금으로 직렬화한다.

C2. 그 잠금이 업로드를 통째로 abort시킬 수 있었다 Codex 1R P2

증상
C1의 해결로 넣은 블로킹 advisory 잠금이 데드락을 만든다.
원인
업로드 트랜잭션의 자원 획득 순서는 user_grids 삽입, 격자 수 판정, streaks 행 UPDATE, 스트릭 판정이다. tx1이 격자 수 임박으로 advisory 잠금을 선취한 뒤 streaks 행에서 대기하고, tx2가 streaks 행을 쥔 채 스트릭 임박에서 advisory 잠금을 기다리면 잠금 순서 사이클이 성립한다. PostgreSQL은 한쪽 업로드를 통째로 abort시킨다. 알림 하나 때문에 영상 업로드가 실패하는 셈이다.
해결
pg_advisory_xact_lockpg_try_advisory_xact_lock으로 바꿨다. 대기 자체가 없으니 사이클이 성립할 수 없고, 못 잡은 쪽은 그 시도의 임박 기록만 조용히 건너뛴다. 하루 1건이 목표라 동시 후보 중 하나만 남으면 되므로 손실이 정책과 맞는다.

C3. 알림 insert와 회원 탈퇴가 서로를 기다린다 Codex 2R P2

증상
영상 상태 전이에 알림을 배선하자, 회원 탈퇴와 동시에 일어날 때 한쪽이 abort된다.
원인
알림 insert는 FK 검사 때문에 users 행의 KEY SHARE를 기다린다. 회원 탈퇴는 users 배타 잠금을 쥔 채 CASCADE로 videos 행 삭제를 기다린다. 전이 트랜잭션이 videos 행 잠금을 쥔 채 알림을 쓰면 videos → users와 users → videos가 맞물린 사이클이 된다.
해결
잠금 순서를 한 방향으로 통일했다. 배선된 4개 메서드는 videos 행을 잠그기 전에 무잠금 선독으로 userId를 얻고(findUserIdById, user_id는 불변) users 행의 KEY SHARE를 선취한다 (findIdForKeyShare). 두 트랜잭션 모두 users 다음 videos가 되어 사이클이 성립하지 않는다. 행이 없으면 탈퇴 중이라는 뜻이므로 전이 자체를 스킵한다.

C4. 제외 목록은 새 값이 생기면 조용히 새는 구조다 Codex 1R P2

증상
임박 판정에서 수집률 축만 제외하는 blocklist를 썼는데, 기념 뱃지 축이 숫자 임박 쿼리로 흘러들어간다.
원인
blocklist는 기본값이 "포함"이다. 지금 당장 SPECIAL이 새는 것도 문제지만, 나중에 enum에 축이 추가돼도 아무 경고 없이 자동 포함된다.
해결
4축 EnumSet allowlist로 뒤집었다. 기본값이 "제외"가 되어 새 축은 명시적으로 넣어야만 들어온다.

D. 협업과 운영

D1. 병렬 레인 둘이 같은 에러 코드 대역을 잡았다 사고

증상
notification이 9400을, friend가 9400을 각각 확정하고 둘 다 develop에 머지되어 충돌했다.
원인
대역 배정에 정본이 없었다. 각 티켓이 자기 스펙에서 "비어 보이는 대역"을 골랐고, 서로의 스펙을 볼 이유가 없었다.
해결
notification을 10xxx로 옮기고(INVALID_PLATFORM 9400 → 10400), response-pattern.md에 대역 배정표를 정본으로 신설했다. 규칙도 같이 넣었다. 새 도메인은 대역을 쓰기 전에 표에 행을 추가하는 커밋을 먼저 넣는다. 선점을 코드가 아니라 문서 커밋으로 만들어 충돌을 머지 전에 드러나게 한 것이다.

D2. 마이그레이션 번호는 재확인해도 못 막는 창이 있다 사고

증상
V20__notifications.sql로 PR을 열었는데, 다른 레인이 V20을 먼저 머지해 충돌했다.
원인
"PR 직전에 develop 최신 번호를 재확인한다"는 규칙을 지켰는데도 발생했다. PR을 연 시점부터 머지되는 시점까지의 창은 재확인으로 덮을 수 없다.
해결
V21로 리네임하고 로컬 DB 이력을 정리했다. 이건 예방보다 탐지가 답이라는 결론이라, 머지 결과의 CI와 충돌을 최후 방어선으로 인정하고 스펙에 경고 문구로 남겼다. 이후 MSG-313의 V25와 MSG-315의 V26은 이 경고 덕분에 순차 처리로 계획해 충돌이 없었다.

D3. 브로커를 두 벌 띄울 메모리가 없다 실측

증상
postgres와 redis는 dev/prod 컨테이너를 두 벌 띄워 물리 격리했는데, Kafka는 같은 방식이 불가능했다.
원인
dev EC2가 t3.small 2GB이고 available 실측이 613Mi였다. 512MB 리밋 브로커 두 개는 물리적으로 안 들어간다.
해결
KRaft 단일 브로커를 공유하고 토픽 prefix와 컨슈머 그룹으로 논리 격리했다. 힙 256MB에 컨테이너 리밋 512MB, 잔여 약 100Mi다. 격리 수준이 낮아진 대신 근거를 명시했다. 브로커는 id를 전달하는 통로일 뿐이고 데이터 자체는 이미 물리 분리된 DB에 있어서, 교차 오염이 나도 컨슈머의 DB 조회는 자기 환경 데이터만 본다. OOM이 나면 t3.medium 승급이 다음 수순이다.

D4. 내 테스트가 남의 테스트를 깨뜨렸다 사고

증상
주간 요약 테스트를 추가한 뒤 Owner A의 GridRegionCodeBackfillTest 2건이 실패했다.
원인
테스트가 grid_y=39500 격자를 커밋해 남겼는데, 그 대역을 쓰는 다른 테스트가 UPDATE 행 수를 단언하고 있었다. 통합 테스트가 공유 로컬 DB를 쓰고, 격자 좌표 대역 배정표가 각 테스트 주석에 흩어져 있는 것이 근본 원인이다.
해결
대역을 39900으로 옮기고 tearDown에서 격자를 지웠다. 잔존 행 0건을 확인했다. 대역표를 한 곳에 모으는 일은 후속 판단으로 올렸다.

D5. 부분 테스트 실행이 조용히 일부를 건너뛴다 실측

증상
--tests '*Notification*'로 돌리면 초록불인데 실제로는 HotZoneEntryDetectorTest가 안 돌았다.
원인
Gradle의 --tests 패턴은 클래스명만 매칭한다. 알림 기능이지만 클래스 이름에 Notification이 없는 테스트는 잡히지 않는다.
해결
부분 실행 시 패턴을 병기하도록 리뷰 기록에 남겼다. 이후 리뷰는 gradle 캐시가 UP-TO-DATE로 건너뛰는 것도 초록불로 오인될 수 있어 cleanTest test를 강제한다.

D6. 리뷰 봇이 인라인 코멘트를 못 달던 이유 실측

증상
프런트엔드 레포는 PR 리뷰가 문제 라인에 직접 달리는데, 백엔드는 요약 코멘트 하나만 달렸다.
원인
프롬프트 문제가 아니라 도구 권한이었다. 워크플로의 --allowedToolsmcp__github_inline_comment__create_inline_comment가 없어 도구 자체가 차단돼 있었다. 봇이 "이 라인이 문제"라고 판단해도 남길 방법이 없었던 것이다.
해결
allowedTools에 인라인 도구와 Read/Grep/Glob를 명시하고, 인라인 코멘트 1건이 턴 1개를 쓰므로 --max-turns를 50에서 70으로 올렸다. 프롬프트에는 "라인이 특정되는 지적은 인라인, 요약에는 목록만"을 넣어 같은 설명이 두 번 나오지 않게 했다(MSG-316).

D7. 워크트리 레인을 옮기면 다른 레인의 쓰기가 막힌다 사고

증상
MSG-313과 MSG-314를 병렬 워크트리로 동시에 구현하다가, 두 번째 레인으로 옮기는 순간 첫 레인 에이전트의 파일 쓰기가 전부 거부됐다.
원인
서브에이전트의 쓰기 격리 범위가 스폰 시점에 고정되는 게 아니라 세션의 현재 워크트리 앵커를 실시간으로 따라간다.
해결
손실은 0파일이었고 완성된 설계를 반환받아 재스폰으로 복구했다. 이후 구현 쓰기는 한 번에 한 레인만 진행한다. 스펙 작성이나 리뷰처럼 읽기 위주 작업은 병렬로 둬도 무방하다.

3. 숫자로 본 결과

처리량 예산

주간 요약은 다른 알림과 성질이 다르다. 나머지는 사용자 행동을 따라 흩어져 발생하는데 이것만 한 시점에 전 사용자 몫이 쏟아진다. 그래서 발송 시각을 정하기 전에 파이프라인이 얼마나 소화하는지 계산했다.

구간설정처리량
릴레이 발행5초 고정 지연, 배치 100건분당 1,200건
컨슈머 소비파티션 1, 리스너 1, max.poll.records 10분당 약 300건
병목FCM 왕복 200ms 가정컨슈머 쪽

티켓별 검증

티켓내용교차 리뷰반영신규 테스트전체 테스트
MSG-178토큰 수집 API2R3건18건907
MSG-179발송 파이프라인5R8건29건970
MSG-180수신 설정 API1R0건11건기록 없음
MSG-181트리거 3종 연동2R1건15건1,093
MSG-313영상 준비 완료/실패3R2건12건1,117
MSG-314뱃지 임박2R2건11건1,117
MSG-315주간 요약1R0건10건1,127

MSG-180은 작업 로그에 전체 건수를 남기지 않았다. 1,117은 MSG-313과 MSG-314를 병합한 트리에서 잰 값이라 두 행에 같이 적었다. "교차 리뷰"는 Codex 라운드 수, "반영"은 그중 실제로 코드가 바뀐 지적 수다.

라운드 수가 줄어드는 게 신호다 MSG-179가 5라운드로 가장 길었고 그중 반드시 고쳐야 할 P1이 5건이었다. 이후 티켓들은 179가 만든 규약(멱등 키 관례, 게이트 경계, 시간대 바인딩 규칙, 사용자 단위 예외 격리)을 그대로 승계하면서 1~3라운드에 수렴했다. 마지막 MSG-315는 리뷰어 사전 경고 5건을 선반영한 덕에 교차 리뷰에서 신규 지적이 0건이었다.

커버리지는 전체 93.14%, 마지막 PR의 변경 파일은 100%다. notification 패키지는 30파일 1,603줄이다. 스키마 변경은 5건인데 번호가 이어지지 않는다. V21(notifications), V22(notification_opt_outs), V24(핫구역 진입 통지용 격자 역조회 인덱스), V25(VIDEO CHECK), V26(WEEKLY CHECK)이고 V23은 검색 도메인 티켓 것이다. 병렬 레인이 번호를 나눠 쓴 흔적이 그대로 남아 있다.

4. 남은 천장

알면서 남겨 둔 것들이다 MVP 규모에서 비용이 이득보다 커서 미룬 것이지, 못 본 것이 아니다. 각 항목에 승격 경로를 같이 적었다.
천장지금 괜찮은 이유넘어가면 할 일
중복 발송 가능 (at-least-once) FCM 호출과 markSent 사이에 죽으면 재전달분이 한 번 더 나간다(§1.4). 다중 디바이스 부분 실패 시에도 성공했던 토큰에 중복이 간다. 둘 다 창이 좁고 알림 한 건이 더 가는 정도의 피해다 발송 직전 SENDING 상태 + 멈춘 행 스윕. 다중 디바이스는 토큰별 상태 추적. 지금은 과설계
단일 브로커, 단일 파티션 메모리 예산 613Mi. 저처리량이라 순서 보장을 덤으로 얻는다 OOM이면 t3.medium 승급, 물량이면 파티션 증설과 컨슈머 병렬화
원본 컬럼 시간대 관례 불일치 UTC 배포에서는 환산이 항등이라 지금 배포에 영향이 없다 배포 시간대를 바꾸면 그 주 집계가 한 번 어긋난다. 근본 수정은 기존 행의 소급 해석 문제라 별도 티켓
단일 앱 인스턴스 전제 릴레이 폴러가 하나뿐이라 FOR UPDATE SKIP LOCKED가 없다 스케일아웃 시 SKIP LOCKED 추가 (코드에 ponytail: 주석으로 표시됨)
인덱스 없는 배치 스캔 streaks는 1인 1행이고, 주간 집계는 주 2회만 돈다 videos가 수백만 행이 되면 created_at 인덱스, 사용자 수십만이면 streaks 인덱스
대상 목록을 메모리에 한 번에 사용자 1만 명이면 수백 KB다 십만 단위부터 커서 페이징
일요일 19시 이후 5시간 사각지대 그 시간 활동은 어떤 주간 요약에도 안 잡힌다. 넛지는 보장이 아니라 기회다 도감과 스트릭 같은 실제 기록에는 정상 반영되므로 그대로 둔다

5. 용어

용어
outbox 알림을 외부로 바로 쏘지 않고 비즈니스 트랜잭션과 같은 커밋으로 DB 테이블에 먼저 적는 패턴. 커밋되면 알림이 반드시 남고, 롤백되면 알림도 없던 일이 된다
멱등 (idempotent) 같은 작업을 여러 번 해도 결과가 한 번 한 것과 같은 성질. 여기서는 (user_id, event_key) 유니크 제약과 ON CONFLICT DO NOTHING이 그 역할을 한다
at-least-once 메시지가 최소 한 번은 전달되지만 중복될 수 있는 보장 수준. 분산 환경에서 정확히 한 번은 현실적이지 않아 중복을 멱등으로 접는 쪽을 택했다
세션 타임존 캐스트 timestamptz 값을 timestamp 컬럼에 넣을 때 PostgreSQL이 접속 세션의 타임존으로 벽시계를 환산하는 동작. JDBC 세션이 JVM 기본 존을 따르므로 같은 코드가 서버마다 다른 값을 저장한다
advisory 잠금 테이블 행이 아니라 임의의 숫자 키를 잠그는 PostgreSQL 기능. try 변형은 이미 누가 쥐고 있으면 대기 대신 즉시 false를 반환해 잠금 순서 사이클이 생기지 않는다
select-then-act 조건을 조회로 확인한 뒤 별도 문장으로 쓰는 2단 패턴. 두 트랜잭션이 동시에 조회를 통과하면 둘 다 조건을 만족한다고 믿고 써 버려 상한이 뚫린다
exhaustive switch enum의 모든 값을 다뤘는지 컴파일러가 검사하는 switch 식. 카테고리를 추가하면 컴파일이 깨지므로 정책 결정을 빠뜨릴 수 없다
선두 차단 (head-of-line blocking) 한 줄로 처리되는 큐에서 앞의 항목이 오래 걸리면 뒤의 항목이 준비돼 있어도 기다리는 현상
KRaft Kafka가 ZooKeeper 없이 자체적으로 메타데이터를 관리하는 모드. 컨테이너 하나로 단일 노드 브로커를 띄울 수 있어 메모리 예산이 빠듯한 환경에 맞는다

스펙 문서: docs/spec/MSG-178.md(토큰) · docs/spec/MSG-179.md(파이프라인) · docs/spec/MSG-180.md(설정) · docs/spec/MSG-181.md(트리거) · docs/spec/MSG-313.md(영상) · docs/spec/MSG-314.md(뱃지 임박) · docs/spec/MSG-315.md(주간 요약)
요구사항 정본: docs/prd/MSG-178-prd.md · docs/prd/MSG-313-prd.md
핵심 코드: com.msg.fillmap.notification(30파일) · VideoStatusWriter · BadgeAwardServiceImpl · StreakRemindScheduler · WeeklySummaryScheduler
PR: #109(179) · #115(314) · #116(313) · #117(316) · #118(315)