Redesigning a Bulk Notification Creation Pipeline with an Outbox Based Structure
대량 알림 생성 파이프라인 재설계
(사내 보안 규정에 따라 실제 서비스·테이블·코드 식별자는 모두 일반화되어 있습니다)
Outbox 기반 큐 구조로 알림 생성 안정성과 운영 안전장치 확보하기
대량 알림(인기 콘텐츠, 위치 기반 글, 운영자 등로 알림) 을 안정적으로 생성하고 운영 리스크를 줄이기 위해, 기존의 즉시 생성 / 즉시 발송 중심 구조를 큐 기반(outbox) 생성 구조로 전환했습니다.
단순히 "큐를 도입했다"가 아니라,
왜 기존 구조가 한계에 부딪혔는지,
어떤 기준으로 구조를 재정의했는지
운영 관점에서 무엇이 좋아졌고, 무엇을 포기했는지
를 문제 -> 판단 -> 설계 -> 결과 -> 트레이드 오프 흐름으로 작성합니다.
TL;DR
문제
대량 알림 생성 작업이 동시에 실행되는 상황이 발생하면서 DB CPU 스파이크가 발생했고, 작업 중단 기능이 없어 pod를 강제 종료 후 다시 띄웠습니다. 또한 재시작 시 어디까지 처리됐는지 추적할 수 없었습니다.
해결
대량 알림 생성을 즉시 실행이 아닌 작업 큐(job queue) + 커서(cursor) 기반으로 관리하도록 구조를 전환했습니다.
이 설계는:
처리량 극대화 X
즉시성 최우선 X
대신,
운영 제어 가능성 O
장애 대응력 O
예측 가능한 부하 O
를 선택한 구조입니다.
효과
단일 실행 보장(single-flight), 부분 재처리, stuck job 복구가 가능해졌고 운영 리스크가 감소했습니다.
배경 1. 용어 정리
채널: 각 사용자는 시스템 내에서 하나의 채널을 가지며, 알림의 실제 수신 단위는 사용자 계정이 아닌 채널입니다.
알림 센터: 앱/서비스 내부에 노출되는 알림 목록으로, 채널 단위로 누적됩니다.
푸시: 단말로 전달되는 실시간 알림 메시지입니다.
생성 큐: 대량/예약 알림을 생성 작업 단위로 적재하는 큐 테이블입니다.
배경 2. 전체 알림 유형
알림은 크게 4가지 유형으로 나뉩니다.
인기 콘텐츠 알림 (대량)
일정 주기마다 조회수가 높은 콘텐츠를 선정
알림 센터 적재 + 푸시 발송
대상 채널 수가 많아 대량 생성에 해당
위치 기반 질문 알림 (대량)
운영자가 특정 콘텐츠를 "질문 콘텐츠"로 지정
콘텐츠 위치 기준 일정 반경 내 채널에 알림 생성
운영자 등록 알림 (대량)
운영자가 제목/내용을 직접 작성
전체 또는 특정 플랫폼에 알림 센터 + 푸시 발송
채널 활동 알림 (소량)
좋아요, 댓글, 팔로우 등 사용자 활동 기반 알림
즉시성이 중요하고 대상 규모가 적음
문제 인식 - 기존 구조의 한계

초기 설계는 요구사항을 빠르게 충족하는데 초점이 맞춰져 있었고, 각 알림은 다음과 같이 처리되었습니다.
인기 콘텐츠: 스케줄러가 특정 시점에 즉시 생성
질문 콘텐츠: 관리자가 지정하는 순간 즉시 생성
운영자 알림: 콘솔 등록 즉시 생성
이 구조는 다른팀의 "시간표" 기반 운영을 참고하였고 시간표를 공유하였지만, 팀 내부에서 지켜지지 않아 운영에서 여러 문제가 드러나게 됐습니다.
1) DB CPU 스파이크
대량 알림 생성 작업이 동시에 실행되면서 DB CPU 사용률이 급격히 상승했고, 최악의 경우 순간적으로 100%에 도달해 서비스 인스턴스를 강제 종료해야했습니다.
2) 재처리 불가 구조
알림 생성과 푸시 생성이 하나의 흐름으로 엮여 있었기 때문에, 작업이 중간에 실패하면 어디까지 처리됐는지 추적할 수 없었고, 이후 구간은 소실되거나 중복 생성될 위험이 있었습니다.
3) 운영 제어 포인트 부재
알림 종류가 늘어날수록 케이스별 분기와 중복 코드가 증가했고, 운영자가 작업을 관찰, 제어할 수 있는 명확한 기준점이 없었습니다.
목표 설정
1. Single-flight
대량 알림 생성 작업은 한번에 하나만 실행
2. Idempotency / 재시도 가능성
중단되어도 이어서 처리 가능
3. Self-healing
장시간 갱신이 없는 작업(stuck job)을 감지하고 복구
4. 확장성
대량 알림을 하나의 그룹으로 관리
해결 전략
처음에는 "즉시 생성 구조를 유지한 채로, 성능만 개선하는 방향"도 고려했습니다. 쿼리 튜닝, 배치 크기 조절, 인덱스 검토 등을 통해 단일 작업의 처리 시간을 줄이고 처리 효율을 높이면 문제가 완화될 거라고 생각했습니다.
하지만 이 방식은 한계를 가지고 있다고 생각했습니다. 알림 생성 요청이 동시에 여러 개 들어오는 상황을 운영적으로 제어가 되지 않았고, 성능이 개선되더라도 '동시에 실행되는 작업 수'가 늘어나면 (예: 새로운 종류의 알림) 동일한 문제가 반복될 수 있었습니다.
"느린 쿼리가 아닌, 통제되지 않은 동시 실행" 문제는 해결되지 않았기 때문입니다.
메시지 큐 도입(X)
대량 작업을 처리하는 방식으로 MQ 기반 구조도 고민했습니다. 메시지 큐를 활용하면 “즉시 생성 로직이 여러 곳에서 동시에 실행되며 DB에 쓰기 부하가 몰리는 문제”를 처리 흐름 자체를 바꿔서 완화할 수 있습니다. 핵심은 생성 요청을 즉시 DB에 쓰지 않고, ‘작업 메시지’로 분해한 뒤 큐에 적재하는 것입니다.
동작 원리
생상자가 메시지를 큐에 넣습니다. 예를 들어, 운영 알림이 시작되면, DB에 쓰기 작업을 수행하지 않고 작업 메시지를 발행합니다.
대량 작업을 메시지 단위로 쪼개어 큐에 적재합니다. 컨슈머가 한 번에 처리하는 양을 파티션으로 쪼개서 관리하여 한번에 처리되는 쓰기량이 메시지 크기로 제한됩니다.
소비자가 정해진 동시성(동시 처리 작업 수) 만큼 처리합니다. 결과적으로 DB에 동시에 날아가는 Insert/Update의 최대치가 제한됩니다.
큐는 버퍼 역할을 하고, 컨슈머가 정해진 속도로만 배출하면서 DB 스파이크를 줄일 수 있습니다.
재시도 / DLQ로 실패를 분리 메시지 처리 실패시에는 일정 횟수 재시도, 계속 실패하면 DLQ로 이동하는 방식으로 실패 케이스를 관리할 수 있습니다.
이를 통해,
DB CPU 스파이크가 완화되고, 처리량을 '소비자 동시성'으로 제한합니다.
대량 작업을 작은 메시지로 나눠 처리할 수 있고,
큐가 버퍼가 되어 순차적으로 처리되도록 제어할 수 있습니다.
하지만, 운영적으로 작업들을 파악하거나 제어하는 문제는 해결하지 못했습니다.
지금 실행 중인 Job이 무엇인지.
어디까지 처리됐는지.
현재 Job을 즉시 중단하는 Abort
이 문제에서 가장 중요했던 것은 “얼마나 빨리 처리하느냐”가 아니라, “문제가 발생했을 때 운영자가 무엇을 보고, 어디를, 어떻게 제어할 수 있느냐”였습니다.
메시지 큐 기반 구조는 처리량 제어에는 매우 효과적이지만, 작업의 진행 상태, 현재 처리 중인 범위, 즉시 중단 여부를 운영자가 직관적으로 파악하기 어렵습니다.
결국 MQ를 선택하더라도, 별도의 Job 상태 저장소, 진행도 관리, Abort 제어를 추가로 설계해야 했고, 이는 곧 ‘Outbox 기반 작업 큐를 직접 설계하는 것’과 다르지 않았습니다.
Outbox 기반 생성 큐 도입
Outbox는 외부 메시지 브로커가 아닌, DB 테이블을 작업 큐로 사용해 상태와 진행도를 함께 관리하는 구조입니다.
이번 문제의 핵심은 단순히 “처리량이 부족하다”가 아니었습니다. 실제로는 다음 세 가지가 동시에 필요했습니다.
대량 작업이 동시에 실행되지 않도록 제어
작업의 진행 상태를 명확히 추적
문제가 발생했을 때, 운영자가 특정 작업만 선택적으로 개입
즉, "빠르게 많이 처리하는 구조”가 아니라 “작업 단위로 상태를 관찰하고 제어할 수 있는 구조”가 필요했습니다.
핵심 아이디어
즉시 생성 구조를 버리고,
알림 생성 요청을 job queue에 적재
배치 프로세스가 이를 순차적으로 소비
진행 상태를 cursor로 기록
하는 방식으로 전환했습니다.
설계 1. 생성 큐(Job Queue) 구조
각 대량 알림 요청은 하나의 작업으로 큐에 저장됩니다.

주요 개념
작업 타입: 알림 센터 생성/ 푸시 생성
작업 카테고리: 인기 콘텐츠 / 질문 콘텐츠 / 운영자 알림
스케줄 시간: 예약 실행 시점
작업 상태: PENDING -> PROCESSING -> PROCESSED
처리 오프셋: 마지막으로 처리한 채널 위치
이 구조를 통해 작업의 목적, 대상, 진행 상태를 명확히 분리했습니다.
설계 2. Cursor 기반 처리
대량 알림은 모든 채널을 한번에 처리 하지 않습니다. 처리 진행도를 기록하는 방식으로 기존offset 기반 처리도 고려할 수 있었습니다. 하지만, offset 기반 조회는 페이지가 뒤로 갈수록 비용이 증가하고, 장시간 실행되는 배치에서는 DB 부하를 예측하기 어렵습니다.
반면 cursor 기반 처리는 “이전 처리 지점 이후만 조회”하는 구조이기 때문에 처리 시간이 데이터 증가에 따라 선형적으로 증가하지 않으며, 대량 작업에서 안정적인 성능 특성을 가집니다.
커서 방식을 사용함으로써:
채널을 일정 크기(chunk)로 나누어 순회
각 chunk 처리 후 cursor 업데이트
장애 발생 시 cursor 이후부터 재개
이 방식으로,
부분 재처리 가능
중복 최소화
장시간 작업에 대한 안정성
을 확보했습니다.
설계 3. Single-flight 보장
배치 프로세스는 아래의 규칙을 따릅니다.

RUNNING 상태의 job이 있으면 신규 job 실행 금지
오래 갱신되지 않은 RUNNING job은 stuck job으로 판단
복구 대상 job은 cursor 기준으로 재시작
이를 통해 대량 생성 작업이 병렬로 실행되는 상황 자체를 제거했습니다.
설계 4. Abort(중단 제어)
Abort(중단) 기능은 처음부터 요구사항에 있던 기능은 아니었습니다. 하지만 실제 운영 중, DB부하, 외부 장애, 예상치 못한 트래픽 상황을 겪으며 "작업을 안전하게 멈출 수 있는 수단이 없다"는 점이 불안했습니다.
강제 인스턴스 종료는 문제를 해결하는게 아니라, 피해를 감수하고 상황을 멈추는 선택이었고, 이후 설계에서는 '언제든 개입하여 해당 작업만 멈출 수 있는 지점'을 만들겓 됐습니다.
운영 중 예상치 못한 상황에서 즉시 작업을 멈출 수 있는 제어 포인트.
Admin Console에서 작업 중단 버튼 제공
중단 플래그를 캐시 기반으로 처리
chunk 처리 전 중단 여부 확인
중단 후 cursor 기준 재시작 지원
을 하게 되면서 강제 인스턴스 종료 없이도 운영 대응이 가능해졌습니다.
결과
1. 동시 실행 문제를 “구조적으로” 제거
기존 구조에서는 알림 생성 요청이 발생하는 순간, 각각 독립적인 트리거로 즉시 실행되었습니다.
스케줄러
관리자 액션
예약 작업
이들은 서로를 인지하지 못했고, 결과적으로 대량 생성 작업이 동시에 DB에 쓰기를 시도하는 상황이 발생했습니다.
Outbox 기반 큐에서는 이 흐름이 바뀝니다.
요청 발생 -> 즉시 실행(X), Job으로 큐에 기록
이제 시스템에서 “실제로 실행되는 대량 생성 작업”은 큐를 소비하는 배치 프로세스 하나로 처리됩니다.
실행 주체가 하나로 통합
동시 실행 자체가 구조적으로 불가능
즉, DB CPU 스파이크를 “속도를 줄여서”가 아니라 “동시에 실행될 수 없게 설계해서” 제거한 것입니다.
2. 재처리 불가 문제를 “상태 모델”로 해결
기존 구조에서는 알림 생성이 하나의 큰 트랜잭션 흐름에 묶여 있었기 때문에,
중간 실패 시
어디까지 처리됐는지 알 수 없고
재실행은 전체 재실행 또는 포기
라는 극단적인 선택만 가능했습니다.
Outbox 큐에서는 작업 상태와 진행도가 명시적인 데이터로 분리됩니다.
Job
상태
진행도
이 설계로 인해:
작업이 중단되더라도
“어디까지 성공했는지”가 DB에 남고
cursor 이후부터 이어서 처리 가능
재처리는 더 이상 운영자의 기억이나 로그 분석에 의존하지 않고, 데이터를 기준으로 결정할 수 있게 되었습니다.
3. 운영 제어 포인트의 명확화
MQ 기반 구조에서도 처리량 제어는 가능하지만, 아래 상황들을 파악하기 어려웠습니다.
지금 실행 중인 작업은 무엇인가?
어느 범위까지 처리됐는가?
이 작업만 멈출 수 있는가?
Outbox 큐에서는 이 질문들이 모두 Job Row 하나로 처리됩니다. (운영자가 이해할 수 있는 최소 단위)
Job 하나 = 하나의 대량 알림 생성 작업
상태, 진행도, 스케줄 시간 모두 한 곳에 존재
이 덕분에:
운영자는 Job 목록을 보고 상황을 파악하고
특정 Job만 중단하거나 재개할 수 있으며
다른 작업에는 영향을 주지 않습니다.
이는 처리량 관점이 아니라, 운영 책임을 시스템이 대신 떠안도록 만든 설계였습니다.
트레이드 오프
큐 테이블이 범용화되며 컬럼 수 증가 대량 알림 생성 작업을 하나의 큐로 통합하면서, 작업 유형(알림/푸시), 카테고리, 예약 시간, 상태, 진행도 등 서로 다른 성격의 작업을 표현
chunk 단위 처리로 전체 처리 시간 증가 한 번에 모두 처리하지 않고, 일정 크기(chunk)로 나누어, 진행도를 기록하며 처리하도록 설계
exactly-once 대신 at-least-once에 가까운 보장 Outbox + Cursor 구조는 중간 실패 시 이어서 처리할 수 있지만, 이론적으로는 동일 chunk가 재처리될 가능성을 완전히 배제하지는 않습니다.
대량 알림의 특성상 완벽한 즉시성보다 안정성과 운영 안전성이 중요하다고 생각됐습니다.
Last updated