수강생 100만 명에게 푸시 알림을 발송하는 상황을 가정하고, 초기 동기 처리 구조의 병목을 분석해
1분 내 처리가 가능한 구조로 개선했습니다.
핵심 결과100만 건 알림 · 61시간 21분 → 5분 48초
1분 목표단일 머신에서 측정한 물리 코어당 1,642건/s의 발송 처리량을 기준으로,
100만 건을 1분 안에 발송하려면 물리 12코어가 필요한 것으로 산정.
Before
After
afterCommit 시점에 Outbox 이벤트를 큐로 발행하도록 구현PENDING으로 남음Before
After
insertForActiveStudents)처리 중 PSQLException 및 DB I/O 오류 발생Memory cgroup out of memory와 Killed process (postgres)를 통해 PostgreSQL 프로세스가 메모리 cgroup 초과로 강제 종료된 것을 확인journalctl -k)postgres 프로세스가 Memory cgroup out of memory로 강제 종료된 것을 확인array_agg로 수신자 전체 UUID를 메모리에 적재하던 구조를 고정 크기의 슬라이스 단위로 분할 처리하도록 변경하여 대량 처리 시 메모리 사용량 증가를 방지WHERE user_id > :cursor ORDER BY user_id LIMIT 5000 쿼리로 DB 내부에서 수신자를 일정 크기로 분할 조회하도록 변경| 슬라이스 | 처리 초 | 알림 건수 | pg 메모리 최고 MB | 익명 메모리 최고 MB | 결과 |
|---|---|---|---|---|---|
| 5,000 | 21.71 | 1,000,000 | 776 | 36 | 완료 |
| 50,000 | 21.62 | 1,000,000 | 803 | 98 | 완료 |
| 200,000 | 20.15 | 1,000,000 | 873 | 227 | 완료 |
| 500,000 | 19.40 | 1,000,000 | 940 | 370 | 완료 |
| 800,000 | 20.07 | 1,000,000 | 1,500 | 864 | 완료 |
Before
After
INSERT를 반복 실행하여 DB Round Trip 100만 회 발생INSERT를 하나의 INSERT...SELECT 문으로 통합ON CONFLICT DO NOTHING)를 하나의 SQL 문에서 DB 내부적으로 처리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
INSERT...SELECT 로 바꿨을때 소요 시간이 22.0초로 줄어든 것을 확인할 수 있음Before
After
| 슬라이스 | 체인 | 소비자 | 소요 | 슬라이스당 DB | DB 총 | pg CPU |
|---|---|---|---|---|---|---|
| 1,000 | 1 | 2 | 26.00초 | 21.9ms | 21.9초 | 103% |
| 5,000 | 1 | 2 | 21.97초 | 99.2ms | 19.9초 | 109% |
| 5,000 | 2 | 4 | 14.11초 | 125.8ms | 25.3초 | 182% |
| 5,000 | 4 | 8 | 11.46초 | 209.0ms | 42.0초 | 293% |
Before
After
Cipher, KeyAgreement, Mac, Signature, KeyPairGenerator)를 호출마다 생성하고, 변경되지 않는 서버 키쌍을 매번 검증하는 것이 주요 오버헤드임을 확인secp256r1 곡선 지정 방식에 따라 각각 1.44 → 0.27ms, 2.24 → 0.34ms로 큰 성능 차이가 발생함을 확인| 구간 | 라이브러리 | 곡선 이름으로 초기화 | 비고 |
|---|---|---|---|
verifyKeyPair | 1.83ms | - | 키쌍은 안 바뀌는데 호출마다 다시 검사 |
| 임시 키쌍 생성 | 1.44ms | 0.27ms | 5.3배 |
| ECDH | 2.24ms | 0.34ms | 6.7배 |
| 나머지 | 약 0.4ms | - |
ECGenParameterSpec("secp256r1")방식과 ECNamedCurveTable 에서 곡선 파라미터를 직접 지정한 방식의 성능을 비교verifyKeyPair, 1.83ms)을 기동 시 한 번만 수행하도록 변경secp256r1 곡선으로 명시적으로 지정하여 초기화하도록 변경하여 임시 키쌍 생성과 ECDH 수행 시간을 각각 5.3배, 6.7배 단축| 설정 | 라이브러리 | 직접 구현 | 배수 |
|---|---|---|---|
| 상한 200 · 워커 1 | 439건/s | 726건/s | 1.65배 |
| 상한 800 · 워커 2 (기본값) | 470건/s | 1,609건/s | 3.42배 |
| 상한 1600 · 워커 2 | 474건/s | 1,605건/s | 3.39배 |
Before
After
Retry-After 준수SETTINGS_MAX_CONCURRENT_STREAMS가 100임을 실측하고 여유를 두어 80%인 80개 스트림만 사용, HTTP/2 연결 10개로 최대 800건의 동시 요청을 처리하도록 구성| 개선 항목 | before | after | 배율 | CPU 사용률 |
|---|---|---|---|---|
| 푸시 발송 동시성 (한 건 → 800건) | 4.2건/s | 3,283건/s | - | |
| 푸시 발송 노드 물리 코어 배정(1코어 → 2코어) | 1,615건/s | 3,283건/s | 2.03배 | 앱 175 → 315% |
Before
After
notificationPushListenerContainerFactory)에 setPrefetchCount(1)을 적용FOR UPDATE SKIP LOCKED를 적용해 다른 릴레이가 이미 잠근 행을 건너뛰도록 구성하여, 동일 아웃박스 이벤트의 동시 처리를 방지dedupe_hash UNIQUE 제약조건과 ON CONFLICT DO NOTHING 을 적용하여 슬라이스가 재전달되어도 동일 알림의 중복 생성을 방지Retry-After 를 준수하여 미처리 수신자를 새 명령으로 재발행하고, 재시도 횟수를 소진한 작업은 DLQ로 이동429 0건, 5xx 0건, 유실 0건.429·5xx 0건)측정 조건 · 노트북 1대(물리 4코어)를 구간에 맞춰 배정 · Mock 서버의 공급자 응답 지연은 실측값인 230ms 사용