> For the complete documentation index, see [llms.txt](https://wonjoon.gitbook.io/joons-til/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wonjoon.gitbook.io/joons-til/trustay/documentation/redesigning-bulk-notification-processing-for-stability.md).

# Redesigning Bulk Notification Processing for Stability

## 배경 및 문제

#### DB CPU 스파이크가 반복적으로 발생한 상황

<figure><img src="/files/KDTUePj5WaOLVzFDChwv" alt=""><figcaption></figcaption></figure>

서비스 내 대량 알림은 특정 시점에 수만\~수십만 채널을 대상으로 알림을 생성합니다.

초기 구조에서는 이러한 대량 알림이 아래와 같은 방식으로 처리되고 있었습니다.

* 스케줄러 트리거
* 관리자 콘솔 액션
* 예약 작업

각 트리거는 요청 발생 즉시 알림 생성 로직을 실행했고,\
서로를 인지하지 못한 채 독립적으로 동작했습니다.

이를 예방하고자, 가장 간단한 해결책으로  별도의 **알림 시간표**를 두었습니다.\
다른 팀에서 사용하는 방식을 그대로 참고하였지만, **운영에서는 실제로 지켜지지 않았습니다.**

그 결과, 운영 중 다음과 같은 상황이 발생했습니다.

* 여러 종류의 대량 알림이 동시에 실행
* 동일한 시점에 대규모 대상 조회 + 대량 INSERT/UDPATE 발생
* DB CPU 사용률이 단시간에 60%이상 급등
* 최악의 경우, pod(DB instance) 강제 종료로 대응

#### 왜 기존 구조는 CPU 스파이크에 취약했는가?

이 문제를 단순 성능 이슈로 보기 어렵다고 판단한 이유는 아래와 같습니다.

**즉시 실행 구조의 특성**

* 요청이 들어오는 순간 곧바로 DB 작업 시작
* 지금 몇개의 대량 작업이 동시에 실행 중인지 에 대한 정보 없음
* 시스템 차원에서 동시 실행 개수를 제한할 수 없음

**DB 관점에서의 실제 문제**

* 여러 작업이 동시에 대상 조회 쿼리 실행
* 각 작업이 각각 커서 없이 offset 기반 페이징 또는 전체 스캔
* 동시에 대량 INSERT/UDPATE 수행

즉, DB CPU 스파이크는 쿼리가 느려서의 문제와는 별개로,\
**느린 쿼리든 빠른 쿼리든, 동시에 너무 많이 실행됐기 때문이었습니다.**

## 초기해결 방향

#### 성능 최적화만으로는 해결되지 않는 문제

초기에는 다음과 같은 접근을 고려했습니다.

* 쿼리 튜닝
* 배치 사이즈 조절
* 인덱스 개선

이 접근은 단일 작업의 처리 효율은 개선할 수 있지만, 아래 문제는 해결하지 못합니다.

* 대량 작업이 동시에 실행되는 구조는 그대로
* 새로운 알림 유형이 추가될수록 동시성은 다시 증가
* CPU 스파이크는 언젠가 다시 발생

## 해결책1: Outbox 기반 생성 큐 도입

#### 즉시 실행 구조 -> 작업 큐 + 배치 소비 구조

<figure><img src="/files/lTV1LKwJUkAvRjJU6D0l" alt=""><figcaption></figcaption></figure>

문제를 구조적으로 해결하기 위해, 대량 알림 생성은 **즉시 실행 모델에서 Outbox 기반 작업 큐 모델로 전환했습니다.**

핵심 변화는 아래와 같습니다.

* 알림 생성 요청 발생
  * 즉시 DB 작업 수행 X
  * Job으로 큐 테이블에 적재
* 실제 알림 생성은
  * 배치 프로세스가 큐를 소비하며 수행

**이렇게하여 요청을 받는 시점과 DB에 실제 부하를 주는 시점을 분리했습니다.**

#### Single-flight 구조 설계

Outbox 큐 위에 **Single-flight 실행 규칙**을 추가했습니다.

* RUNNING 상태의 Job이 존재하면
* 신규 Job은 대기(PENDING)
* 동시에 실행되는 대량 생성 작업은 **항상 1개**

**왜 Single-flight인가**

* 대량 작업은 병렬 처리보다 **부하 예측 가능성**이 더 중요
* 병렬성을 허용하면 다시 CPU 스파이크 위험

**구조적으로 얻은 효과**

* 대량 작업이 동시에 실행되는 상황 자체를 제거
* DB 부하의 상한선을 “구조적으로” 고정
* CPU 사용률이 “순간 폭증”하지 않고 **완만하게 유지**

즉, CPU 스파이크를 “느리게 만들어서”가 아니라\
**“동시에 실행되지 않게 설계해서” 제거했습니다.**

## 대안 검토: 메시지 큐를 활용한 대량 알림 처리 구조

DB CPU스파이크의 직접적인 원인은\
대량 알림 생성 작업이 동시에 실행되며 DB에 쓰기/조회 요청이 몰리는 상황이었습니다.

이 문제를 처음 마주쳤을 때, 가장 직관적으로 떠오른 해결책은\
**메시지 큐를 활용한 비동기 처리 구조입니다.**

메시지 큐는 일반적으로 아래와 같은 장점을 가집니다.

* 요청과 실행을 분리하여 트래픽 버퍼 역할 수행
* 컨슈머 개수/동시성 조절을 통해 처리량 상한선 제어
* 실패 시 재시도, DLQ를 통한 실패 격리 가능

즉, 한번에 DB로 몰려가는 요청을 큐라는 buffer로 흡수 할 수 있는 점에서,\
DB CPU 스파이크 문제를 해결하는데 적합해 보였습니다.

#### 메시지 큐 기반 구조의 예상 동작 방식

기본 구조

* 대량 알림 생성 요청 발생
  * 즉시 DB 작업 수행 X&#x20;
  * 알림 생성 메시지를 큐에 발행
* 컨슈머는 큐에서 메시지를 가져와
  * 알림 센터에 적재
  * 푸시 생성 수행
* 컨슈머 수로 동시 DB 접근 수 제한

이 구조에서는 DB에 동시에 날아가는 요청 수가\
**컨슈머 동시성 수준으로 자연스럽게 제한됩니다.**

이에 대한 효과는:

* 큐가 요청을 흡수하며 순간 트래픽 완화
* 컨슈머 수를 줄이면 DB CPU 사용률도 함께 하락
* 여러 작업이 동시에 실행되는 상황을 큐 레벨에서 완화

즉, CPU 스파이크를\
**애플리케이션 코드가 아닌 인프라 레벨에서 제어**할 수 있다는 점이 장점이었습니다.

### 고민 지점1: 작업 단위가 메시지로 쪼개지며 사라지는 ‘Job’의 개념

하지만 구조를 구체화할수록,\
메시지 큐 기반 접근에는 **운영 관점에서의 중요한 질문**들이 생겼습니다.

**메시지는 잘 처리되는데, “이 작업은 어디까지 왔는가?”**

메시지 큐에서는 처리 단위가 **메시지 하나**입니다.

* 메시지는 성공/실패 여부가 명확
* 재시도/DLQ 처리도 메시지 기준으로 동작

하지만 대량 알림은 본질적으로\
**“수만 건을 묶은 하나의 작업(Job)”** 이었습니다.

* 지금 실행 중인 대량 알림은 무엇인가?
* 전체 대상 중 몇 %가 처리되었는가?
* 이 작업을 지금 멈출 수 있는가?

이 질문들에 MQ는 직접적인 답을 주지 못했습니다.

**결국 필요한 추가 설계**

메시지 큐 위에서도 결국 다음이 필요하다는 결론에 도달했습니다.

* 작업(Job) 단위 상태 저장소
* 전체 진행도를 표현할 수 있는 cursor
* 특정 작업만 중단/복구할 수 있는 제어 포인트

즉, MQ만으로는 부족했고,\
**Job 상태를 관리하는 별도의 시스템을 추가로 설계해야 했습니다.**

### 보완 검토: 메시지 이벤트에 Cursor Offset을 포함하는 방식

앞서 메시지 큐 기반 구조의 한계로\
“그렇다면 메시지 자체에 작업의 진행 정보(cursor)를 포함하면\
Job 개념을 메시지 레벨에서 복원할 수 있지 않을까?” 라고 생각했습니다.

#### 기본 아이디어

이 접근은 메시지를 단순한 “처리 요청”이 아니라,\
**작업의 현재 위치를 포함한 상태 전이 이벤트**로 사용하는 방식입니다.

예를 들면:

* 메시지 구조에 다음 정보를 포함
  * jobId
  * cursorOffset (마지막 처리 지점)
  * chunkSize
* 컨슈머는 메시지를 소비하면서
  * 해당 cursor 이후 대상 조회
  * chunk 처리
  * 다음 cursor를 포함한 **후속 메시지 발행**

이 방식은 처음 보면 상당히 설득력이 있습니다.

**MQ의 장점을 그대로 유지**

* 큐를 통한 자연스러운 버퍼링
* 컨슈머 동시성으로 처리량 제어
* 메시지 단위 재시도 / DLQ 활용 가능

**Job 개념을 이벤트로 복원**

* 각 메시지가 “이 Job의 현재 진행 상태”를 포함
* 특정 메시지만 봐도 어디까지 처리됐는지 추론 가능
* 이론적으로는 별도의 Job 테이블 없이도 진행 상태 관리 가능

그래서 이 접근은\
“MQ + 상태 관리”를 동시에 만족시키는 것처럼 보입니다.

하지만 이 방식을 **실제 운영 시스템 관점에서 사용시**, 여러 가지 구조적 문제가 드러납니다.

**메시지가 ‘이벤트’가 아니라 ‘상태 저장소’가 되는 문제**

이벤트는 보통 다음 성격을 가집니다.

* 이미 발생한 사실
* 불변(immutable)
* 재처리되어도 의미가 바뀌지 않음

하지만 cursor를 포함한 메시지는:

* “현재 상태”를 표현
* 다음 메시지를 만들기 위한 **중간 상태**
* 소비 순서와 정확성에 민감

즉, 메시지가\
**이벤트(event)** 가 아니라\
**상태(state)** 처럼 동작하게 됩니다.

**운영 관점에서의 부담**

* 큐에 남아 있는 메시지를 직접 보면
  * 이게 최신 상태인지
  * 이미 supersede 된 메시지인지
  * 중복으로 발행된 건지\
    판단하기 어려움
* 메시지 자체가 “정답 상태”인지 확신할 수 없음

결과적으로,\
**큐를 상태 저장소처럼 해석해야 하는 운영 복잡도**가 생깁니다.

### 고민 지점 2: 운영 제어의 어려움

메시지 큐는 처리량 제어에는 강력하지만,\
**운영자가 “지금 무엇이 실행 중인지”를 이해하고 개입하기에는 추상도가 높았습니다.**

**운영자가 보고 싶은 정보**

운영 환경에서는 다음과 같은 정보가 중요했습니다.

* 현재 실행 중인 대량 알림 작업은 무엇인가?
* 이 작업은 언제 시작되었고, 얼마나 진행되었는가?
* 지금 이 작업만 멈출 수 있는가?

메시지 큐에서는 이러한 정보가\
여러 메시지와 파티션, 컨슈머 상태로 분산됩니다.

**운영 대응의 현실적인 문제**

* 특정 알림 작업을 멈추기 위해
  * 컨슈머를 내려야 하거나
  * 큐 전체 처리를 중단해야 하는 상황 발생
* 이는 다른 작업까지 함께 영향을 주는 방식

결국 운영 제어가\
**“작업 단위”가 아니라 “시스템 단위”로 이루어질 위험**이 있었습니다.

### 최종 판단: MQ는 “처리량 제어”에는 적합했지만, “운영 안정성”에는 부족했다

메시지 큐 기반 구조는 분명 다음 문제를 잘 해결합니다.

* DB CPU 스파이크 완화
* 동시 실행 요청 제어
* 실패 메시지 격리

하지만 이번 문제의 핵심은\
**단순히 DB 부하를 줄이는 것**이 아니라,

* 대량 작업이 **언제, 얼마나, 어떤 속도로 실행되는지**
* 문제가 발생했을 때 **어디까지 진행됐고, 어떻게 멈출 수 있는지**
* 운영자가 **작업 단위로 상황을 이해하고 개입할 수 있는지**

였습니다.

결국 MQ를 도입하더라도:

* Job 상태 관리
* 진행도(cursor) 관리
* Abort / Resume 제어

를 별도로 설계해야 했고,\
이는 **Outbox 기반 생성 큐를 직접 설계하는 것과 본질적으로 다르지 않다**고 판단했습니다.

정리하면,\
**이벤트에 cursor를 포함하는 MQ 기반 설계는 기술적으로는 가능**합니다.\
**(MQ의 cursor는 메시지 흐름을 이어가기 위한 장치이고,**\
**outbox의 cursor는 DB 조회 성능과 작업 재시작을 위한 처리 지점입니다.)**

하지만 이번 문제에서는:

* 메시지가 상태를 들고 다니며
* 순서/중복/일관성에 지나치게 민감해지고
* Abort, 재시작, 가시성 같은 운영 요구사항을 충족하기 위해\
  추가 설계가 계속 필요해졌습니다.

결국 아래와 같이 판단했습니다.

MQ 위에서 Job과 상태를 “우회적으로 복원”하느니,\
처음부터 Job을 개념으로 두고 상태와 진행도를 명시적으로 관리하는 편이 **복잡도도 낮고, 운영에도 안전하다.**

그래서 Outbox 기반 생성 큐 + cursor 모델을 선택했습니다.

## 해결책2: cursor 기반 대상 조회

#### Offset 기반 페이징의 문제점

기존 구조에서는 대상 채널 조회 시 offset 기반 페이징 또는 전체 스캔에 가까운 조회가 사용되었습니다.

이 방식은 대량 데이터 환경에서 아래 문제를 발생시킵니다.

* offset이 커질수록 DB가 앞 데이터를 계속 스킵
* 페이지가 뒤로 갈수록 CPU 사용량 증가
* 장시간 실행 시 부하 예측 불가

#### Cursor 기반 페이징으로 전환

배치에서는 대상 조회 방식을 cursor 기반 페이징으로 전환하였습니다.

* 마지막 처리된 지점을 cursor로 저장
* 다음 조회는 WHERE id > cursor LIMIT n
* 항상 인덱스를 활용한 범위 조회

성능 특성 변화

* 조회 비용이 데이터 증가에 따라 큰 차이가 없음
* chunk 크기만큼의 일정한 비용 유지

CPU 스파이크와의 관계

* 대량 작업이 길어져도 CPU 사용량이 안정적으로 유지
* 뒤 페이지로 갈수록 느려지는 현상 제거
* 장시간 실행 작업에서도 DB 부하 예측 가능

## 결과

#### 수치로 확인된 개선 효과

구조 전환 이후, 운영 환경에서 다음과 같은 변화가 있었습니다.

* DB CPU 사용률\
  **약 60% → 15% 수준으로 안정화**
* 대량 알림 실행 시에도\
  CPU 사용률이 급격히 치솟는 현상 제거
* pod 강제 종료 없이 정상 운영 가능

#### 운영 관점에서의 변화

* 대량 작업이 **언제 실행되고, 얼마나 부하를 줄지 예측 가능**
* 새로운 알림 유형 추가 시에도\
  “또 CPU 터지는 거 아닌가?”라는 불안 감소
* 운영 안정성이 **개별 튜닝이 아닌 구조 설계**에 의해 확보됨

## 트레이드 오프

#### 전체 처리 시간 증가

이 구조는 명확한 트레이드 오프를 가집니다.

* Single-flight로 인해 병렬 처리 포기
* Cursor + chunk 단위 처리로 전체 실행 시간 증가

즉, “가장 빠른 처리”는 아닙니다.

하지만 대량 알림의 특성상,

* 즉시성보다 **안정성**
* 최대 처리량보다 **예측 가능성**
* 순간 성능보다 **운영 리스크 감소**

가 더 중요하다고 판단했습니다.
