대규모 알림 발송 시스템의 병목 분석과 성능 개선

수강생 100만 명에게 푸시 알림을 발송하는 상황을 가정하고, 초기 동기 처리 구조의 병목을 분석해
1분 내 처리가 가능한 구조로 개선했습니다.

핵심 결과100만 건 알림  ·  61시간 21분 → 5분 48초

1분 목표단일 머신에서 측정한 물리 코어당 1,642건/s의 발송 처리량을 기준으로,
100만 건을 1분 안에 발송하려면 물리 12코어가 필요한 것으로 산정.

사용 기술SpringBoot | Java | PostgreSQL | RabbitMQ | Prometheus | Grafana | Docker

측정 환경노트북 1대 · CPU(i7-6700HQ 4C/8T) | RAM(16GB) | SSD

[개요]

[개선 사항]

단계 흐름도. 자료 게시가 PostgreSQL 아웃박스에 기록되고, 아웃박스가 RabbitMQ 팬아웃 큐로 이어진다. 팬아웃 큐는 Spring 팬아웃 워커 여러 개가 소비하며, 워커는 PostgreSQL 의 notification 표와 아웃박스에 쓴다. 그 아웃박스가 RabbitMQ 웹 푸시 큐로 이어지고, 큐를 Spring 푸시 워커 여러 개가 노드마다 소비한다. 컨테이너별 자원은 PostgreSQL cpu 2.0 mem 1.5GB, RabbitMQ cpu 1.0 mem 512MB, 팬아웃 워커 cpu 2.0 mem 1.6GB, 푸시 워커 노드당 cpu 2.0 mem 1.6GB

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

Before

비포 구조. 자료 게시 요청이 게시 트랜잭션으로 들어가 자료 저장, 알림 생성, 푸시 발송을 차례로 처리하고 푸시 발송은 수신자 수만큼 반복한다. 정상이면 HTTP 201 로 수신자 1,000명 기준 응답 시간 220.9초, 실패하면 HTTP 500 과 함께 자료 저장까지 롤백된다

After

애프터 구조. 게시 트랜잭션은 자료 저장과 OutBox 1행만 커밋한다. 커밋 후 한쪽으로는 0.42초 응답이 나가고, 다른 쪽으로는 Queue 를 거쳐 알림 생성과 푸시 발송이 이어지며 푸시 발송은 수신자 수만큼 반복한다
문제
  • 초기에는 강의 자료 게시 트랜잭션 내에서 알림 생성부터 발송까지 동기 방식으로 처리
  • 수신자 1,000명 기준 게시 API 처리 시간이 220.9초로 측정되었으며, 100만 명 규모로 환산 시 약 61시간 21분 소요
  • 알림 처리가 완료될 때까지 강의 자료 게시 API 응답이 지연되고, 알림 처리 실패 시 강의 자료 게시까지 롤백되는 문제 발생
수신자 1,000명 기준 강의자료 게시 API 한 번 호출한 로그.게시 API POST 요청이 HTTP 201 을 220.866276초 만에 반환하고, 저장된 강의 자료 개수를 세는 쿼리 결과가 1건인 출력
응답까지 220.9초. (구독 1,079건, 공급자 왕복 230ms 기준)
100만 명이면 약 61시간 21분으로 계산할 수 있음
같은 API에서 알림 단계를 실패시킨 케이스.같은 API 에서 알림 단계에 예외를 낸 경우. HTTP 500 을 219.533911초 만에 반환했고, 목 수신 서버는 푸시 1,079건을 받았으며, 저장된 강의 자료 개수는 0건인 출력
푸시 1,079건이 나간 뒤 응답은 500.
자료 게시 행은 0건, 나간 푸시는 되돌릴 수 없고 게시만 롤백됨을 확인할 수 있음
해결
  • 강의 자료 게시 API는 게시 트랜잭션 커밋 완료 후 응답을 반환하고, 알림 처리는 비동기로 분리
  • 이 과정에서 DB 커밋과 메시지 발행을 하나의 트랜잭션으로 보장할 수 없는 이중 쓰기 문제 발생
  • 메시지를 먼저 발행하면 게시 트랜잭션 롤백 시 존재하지 않는 게시물에 대한 알림이 발송되고, DB 커밋을 먼저 수행하면 커밋 직후 메시지 발행 전에 장애 발생 시 이벤트가 유실될 수 있음
  • Transactional Outbox 패턴을 적용하여 게시 트랜잭션에서 Outbox 레코드 1건을 함께 커밋하고, Relay가 afterCommit 시점에 Outbox 이벤트를 큐로 발행하도록 구현
  • 커밋 후 Relay 장애로 발행되지 않은 이벤트는 30초 주기 폴링을 통해 재발행하도록 구현
결과
  • 게시 API 응답 시간이 팬아웃 및 푸시 발송 처리와 분리되어 0.42초 내 응답
  • 커밋 전 장애: Outbox 레코드가 생성되지 않아 이벤트 미발행
  • 커밋 후 발행 전 장애: 미발행 Outbox 레코드를 폴링하여 이벤트 재발행
  • 팬아웃 처리 중 애플리케이션 장애 발생 시에도 재기동 후 미처리 구간부터 이어서 처리되며, 최종 알림 건수가 수강생 수와 일치함을 검증
수신자 100만 명 기준 강의 자료 게시 API 요청 로그.A. 게시 응답. pg 코어 0,4,1,5 와 앱 2,6, 인프라 3,7, 체인 1개 조건에서 수신자 100만 명 기준 게시 API POST 요청이 HTTP 201 을 0.415308초 만에 반환한 출력
응답까지 0.42초. 발송이 끝나기를 기다리지 않고 반환됨을 확인할 수 있음
RabbitMQ를 내린 채 게시한 케이스.B. 브로커가 내려간 채로 게시. RabbitMQ 컨테이너를 정지한 뒤 게시하면 HTTP 201 이 0.319808초에 반환되고, 아웃박스 행은 시도 0회의 PENDING 으로 남으며, 강의 자료는 1건 정상 저장된 출력 RabbitMQ 를 다시 올린 뒤 아웃박스 행이 PUBLISHED 로 바뀌고 커밋에서 발행까지 12,657밀리초가 걸렸으며, notification.fanout 큐에 명령 1건이 소비자 없이 쌓였고, 수강생 100만 명과 알림 100만 행이 일치한 출력
응답은 0.32초로 나갔고 아웃박스 행은 PENDING으로 남음
RabbitMQ를 올리자 폴링이 12.7초 뒤에 발행.(30초 폴링 주기 중에 걸린 것을 확인)
명령 1건이 소비자 없이 큐에 쌓인 것을 확인
소비자를 켜 알림 행 100만 건이 채워짐을 확인함
팬아웃 도중 앱을 강제 종료한 케이스.C. 팬아웃 도중 kill -9. 게시가 HTTP 201 을 0.377539초에 반환한 뒤 알림 75,000건 시점에 앱 컨테이너를 강제 종료해 Exited (137) 이 되고, 다시 켠 뒤 수강생 100만 명, 알림 100만 행, 차이 0 인 출력
재기동 뒤 수강생 100만 명, 알림 100만 건. 정상 처리됨을 확인할 수 있음

2. 수신자 100만 명 일괄 처리 시 PostgreSQL OOM → 커서 범위 슬라이싱 도입

Before

비포 구조. 팬아웃 큐에서 이벤트 1건이 팬아웃 워커로 가고, 워커가 PostgreSQL 에 전체 수신자 100만 명의 UUID 집계를 요청한다. PostgreSQL 이 OOM 으로 강제 종료된다

After

애프터 구조. 팬아웃 큐에서 커서가 팬아웃 워커로 가고, 워커가 user_id 가 커서보다 큰 행을 LIMIT 5,000 으로 조회해 수신자 5,000건을 받는다. 워커는 다음 커서를 팬아웃 큐에 다시 발행해 이 과정을 반복한다
문제
  • 초기에는 수신자 100만 명을 전체 조회하여 한 번에 처리하는 구조로 팬아웃을 구현
  • 수신자 처리 과정에서 PostgreSQL 컨테이너의 메모리 사용량이 급증하며 OOM 발생, 팬아웃 처리가 중단됨
원인 파악
  • 애플리케이션 로그에서 팬아웃 워커가 수신자 삽입 처리 중 DB I/O 오류로 중단된 것을 확인
팬아웃 워커에서 올라온 PSQLException 스택 트레이스. An I/O error occurred while sending to the backend 아래로 NotificationFanoutRepository.insertForActiveStudents, NotificationFanoutWorker.fanOutToCurriculumStudents, NotificationFanoutWorker.handle 프레임이 이어지는 출력
애플리케이션 로그.
팬아웃 워커의 수신자 삽입(insertForActiveStudents)처리 중 PSQLException 및 DB I/O 오류 발생
  • Prometheus에서 PostgreSQL 컨테이너의 cgroup 메모리 사용량이 1.5GB 한도까지 증가하는 것을 확인
  • 동일 시점의 애플리케이션 JVM 힙 사용량은 1GB 상한에 도달하지 않아, 메모리 부족의 원인이 애플리케이션 JVM이 아닌 PostgreSQL 컨테이너임을 확인
Grafana cgroup 메모리 패널. 21:31:33부터 21:32:08까지 bench_cgroup_memory_current_bytes가 100MB에서 꾸준히 올라 21:32:02에 한도선 1.50GiB에 닿은 뒤 100MB로 떨어지는 곡선Grafana JVM 힙 패널. 같은 시각 범위에서 jvm_memory_used_bytes 합계가 656MB까지 오르고 상한선 1GiB에 닿지 않은 채 21:32:04에 0으로 떨어지는 곡선
같은 시각(21:31:33~21:32:08)의 두 패널. 왼쪽은 Postgres cgroup 메모리, 오른쪽은 애플리케이션 JVM 힙.
Postgres는 한도 1.5GB에 닿은 뒤 강제 종료. JVM 힙은 최대 656MB로 1GB 상한에 도달하지 않음
  • 커널 로그의 Memory cgroup out of memory와 Killed process (postgres)를 통해 PostgreSQL 프로세스가 메모리 cgroup 초과로 강제 종료된 것을 확인
커널 로그 한 줄. 21:32:02 Memory cgroup out of memory: Killed process 2811795 (postgres) total-vm 1796076kB, anon-rss 1114160kB, file-rss 60324kB, shmem-rss 380664kB
Grafana와 동일 시점에 확인한 커널 OOM 로그(journalctl -k)
anon-rss 1,088MB · shmem-rss 372MB · file-rss 59MB의 메모리 사용 상태에서 postgres 프로세스가 Memory cgroup out of memory로 강제 종료된 것을 확인
해결
  • array_agg로 수신자 전체 UUID를 메모리에 적재하던 구조를 고정 크기의 슬라이스 단위로 분할 처리하도록 변경하여 대량 처리 시 메모리 사용량 증가를 방지
  • 메시지에는 커서 범위만 전달하고, 워커가 WHERE user_id > :cursor ORDER BY user_id LIMIT 5000 쿼리로 DB 내부에서 수신자를 일정 크기로 분할 조회하도록 변경
  • 각 슬라이스 처리가 완료되면 워커가 다음 커서 기반 슬라이스 메시지를 큐에 발행하는 체인 구조로 구현
  • 수신자 목록을 애플리케이션에서 조회해 메시지에 포함하는 방안도 검토했으나, UUID 100만 개가 423MB에 달하고 RabbitMQ와 워커에도 동일한 데이터가 전달되므로 수신자 목록은 DB에 유지하고 커서만 메시지로 전달하도록 결정
결과
  • 슬라이스 단위 분할 처리로 PostgreSQL OOM 없이 100만 건 알림 생성 완료
  • 슬라이스 크기를 5,000~800,000건으로 변경해 측정한 결과, 처리 시간은 19.40~21.71초로 큰 차이가 없었으나 슬라이스 크기가 증가할수록 PostgreSQL 메모리 사용량이 증가하는 경향을 확인
슬라이스처리 초알림 건수pg 메모리 최고 MB익명 메모리 최고 MB결과
5,00021.711,000,00077636완료
50,00021.621,000,00080398완료
200,00020.151,000,000873227완료
500,00019.401,000,000940370완료
800,00020.071,000,0001,500864완료
같은 100만 건. 슬라이스 크기만 바꾸고 나머지는 같음(체인 1 · 앱 힙 1GiB · 푸시기능 off · pg 한도 1,500MB).

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

Before

비포 구조. 팬아웃 워커가 user_id 가 커서보다 큰 행을 LIMIT 5,000 으로 조회해 PostgreSQL 에서 수신자 5,000건을 받은 뒤, 수신자마다 INSERT 를 1건씩 보내 슬라이스 하나에 5,000회를 왕복한다

After

애프터 구조. 팬아웃 워커가 조회와 삽입, 중복 제거를 합친 INSERT ... SELECT 를 1회 보내고, PostgreSQL 이 삽입 건수와 다음 커서를 돌려준다
문제
  • 수신자별로 INSERT를 반복 실행하여 DB Round Trip 100만 회 발생
  • 100만 건 처리에 2분 28초가 소요되어, 팬아웃 처리만으로도 1분 이내 발송 요구사항을 초과
앱 로그 두 줄. 15:29:46.930 넓은 팬아웃 시작 슬라이스=5000 방식=행마다 INSERT, 15:32:15.140 넓은 팬아웃 끝 슬라이스=5000 소요=148.1초 방식=행마다 INSERT
수신자 100만 명을 슬라이스 5,000씩 행마다 INSERT로 넣었을 때의 앱 로그. 소요 148.1초.
해결
  • 항목 2의 슬라이스 구조를 유지하면서, 슬라이스 내 수신자별 INSERT를 하나의 INSERT...SELECT 문으로 통합
  • 수신자 조회, 알림 레코드 삽입, 중복 제거(ON CONFLICT DO NOTHING)를 하나의 SQL 문에서 DB 내부적으로 처리
슬라이스 단위 INSERT ... SELECT 전문
WITH slice AS (
    SELECT e.user_id
    FROM core.enrollment e
    WHERE e.curriculum_id = CAST(:curriculumId AS uuid)
      AND e.status = 'ACTIVE'
      AND (CAST(:afterUserId AS uuid) IS NULL OR e.user_id > CAST(:afterUserId AS uuid))
      AND (CAST(:untilUserId AS uuid) IS NULL OR e.user_id <= CAST(:untilUserId AS uuid))
    ORDER BY e.user_id
    LIMIT :sliceLimit
), content AS (
    INSERT INTO core.notification_content (
        id, type, severity, title, message, targets, group_key,
        previous_starts_at, next_starts_at)
    SELECT CAST(:contentId AS uuid), :type, :severity, :title, :message,
           CAST(:targets AS jsonb), :groupKey,
           CAST(:previousStartsAt AS timestamptz), CAST(:nextStartsAt AS timestamptz)
    WHERE EXISTS (SELECT 1 FROM slice)
    ON CONFLICT (id) DO NOTHING
), inserted AS (
    INSERT INTO core.notification
            (id, recipient_id, content_id, dedupe_hash, read_status, action_status)
    SELECT gen_random_uuid(), s.user_id, CAST(:contentId AS uuid),
           md5(:dedupePrefix || s.user_id)::uuid, 'UNREAD', :actionStatus
    FROM slice s
    ON CONFLICT (dedupe_hash) WHERE dedupe_hash IS NOT NULL DO NOTHING
    RETURNING recipient_id
)
SELECT (SELECT count(*) FROM slice)    AS scanned,
       (SELECT count(*) FROM inserted) AS inserted,
       (SELECT array_agg(recipient_id) FROM inserted) AS inserted_recipients,
       (SELECT s.user_id FROM slice s ORDER BY s.user_id DESC LIMIT 1) AS last_user_id
결과
  • 슬라이스마다 하나의 SQL 문으로 처리하여 DB Round Trip 횟수를 100만 회에서 201회로 감소
  • 처리 시간을 22.0초로 단축하여 행 단위 INSERT 대비 6.7배 개선
앱 로그 두 줄. 15:28:39.054 넓은 팬아웃 시작 슬라이스=5000 방식=INSERT ... SELECT, 15:29:01.191 넓은 팬아웃 끝 슬라이스=5000 소요=22.0초 방식=INSERT ... SELECT
같은 100만 명·같은 슬라이스 5,000을 INSERT...SELECT 로 바꿨을때 소요 시간이 22.0초로 줄어든 것을 확인할 수 있음

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

Before

비포 구조. UUID 전체 공간이 체인 1 구간 하나로 전체 범위를 덮고, 구간 1건을 팬아웃 큐에 발행한다. 팬아웃 워커 안의 소비자 1이 체인 1 메시지를 받아 LIMIT 5,000 으로 PostgreSQL 을 조회하고, 다음 슬라이스를 큐에 다시 발행한다

After

애프터 구조. UUID 전체 공간을 체인 1 구간 (MIN, boundary) 과 체인 2 구간 (boundary, MAX) 으로 나눠 구간 2건을 팬아웃 큐에 발행한다. 팬아웃 워커 안의 소비자 1과 소비자 2가 각각 체인 1과 체인 2를 받아 LIMIT 5,000 으로 PostgreSQL 을 조회하고, 각자 다음 슬라이스를 큐에 다시 발행한다
문제
  • 하나의 체인은 이전 슬라이스 처리가 완료된 후 다음 슬라이스를 발행하는 직렬 처리 구조로 동작
  • 큐에 처리 가능한 메시지가 하나뿐이므로 소비자 수를 늘려도 병렬 처리가 발생하지 않아 처리 시간이 단축되지 않음
  • 체인 1에서 PostgreSQL CPU 사용량이 109%로, 물리 코어 2개의 최대 사용량인 200%에 미치지 않아 CPU 사용량에 여유가 있음을 확인
해결
  • 발행 시 UUID 공간을 균등한 범위로 분할하여 여러 체인을 생성하고, 각 체인이 독립적으로 슬라이스를 처리하도록 병렬화
  • 체인 수는 PostgreSQL 물리 코어 수, 소비자 수는 체인 수의 2배를 기본 기준으로 설정하고, DB 코어 증설 시 두 값을 함께 조정하도록 기준 정립
  • 슬라이스 크기와 체인 수를 변경하며 조건별 3회 측정 후 중앙값을 비교하여 처리 단위를 결정
  • 슬라이스를 1,000 → 5,000건으로 늘리면 처리 시간이 26.00초 → 21.97초로 15.5% 단축됨을 확인
  • 체인을 1 → 2개로 늘리면 처리 시간이 21.97초 → 14.11초로 35.8% 단축됨을 확인
  • 체인 1에서는 DB 처리 시간이 19.9초로 전체 21.97초의 91%를 차지하여, DB 작업을 병렬화하는 효과가 크게 나타남
  • 체인을 2 → 4개로 늘리면 처리 시간은 추가로 18.8% 단축되지만, PostgreSQL CPU 사용량이 182% → 293%로 증가하고 슬라이스당 DB 처리 시간이 125.8ms → 209.0ms로 66% 증가하여 추가 병렬화에 따른 DB 경합이 발생함을 확인
  • 이에 따라 체인 2개, 소비자 4개를 기본 구성으로 채택
슬라이스체인소비자소요슬라이스당 DBDB 총pg CPU
1,0001226.00초21.9ms21.9초103%
5,0001221.97초99.2ms19.9초109%
5,0002414.11초125.8ms25.3초182%
5,0004811.46초209.0ms42.0초293%
수신자 100만 명. 조건마다 3회 측정한 중앙값.
측정 조건: 수신자 100만 명 · 조건별 3회 측정 중앙값 · PostgreSQL 물리 코어 2개 · 브로커 1코어 · 애플리케이션 1코어
결과
  • 슬라이스 크기와 체인 수를 조정하여 전체 처리 시간을 26.00초에서 14.11초로 45.7% 단축

5. 발송 라이브러리의 암호 처리 오버헤드 → 암호 객체 재사용 구조로 직접 구현

Before

비포 구조. 푸시 1건이 들어오면 매 호출마다 암호 객체 5종 생성, 서버 키쌍 검증, 임시 키쌍과 salt 생성, 암호화와 서명을 차례로 수행해 건당 5.95ms가 걸린다

After

애프터 구조. 암호 객체 5종 생성은 스레드별 1회로 빠지고, 매 호출마다 반복하는 것은 임시 키쌍과 salt 생성, 암호화와 서명뿐이다. 서버 키쌍 검증은 기동 시 한 번으로 옮겼다. 건당 0.36ms가 걸린다
문제
  • 발송 구간에서 애플리케이션 CPU 사용량이 376%로 할당 한도 400%에 근접한 반면 PostgreSQL은 18%에 그쳐, 발송 처리의 CPU 병목이 애플리케이션에 있음을 확인
  • Web Push는 표준(RFC 8291, RFC 8292)에 따라 기기별 키를 사용한 암호화·서명이 필요하며, 발송 라이브러리(nl.martijndwars:web-push)의 건당 암호 처리 시간이 5.95ms로 측정되어 암호 처리 구간을 세부 분석
Grafana 컨테이너 CPU 패널. 22:26:48부터 22:31:14까지 liveclass-bench-app-1이 400%에 붙어 있고 liveclass-bench-postgres-1을 비롯한 나머지 컨테이너는 바닥에 깔린 곡선
발송이 도는 4분 26초 동안의 컨테이너 CPU (100만 건 발송).
앱만 배정 400%에 붙어 있고 Postgres는 20% 아래. 팬아웃은 이 구간에 돌지 않음
원인 파악
  • 실행 시간을 단계별로 분해한 결과, 실제 암호 연산은 약 0.9ms인 반면 암호 객체 생성 및 서버 키쌍 검증에 대부분의 시간이 소요됨을 확인
  • 라이브러리가 암호 객체(Cipher, KeyAgreement, Mac, Signature, KeyPairGenerator)를 호출마다 생성하고, 변경되지 않는 서버 키쌍을 매번 검증하는 것이 주요 오버헤드임을 확인
  • 임시 키쌍 생성과 ECDH가 전체 암호 처리 시간에서 큰 비중을 차지하며, secp256r1 곡선 지정 방식에 따라 각각 1.44 → 0.27ms, 2.24 → 0.34ms로 큰 성능 차이가 발생함을 확인
구간라이브러리곡선 이름으로 초기화비고
verifyKeyPair1.83ms-키쌍은 안 바뀌는데 호출마다 다시 검사
임시 키쌍 생성1.44ms0.27ms5.3배
ECDH2.24ms0.34ms6.7배
나머지약 0.4ms-
기기 1건당 5.95ms의 암호 처리 시간을 단계별로 분해한 결과.
ECGenParameterSpec("secp256r1")방식과 ECNamedCurveTable 에서 곡선 파라미터를 직접 지정한 방식의 성능을 비교
해결
  • RFC 8291·8292의 암호화·서명 절차를 직접 구현하여 호출마다 생성하던 암호 객체를 스레드별로 재사용
  • 암호 객체는 연산 상태를 가지므로 스레드 간 공유하지 않고 ThreadLocal로 스레드별 독립 인스턴스를 유지
  • 라이브러리가 기기 1건마다 수행하던 서버 키쌍 일치 검증(verifyKeyPair, 1.83ms)을 기동 시 한 번만 수행하도록 변경
  • 키 생성기를 secp256r1 곡선으로 명시적으로 지정하여 초기화하도록 변경하여 임시 키쌍 생성과 ECDH 수행 시간을 각각 5.3배, 6.7배 단축
  • 임시 키쌍과 salt는 규격에 따라 메시지마다 새로 생성
결과
  • 기기 1건의 암호 처리 시간을 5.95ms → 0.36ms로 16.5배 단축
  • 전체 발송 처리량을 470 → 1,609건/s로 3.42배 향상
  • 동시성 상한을 800 → 1,600으로 높여도 처리량이 1,609 → 1,605건/s로 유의미하게 증가하지 않아 워커 2개·상한 800을 기본 설정으로 채택
설정라이브러리직접 구현배수
상한 200 · 워커 1439건/s726건/s1.65배
상한 800 · 워커 2 (기본값)470건/s1,609건/s3.42배
상한 1600 · 워커 2474건/s1,605건/s3.39배
동일한 2만 건을 대상으로 오류 없이 측정하고, 각 설정을 3회 실행하여 중앙값을 사용.
라이브러리 구현과 직접 구현을 번갈아 실행하여 실행 순서에 따른 배경 부하 영향을 최소화

6. 코어당 처리량 한계 및 개별 HTTPS 발송 병목 → 푸시 워커 분리 및 동시 발송 구조

Before

비포 구조. 팬아웃 큐에서 단일 애플리케이션으로 들어가고, 그 안에서 팬아웃 워커가 푸시 워커를 직접 호출한다. 푸시 워커는 FCM에 HTTPS 요청을 1건씩 순차로 보내 약 4.2건/s에 그친다

After

애프터 구조. 팬아웃 큐와 푸시 큐를 나누고 팬아웃 애플리케이션과 푸시 애플리케이션을 별도 배포 단위로 분리한다. 푸시 워커는 HTTP/2로 최대 800건을 동시에 FCM에 보내 약 3,283건/s를 낸다
문제
  • 암호화 최적화 이후 Web Push 암호화 연산을 분리해 측정한 결과 물리 코어당 2,796건/s로, 구독 1건마다 암호화가 1회 필요한 구조에서는 109만 건 발송을 1분 내 처리하기 위해 CPU 확장이 필요
  • 디바이스별 Web Push 요청을 개별적으로 전송해야 하므로 109만 건의 발송에는 109만 회의 HTTPS 요청이 필요
  • 단일 인스턴스의 CPU 처리량으로는 1분 이내 처리 요구사항을 충족할 수 없음
  • 공급자 왕복 시간이 약 230ms여서 순차 발송 시 처리량이 약 4.2건/s에 불과함
  • 팬아웃 워커와 푸시 워커가 동일 애플리케이션에 배포되어 있어, 푸시 발송 처리량만 독립적으로 확장하기 어려운 구조
해결
  • fanout-queue와 push-queue를 분리하고, 푸시 발송 워커를 독립 배포 단위로 분리해, 이후 발송 노드만 별도로 확장할 수 있는 구조로 변경
  • 실제 노드 확장에 앞서 푸시 워커의 CPU 자원을 1코어 → 2코어로 늘려 자원 증가에 따라 처리량이 증가하는지 사전 검증
  • 외부 요청의 왕복 대기 시간을 겹쳐 처리하기 위해 동시 발송 구조로 변경
  • FCM의 HTTP/2 지원을 확인하고, HTTP/2 우선 사용과 HTTP/1.1 폴백이 가능하도록 발송 클라이언트 구성
  • 여러 HTTP/2 연결과 멀티플렉싱을 활용해 최대 800건을 동시 발송하고, HTTP 429 발생 시 동시 요청 수를 조정하며 Retry-After 준수
  • FCM 연결에서 SETTINGS_MAX_CONCURRENT_STREAMS가 100임을 실측하고 여유를 두어 80%인 80개 스트림만 사용, HTTP/2 연결 10개로 최대 800건의 동시 요청을 처리하도록 구성
개선 항목beforeafter배율CPU 사용률
푸시 발송 동시성 (한 건 → 800건)4.2건/s3,283건/s-
푸시 발송 노드 물리 코어 배정(1코어 → 2코어)1,615건/s3,283건/s2.03배앱 175 → 315%
수신자 100만 명 · 공급자 응답 230ms.
4.2건/s는 공급자 왕복 230ms를 건마다 기다린 값.
결과
  • 푸시 워커의 CPU 자원을 1코어 → 2코어로 늘리자 1,615건/s → 3,283건/s로 약 2배 증가
    물리 코어 증가에 따른 선형적인 처리량 증가를 확인
  • 순차 발송 시 4.2건/s였던 외부 요청을 최대 800건 동시 처리해 3,283건/s까지 개선
  • 실제 발송 처리량은 물리 코어당 약 1,642건/s로 측정됐으며, 암호화 연산만 측정한 2,796건/s 상한의 약 59% 수준
  • 측정된 물리 코어당 약 1,642건/s를 이후 발송 워커의 용량 산정 기준으로 사용

7. 2노드 클러스터 구성 시 단일 노드 처리 편중 → 푸시 컨슈머 prefetch=1 적용

Before

비포 구조. 푸시 큐의 메시지 201건이 푸시 app 한 노드로 전부 가고 푸시 app2는 0건을 받아 놀고 있다. 두 노드 합계 처리량은 1,539건/s다

After

애프터 구조. 같은 201건이 푸시 app에 101건, 푸시 app2에 100건으로 나뉘어 두 노드가 함께 처리하고, 합계 처리량이 2,885건/s가 된다
문제
  • 발송 노드를 2대로 확장했으나 메시지 소비량이 특정 노드에 편중되는 현상을 확인
  • 전체 발송 메시지 201건 중 app 노드가 201건, app2 노드가 0건을 처리
  • 노드별 CPU 사용률도 167% 대 2.8%로 큰 편차가 발생
터미널 두 줄. docker compose logs app 을 푸시 전달로 세면 201, app2 를 세면 0
노드별 푸시 메시지 처리 로그.
app 201건, app2 0건으로 전체 메시지가 단일 노드에 편중됨을 확인
원인 파악
  • Spring AMQP 메시지 리스너 컨테이너의 prefetch 기본값이 250건으로 설정되어 있음을 확인
    (Spring AMQP 공식 문서)
  • 전체 발송 메시지가 201건이고 prefetch 한도인 250건보다 적어, 한 컨슈머가 처리 완료 전에 201건 전체를 전달받을 수 있는 상태였으며 실제로 한 노드의 컨슈머에 전체 메시지가 전달됨
  • 다른 노드의 컨슈머에는 처리할 메시지가 남지 않아, 컨슈머를 여러 개 배치했음에도 메시지 처리가 단일 노드에 편중됨
해결
  • 푸시 메시지 전용 리스너 컨테이너 팩토리(notificationPushListenerContainerFactory)에 setPrefetchCount(1)을 적용
  • 메시지 하나가 5,000건의 푸시 작업을 포함해 처리에 수 초가 소요되므로, 컨슈머가 처리 중인 메시지를 추가로 미리 전달받을 필요가 없어 prefetch를 1로 제한
결과
  • 메시지가 두 노드에 101건 / 100건으로 균등하게 분배되고, 2노드 전체 처리량이 1,539 → 2,885건/s로 1.87배 향상
터미널 두 줄. docker compose logs app 을 푸시 전달로 세면 101, app2 를 세면 100
prefetchCount=1 적용 후 동일 조건에서 재실행한 결과.
app 101건, app2 100건으로 메시지가 양쪽 노드에 균등하게 분배됨

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

문제
  • 파이프라인이 DB → 브로커 → 발송 노드 → 외부 공급자로 분리되어 있어 전체를 하나의 트랜잭션으로 묶을 수 없음
  • 메시지 전달은 at-least-once를 전제로 하므로 재전달 시 동일 작업이 중복 수행될 수 있음
  • 반대로 실패(429)를 성공으로 기록하면 재시도가 중단되어 알림이 유실될 수 있음
해결
각 트랜잭션 경계마다 중복처리와 재처리를 흡수할 수 있는 제어 장치를 적용
  • 릴레이의 아웃박스 조회에 FOR UPDATE SKIP LOCKED를 적용해 다른 릴레이가 이미 잠근 행을 건너뛰도록 구성하여, 동일 아웃박스 이벤트의 동시 처리를 방지
  • 두 릴레이가 같은 행을 동시에 집지 않음
  • 알림 삽입은 dedupe_hash UNIQUE 제약조건과 ON CONFLICT DO NOTHING 을 적용하여 슬라이스가 재전달되어도 동일 알림의 중복 생성을 방지
  • 슬라이스가 재배달돼도 알림 수가 증가하지 않음
  • 발송 시 진행 테이블과 anti-join하여 이미 발송 완료된 수신자를 제외. 정상 경로에서는 조회를 생략하고, 명령 재전달/재시도 상황에서만 중복 발송 여부를 확인
  • 명령 재배달 시 중복 발송 방지. 정상 경로에서는 조회를 생략하고 재배달·재시도 시에만 실행.
  • 429는 성공으로 처리하지 않고 Retry-After 를 준수하여 미처리 수신자를 새 명령으로 재발행하고, 재시도 횟수를 소진한 작업은 DLQ로 이동
결과
  • Mock 서버 수신 1,097,488건 전부 성공 처리
  • 429 0건, 5xx 0건, 유실 0건.
  • 발송 성공 후 진행 상태를 기록하기 전에 프로세스가 종료되면 동일 수신자에게 일부 중복 발송이 발생할 수 있음
  • 유실보다 중복을 허용하는 at-least-once 발송 정책을 적용하고, 재전달에 의한 중복은 각 단계의 멱등성 제어로 방지하되 발송 성공 후 상태 기록 이전의 장애로 발생하는 중복 발송은 허용.
커밋 경계 네 개. 게시 트랜잭션은 게시와 outbox 를 함께 저장하고 커밋 후에 Relay 를 실행한다. Outbox Relay 는 outbox 행을 선점해 큐로 발행하며 FOR UPDATE SKIP LOCKED 로 같은 행의 동시 선점을 막는다. 슬라이스 처리 트랜잭션은 알림 생성과 push 명령 저장을 하고 dedupe_hash UNIQUE 와 ON CONFLICT DO NOTHING 으로 재전달 시 중복 알림 생성을 막는다. Push 발송은 발송과 진행 상태 기록을 하고 재전달 시 진행 이력 anti-join 으로 발송이 끝난 수신자를 제외한다. 전달은 at-least-once 이고 재시도 한도를 넘기면 DLQ 로 보낸다. 발송에 성공한 뒤 상태를 기록하기 전에 장애가 나면 생기는 일부 중복은 허용한다.
DB 트랜잭션과 큐 전달을 하나의 원자적 작업으로 묶을 수 없으므로,
재전달에 따른 중복은 각 처리 단계의 멱등 장치로 제어함

[최종 성과]

측정 조건 · 노트북 1대(물리 4코어)를 구간에 맞춰 배정 · Mock 서버의 공급자 응답 지연은 실측값인 230ms 사용