알림¶
작성일: 2026-08-10 · 작성: prd-writer (MSG-359 역산) 상태: 역산 (사후 문서. 구현이 이미 끝난 기능의 요구사항을 스펙과 위키 결정에서 복원했다) 커버 티켓: MSG-178, MSG-179, MSG-180, MSG-181, MSG-313, MSG-314, MSG-315 관련 SRS: NOTI 영역 (FR-NOTI-01 ~ 13), BADGE 영역 일부 (FR-BADGE-10)
0. 이 문서의 자리¶
알림에는 이미 착수 시점 PRD가 두 개 있다. 파이프라인 뼈대를 세운
MSG-178-prd.md가 MSG-178부터 181까지를, 종류를 늘린
MSG-313-prd.md가 MSG-313부터 315까지를 담는다. 둘 다 구현 전에 쓰여
승인을 받은 문서라 요구사항 표와 다이어그램은 그쪽이 정본이다. 이 문서는 그것을 다시 쓰지
않는다.
이 문서가 맡는 것은 두 가지다. 첫째로 알림 영역 전체를 하나로 묶어 SRS의 NOTI 요구 13건이 어느 티켓에서 왔는지 보여준다. 둘째로 왜 그렇게 정했는지를 모은다. 검토했다가 버린 설계가 지금은 스펙의 결정 절에 흩어져 있고, 이 기능이 애초에 왜 이만한 기계 장치를 갖게 됐는지는 팀 위키의 멘토링 기록에만 남아 있다. 그 둘을 한자리에 옮겨, 새로 합류한 사람이 코드와 스펙을 역으로 읽어 추측하지 않아도 되게 하는 것이 목적이다.
읽는 순서를 굳이 적자면 이렇다. 지금 무엇이 돌고 있는지는 .claude/docs/status.md의
notification 절, 요구 문장은 docs/srs.md의 NOTI 절, 착수 시점 판단은 위 PRD 두 개,
구현 세부와 실측 근거는 각 스펙, 그리고 그 사이에서 실제로 부러졌던 지점은
docs/explainers/notification-system.html이다. 이 문서는 그 목록의 "왜" 칸이다.
1. 문제 상황¶
알림을 붙이기 전 FillMap에는 앱 밖의 사용자를 다시 부를 수단이 하나도 없었다. 서비스의 리텐션 루프는 스트릭, 핫구역, 뱃지 세 개인데 전부 앱을 연 사람에게만 보인다. 스트릭이 오늘 자정에 끊긴다는 사실도, 내가 채운 격자가 지금 인기라는 사실도, 앱을 열지 않으면 영원히 모른다.
스키마 쪽 사정도 있었다. V1에 push_tokens 테이블이 있었지만 이 테이블을 읽거나 쓰는 코드가
한 줄도 없었다. 팀 위키의 03-specs/Notification API 예정(2026-07-17)은 이 상태를 그대로
적어 두었다. 정의할 수 있는 API가 토큰 등록과 해제 둘뿐이고, 설정 토글도 이력도 읽음 처리도
"테이블이 없어 스펙을 못 쓴다"고 되어 있다. IA v2의 "설정 > 알림 설정" 화면도 P2 표시만 달린
채 백엔드가 비어 있었다.
여기까지는 흔한 미구현이다. 이 기능이 지금의 형태를 갖게 된 이유는 따로 있다.
2026-08-01 멘토 멘토링(위키 05-meetings)에서 이력서와 포트폴리오를 놓고 받은
판정이 갈림길이었다. 지도(PostGIS)와 영상 처리, AI 기능은 서비스 백엔드 지원 기준으로
변별력이 없다는 것이 결론이었고, 신규로 추천된 주제 두 개 중 하나가 "대규모 알림 서비스"였다.
추천 이유는 기능 자체가 아니라 그 안에 들어가는 요소였다. 배치 발송, 스로틀링, 중복 방지,
데이터 관리를 한 세트로 묶을 수 있다는 것이다. 후속으로 정리된 위키
06-research/백엔드 학습 로드맵의 트랙 2가 이 추천을 outbox[^1], 예약 발송 배치, 스로틀링,
멱등 키, 재시도와 DLQ로 분해했고, 같은 문서가 "FillMap에 알림 도메인이 아직 없다. 진행하려면
팀 합의와 티켓화가 선행되어야 한다"고 적었다.
이틀 뒤인 2026-08-03에 티켓 네 개가 열렸다. MSG-178-prd.md가 "단순 FCM 호출 붙이기가
아니다"라고 못 박고 학습 목적을 명시한 것이 그래서다. 알림이 다른 기능보다 유실 방지 장치가
촘촘한 이유는 사용자 수 때문이 아니라 이 경위 때문이고, 이 사실은 지금 레포 어디에도 남아
있지 않다.
2. 목적과 목표¶
- 목적: 도메인에서 일어난 사건을 사용자의 기기까지, 커밋된 것은 반드시, 같은 사건은 한 번만 전달한다.
- 목표: 착수 시점 목표는 두 PRD의 §2에 있다. 영역 전체로 다시 쓰면 세 줄이다.
- 커밋이 곧 알림 보장 시점이 된다. 그 뒤 무엇이 죽어도 기록은 남는다.
- 사용자가 알림 때문에 앱을 지우지 않는다. 카테고리별로 끌 수 있고, 종류별 상한이 있다.
- 알림 종류를 늘릴 때 파이프라인을 다시 짜지 않는다. 트리거만 얹으면 된다.
- 비목표: 알림 이력과 읽음 처리 API(FR-NOTI-12), 푸시 딥링크 데이터, 친구 활동 알림(FR-NOTI-13), FCM 외의 채널. 앞의 셋은 후속으로 열려 있고 마지막은 아래 5.1에서 다룬다.
3. 기능 요구사항¶
요구 문장의 정본은 docs/srs.md다. 여기서는 어느 요구가 어느 티켓에서 나왔고 그 근거가 이
문서 어디에 있는지만 잇는다.
| SRS ID | 다루는 것 | 티켓 | 근거 절 |
|---|---|---|---|
| FR-NOTI-01 | 기기 토큰 등록과 해제, 계정 전환 이관, 로그아웃 동시 정리 | MSG-178 | 5.5 |
| FR-NOTI-02 | 커밋된 사건의 알림이 유실되지 않음 | MSG-179 | 5.1 |
| FR-NOTI-03 | 같은 사건의 중복 발송 차단 | MSG-179 | 5.2 |
| FR-NOTI-04 | 재시도 상한과 DEAD 격리, 상태 집계 | MSG-179 | 5.1 |
| FR-NOTI-05 | 무효 토큰 즉시 삭제와 60일 미갱신 정리 | MSG-179 | 5.1 |
| FR-NOTI-06 | 카테고리 5종 수신 켜고 끄기, 기본 전부 켜짐 | MSG-180, 313, 315 | 5.3 |
| FR-NOTI-07 | 사용자와 카테고리별 발송 상한 | MSG-179, 313, 315 | 5.3 |
| FR-NOTI-08 | 뱃지 발급 알림 | MSG-181 | 5.4 |
| FR-NOTI-09 | 내 격자의 핫구역 신규 진입 알림 | MSG-181 | 5.4 |
| FR-NOTI-10 | 영상 처리 종결 알림(완료와 실패 모두) | MSG-313 | 5.4 |
| FR-NOTI-11 | 주간 활동 요약 | MSG-315 | 5.4 |
| FR-NOTI-12 | 이력과 읽음 API는 아직 없음 | 후속 | 2 |
| FR-NOTI-13 | 친구 활동 알림은 도입하지 않음 | 후속 | 5.4 |
| FR-BADGE-10 | 뱃지 획득 임박 알림 | MSG-314 | 5.2, 5.4 |
마지막 행은 NOTI가 아니라 BADGE 영역에 등재돼 있다. 판정 재료와 발생 시점이 뱃지 발급 엔진에 속하기 때문인데, 알림 카테고리로는 BADGE를 재사용하므로 이 문서의 범위에도 들어온다.
4. 비기능 요구사항¶
전 서비스 공통 항목(멱등, 게이트, 환경 분리)은 SRS의 NFR-DATA-03과 NFR-OPS-06이 담고 있다. 알림 묶음에만 해당하는 것은 넷이다.
| 분류 | 요구사항 | 출처 |
|---|---|---|
| 전달 보장 | at-least-once[^2]와 멱등 dedupe. exactly-once는 추구하지 않고 문서로 남긴다 | MSG-178-prd §4 |
| 인프라 예산 | dev EC2(t3.small)의 가용 메모리 실측이 613Mi다. 브로커는 힙 256MB, 컨테이너 상한 512MB 안에서 돈다 | MSG-179 D1, D2 |
| 실시간성 | 연성 실시간이다. 수 분 지연을 허용하고, 시각 정확성보다 발송 보장을 앞에 둔다 | MSG-178-prd §4 |
| 운영 가시성 | 대기, 발송, 스킵, DEAD가 집계 쿼리 한 문장으로 나온다 | MSG-179 성공 기준 6 |
5. 결정과 기각된 대안¶
5.1 유실을 막는 구조¶
바로 쏘지 않고 DB에 먼저 적는다. 가장 단순한 구현은 업로드가 끝나는 자리에서 FCM을
호출하는 것이다. 이걸 쓰지 않은 이유는 이중 쓰기 문제[^3] 하나다. DB 커밋과 외부 호출은
한 트랜잭션으로 묶이지 않아서, 커밋 뒤 호출 직전에 죽으면 알림이 사라지고 호출 뒤 롤백되면
있지도 않은 사건의 알림이 나간다. 그래서 알림 요청을 비즈니스 트랜잭션과 같은 커밋으로
notifications 테이블에 적고, 발송은 별도 릴레이가 맡는다. 진입점인 record()가 전파
REQUIRED라 호출자의 트랜잭션에 그대로 참여한다.
Kafka는 필요해서가 아니라 배우려고 넣었고, 그 사실을 그대로 적어 두었다. 발송량 자체는 DB 폴링과 직접 발송으로 충분한 규모다. 2026-08-03 결정은 단일 브로커 운영 부담과 구현 복잡도 증가를 인지하고 수용한다는 조건부 승인이었다. 함께 확정된 것이 Kafka를 넣어도 outbox는 뺄 수 없다는 판단인데, Kafka가 해결하는 것은 전달이지 이중 쓰기가 아니기 때문이다.
dev와 prod가 브로커 하나를 공유한다. 이 레포의 다른 인프라는 DB도 Redis도 컨테이너를 두 벌씩 띄워 물리 격리한다. 알림 브로커만 예외인 이유는 메모리다. 512MB 리밋 두 벌을 얹을 자리가 없다. 대신 토픽 접두사와 컨슈머 그룹으로 논리 격리하고, 브로커에 흐르는 것이 outbox의 행 번호 하나뿐이라는 점을 근거로 삼았다. 교차 오염이 나도 컨슈머가 DB에서 자기 환경 데이터만 읽는다. 승격 조건도 함께 적혔다. OOM이 나면 인스턴스를 t3.medium으로 올리고 브로커 분리를 다시 본다.
죽은 편지함은 토픽이 아니라 DB 행이다. 재시도 상한을 소진한 건은 별도 DLT 토픽으로 보내는 것이 Kafka 관례지만, outbox 행 자체에 DEAD 상태를 남기는 쪽을 골랐다. 이미 모든 건이 DB에 행을 갖고 있어서 토픽과 컨슈머를 하나 더 늘릴 이유가 없고, 운영자가 보는 화면이 상태 집계 쿼리 하나로 통일된다.
토큰 정리는 두 갈래로 나뉜다. FCM이 무효라고 답한 토큰은 즉시 지운다. 여기에 60일 무갱신 토큰의 일 배치가 더해졌는데, 이건 MSG-178의 교차 리뷰에서 나온 프라이버시 지적의 잔여 보험이다 (5.5 참조). 60일이라는 값에는 근거 셋이 붙어 있다. Firebase 공식 가이드의 스테일 기준이 2개월이고, 클라이언트 규약이 로그인 직후 등록이라 60일 무갱신은 60일간 앱을 한 번도 안 연 기기를 뜻하며, FCM 자체 무효화(270일)보다 훨씬 이르게 치워야 발송 루프가 가벼워진다.
FCM과 SSE 중 무엇을 쓸지는 정식으로 비교된 적이 없다. 위키가 이 질문을 열린 채로 남겼고,
MSG-178-prd.md는 "티켓 4종이 FCM 전제로 확정돼 있어 FCM으로 간주"한다고 적었다. 앱을 닫은
사용자를 부르는 것이 목적이라 결과적으로는 맞는 선택이지만, 판정 기록이 아니라 전제 승계라는
점은 남겨 둔다. 같은 이유로 SMS와 이메일도 검토 대상에 오르지 않았다. 다만 발송기는 인터페이스
뒤에 두어 채널 추가와 제3자 교체의 자리를 비워 두었다.
5.2 중복을 막는 키¶
키는 사건의 자연 식별자로 짓는다. notifications의 (user_id, event_key) 유니크 제약과
ON CONFLICT DO NOTHING이 중복 기록을 한 건으로 접는다. 그래서 키를 어떻게 짓느냐가 곧
"같은 사건이란 무엇인가"의 정의가 된다. 다섯 종류가 모두 다른 답을 갖는다.
| 종류 | 키 | 이 키가 뜻하는 것 |
|---|---|---|
| 뱃지 획득 | BADGE:{badgeId} |
뱃지는 생애 1회라 날짜가 필요 없다 |
| 뱃지 임박 | BADGE_NEAR:{badgeId} |
임박 구간을 오가도 뱃지당 한 번만 |
| 핫구역 | HOTZONE:{KST일자}:{gridId} |
같은 날 이탈 후 재진입은 같은 사건, 다음 날은 새 사건 |
| 스트릭 리마인드 | REMIND:{KST일자} |
하루가 사건 단위. 재실행이 자동으로 멱등해진다 |
| 영상 처리 | VIDEO:{videoId}:{원본 키 꼬리} |
시도 하나가 사건 단위. 교체 업로드는 자연히 다른 키가 된다 |
| 주간 요약 | WEEKLY:{ISO 주차} |
한 주가 사건 단위 |
이 설계가 낳은 부수 효과 하나가 재시도 코드의 소멸이다. 스트릭 리마인드는 20시부터 23시까지 매시 발화하는데, 별도의 재시도 로직이 없다. 이미 기록된 대상은 같은 키라 조용히 흡수되므로 재발화가 곧 당일 재시도가 된다. 주간 요약의 19시와 19시 30분 두 번 발화도 같은 방식이다.
연말 경계에 달력 연도를 쓰지 않는 이유도 여기 있다. 2026-12-28과 2027-01-01은 같은 주인데 달력 연도로 키를 지으면 두 키로 갈라져 한 주에 두 번 발송된다. ISO 주 기반 연도가 이걸 흡수한다.
키 하나로 두 축을 동시에 잡을 수 없다는 것이 뱃지 임박에서 드러났다. 임박 알림에는 제약이 둘 붙는다. 같은 뱃지는 생애 1회, 하루 전체로는 1건. 유니크 제약은 (user_id, event_key) 하나뿐이라 키에 날짜를 넣으면 하루 상한은 원자적으로 잡히지만 어느 뱃지였는지가 사라져 생애 1회를 못 걸고, 뱃지 번호를 넣으면 반대가 된다. 결론은 축을 나눠 다른 도구에 맡기는 것이었다. 생애 축은 키가, 하루 축은 사용자 단위 advisory 잠금[^4]의 비대기 획득이 지킨다. 잠금을 블로킹으로 잡지 않은 이유는 데드락이다. 업로드 트랜잭션이 이미 user_grids와 streaks 행을 순서대로 쥐고 있어서, 여기서 대기하면 잠금 순서 사이클이 생긴다.
중복이 아예 없다고는 말하지 않는다. FCM 호출과 상태 기록 사이에서 죽으면 재전달분이 한 번 더 나가고, 다중 기기 부분 실패의 재시도도 성공했던 기기에 중복을 만든다. 토큰별 상태 추적으로 막을 수 있지만 다중 기기 자체가 소수라 과설계로 판단했다. at-least-once를 택한 이상 이건 버그가 아니라 선언된 성질이다.
5.3 사용자를 지치지 않게 하는 것¶
수신 거부는 행이 없으면 허용이다. "기본은 전부 켜짐"을 표현하는 방법 셋을 놓고 골랐다.
- 채택: 거부한 카테고리만 행으로 남긴다. (user_id, category) 행이 있으면 그 카테고리가 꺼진 것이다. 기본값이 스키마에 내장돼 시딩도 백필도 필요 없고, 카테고리가 늘어도 기존 사용자는 마이그레이션 없이 자동으로 켜짐이 된다. 판정은 기본 키 단건 조회 한 문장이다.
- 기각: 가입할 때 카테고리 수만큼 행을 만든다. 가입 훅과 기존 사용자 백필이라는 코드 경로 둘이 생기고, PRD 비기능의 "기존 테이블 소급 없음"과 반대 방향이다.
- 기각: 켜짐과 꺼짐을 불리언 값으로 저장한다. "명시적 켜짐"을 표현할 수 있지만 지금 요구사항에서 그 상태가 쓰이는 자리가 없다.
이 선택은 레포 관례와도 맞는다. 격자도 스트릭도 토큰도 전부 "행이 없는 것이 초기 상태"다.
테이블 이름을 notification_opt_outs로 지은 것도 같은 맥락인데, _preferences라고 부르면
행을 설정값으로 오독하게 된다. 행은 거부 표식이다.
값으로 치른 대가도 하나 있다. 옵트아웃[^5] 모델이라 새 카테고리는 별도 동의 없이 켜진 채로 추가된다. 카테고리를 늘릴 때마다 기존 사용자 전원이 그 알림을 받기 시작한다는 뜻이라, 종류를 늘리는 판단이 곧 사용자 피로에 대한 판단이 된다.
상한은 종류마다 다르고, 그 차이에 이유가 있다.
| 카테고리 | 상한 | 이유 |
|---|---|---|
| 뱃지 획득 | 없음 | 사용자 행동에 직결되고 발생 자체가 희소하다 |
| 뱃지 임박 | 하루 1건 | 축이 다섯이라 한 업로드에서 여러 축이 동시에 임박할 수 있다 |
| 핫구역 | 하루 1건 | 격자를 여러 개 채운 사용자는 하루에도 여러 번 진입 대상이 된다 |
| 스트릭 리마인드 | 하루 1건 | 하루가 사건 단위라 그 이상은 의미가 없다 |
| 영상 처리 | 없음 | 자기가 올린 것의 결과 통지라 도배 벡터가 없다 |
| 주간 요약 | 없음 | 트리거가 주당 1건 이상 만들 수 없어 발송 단계에서 또 셀 이유가 없다 |
일 경계는 KST 자정으로 통일했다. 스트릭 규칙과 같은 기준이고, 사용자별 시간대는 두지 않는다. MVP 대상이 한국이라 users에 시간대 컬럼 자체가 없다.
상한을 어디에서 거느냐는 카테고리 재사용의 대가로 갈렸다. 발송 단계의 상한 판정은 (사용자, 카테고리) 단위다. 뱃지 임박이 BADGE 카테고리를 재사용하기로 하면서(5.4) 획득의 무제한과 임박의 하루 1건을 발송 쪽에서 구분할 수 없게 됐고, 그래서 임박만 상한이 트리거 쪽으로 내려갔다. 설계 하나가 다른 설계의 자리를 옮긴 사례라 남겨 둔다.
멘토가 권한 시차 발송은 들어가지 않았다. 위키 로드맵 트랙 2의 실습 후보 네 개 중 "핫구역 진입 알림을 N분할해 시차 발송하고 몰림과 분산을 비교"는 티켓이 되지 않았다. 물량이 한 시점에 몰리는 유일한 경로인 주간 요약을 두고 MSG-315가 처리량 예산을 계산해 분할을 넣지 않기로 했지만, 그 판단이 로드맵 후보를 기각한 것이라는 서술은 어느 문서에도 없다. 두 사실을 병기만 해 둔다.
5.4 무엇을 언제 알릴 것인가¶
처음 세 종류는 "이미 구현된 사건"으로 정해졌다. 2026-08-03 시점에 트리거로 쓸 수 있는 사건은 뱃지 발급, 핫구역 진입, 스트릭 리마인드였다. 친구 활동이 후보에서 빠진 이유는 취향이 아니라 friend 도메인이 그때 없었기 때문이다. 도메인은 그 뒤 완성됐지만 알림은 여전히 열려 있고, SRS FR-NOTI-13이 "도입하지 않는다"로 기록해 두었다.
두 번째 확장 세 종류는 리텐션 구멍을 보고 골랐다. 업로드 UX가 "올리면 끝, 확인은 나중에"로 정리됐는데 그 "나중"이 왔음을 알릴 수단이 없었고, 뱃지는 받은 뒤에만 알림이 갔으며, 활동이 쌓여도 주 단위로 되짚어 주는 장치가 없었다. 셋에 각각 영상 처리 결과, 뱃지 임박, 주간 요약이 붙었다.
뱃지 임박에 새 카테고리를 만들지 않았다. 설정 화면에서 획득과 임박이 한 토글로 묶인다는 뜻이다. 두 알림이 같은 관심사(뱃지)를 다루므로 사용자가 따로 끄고 싶어 할 이유가 약하다는 판단이었고, 카테고리를 늘리면 두 테이블의 CHECK 제약을 재정의하는 마이그레이션이 따라붙는 비용도 있었다. 대가는 위 5.3에서 적은 상한 위치 이동이다.
임박 판정의 대상 축은 목록을 뒤집어 정했다. 초안은 "수집률만 건너뛴다"는 제외 목록이었는데, 그러면 개수가 아닌 축이 숫자 비교 쿼리로 흘러 들어가고 나중에 축이 추가될 때 기본값이 "포함"이 된다. 포함 목록으로 뒤집어 업로드 수, 격자 수, 스트릭 일수, 미션 수 넷만 남겼다. 수집률이 빠진 이유는 소수 비율이라 "정확히 1 남음"이 성립하기 어렵고, 성립해도 1퍼센트포인트 남았다는 뜻이 되어 문구와 어긋나기 때문이다.
핫구역 진입은 이벤트가 없어서 주기 검출로 갔다. 핫스코어는 조회 시점 계산이라 "진입한 순간"이라는 사건 자체가 존재하지 않는다. 업로드 시점에 판정하는 안은 근거 셋으로 기각됐다. 점수 반영이 커밋 후 비동기라 판정이 항상 한 건 뒤처지고, 다른 격자의 점수 만료로 밀려 들어오는 진입은 업로드 훅으로 영원히 잡히지 않으며, 업로드 경로에 캐시 왕복이 하나 늘어난다. 채택된 것은 10분마다 현재 핫구역 집합을 직전 스냅숏과 비교하는 방식이다.
그 스냅숏을 어디에 둘지도 셋을 비교했다. 메모리 필드는 배포할 때마다 비어서 계속 뜨거운 격자를 진입으로 오판한다. 알림 테이블을 역산하는 방법은 "어제 기록이 있으면 어제도 뜨거웠다"가 참이 아니라서 안 된다. 계속 뜨거운 격자는 중복 방지 때문에 둘째 날부터 기록이 없고, 그러면 셋째 날에 진입으로 오판된다. 남은 것이 Redis 집합이고, 재기동을 견디면서 알림 도메인 자기 이름공간을 쓴다는 점이 채택 근거였다.
순서 하나가 명시돼 있다. 스냅숏 갱신은 알림 기록 뒤에 한다. 사이에서 죽으면 다음 사이클이 다시 검출하고 키가 흡수하지만, 순서가 반대면 유실이 된다.
스트릭 리마인드 대상은 조건 하나로 끝난다. 마지막 기록일이 KST 어제인 사용자다. 오늘이면 이미 올렸고, 그제 이전이면 이미 끊겨서 오늘 올려도 1부터 다시 시작한다. 지킬 스트릭이 있는 마지막 날이 정확히 어제 기록자다. "다시 시작해 보세요" 류의 재획득 넛지는 별개 기획이라 범위 밖으로 두었다.
발송 시각을 20시로 잡은 것도 기록해 둔다. 끊김 경계까지 네 시간이 남아 받고 행동할 여유가 있고, 저녁 여가 시간이라 확인 기대가 높으며, 새벽 4시 토큰 정리 배치와 겹치지 않는다. 23시가 마지막인 것은 자정 직전 발화가 행동할 여유를 주지 못해서다.
주간 요약의 집계 창은 "지금 끝나가는 주"다. 직전에 완료된 월요일부터 일요일까지로 잡을 수도 있었지만, 일요일 저녁에 보내면 일주일 묵은 요약이 된다. 대신 일요일 19시 이후의 활동은 그 주 요약에도 다음 주 요약에도 들어가지 않는 다섯 시간 사각지대가 생긴다. 넛지는 보장이 아니라 기회라서 이 손실을 받아들였고, 도감과 스트릭 같은 실제 기록에는 당연히 반영된다.
활동이 없는 사용자에게는 보내지 않는다. 빈 요약은 리텐션이 아니라 소음이라는 것이 근거다. 다만 새 격자가 0이고 영상만 올린 주는 흔해서(재방문 업로드) 그 경우의 문구를 따로 두었다. "새 격자 0개"라고 말하는 요약은 넛지를 깎는다.
영상 처리 실패도 알린다. 2026-08-05에 확정된 판단이다. 실패 화면도 사유 노출 API도 없는 상태라 문구가 안내를 전담하게 되는데, 재업로드를 유도하되 탈락 사유 자체는 싣지 않기로 했다. 사유를 보여주는 것은 별도 티켓의 주제이고, 여기서는 실패 사실만 전한다.
문구는 서버가 정하고 서버가 유지한다. 다섯 종류(뱃지 획득, 뱃지 임박, 핫구역 진입, 스트릭 리마인드, 영상 처리 완료)의 제목과 본문을 백엔드가 쓰고 트리거 코드에 둔다. 디자인이나 기획이 문구를 따로 승인하는 절차는 없다 (2026-08-10 성민 확인).
여기 딸려 오는 제약이 있다. 문구를 바꾸려면 배포해야 한다. 오타 한 글자를 고치는 데도 배포 주기를 타야 하고, 클라이언트가 문구를 다듬을 방법은 없다. 종류가 다섯인 지금 규모에서는 감당할 만한 거래지만, 알림 종류가 늘어나면 다시 볼 지점이다.
다른 길이 있다는 것만 적어 둔다. 완성된 문구 대신 사건 종류와 값(뱃지 이름, 격자 이름, 남은 일수)만 보내고 클라이언트가 문장을 조립하면 문구 변경이 배포에서 풀린다. 지금 구조와 다르므로 채택이 아니라 대안이다.
5.5 도중에 번복된 결정¶
역산 문서라 결론만 적기 쉬운데, 이 묶음에는 착수 후에 뒤집힌 판단이 셋 있다. 뒤집힌 이유가 곧 요구사항의 근거라 남긴다.
로그아웃과 토큰 해제를 합쳤다. 초안은 두 API를 분리해 두고, 잔여 토큰은 발송 파이프라인이 FCM 무효 응답으로 알아서 치운다는 계획이었다. 이 논리는 교차 리뷰에서 깨졌다. FCM 토큰은 앱 로그아웃으로 무효화되지 않아 무효 응답이 영영 오지 않고, 별도 해제 API는 로그아웃 뒤에 부르면 인증이 없어 막힌다. 공유 기기에서 이전 사용자의 알림이 무한히 남는 구멍이라, 로그아웃 요청에 선택 필드를 두어 세션 삭제와 같은 처리에서 토큰을 지우도록 바꿨다. 60일 배치(5.1)가 이 경로의 잔여 보험이다. 프라이버시 요구가 API 형상을 바꾼 사례다.
토큰 해제에 소유 검증을 넣었다. 초안은 검증 없이 토큰만으로 지우려 했다. 근거는 "잔류 행을 현재 사용자가 못 지우게 된다"였는데, 로그인 직후 등록이라는 클라이언트 규약 아래에서는 그 상황이 성립하지 않는다. 검증을 넣지 않으면 반대로 계정 전환 직후 늦게 도착한 이전 사용자의 삭제 요청이 새 사용자의 행을 지운다. 불일치는 0행 삭제라 멱등도 그대로 유지된다.
부분 실패를 회복할 주체가 없던 배치에 재발화를 넣었다. 스트릭 리마인드 초안은 20시 단발 실행이었다. 한 사용자의 기록이 실패하거나 그 시각에 장애가 나면 그날의 시간 민감 알림은 영구 유실된다. 20시부터 23시까지 매시 발화로 바꾸면서 별도 재시도 코드 없이 회복 경로가 생겼고, 루프 안 예외 격리로 한 명의 실패가 나머지를 막지 않게 했다. 주간 요약의 두 번 발화도 이 방식을 그대로 따랐다.
6. 알려진 한계와 승격 경로¶
MVP 규모에서 비용이 이득보다 커서 남겨 둔 것들이다. 상세와 실측 근거는
docs/explainers/notification-system.html 4장에 있고, 여기서는 제품 관점에서 기대 수준에
영향을 주는 것만 옮긴다.
| 한계 | 지금 괜찮은 이유 | 넘어가면 |
|---|---|---|
| 중복 발송 가능 | 창이 좁고 피해가 알림 한 건 더 가는 정도다 | 발송 직전 상태 하나를 더 두고 멈춘 행을 쓸어낸다 |
| 브로커와 파티션이 하나씩 | 메모리 예산 613Mi. 저처리량이라 순서 보장을 덤으로 얻는다 | 인스턴스 승격, 파티션 증설, 컨슈머 병렬화 순서 |
| 주간 요약이 다른 알림을 밀어냄 | 파티션이 하나라 선두 차단[^6]이 있다. 19시 발송이라 20시 리마인드까지 한 시간 예산이 남는다 | 발송 시각을 앞당기는 것이 가장 싸다 |
| 일요일 19시 이후 다섯 시간 | 그 시간 활동은 어떤 주간 요약에도 안 잡힌다 | 실제 기록에는 반영되므로 그대로 둔다 |
| 앱 인스턴스가 하나라는 전제 | 릴레이 폴러가 하나뿐이다 | 스케일아웃 시 잠금 건너뛰기 추가(코드에 표시돼 있다) |
7. 전제가 달라진 결정 하나¶
핫구역 알림 문구는 행정동 이름만 쓴다. 2026-08-05 당시 근거는 "구역 표시명은 클라이언트가 계산하는 것이 정본이라 서버가 그 문자열을 만들 수 없다"였다. 이 전제는 그 뒤 바뀌었다. MSG-341(2026-08-07)이 표시명 계산을 서버로 옮겼고 MSG-349(2026-08-10)가 지도 응답에 행정동 이름을 동봉했다. 지금은 서버가 "서면 A-14" 같은 문자열을 만들 수 있다.
문구를 바꿔야 한다는 결정은 어디에도 없고, 바꾸지 않기로 한 결정도 없다. 전제가 사라진 채로 결과만 남아 있는 상태이므로 사실만 기록한다.
8. 미확인¶
스펙에도 위키에도 근거를 찾지 못한 것들이다. 추정으로 채우지 않는다.
- 카테고리 다섯이 최종 개수인지. 3종에서 5종으로 늘어난 경위는 남아 있지만, 더 늘리지 않기로 한 판단이나 개수 상한에 대한 서술은 없다.
- 친구 활동 알림의 재개 조건. friend 도메인이 완성된 뒤에도 "도입하지 않는다"로만 남아 있고, 무엇이 갖춰지면 다시 볼지가 적혀 있지 않다.
- 발송 성공률의 목표치. 상태 집계로 측정 가능하게 만들었지만 "얼마 이상이어야 한다"는 목표는 어느 문서에도 없다.
9. SRS 갱신 후보¶
SRS에 없어 요구사항 표에 넣지 않은 것들이다. 등재 여부는 srs-writer 판단에 맡긴다.
- 알림 브로커의 환경 격리 수준. NFR-OPS-01이 "local, dev, prod 3환경을 DB까지 분리한다"고 선언하는데, 알림 브로커는 물리 공유에 토픽과 컨슈머 그룹 논리 격리다. 메모리 예산이 근거인 의도된 예외인데 SRS에서는 읽히지 않는다.
- 채널이 FCM 푸시 하나라는 사실. FR-NOTI-13이 친구 활동 미도입을 기록하듯, SMS와 이메일 채널을 두지 않는다는 범위 결정도 기록될 자리가 있어 보인다. 발송기가 인터페이스 뒤에 있어 확장 지점은 열려 있다.
- 뱃지 임박의 하루 1건 상한이 NOTI 쪽에서는 안 보인다. FR-NOTI-07의 상한 목록은 획득을 무제한으로만 적고 임박을 다루지 않는다. 상한 자체는 FR-BADGE-10에 있으니 누락은 아니지만, NOTI만 읽으면 BADGE 카테고리 전체가 무제한으로 읽힌다.
[^1]: outbox: 알림을 외부로 바로 쏘지 않고 비즈니스 트랜잭션과 같은 커밋으로 DB 테이블에 먼저 적어 두는 패턴. 커밋되면 알림이 반드시 남고 롤백되면 알림도 없던 일이 된다. 발송은 그 테이블을 읽는 별도 프로세스가 맡는다. [^2]: at-least-once: 메시지가 최소 한 번은 전달되지만 재시도 과정에서 중복될 수 있는 보장 수준. 분산 환경에서 정확히 한 번은 현실적이지 않아, 유실을 막고 중복은 수신 측 멱등으로 접는 쪽을 택한다. [^3]: 이중 쓰기 문제(dual write): DB 커밋과 외부 시스템 호출처럼 하나로 묶을 수 없는 두 번의 쓰기 사이에서 프로세스가 죽으면 두 시스템의 상태가 어긋나는 문제. outbox가 존재하는 이유다. [^4]: advisory 잠금: 테이블 행이 아니라 임의의 숫자 키를 잠그는 PostgreSQL 기능. 비대기(try) 변형은 다른 트랜잭션이 같은 키를 쥐고 있으면 기다리지 않고 즉시 실패를 돌려주므로, 잠금 순서가 꼬여 생기는 교착이 없다. [^5]: 옵트아웃(opt-out): 기본이 켜져 있고 사용자가 원할 때 끄는 모델. 반대는 옵트인이다. 새 카테고리가 별도 동의 없이 켜진 채 추가된다는 뜻이라, 종류를 늘리는 판단이 곧 피로도 판단이 된다. [^6]: 선두 차단(head-of-line blocking): 한 줄로 처리되는 큐에서 앞의 항목이 오래 걸리면 뒤의 항목이 준비돼 있어도 기다리게 되는 현상. 알림 토픽이 파티션 하나라 주간 요약 물량이 뒤따르는 다른 알림의 발송 시각을 밀어낸다.