알림 시스템의 어려움은 처리량이 아니라 경계다
결제 서비스의 알림 시스템을 설계했다. 이 글은 대규모 시스템 설계 기초 10장 알림 시스템 설계를 읽고, 책이 한 문장으로 넘긴 "알림이 사라지면 안 된다"는 요구를 판정 가능한 형태로 만들어 본 기록이다.
제목의 경계는 서비스 경계가 아니라 보장 경계다. 어디까지를 코드로 보장하고 어디부터는 다른 설계로 대신할지를 확인하는걸 말한다. 처리량이 문제가 아니라는 뜻은 아니다. 처리량을 해결하고 나서도 보장 문제는 그대로 남는다는 뜻이다.
1. 아무것도 고장나지 않았는데 62분이 걸린다
책의 개략 구조는 채널별로 큐를 하나씩 두고, 먼저 들어온 것이 먼저 나간다. 그러면 마케팅 캠페인과 결제 승인 알림이 같은 푸시 큐에 들어간다.
이 글에 나오는 숫자는 모두 아래 가정에서 나온다. 전부 추정치이고, 실제로는 실측해야 하는 값이다.
| 가정 | 값 | 용도 |
| 거래 통지 | 900만 요청/일 | 평시 트래픽 |
| 이상거래 경고 | 2만 요청/일 | 멀티채널 동시 발송의 비용 판단 |
| 캠페인 수신자 | 500만 명 | 폭주 시나리오 |
| 사용자당 단말 수 | 평균 1.5대 | 요청을 전송으로 환산 |
| 거래 통지 지연 목표 | 99번째 백분위 5초 | 지연 SLO |
| 제3자 API 평균 응답 | 100ms | 푸시 게이트웨이 왕복 |
| 워커 1대가 유지하는 동시 요청 | 50 | 비동기 HTTP 클라이언트 컨테이너 1개 기준 |
| 워커 1대 처리량 | 500 전송/초 | 50 / 0.1초 |
| 푸시 워커 풀 | 4대, 2,000 전송/초 | 워커 1대 처리량 × 4 |
여기에 상황을 하나 넣는다. 마케팅 캠페인 수신자 500만 명에게 발송을 걸고, 그 직후에 결제 승인 알림 1건이 발생한다.
계산은 요청이 아니라 전송 기준으로 한다. 한 사용자가 단말을 평균 1.5대 가지므로 푸시 요청 1건이 제3자 호출 1.5건이 된다. 요청 기준으로 세면 실제 대기를 과소평가한다. 두 단위를 섞지 않도록 환산값을 먼저 적어 둔다.
| 개념 | 값 |
| 캠페인 푸시 전송량 | 750만 전송 (500만 × 1.5) |
| 거래 알림 요청 유입 | 약 104 요청/초 (900만 / 86,400) |
| 거래 푸시 전송 유입 | 약 156 전송/초 (104 × 1.5) |
결제 알림 앞에 쌓인 적체 7,500,000 전송
푸시 워커 풀 소화율 2,000 전송/초
FIFO 대기 7,500,000 / 2,000 = 3,750초 = 62분 30초
먼저 들어온 것이 먼저 나가므로, 이 결제 알림보다 뒤에 도착하는 거래 통지 156 전송/초는 이 알림의 대기시간에 영향을 주지 않는다. 그 알림 앞에 있는 것은 캠페인 750만 전송뿐이다. 유입을 빼는 계산(2,000 − 156 = 1,844)은 적체 전체가 언제 해소되는지를 물을 때 쓰는 식이지, 특정 알림 하나의 대기시간을 구하는 식이 아니다.
결제 승인 알림 하나가 62분을 기다린다. 목표는 99번째 백분위 5초이므로 750배가 넘는다.
이 62분은 워커가 죽었거나 사업자가 느려서 나온 값이 아니다. 모든 컴포넌트가 정상 동작하는 상태에서 처리 순서만으로 발생한다. 그리고 단순 증설만으로 풀기 어렵다. 5초 안에 비우려면 순간 150만 전송/초가 필요하다. 워커로 환산하면 약 3,000대이고, 동시에 열리는 외부 요청은 약 15만 건이다. 이 정도 폭주를 애플리케이션 서버 증설만으로 흡수할 수 있다고 가정할 수 없고, 제3자 사업자의 할당량과 조절 정책도 함께 제약이 된다.
이 설계에서는 자원 격리를 택했다. 큐를 등급별로 나누는 데서 그치지 않고 거래 통지용 처리 용량까지 분리해, 마케팅 트래픽이 거래 통지의 지연 예산을 침범하지 못하게 한다. 우선순위 큐, 가중 스케줄링, 예약 용량 같은 다른 선택지도 있지만 어느 쪽이든 답의 방향은 비교적 분명하다.
그래서 이 글은 그 문제를 푸는 글이 아니다. 이 대기시간을 계산하다가 더 앞에 있는 질문을 만났다. 워커를 분리하고 지연 목표를 지켜도, 알림이 사용자에게 닿았는지는 여전히 모른다는 것이다. 지연을 5초로 줄이는 일과 유실을 없애는 일은 다른 문제이고, 후자는 우리가 통제하지 못하는 구간을 지나간다.
2. "알림이 유실되면 안 된다"는 판정 불가능한 문장이다
책이 신뢰성 요구로 제시한 것은 "알림이 지연되거나 순서가 틀려도 되지만 사라지면 안 된다"이다. 이 요구 자체는 명확해 보인다. 문제는 검증이다.
알림 시스템은 애플 푸시, 구글 푸시, 문자 사업자, 이메일 사업자 위에 얹혀 있다. 넷 다 우리 코드가 아니다. 우리 서버에서 사업자로 가는 호출은 우리가 재시도할 수 있지만, 사업자가 단말로 보내는 최종 전달은 재시도 일정도 전달 정책도 우리가 정하지 못한다. 그리고 넷 중 어느 것도 "이 알림이 단말 화면에 떴다"를 알려주지 않는다.
그러면 "사라지지 않았다"를 무엇으로 확인하는가. 확인할 방법이 없으면 그 요구는 지켰다고도 어겼다고도 말할 수 없는 문장이 된다. 장애 후 회고에서 "유실은 없었습니다"라고 말할 때, 그 말이 무엇에 근거하는지 대답할 수 없다면 그것은 주장이지 사실이 아니다.
그래서 요구를 이렇게 바꿔야 한다.
X 알림이 유실되면 안 된다
O 접수 응답을 반환한 알림은 외부 발송 결과가 추적 가능한 종료 상태에
도달할 때까지 우리 시스템 안에서 사라지지 않는다
사업자 수락 이후의 단말 전달은 별도의 보장 범위이며, 다른 설계로 대신한다
처음에는 "제3자에게 요청을 넘기는 시점까지"라고 쓰려 했다. 그런데 그 시점은 우리가 판정할 수 없는 시점이다. 5절에서 보겠지만 호출이 타임아웃되면 요청을 넘겼는지 자체가 확정되지 않는다. 우리가 남길 수 있는 기록은 "넘겼다"가 아니라 "호출을 시도했고 결과를 모른다"까지다. 판정할 수 없는 시점을 경계로 쓰면 그 경계로 정의한 요구도 판정할 수 없다.
그래서 보장 경계를 네트워크상의 한 지점이 아니라 우리 저장소의 상태 전이로 정의한다. 접수된 알림마다 발송 기록이 종료 상태에 도달했는지 대사하고, 도달하지 못한 건을 세면 된다. 종료 상태는 4절에서 확정할 네 가지, 제3자 접수됨과 영구 실패와 재시도 소진과 불명이다. 불명이 종료 상태에 들어간다는 점이 중요하다. 응답을 받지 못한 건에 대해서도 "결과를 모르는 상태로 기록됐다"까지는 보장할 수 있고, 보장할 수 있는 것은 정확히 거기까지다. 반면 범위를 적지 않은 "알림이 유실되면 안 된다"에는 대사할 대상 자체가 없다.
경계를 정하는 일은 책임을 줄이는 일이 아니다. 어디까지 코드로 보장하고 어디부터는 다른 설계로 대신할지를 나누는 일이다. 그리고 선을 어디에 긋느냐가 그 앞을 얼마나 촘촘하게 설계할지를 결정한다. 종료 상태에 도달할 때까지 추적하기로 했다면, 그때까지의 모든 저장과 발행과 상태 갱신에 내구성이 요구된다.
3. 소유권이 넘어가는 지점마다 유실과 중복이 새로 생긴다
경계를 여섯 구간으로 잘랐다. 자르는 기준은 컴포넌트가 아니라 소유권이 넘어가는 지점이다. 메시지의 소유권이 다음 주체로 넘어갈 때마다 유실과 중복의 가능성이 새로 열리기 때문이다. 저장과 발행을 한 칸으로 묶지 않은 것도 같은 이유다. 디스크에 커밋하는 일과 브로커에 넘기는 일은 서로 다른 두 번의 이동이고, 아웃박스는 정확히 그 둘을 떼어 놓기 위한 장치다.
책이 제시한 것은 유실 불허 요구와 그에 대한 대응(최소 1회 전달, 중복 제거 장치)까지다. 여섯 구간으로 자른 것은 그 요구를 판정 가능하게 만들기 위한 내 정리다.

| 구간 | 소유권 이동 | 보장하는 것 | 보장할 수 없는 것 |
| 요청 접수 | 발신 서비스 → 알림 시스템 | 접수 응답 이후의 내구성 | 발신 서비스가 요청을 보냈는지 |
| 저장 | 프로세스 메모리 → 디스크 | 커밋된 요청은 정본과 아웃박스에 함께 남는다 | 커밋 전에 죽은 요청 |
| 발행 | 아웃박스 → 브로커 | 커밋된 아웃박스 행은 반드시 발행된다 | 발행 시점, 중복 발행 |
| 큐 소비 | 브로커 → 워커 | 처리되지 않은 메시지는 재배달된다 | 재배달로 인한 중복 |
| 외부 호출 | 워커 → 제3자 | 제3자가 응답한 결과의 기록 | 응답이 없을 때 도달 여부 |
| 단말 전달 | 제3자 → 단말 | 없음 | 전부 |
구간별로 무엇이 유실이고 무엇이 중복인지 붙여 본다.
발신 서비스가 알림 시스템으로 요청을 넘기는 구간에서는 접수 응답의 의미를 먼저 정해야 한다. 이 설계의 접수 API는 202를 반환한다. 이 API의 완료 조건을 발송 완료가 아니라 비동기 발송 작업의 접수로 정의했기 때문이다. 이 응답은 "알림함에 기록됐고 발행 의도가 커밋됐다"를 뜻하고, 발송 성공을 뜻하지 않는다. 응답을 못 받은 발신 서비스는 재시도하므로 같은 결제에 알림이 두 번 접수될 수 있다. 멱등 키로 막는데, 조회 후 삽입으로 구현하면 동시 요청 둘이 모두 조회를 통과한다. 유니크 제약을 건 조건부 삽입으로 처리한다. 멱등 키 보존 기간 24시간은 발신 서비스의 재시도 창을 덮도록 잡은 정책값이다.
이 글의 SQL 예시는 MySQL 문법이다.
INSERT INTO idempotency_key (scope, key_hash, request_hash, notification_id, expires_at)
VALUES (?, ?, ?, ?, NOW(3) + INTERVAL 24 HOUR);
-- 중복 키 오류가 나면 기존 행의 request_hash를 비교한다
-- 같은 키에 같은 본문이면 이전 응답을 반환하고, 같은 키에 다른 본문이면 409를 반환한다
같은 키에 다른 본문이 오는 것을 조용히 흘리면 발신 서비스의 버그가 발견되지 않는다. 분산 락을 쓰지 않은 이유는 락 서버라는 컴포넌트가 필요 없고, 획득 실패와 타임아웃 처리를 만들지 않아도 되며, 프로세스가 죽어도 잠금이 남지 않기 때문이다.
요청을 저장하는 구간과 큐로 발행하는 구간을 굳이 나눈 이유가 이중 쓰기다. 사용자에게 보여줄 레코드를 남기는 것과 큐에 발행하는 것이 서로 다른 트랜잭션이면 두 방향으로 깨진다.
레코드 커밋 성공 → 큐 발행 실패
기록은 있는데 아무도 처리하지 않는다
큐 발행 성공 → 레코드 커밋 실패
발송은 되는데 조회하면 아무것도 없다
그래서 레코드와 아웃박스 행을 같은 트랜잭션에 넣고, 별도 릴레이가 아웃박스를 읽어 발행한다. 릴레이는 1초 주기 폴링으로 시작한다. 이 단계가 쓰는 지연은 정상 상태에서 최대 약 1초다. 거래 통지의 99번째 백분위 5초는 접수 API, 트랜잭션 커밋, 아웃박스 폴링, 큐 대기, 워커 스케줄링, 제3자 왕복을 모두 합친 예산이므로, 아웃박스가 1초를 쓰고 나머지 4초를 뒤 단계에 남긴다는 가정으로 시작한다. 폴링 주기 역시 실측 후 조정할 정책값이다. 다만 아웃박스가 만드는 것은 최소 1회 전달뿐이다. 릴레이가 발행한 뒤 상태 갱신 전에 죽으면 재기동 후 같은 행을 다시 발행한다.
큐에서 워커가 메시지를 꺼내는 구간의 문제는 확인 응답 타이밍이다. 유실과 중복 중 무엇을 고를지의 문제이기도 하다.
| 확인 시점 | 워커가 죽으면 | 결과 |
| 처리 전 확인 | 메시지가 이미 큐에서 사라짐 | 유실 |
| 처리 후 확인 | 메시지가 재배달됨 | 중복 |
책의 요구가 "사라지면 안 된다"이므로 처리 후 확인을 택하고 중복을 감수한다. 브로커가 메시지를 다시 전달할 조건이 충족되면 다른 워커가 같은 메시지를 받는다. SQS형 큐라면 가시성 타임아웃 만료가 대표적인 경우다. 처리가 그 시간보다 오래 걸리는 상황은 제3자 응답이 느려지는 구간에서 실제로 발생한다. 처리 중 타임아웃 연장 신호로 완화할 수는 있어도 완전히 막지는 못한다.
워커가 제3자를 호출하는 구간과 제3자가 단말로 보내는 구간이 이 글의 나머지다. 그리고 마지막 구간, 제3자가 사용자 단말로 보내는 구간에 보장하는 것이 없다고 적는 것이 이 설계의 출발점이다. 그 칸을 비워 두지 않으면 앞의 다섯 구간을 얼마나 촘촘하게 설계해야 하는지 정할 수 없다.
4. 사업자의 2xx 응답은 전달을 뜻하지 않는다
경계 밖에서 우리가 얻을 수 있는 신호를 사실대로 적어 본다.
| 채널 | 얻는 신호 | 도착 시점 | 성격 |
| 푸시 | 사업자 2xx 응답 | 요청 시점 | 사업자가 요청을 수락함. 단말 표시와는 무관 |
| 푸시 | 오류 응답, 무효 토큰 | 요청 시점 | 요청 단계의 명시적 실패 |
| 푸시 | 사업자 전달 통계 | 사후 집계 | 개별 건의 동기식 전달 확인이 아니라 보조 관측 데이터 |
| 푸시 | 앱이 보내는 수신 확인 | 앱 실행 중일 때만 | 오면 확실. 안 와도 미도달을 뜻하지 않음 |
| 문자 | 전달 결과 콜백 | 수 초에서 수 시간 | 사업자와 통신사에 따라 편차 |
| 이메일 | 반송 웹훅 | 수 초에서 수 일 | 영구 반송은 확실 |
푸시 사업자가 2xx로 응답하는 것은 "요청을 접수했다"이지 "단말에 표시했다"가 아니다. 단말이 꺼져 있거나, 앱이 삭제됐거나, 사용자가 알림을 꺼 두었거나, 사업자 내부에서 폐기되는 경우에 우리는 요청 시점에 아무 신호도 받지 못한다. 사업자가 제공하는 사후 전달 통계로 집계 수준의 관측은 할 수 있지만, 그것은 개별 알림마다 돌아오는 전달 확인과 다르다.
사업자 수락, 단말 전달, 사용자 확인은 서로 다른 사건이다. 따라서 신호가 없다는 사실만으로는 성공과 실패 어느 쪽도 판정할 수 없다.
그래서 최종 상태를 이렇게 확정했다. 접수됨은 우리 시스템이 요청을 받아 정본에 커밋했다는 뜻이고, 제3자 접수됨은 사업자가 우리 요청을 수락했다는 뜻이다. 같은 접수라는 말이 두 주체에 쓰이므로 상태 이름에 계층을 드러낸다.
| 조건 | 최종 상태 | 후속 동작 |
| 제3자가 2xx 응답 | PROVIDER_ACCEPTED (제3자 접수됨) | 없음 |
| 영구 실패 응답 | PERMANENT_FAILURE (영구 실패) | 사유가 무효 토큰일 때만 토큰 비활성화 |
| 재시도 상한 도달 | RETRY_EXHAUSTED (재시도 소진) | 경보 |
| 응답 불명이 지속 | UNKNOWN (불명) | 대사 대상 |
| 문자 전달 콜백 성공 | DELIVERED (전달됨) | 없음 |
| 앱 수신 확인 도착 | DELIVERED (전달됨, 보조 신호) | 없음 |
상태와 후속 동작을 한 칸에 적지 않는다. 영구 실패는 전달 상태이고 토큰 비활성화는 그 실패 사유 중 무효 토큰에만 붙는 부수 효과다. 둘을 붙여 두면 모든 영구 실패에서 토큰을 회수하게 된다.
푸시의 정상 종료 상태는 "전달됨"이 아니라 "제3자 접수됨"이다. 이 둘을 한 칸으로 합치고 싶은 유혹이 있다. 상태값이 하나 줄고, 대시보드가 단순해지고, 도달률 숫자가 올라간다.
합쳤을 때 무슨 일이 생기는지 숫자로 보면 이렇다. 푸시 100만 건을 보내고 전부 2xx를 받았다고 하자. 2xx를 전달로 기록하면 도달률은 100퍼센트다. 실제로는 그중 얼마가 단말에 떴는지 우리는 모른다. 이제 알림 문구를 바꾸는 실험을 하고 전환율이 떨어졌다고 하자. 원인 후보는 문구일 수도, 도달률일 수도 있는데 도달률 지표가 상수 100퍼센트이므로 그 축은 아예 조사 대상에서 빠진다. 지표가 거짓이면 그 지표를 근거로 한 모든 판단이 조용히 틀어진다.
"불명" 상태를 따로 두는 이유도 같다. 성공도 실패도 아닌 칸이 없으면, 사후에 응답 불명 건과 정상 건을 구분할 방법이 사라진다.
5. 응답이 없을 때 재시도하면 중복, 안 하면 유실
워커가 제3자를 호출하는 구간에는 앞의 네 구간에 없는 상태가 하나 있다. 요청이 갔는지 모르는 상태다.
워커 ──요청──> 애플 푸시
<──없음── 타임아웃
타임아웃 시점에 실제로 벌어졌을 수 있는 일은 셋이다. 요청이 도달하지 못했거나, 도달해서 처리됐는데 응답이 유실됐거나, 도달했으나 사업자 내부에서 실패했거나. 워커 입장에서 셋을 구분할 방법은 없다.
이것은 분산 호출에서 말하는 결과 불명(ambiguous outcome)이다. 원격 효과가 발생했는지 아닌지를 응답만으로 구분할 수 없다. 두 장군 문제와 같은 계열의 불확실성으로, 유실 가능한 채널만으로 두 노드가 공통 지식에 도달하는 유한 프로토콜은 존재하지 않는다. 그래서 전달을 정확히 한 번으로 만드는 것은 가능한 목표가 아니고, 여러 번 전달을 전제하고 효과를 한 번으로 접는 것이 실제 목표가 된다.
여기서 할 수 있는 선택은 둘이다.
| 선택 | 결과 | 판단 |
| 재시도한다 | 요청이 도달해 처리됐는데 응답만 유실된 경우에 중복 발송 | 채택 |
| 재시도하지 않는다 | 요청이 도달하지 못했거나 사업자가 처리에 실패한 경우에 유실 | 기각 |
여기서 발생하는 중복은 우리 쪽 저장소로 막을 수 없다. 저장과 발행에서 생기는 중복, 큐가 메시지를 다시 배달해서 생기는 중복은 우리 시스템 안에서 벌어지므로 멱등 판정으로 걸러낸다. 그런데 제3자를 호출하는 구간은 다르다. 중복 호출을 만드는 쪽은 우리 워커지만, 그 호출이 사업자 안에 이미 남긴 효과까지 우리 저장소의 트랜잭션으로 함께 묶을 수는 없다. 사업자가 멱등 키를 제공하지 않는 한, 타임아웃 이후의 재시도에서 외부 효과의 중복 가능성은 남는다.
이 지점에서 사업자가 제공하는 병합 수단에 기대고 싶어진다. 애플 푸시의 병합 식별자, 구글 푸시의 병합 키가 그것이다. 그런데 이 수단이 실제로 하는 일을 정확히 봐야 한다.
병합 식별자는 전송 멱등 키가 아니라 서비스 품질 도구다. 중복 요청을 거부하지 않고, 같은 요청의 외부 효과를 정확히 한 번으로 만들지도 않는다. 하는 일은 아직 전달되지 않은 오래된 알림을 최신 알림으로 대체하거나 합치는 것이다. 최종적으로 사용자에게 어떻게 보이는지는 단말 상태와 운영체제 동작에 달려 있으므로, 이 수단을 중복 제거 장치로 적을 수 없다.
병합이 실제로 유용한 자리는 다르다. 최신 상태만 의미가 있는 알림(잔액 변동, 진행률)에서 오래된 알림이 쌓이는 것을 막는 용도다.
그래서 이 구간에서 실제로 할 수 있는 일은 두 가지다. 중복 호출 자체를 없애는 대신 중복이 사용자에게 해를 끼치지 않도록 알림 내용을 설계하는 것, 그리고 사용자에게 같은 사건이 두 번 표시되는 것을 우리 쪽에서 막는 것이다. 알림 내용을 설계한다는 것은 문구 규칙을 정한다는 뜻이다. "결제 3,000원이 승인되었습니다"는 두 번 받아도 상태가 바뀌지 않지만, "잔액이 3,000원 차감되었습니다"는 두 번 받으면 6,000원으로 오해된다. 후자를 쓰지 않는다.
6. 그래서 방향을 바꿨다
여기까지가 책의 구조를 따라가며 얻은 결론이다. 푸시는 도달을 보장하지 않고, 도달 여부를 알려주지도 않으며, 응답 불명 구간의 중복은 우리 손이 닿지 않는다.
그러면 "사용자가 결제 사실을 모르면 어떻게 하나"라는 질문에 답하는 방법은 둘이다. 도달률을 올리거나, 도달에 의존하지 않는 구조로 바꾸거나.
도달률을 올리는 길은 문자 폴백이다. 푸시가 도달하지 않은 것 같으면 문자를 보낸다. 그런데 미도달을 판정할 근거가 없으므로 이 판정은 시간 기반 추측이 되고, 추측으로 건당 과금 채널을 쓰게 된다. 추측이 헐거우면 오발송이 비용으로 쌓이고, 빡빡하면 미발송이 사고가 된다. 판정할 수 없는 것 위에 자동화를 얹는 구조다.
도달에 의존하지 않는 구조로 바꾸는 쪽을 택했다. 여기부터가 책에 없는 내 설계다.
알림의 정본은 서버의 알림함이고, 푸시는 알림함을 보라는 신호다.

핵심은 마지막 두 줄이다. 푸시가 유실돼도 정보는 서버 정본에 남아 있고, 앱이 동기화하는 시점에 복구된다. 경계 밖 실패가 정보 손실로 이어지지 않는다. 다만 복구되는 것은 정보이지 사용자의 인지가 아니다.
무엇이 달라지는지 정리하면 이렇다.
| 항목 | 알림함이 없을 때 | 알림함이 정본일 때 |
| 푸시 유실의 결과 | 알림 정보가 복구되지 않음 | 앱 동기화 시 서버 정본에서 복구 |
| 문자 폴백의 필요성 | 높음. 미도달을 추측해서라도 보내야 함 | 낮음. 고액 거래에만 선택적으로 |
| 푸시 페이로드 | 내용을 다 담아야 함 | 식별자와 요약만 담아도 됨 |
| 잠금 화면 민감정보 | 담을 수밖에 없음 | 사용자가 숨김을 켜면 제목만 |
| 중복 억제 실패 시 대응 | 중복 발송 또는 미발송 중 선택 | 미발송을 택할 수 있음 |
정본을 옮기면 그만큼 짐이 늘어난다. 앱이 마지막으로 받은 알림 식별자를 들고 그 이후만 요청하는 커서 기반 동기화가 필요하다. 이때 커서로 쓸 식별자는 단조 증가하거나 시간순으로 정렬 가능해야 한다. UUID v4처럼 순서가 없는 값은 커서가 되지 못한다. 생성 시각만 커서로 쓰면 같은 시각에 삽입된 여러 행 사이에 안정적인 순서 기준이 없어 누락이 생기므로, (생성 시각, 알림 식별자) 복합 커서나 단조 증가 식별자를 쓴다. 조회와 읽음 표시도 분리해야 하고(목록을 열었다고 전부 읽음으로 처리하면 사용자가 놓친다), 미읽음 배지 카운터를 따로 두고 어긋나면 앱 실행 시 재계산으로 맞춰야 한다. 결제 이력과 함께 조회되므로 보관 기간은 로그보다 길게 잡는다. 이 설계에서는 1년을 예시로 두었을 뿐이고, 실제 기간은 법적 요구와 개인정보 정책, 저장 비용에 따라 정할 정책값이다.
7. 이 선택이 장애 대응에서 돌려준 것
알림의 정본을 서버 알림함으로 옮기면서 치른 대가는 알림함 조회 경로와 읽음 처리가 새로 필요해지고, 알림함 가용성이 시스템 전체의 상한이 된다는 것이다. 배당은 다른 곳에서 돌아왔다.
제3자를 호출하는 구간에서 생기는 중복은 막을 수 없다고 했다. 정확히는 사업자에게 같은 요청이 두 번 가는 것을 막을 수 없다. 그러나 서로 다른 발송 건이 같은 사건을 중복 발송하는 것은 막을 수 있다. 사건 단위 중복 발송 억제를 워커 앞에 두고, 억제 단위를 채널이 아니라 사건으로 잡았다. 억제하는 것은 사용자 노출이 아니라 발송이다. 단말에 무엇이 떴는지는 4절에 적은 대로 우리가 모른다.
중복 발송 키 = (사용자, 사건 식별자, 알림 종류)
소유 발송 건 = 그 키를 처음 선점한 발송 건 식별자
채널이 키에 들어가지 않는다. 푸시로 보낸 뒤 문자로 또 보내는 것도 같은 사건의 중복 발송이기 때문이다. 다만 이상거래 경고처럼 정책적으로 여러 채널에 동시 발송하는 알림은 이 억제의 대상에서 제외한다. 그 경우 푸시와 문자는 중복 발송이 아니라 하나의 발송 정책을 이루는 두 채널이다.
판정과 기록은 제3자 호출 직전에 한다. 호출 후에 기록하면 호출과 기록 사이에 두 번째 워커가 판정을 통과한다. 그런데 키만 가지고 호출 전에 기록하면 5절에서 채택한 재시도가 자기가 남긴 키에 걸려 막힌다. 워커가 키를 기록한 직후 호출 전에 죽는 경우는 더 나쁘다. 재배달된 메시지까지 차단되어 그 알림은 한 번도 발송되지 않는다.
그래서 논리적 중복 제거와 전송 재시도를 다른 축으로 나눈다. 키에 소유 발송 건 식별자를 함께 기록하고 판정을 이렇게 한다.
| 상황 | 판정 |
| 키 없음 | 내 발송 건을 소유자로 기록하고 진행 |
| 키 있고 소유자가 내 발송 건 | 같은 발송 건의 재시도이므로 진행 |
| 키 있고 소유자가 다른 발송 건 | 같은 사건의 중복 발송이므로 차단 |
이 장치가 막는 것은 서로 다른 발송 건이 같은 사건을 중복 발송하는 일이다. 같은 발송 건이 응답 불명 때문에 재시도해서 사업자 쪽에 남기는 중복은 5절에 적은 그대로 남는다. 사용자에게 같은 알림이 두 번 보이는 경우가 완전히 사라지지는 않는다는 뜻이다. 두 문제를 한 장치로 처리하려다 5절의 결론을 뒤집는 것보다, 서로 다른 축으로 두는 편이 낫다.
소유권 기록에는 임차 시간을 둔다. 소유자가 된 발송 건이 끝내 종료 상태에 도달하지 못하면 키가 남아 그 사건이 다시는 발송되지 않기 때문이다. 임차가 만료된 키는 다른 발송 건이 다시 선점할 수 있게 하고, 그렇게 만료된 건은 8절의 대사 대상이 된다. 임차 만료가 중복 가능성을 없애지는 않는다. 이전 소유자가 실제로 외부 호출을 진행 중인데 임차가 먼저 끝나면 새 소유자의 호출과 겹친다. 임차 시간은 영구 미발송 위험과 중복 발송 위험 사이에서 고르는 정책값이다.
이제 질문은 이것이다. 이 억제 저장소가 죽으면 어떻게 하는가.
| 선택 | 결과 |
| 통과시킨다 | 억제가 무력화되어 중복 발송. 결제 알림을 두 번 받는다 |
| 막는다 | 채널 발송이 전면 중단된다 |
알림함이 없는 구조에서는 막는 쪽을 고를 수 없다. 채널 발송이 사용자에게 닿는 유일한 경로이므로, 막는다는 것은 알림을 안 보낸다는 뜻이다. 그래서 통과시킬 수밖에 없고 중복을 감수하게 된다. 선택지가 하나뿐인 상황을 선택이라고 부를 수는 없다.
알림함이 정본이면 막는 쪽을 고를 수 있다. 채널 발송을 건너뛰어도 사용자는 앱을 열면 알림함에서 본다. 억제 저장소 장애가 중복 발송으로 나타나지 않고 알림 도착 지연으로만 나타난다.
정본이 채널 밖에 있는 덕분에 장애 시 선택지가 하나 늘었다.
다만 예외가 하나 있다. 이상거래 경고에는 이 규칙을 적용하지 않는다.
알림 종류 억제 저장소 장애 시
| 이상거래 경고 | 통과. 중복을 감수 |
| 거래 통지 | 차단. 알림함으로 대체 |
| 마케팅 | 차단 |
| 내부 경보 | 통과. 중복 무해 |
이상거래 경고만 다른 이유는 알림의 성격이 다르기 때문이다. 거래 통지는 사후 통지라서 사용자가 몇 분 뒤 앱을 열어 확인해도 결과가 바뀌지 않는다. 이상거래 경고는 사용자가 보고 차단 여부를 응답해야 하는 알림이라, 앱을 열 때까지 기다리는 동안 부정 거래가 진행된다. 알림함이라는 대체 경로가 이 종류에는 대체가 되지 못한다. 그래서 이 종류만 중복을 감수하고 통과시킨다.
같은 이유로 이상거래 경고는 평소에도 푸시와 문자를 동시에 보낸다. 채널 하나가 실패했는지 판정할 방법이 없으므로, 판정을 포기하고 처음부터 둘 다 보내는 선택이다. 1절 가정표의 하루 2만 건이라는 규모가 이 선택을 가능하게 한다. 900만 건인 거래 통지에는 쓸 수 없는 방법이다.
8. 이 설계가 못 하는 것
| 못 하는 것 | 이유 |
| 푸시가 단말에 도달했는지 확인 | 사업자가 알려주지 않는다. 앱이 보내는 수신 확인만 보조 신호 |
| 제3자 호출 구간의 중복 제거 | 사업자에 이미 남은 효과를 우리 트랜잭션에 묶을 수 없다. 사건 단위 억제는 다른 발송 건의 중복 발송을 막을 뿐, 같은 발송 건의 재시도가 만드는 호출 중복은 막지 않는다 |
| 앱을 열지 않는 사용자에 대한 도달 보장 | 알림함은 앱을 여는 사용자만 본다 |
| 알림함 장애 중의 알림 접수 | 정본을 남기지 못하면 접수를 거부한다 |
앱을 열지 않는 사용자에 대한 도달 보장이 이 설계에서 가장 크게 남는 문제다. 알림의 정본을 알림함으로 옮기는 순간, 보장의 마지막 단계가 "사용자가 앱을 연다"라는 조건에 걸린다. 웹만 쓰는 사용자, 앱을 지운 사용자에게는 도달 보장이 여전히 없다. 웹 세션에도 같은 알림함을 노출하고 고액 거래에는 문자를 병행하지만, 부분 대응일 뿐 이 사용자들에 대한 보장이 성립하지는 않는다.
알림함 장애 중에 알림을 접수하지 못하는 것은 가용성 문제다. 알림함 쓰기가 실패하면 접수 API는 503을 반환하고 발신 서비스가 재시도한다. 기록 없이 발송하면 사용자가 앱을 열었을 때 알림이 없고, 알림함이 정본이라는 약속이 그 순간 거짓이 되기 때문이다. 이 결정의 대가는 분명하다. 알림함 가용성이 알림 시스템 전체의 가용성 상한이 된다. 발송 경로가 죽는 것보다 알림함이 죽는 것이 영향이 크다.
그리고 유실이 없다는 주장 자체를 측정 가능하게 만드는 장치가 따로 필요하다. 장치는 둘이다.
첫째는 발송이 멈춰 있는 건을 찾는 감시 배치다.
SELECT delivery_id, notification_id, channel, attempt_count, updated_at
FROM notification_delivery
WHERE status IN ('SENDING', 'RETRYING')
AND updated_at < NOW() - INTERVAL 30 MINUTE
ORDER BY updated_at
LIMIT 1000;
이 쿼리가 찾는 것은 정체 후보이지 유실 확정 건이 아니다. 워커가 죽어서일 수도, 상태 갱신이 실패해서일 수도, 재시도 간격이 길어서일 수도, 브로커 소비가 막혀서일 수도 있다. 원인은 이 쿼리로 확정되지 않고, 30분이라는 값도 실측 후 조정할 정책값이다.
둘째는 정본에서 출발하는 대사 배치다. 감시 배치는 이미 만들어진 발송 행만 보므로, 행 자체가 만들어지지 않은 실패를 원리적으로 잡지 못한다.
알림함에 접수된 알림 → 대응하는 아웃박스 행이 있는가
발행된 아웃박스 행 → 필요한 발송 행이 있는가
종료되지 않은 발송 행 → 처리 중 임차나 다음 재시도 시각이 있는가
종료된 발송 행 → 제3자 응답 결과나 실패 사유가 기록돼 있는가
접수는 됐는데 아웃박스 행이 없는 건, 발행은 됐는데 발송 행이 없는 건은 감시 배치의 조건에 아예 걸리지 않는다. 유실이 없었음을 확인하려면 정본부터 내려오면서 연결이 끊긴 지점을 찾아야 한다.
발견된 건을 모두 같은 방식으로 재처리하지는 않는다. 내부 상태의 불일치처럼 외부 효과가 발생하지 않았음을 판정할 수 있는 건은 멱등하게 자동 복구하고, 제3자 호출 결과가 불명인 건은 중복 가능성 때문에 자동 재발송하지 않고 경보를 올린다. 이 장치들 없이 유실 없음을 주장하면 그 주장은 검증할 수 없다.
9. 정리
알림 시스템의 어려움은 처리량이 아니라 경계다. 우리가 보장할 수 있는 것은 발송 결과가 추적 가능한 종료 상태에 도달할 때까지이고, 사업자가 요청을 수락한 다음부터는 관측도 재시도도 우리 손을 떠난다. 그래서 이 설계는 경계 밖을 더 열심히 보장하려 하지 않고 경계 밖에 의존하지 않는 구조로 바꾸는 쪽을 택했다. 알림의 정본을 서버의 알림함에 두고 푸시를 신호로 강등한 것이 그 선택이며, 그 덕분에 억제 저장소가 죽었을 때 중복 발송 대신 발송 지연을 고를 수 있게 되었다.
'스터디' 카테고리의 다른 글
| [Sutskever's List 스터디] dropout의 자리와 CTC를 계산해 본 후기 (0) | 2026.10.05 |
|---|---|
| [Sutskever's List 스터디] AlexNet, ResNet, LSTM을 읽고 계산해 본 후기 (0) | 2026.10.05 |
| [대규모 시스템 설계 스터디] 웹 크롤러 설계 (0) | 2026.08.27 |
| [대규모 시스템 설계 스터디] URL 단축기 설계 (0) | 2026.08.02 |
| [대규모 시스템 설계 스터디] 분산 유일 ID 생성기 설계 (0) | 2026.07.26 |