Redesigning Bulk Notification Processing for Stability

대량 알림 생성 시 DB CPU 스파이크 문제 해결로 서비스 안정성 강화

배경 및 문제

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

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

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

  • 스케줄러 트리거

  • 관리자 콘솔 액션

  • 예약 작업

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

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

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

  • 여러 종류의 대량 알림이 동시에 실행

  • 동일한 시점에 대규모 대상 조회 + 대량 INSERT/UDPATE 발생

  • DB CPU 사용률이 단시간에 60%이상 급등

  • 최악의 경우, pod(DB instance) 강제 종료로 대응

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

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

즉시 실행 구조의 특성

  • 요청이 들어오는 순간 곧바로 DB 작업 시작

  • 지금 몇개의 대량 작업이 동시에 실행 중인지 에 대한 정보 없음

  • 시스템 차원에서 동시 실행 개수를 제한할 수 없음

DB 관점에서의 실제 문제

  • 여러 작업이 동시에 대상 조회 쿼리 실행

  • 각 작업이 각각 커서 없이 offset 기반 페이징 또는 전체 스캔

  • 동시에 대량 INSERT/UDPATE 수행

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

초기해결 방향

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

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

  • 쿼리 튜닝

  • 배치 사이즈 조절

  • 인덱스 개선

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

  • 대량 작업이 동시에 실행되는 구조는 그대로

  • 새로운 알림 유형이 추가될수록 동시성은 다시 증가

  • CPU 스파이크는 언젠가 다시 발생

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

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

문제를 구조적으로 해결하기 위해, 대량 알림 생성은 즉시 실행 모델에서 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

    • 알림 생성 메시지를 큐에 발행

  • 컨슈머는 큐에서 메시지를 가져와

    • 알림 센터에 적재

    • 푸시 생성 수행

  • 컨슈머 수로 동시 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 단위 처리로 전체 실행 시간 증가

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

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

  • 즉시성보다 안정성

  • 최대 처리량보다 예측 가능성

  • 순간 성능보다 운영 리스크 감소

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

Last updated