Designing a Safe Abort Mechanism for Long-Running Bulk Jobs

운영 중 안전한 중단 지점 확보: 강제 종료에서 의도된 Abort로

배경 및 문제

멈추는 방법이 설계돼 있지 않았다

운영 환경에서 얘기치 못한 트래픽 급증이 발생하면, 대량 알림 생성 작업(알림센터 적재 + 푸시 생성)이 DB에 대량 쓰기/조회 부하를 유발하며 DB CPU가 급격히 상승할 수 있었습니다. 이 작업은 단발성 API 호출과 달리 장시간 실행되며, 부하를 유발하는 쿼리/쓰기 작업을 지속적으로 발생시키기 때문에 문제를 감지한 뒤 대응하더라도 부하가 계속 유지되는 시간 구간이 생깁니다.

당시에는 실행 중인 작업을 안전하게 멈출 수 있는 시스템 기능이 없었고, 사실상 대응 수단은 Pod 강제 종료뿐이었습니다. 하지만 Pod 강제 종료는 작업을 중단 하는 것이 아니라, 프로세스를 죽이는 것이어서 아래 문제들이 생겼습니다.

  • 중단 지점 불명확: 코드가 어떤 시점에서 끊길지 통제 불가(쿼리 중/쓰기 중/커서 갱신 전/후 등)

  • 부분 처리 일관성 붕괴: 어떤 chunk가 끝났는지, 어떤 chunk가 진행 중이었는지 애매해짐

  • 운영 리스크 증가: 장애 대응이 시스템 안정화가 아니라 더 큰 장애를 막기 위한 강제 조치가 됨

Outbox 기반 생성 큐와 쿼리 최적화로 CPU 스파이크는 상당 부분 완화했지만, 성능 테스트에서 환경별 처리 성능 차이를 확인했습니다.

  • Local 대비 Dev 환경에서 처리 속도가 약 3배 이상 빠름

  • 네트워크 RTT 차이로 DB round trip 비용이 달라져 동일 코드라도 처리량이 크게 달라지는 것이라고 추측

즉, 코드가 빠르게 동작할 수록 DB에 더 빠른 속도로 요청이 쏟아져 부하가 발생할 수 있고, 운영에서는 언제든 다시 멈춰야 하는 순간이 생깁니다. 그래서,

성능을 개선하는 것만으로는 부족했고, 운영 중 안전하게 멈출 수 있는 제어 포인트가 필요했습니다.

설계 목표

강제 종료가 아닌 안전한 중단

Abort 기능을 설계하면서 먼저 안전한 중단이 뭔지 생각해봤습니다. 단순히 stop 버튼이 아니라 여러 조건들을 만족해야합니다.

  1. 작업 단위로 선택적 중단

    1. 특정 Job만 중단할 수 있어야 함(다른 Job / 기능 영향 X)

  2. 일관성이 보장되는 중단 지점

    1. 처리 도중 반쯤 끝난 상태로 멈추면 재처리 시 중복/누락이 발생

    2. 따라서 중단 가능 지점을 명확히 해야함

  3. 재시작 / 복구 흐름과 자연스럽게 연결

    1. Abort는 실패가 아니라 의도된 상태 전이여야 함

    2. cursor 기반 재처리, stuck job 복구, single-flight 규칙과 충돌이 발생하면 안됨

  4. 운영자 경험 (UX)

    1. 운영자가 지금 어떤 작업을 왜 어떻게 멈추는지 알 수 있어야 함

    2. Admin-console에서 확인/중단/삭제 같은 제어를 제공해야 함

핵심 아이디어

Chunk 경계에서 Abort를 체크

대량 알림 생성은 이미 chunk 단위로 대상 채널을 순회하고 있었고, 각 chunk가 끝날 때마다 cursor를 업데이트하는 모델을 갖고 있었습니다.

왜 Chunk 단위가 안전한 경계인가?

  • chunk는 이번에 처리할 대상 목록이 결정된 뒤, 그 대상들에 대해 조회/쓰기 작업을 수행 하는 단위

  • chunk가 끝나고 cursor가 갱신되면, 이전 구간은 완료로 확정할 수 있음

  • 따라서 chunk 시작 전/후는 진행 상태가 깔끔하게 구분되는 경계

왜 chunk 중간 중단은 위험한가?

  • 일부 대상은 알림센터에 들어갔고, 일부는 안 들어간 상태

  • push 생성은 일부만 되었을 수 있음

  • cursor는 갱신되지 않았음

  • 결과적으로 중복 발송/누락이 chunk 내부에서 발생할 수 있음

그래서 Abort 체크 지점은 항상 동일한 의미를 가지는 위치여야 했고, 그 지점을 chunk 처리 직전으로 고정했습니다.

해결책1: Redis 기반 Chunk Abort 플래그

Abort는 누르는 순간 반영되어야 합니다. 이를 시스템 요구사항으로 풀면:

  • Low latency read: chunk 마다 한번씩 체크하므로 조회에 부담이 없어야 함

  • 쓰기-읽기 즉시성: Admin-console에서 설정 -> 곧바로 worker에서 감지

  • 일회성/자동 정리: 중단 신호가 영원히 남으면 운영 사고로 이어짐

DB에 flag를 저장하는 방식은 일관성은 좋지만

  • chunk 마다 DB를 추가로 조회하면 부하를 발생할 수 있고

  • 운영 상황에서 이미 DB가 불안정할 수 있는 점이 문제

반면 Redis는

  • in-memory라 빠르고

  • 장애 상황에서도 DB에 추가 부하를 주지 않고 중단 신호를 전달 할 수 있으며

  • TTL로 자동 만료를 제공할 수 있습니다.

따라서 Abort flag는 Redis 키로 관리했습니다.

  • Key: notificaiton_creation_abort:{queueId}

  • Value: 단순 존재 여부(또는 사유/요청자 정도의 메타)

  • TTL: 일정 시간 후 자동 만료

TTL을 둔 이유는 중단 신호가 영구 유지되며 다음날까지 작업이 계속 멈춰있는 운영 사고를 막기 위한 장치

Abort 동작 흐름

대량 알림 생성 워커는 다음처럼 동작합니다.

  1. Job을 가져온다

    1. single-flight 규칙에 의해 RUNNING Job이 있으면 신규 실행 금지

    2. (여기까지는 기존 Job Queue 설계와 동일)

  2. chunk 실행 직전에 Abort 플래그를 확인한다 (Redis GET/EXISTS)

    1. 플래그가 있으면:

      1. 더 이상 처리하지 않고 종료

      2. Job 상태를 “처리중”으로 기록 -> 적절하지 않은 상태값이지만, 재처리 기능을 수정하지 않기 위한 대안

      3. cursor는 마지막 완료 지점 그대로 유지

  3. 다음 chunk 범위를 결정한다

    1. cursor 이후부터 limit N개 채널을 조회해 chunk를 만든다

  4. Abort 플래그가 없다면 chunk를 처리한다

    1. 알림센터 insert / 푸시 생성 등

    2. chunk가 끝나면 cursor 업데이트

    3. 다음 chunk로 반복

이 설계로 확보되는 운영적/기술적 이득은 다음과 같습니다.

  • 중단은 chunk 경계에서만 발생 → 부분 처리 일관성 유지

  • cursor는 “완료된 chunk까지만” 반영 → resume 시 안전

  • Pod kill 없이도 즉시 대응 가능 → 장애 대응 자체의 리스크 감소

운영 제어 포인트

admin-console에서 특정 작업만 중단/삭제

Abort가 기술적으로 가능해도 운영자가 쓰기 어렵다면 의미가 없습니다. 그래서 Admin-console에 “Job 단위 제어”를 제공했습니다.

운영자가 할 수 있는 일:

  • 실행 중 Job 목록 확인(카테고리, 스케줄, 상태, cursor 진행도)

  • 특정 Job 선택 → Abort 요청

    • Redis에 abort flag set(TTL 포함)

  • 필요하면 해당 Job 삭제(또는 상태 전이로 비활성화)

  • Abort 후에는 cursor 기반으로 재시작/복구 가능

여기서 중요한 점은 “선택적 제어”입니다.

  • Pod kill은 프로세스 단위 제어

  • Abort는 Job 단위 제어

즉, 운영 대응의 단위가 인프라에서 애플리케이션으로 내려왔고, 이는 “운영 책임을 사람이 아니라 시스템이 떠안게 만든 것”과 같은 방향입니다.

해결책2: Throttling으로 환경별 처리 편차를 제거

환경별 처리 성능 차이(Dev가 Local보다 3배 빠름)는 “같은 chunk 크기라도” 실제 DB에 요청이 쏟아지는 속도가 달라질 수 있음을 의미합니다. 특히 RTT가 낮은 환경에서는 round trip이 빨라지고, 워커는 더 많은 chunk를 더 빠르게 처리하려 하며, 결과적으로 DB에 부하가 집중될 수 있습니다.

그래서 처리량 최대화 대신 부하 예측 가능성을 선택했습니다.

  • chunk 처리 후 의도적인 대기(sleep)를 삽입

  • 목표: DB에 유입되는 쓰기/조회 속도를 상한선으로 제한

  • 운영 중 특정 시간대에 작업이 겹쳐도 “CPU 스파이크”로 튀지 않게 완화

Throttling의 의미

  • Throttling은 단순히 느리게 만드는 게 아니라 “한 번에 처리되는 DB 요청량의 시간 분포를 평탄화(smoothing)” 하는 역할입니다.

  • Abort와 결합하면,

    • 중단 신호를 더 빨리 반영할 수 있고(다음 chunk까지의 간격 감소)

    • 부하가 급등하기 전에 “멈출 수 있는 여유”를 확보합니다.

결과: 강제 종료에서 의도된 중단 및 제어

이 설계로 운영에서 달라진 점은 명확합니다.

  • 장애 상황 대응 방식: Pod 강제 종료 → Job 단위 Abort로 전환

  • 중단 지점이 chunk 경계로 고정되며, 진행 상태/커서 기반 재시작이 자연스럽게 연결

  • 환경별 성능 편차를 고려해 Throttling까지 추가함으로써 “빠르면 더 위험해지는” 상황을 운영 전략으로 흡수

결국 이 변화는 기능 추가가 아니라 운영 방식의변화였습니다.

“장애가 나면 인프라를 끊는다”가 아니라 “장애가 나면 시스템이 제공하는 제어 포인트로 안전하게 멈춘다”

트레이드 오프와 정합성

At-least-once 선택과 abort의 관계

Abort는 실패가 아니라 의도된 중단이지만, 중단 직후 재처리가 붙으면 결국 “중복 가능성” 문제가 남습니다. 그래서 이 시스템은 처음부터 chunk 단위 at-least-once를 선택했습니다.

  • Abort 직전 chunk는 처리되지 않았으므로 안전

  • Abort 직후 resume 시, 마지막 cursor 이후 chunk부터 재개

  • 다만 장애 타이밍/네트워크 오류로 “chunk 완료 여부”가 애매해지는 극단 케이스에서는 동일 chunk 재처리 가능성은 남음 → 이를 수용하는 대신, 운영 안정성과 복구 가능성을 우선

즉 Abort는 at-least-once 선택과 일관된 방향이며, 운영 안정성을 위해 “정확히 한 번”보다 “안전하게 다시 할 수 있음”을 선택한 설계입니다.

Last updated