100만 유저 대상 알림 팬아웃·웹 푸시 발송

SKILL = Spring Boot 4.1 | Java 21 | PostgreSQL 16 | RabbitMQ | Prometheus | Grafana | Docker

SPEC = 노트북 1대 — CPU(i7-6700HQ 4C/8T) | RAM(16GB) | SSD

[문제상황]

수강생 1,000,000명 · 푸시 구독 1,097,488건

[해결과정]

수치는 노트북 1대 측정값 — 별도 서버 재검증 후 교체 예정 · 점선 상자 = 그때 캡처할 화면

전체 구조도. 자료 게시 업무 트랜잭션이 outbox 1행을 커밋(1)하면 relay가 아웃박스를 조회(2·8)해 fanout-queue(3)와 push-queue(9)로 발행한다. fanout worker는 fanout-queue를 소비(4)해 커서 조각을 조회(5)하고 알림 삽입과 푸시 명령 적재를 한 커밋으로 수행(6)하며 다음 조각을 스스로 발행(7)한다. push worker는 push-queue를 소비(10)하고 구독·진행을 조회(11)해 FCM으로 발송(12)한 뒤 진행 표를 기록(13)한다. 컨테이너별 자원은 PostgreSQL cpu 2.0 mem 1.5GB, notification-app cpu 2.0 mem 1.6GB, RabbitMQ cpu 1.0 mem 512MB, backend-push 노드당 cpu 2.0 mem 1.6GB
최종 구조. 번호는 메시지 흐름 순서 — 1 게시 tx+outbox 커밋 · 2/8 relay 아웃박스 조회 · 3 fanout-queue 발행 · 4 소비 · 5/6 커서 조회·알림 삽입+푸시 명령 적재(한 커밋) · 7 다음 슬라이스 발행 · 9 push-queue 발행 · 10 소비 · 11 구독·진행 조회 · 12 FCM 발송 · 13 진행 표 기록. 푸시 명령이 아웃박스를 거치는 것(6→8→9)은 커밋과 발행 사이의 크래시에서 슬라이스 전원의 푸시가 유실되지 않게 하기 위함
자료 게시 업무 tx outbox relay fanout-queue fanout worker PostgreSQL push-queue push worker ×3 FCM · Mozilla outbox 1행 커밋 커밋 후 발행 SQL 한 문장 · 5,000씩 알림 1,000,000행 동시 800 · prefetch 1 외부 다음 조각 발행 푸시 명령 발행
최종 구조. 푸시 명령은 fanout worker가 알림 삽입과 같은 트랜잭션으로 아웃박스에 싣고, relay가 push-queue로 발행한다 — 커밋과 발행 사이의 크래시에서 조각 전원의 푸시가 유실되지 않게 하기 위함. 아래 항목들이 이 구조가 만들어진 순서다

1. 트랜잭션 분리 : 게시와 알림의 분리, Transactional Outbox 도입

문제
  • 최초에 강의 자료 게시 API 트랜잭션 안에서 알림 생성과 발송까지 처리하는 구조로 구현
  • 수신자 1,000명에서 게시 API 한 번이 220.9초. 100만 명이면 약 61시간
  • 100만 명은 측정하기 어려워 1,000명으로 재고 1000배로 계산
  • 게시 API 응답이 발송 완료까지 지연되고, 알림 처리 실패 시 자료 게시까지 롤백되는 문제
게시 API POST 요청이 HTTP 201을 응답 220.866276초 만에 반환하고, 목 수신 서버가 푸시 1,079건을 받았으며 자료 게시 행이 1건인 출력
수신자 1,000명 기준 강의자료 게시 API 한 번.
응답까지 220.9초. (구독 1,079건, 공급자 왕복 230ms 기준)
100만 명이면 약 61시간.
같은 API에서 알림 단계 실패 시 HTTP 500, 목 수신 푸시 1,079건, 자료 게시 행 0건인 출력
같은 API에서 알림 단계를 실패시킨 경우.
푸시 1,079건이 나간 뒤 응답은 500.
자료 게시 행은 0건, 나간 푸시는 되돌릴 수 없고 게시만 롤백됨.
해결
  • 게시 응답은 게시 커밋 시점에 반환하고 알림 처리는 비동기로 분리
  • 이 과정에서 DB 커밋과 큐 발행이 하나의 트랜잭션으로 묶이지 않는 이중 쓰기 문제 발생
  • 발행을 먼저 하면 게시 롤백 시 잘못된 알림이 나가고, 커밋을 먼저 하면 발행 직전 장애 시 이벤트가 유실됨
  • Transactional Outbox 패턴을 적용해 게시 트랜잭션에 아웃박스 1행을 함께 커밋하고 Relay가 커밋 이후(afterCommit)에 큐로 발행하도록 구현
  • 추가로 커밋 알림이 유실된 경우를 대비해 30초 주기 폴링으로 미발행 행을 처리
결과
  • 게시 응답 시간이 팬아웃 · 발송 소요와 무관해져 0.42초에 반환됨
  • 알림 경로가 통째로 죽어도 게시는 커밋됨. 브로커가 내려간 상태의 게시도 201
  • 커밋 전 장애면 이벤트 자체가 없고, 커밋 후 발행 전 장애면 폴링이 발행함
  • 팬아웃 처리 도중 앱이 죽어도 다시 켜면 이어서 처리되어 알림 수가 수강생 수와 일치
게시 API POST 요청이 HTTP 201을 응답 0.415308초 만에 반환한 출력
수신자 100만 명 기준 강의 자료 게시 API 한 번. 앱 물리 1코어.
응답까지 0.42초. 발송이 끝나기를 기다리지 않고 반환됨.
RabbitMQ를 정지한 상태의 게시가 201을 반환하고 아웃박스 행이 PENDING으로 남은 출력 브로커 복구 후 아웃박스 행이 PUBLISHED로 바뀌고 큐에 명령 1건이 소비자 없이 쌓였으며 알림 행 100만 건이 채워진 출력
브로커를 내린 채 게시한 케이스.
응답은 0.32초로 나갔고 아웃박스 행은 PENDING으로 남음.
브로커를 올리자 폴링이 12.7초 뒤에 발행.
명령 1건이 소비자 없이 큐에 쌓인 것을 확인.
소비자를 켜자 알림 행 100만 건이 채워짐.
팬아웃 도중 앱을 강제 종료하고 다시 켠 뒤 수강생 수와 알림 수가 각각 100만이고 차이가 0인 출력
팬아웃 도중 앱을 강제 종료한 케이스.
재기동 뒤 수강생 100만 명, 알림 100만 건. 정상 처리됨을 확인.

2.일괄 조회·단일 INSERT 시 Postgres OOM → 커서 범위 슬라이싱 도입

문제
수신자 100만 명을 단일 쿼리로 조회해 단일 INSERT 문으로 삽입하는 구조 테스트 시 PostgreSQL 컨테이너가 메모리 한도 1.5GB를 초과해 강제 종료됨.
재시작 후 크래시 복구로 전량 롤백되어 생성된 알림 0건, WAL 615.9MB 롤백 확인
원인 파악
docker inspectOOMKilled=true 플래그와 크래시 복구 로그로 종료 원인을 확인.
cgroup 메모리 표본에서 일괄 조건에서만 메모리가 한도까지 상승함을 확인.
애플리케이션 JVM 힙은 조건과 무관하게 일정해 병목 지점이 앱이 아니라 PostgreSQL임을 확인.
대안으로 수신자 목록을 앱에서 조회해 메시지에 싣는 방법을 검토했으나, uuid 100만 개만 423MB로 힙 상한(1GiB)의 절반을 차지하고 RabbitMQ와 워커 힙에도 동일한 부하가 발생해 기각
캡처 — docker inspect OOMKilled=true · Postgres 크래시 복구 로그 터미널
캡처 — Postgres 컨테이너 cgroup 메모리 1초 표본 그래프 (일괄 vs 슬라이스 5,000)
캡처 — 커서 구조 100만 실행의 JVM 힙 그래프 (수신자 수와 무관하게 평탄)
해결
수신자 목록을 DB 밖으로 꺼내지 않는 방식으로 변경.
메시지에는 커서 범위만 싣고 워커가 WHERE user_id > :cursor ORDER BY user_id LIMIT 5000 쿼리로 DB 안에서 분할 처리.
각 슬라이스 처리가 끝나면 워커가 다음 슬라이스 메시지를 발행하는 체인 구조로 개발.
앱이 받는 데이터는 처리 건수, 다음 커서, 신규 구독자 목록(최대 슬라이스 갯수)뿐
결과
완료되지 않던 작업이 완료됨.
슬라이스 크기를 5,000~800,000으로 변경해도 소요 시간이 동일함을 확인
분할은 속도 개선이 아니라 완료를 위한 조치
일괄 커서 수신자 1,000,000명 단일 조회 단일 문장 INSERT Postgres OOMKilled 전량 롤백 · 알림 0건 커서 범위만 메시지에 적재 명단은 DB 밖으로 안 나옴 5,000명 × 201슬라이스 슬라이스 끝에서 다음 슬라이스 발행 완료 1.5GB 초과
같은 100만 건 처리. 일괄 구조는 완료되지 않고 커서 분할 구조는 완료됨. 분할해도 소요 시간은 같으므로 목적은 속도가 아니라 완료

3.단일 INSERT → 슬라이스 단위 INSERT ... SELECT 벌크 인서트로 전환

문제
수신자마다 INSERT를 반복하는 구조는 DB 왕복 100만 회, 772건/s로 측정됨.
100만 건 기준 21분 35초로 팬아웃만으로 1분 요구를 초과
해결
항목 2의 슬라이스 구조는 유지한 채, 한 슬라이스 안의 삽입을 INSERT ... SELECT 한 문장으로 통합.
수신자 조회, 삽입, 중복 제거(ON CONFLICT DO NOTHING)가 SQL 한 문장으로 DB 안에서 실행됨.
알림 문구 확보, 본체 삽입, ON CONFLICT DO NOTHING이 한 트랜잭션에서 처리되어 DB 왕복이 슬라이스 수만큼(201회)으로 감소
결과
55,063건/s로 개선(행 단위 대비 71배)
팬아웃 소요의 91%가 DB 삽입 시간으로, 애플리케이션은 커서 전달만 수행
캡처 — 각 SQL의 EXPLAIN ANALYZE · 행 단위 / 벌크 삽입 처리량 비교 로그

4.uuid 공간 분할로 체인 수 분할 및 슬라이스 갯수 튜닝

문제
체인 하나는 직렬 처리(이전 슬라이스 완료 후 다음 슬라이스 발행)라 체인 1개로는 DB 코어를 모두 활용하지 못함
원인 파악
슬라이스 크기와 체인 수를 조건마다 3회씩 변경하며 측정.
슬라이스 1,000 → 5,000에서 16% 단축(슬라이스당 DB 시간은 4.5배가 되지만 실행 횟수가 5배 감소).
체인 2 → 4는 경과 시간이 19% 줄지만, 슬라이스별 실행 시간의 합(DB 실행 시간 합계)이 25.4초 → 42.8초로 1.68배가 됨을 확인해 기각.
실행 시간 증가의 원인은 pg_stat_activity 대기 이벤트 수집으로 특정 예정(재검증 항목)
캡처 — 슬라이스 크기·체인 수 스윕 표 (소요 · 슬라이스당 DB 시간 · pg CPU)
캡처 — 체인 수 초과 시 pg_stat_activity 대기 이벤트 분포 (부풀린 시간이 어디로 갔는지)
해결
발행 시점에 uuid 공간을 균등 분할해 체인을 병렬로 실행.
체인 수 = PostgreSQL 물리 코어 수, 소비자 수 = 체인 수 × 2 기준을 정립
DB 코어 증설 시 두 값을 함께 늘림
결과
슬라이스 1,000 · 체인 1의 26.46초 → 슬라이스 5,000 · 체인 2의 14.42초

5.발송 라이브러리의 암호화 비용 → 암호 객체를 재사용하는 구조로 직접 구현

문제
발송 중 docker stats로 앱 CPU 323%(배정 400%, 100% = 논리 코어 1개), PostgreSQL 26%를 확인 : 발송 구간의 병목은 앱 CPU.
Web Push는 표준(RFC 8291, RFC 8292)에 따라 기기마다 다른 키로 알림 본문을 암호화·서명해 전송해야 하는데, 라이브러리(nl.martijndwars:web-push) 사용 시 기기 1건 암호화에 5.95ms 소요됨.
원인 파악
실행 시간을 단계별로 분해한 결과 실제 암호 연산은 0.9ms면 충분함을 확인.
초기화 비용이 큰 암호 객체(Cipher·KeyAgreement·Mac·Signature·KeyPairGenerator)를 호출마다 새로 생성하고, 불변인 서버 키쌍을 매번 재검증하는 것이 주 원인.
추가로 키 생성기를 곡선 이름("secp256r1")으로 초기화하면 BouncyCastle이 그 곡선 전용 구현을 선택해 키 생성·ECDH가 5~7배 빨라짐을 확인

캡처 — 발송 중 docker stats 터미널 (앱 CPU 상한 · Postgres 여유)
캡처 — 컨테이너별 CPU 시계열 그래프 (팬아웃 구간은 Postgres, 발송 구간은 앱만 상승)
해결
RFC 8291·8292 처리 절차를 직접 구현하며 암호 객체를 한 번 만들어 재사용하도록 변경.
이 객체들은 연산 상태를 가져 스레드 안전하지 않으므로, 공유하는 대신 ThreadLocal로 스레드마다 1개씩 두어 경합 없이 재사용.
임시 키쌍과 salt는 규격 요구대로 메시지마다 새로 생성
결과
단건 5.95ms → 0.9ms, 코어당 암호화 상한 2,796건/s 확보

6.남는 코어당 상한과 배치 전송 불가 → 발송 전용 큐·워커 노드 분리

문제
암호화를 최적화해도 코어당 상한(2,796건/s)은 남고 여러 기기를 묶는 배치 전송 API가 표준에 없어 109만 건 발송 = HTTPS 요청 109만 회를 개별 처리해야 함.
한 대의 CPU로는 1분 요구를 만족할 수 없음
원인 파악
팬아웃 워커와 푸시 워커는 별개의 리스너지만 분리 전에는 한 애플리케이션에 함께 배포되어 있어 발송 처리량을 늘리려는 인스턴스 증설이 팬아웃 소비자까지 함께 늘림
CPU가 더 필요한 것은 발송뿐인데 DB가 병목인 팬아웃에는 부하만 추가됨
캡처 — 컨테이너별 CPU 시계열 그래프 (팬아웃 구간은 Postgres, 발송 구간은 앱만 상승)
해결
큐를 fanout-queue와 push-queue로 분리하고 발송 워커를 별도 노드로 분리
병목인 구간만 독립적으로 확장할 수 있는 구조로 개발.
발송은 HTTP/2 다중 연결로 동시 요청 800건 처리. 429 응답 시 동시 수를 줄이고 Retry-After를 준수.
추가 필요 용량 산정과 노드 증설은 물리 코어·기계 추가 기준으로 결정
결과
순차 4.2건/s → 3,299건/s, 물리 코어당 1,650건/s(암호화 상한의 59%).
추후 이 값을 용량 산정의 단위로 사용

7.발송 노드를 2개로 늘려도 처리량이 늘지 않는 문제 → JFR 프로파일링으로 원인 파악, 전용 prefetch 1 적용

문제
발송 노드를 2개로 확장했으나 1개가 명령 197개를 전부 가져가고 다른 1개는 CPU 3%로 유휴 상태임을 확인. 노드를 늘려도 처리량이 증가하지 않음
원인 파악
JFR 프로파일링으로 실행 샘플 76,070개 중 96.6%가 암호화 작업임을 확인. 그런데 해당 스레드는 배정 400% 중 163%만 사용함. 파크 시간을 스택 기준으로 분류한 결과 100%가 ThreadPoolExecutor.getTask — HTTP 응답 대기나 락 경합이 아니라 작업이 공급되지 않아 대기한 것으로 확인. Spring AMQP 소비자의 prefetch 기본값이 250인데 (Spring AMQP Docs) 넓은 팬아웃이 만드는 푸시 명령은 슬라이스 수만큼(201건)이라, 먼저 연결된 소비자가 명령을 전부 선점함을 파악
캡처 — JFR 실행 샘플 분포 · 파크 스택 화면 (getTask 100%)
해결
푸시 리스너에만 전용 prefetch 1을 적용
명령 하나가 수천 건의 발송 작업이므로 미리 가져올 필요가 없음.
전역 설정이 덮어쓰지 않도록 적용 순서를 테스트로 고정.
팬아웃 큐는 대기 메시지가 항상 체인 수만큼이라 prefetch가 분배에 영향을 주지 않으므로 변경하지 않음
결과
분배가 99 대 100으로 균등해지고 2노드 처리량 1,1932,623건/s로 개선.
1노드 단독에서도 같은 조건에서 2,3463,253건/s(+39%) 개선 확인.
2노드 합계가 1노드 실측 3,299건/s보다 낮은 것은 동일 노트북의 물리 2코어를 나눠 배정한 조건이기 때문
이 실험의 확인 대상은 절대 처리량이 아니라 분배 여부
prefetch 250 (기본값) prefetch 1 (채택) push-queue 명령 201건 노드1 — 197건 CPU 120% 노드2 — 0건 CPU 3% 유휴 push-queue 명령 199건 노드1 — 99건 CPU 148% 노드2 — 100건 CPU 158% 1,193건/s 2,623건/s (+120%)
같은 총 코어·같은 조건에서 prefetch만 변경. 왼쪽은 먼저 연결된 소비자가 명령을 전부 가져가 두 번째 노드가 유휴 상태가 된다

8.여러 컴포넌트에 걸친 파이프라인의 정합성 → 경계별 멱등 처리

문제
파이프라인이 DB → 브로커 → 발송 노드 → 외부 공급자로 나뉘어 있어 전체를 하나의 트랜잭션으로 묶을 수 없음.
DB 트랜잭션의 원자성은 큐를 건너는 순간 끝나고, 큐의 전달 보장은 at-least-once라 경계마다 같은 메시지가 두 번 처리될 수 있음.
반대로 실패(429)를 성공으로 기록하면 재시도가 끊겨 유실됨
해결
경계마다 멱등 장치를 하나씩 배치.
릴레이의 아웃박스 조회는 FOR UPDATE SKIP LOCKED — 두 릴레이가 같은 행을 집지 않음.
알림 삽입은 dedupe_hash 유니크 + ON CONFLICT DO NOTHING — 슬라이스가 재배달돼도 알림 수가 늘지 않음.
발송은 진행 테이블 anti-join으로 이미 보낸 수신자 제외 — 명령이 재배달돼도 다시 보내지 않음. 이 조회는 재배달이거나 2회째 이후 시도일 때만 실행(정상 경로에서는 항상 0행).
429는 성공으로 기록하지 않고 남은 수신자를 새 명령으로 재발행(Retry-After 준수), 재시도 소진 시 DLQ로 이동
결과
목 서버 수신 1,097,488건 전부 성공 — 429 0건 · 5xx 0건 · 유실 0건.
발송 후 기록 전에 프로세스가 종료되면 일부 중복 발송 가능 — 유실 대신 중복을 허용하는 방향으로 결정
캡처 — 재배달 주입 후 알림 카운트 불변(ON CONFLICT) · 429 주입 후 최종 유실 0 카운트
캡처 — 발송 중 push worker kill -9 → 재기동 후 진행 테이블 기준으로 이어 보내는 로그
업무 tx 릴레이 tx 조각 tx 발송 tx 게시 + outbox 1행 큐로 발행 알림 삽입 + 푸시 명령 발송 + 진행 표 기록 커밋 후에만 릴레이 깨움 FOR UPDATE SKIP LOCKED ON CONFLICT DO NOTHING 진행 표 anti-join 큐를 건널 때마다 중복 전달 가능 — 경계마다 멱등 장치가 받아냄 · 소진된 메시지는 DLQ
점선 상자 하나가 한 커밋의 범위. 트랜잭션은 큐에 걸치지 않으므로 중복 전달은 각 경계의 멱등 장치가 처리한다

[성과]

노트북 1대 · 물리 4코어를 구간에 맞춰 배정 · 공급자 응답 230ms — 전 수치 별도 서버 재검증 예정

요구 발송 1분 내 완료 팬아웃 15.00초 1분 예산의 25% (pg 물리 2코어 · 앱 1코어) 발송 332.71초 코어당 1,650건/s (앱 물리 2코어 · pg 1코어) 목 수신 1,097,488건 전부 성공 · 429 0 · 5xx 0 1분 산정 발송이 팬아웃 완료를 기다리지 않고 1.64초에 시작 → 창 58.36초 1,097,488건 ÷ 58.36초 = 18,806건/s 필요 18,806 ÷ 1,650 = 11.4 물리 코어 → 발송 8코어 서버 3대
동기 구조 대비66시간 → 5분 48초
팬아웃26.46 → 14.42초
발송 코어당1,650건/s
1분 내 처리8코어 서버 3대
유실0건
캡처 — 발송 인스턴스 1→2→3 확장 시 처리량·인스턴스별 배분·Postgres 여유 (확장성 검증 런)
실측 · 물리 2코어 1대 산정 · 11.4 물리 코어 팬아웃 15.00초 발송 끝 334.1초 발송 58.36초 창 목표 60초 0 60 100 200 300초
발송은 팬아웃 완료를 기다리지 않고 1.64초에 시작하므로 창은 60초가 아니라 58.36초. 실측 코어당 1,650건/s에서 11.4 물리 코어를 역산했다