> 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/designing-a-safe-abort-mechanism-for-long-running-bulk-jobs.md).

# Designing a Safe Abort Mechanism for Long-Running Bulk Jobs

## 배경 및 문제

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

운영 환경에서 얘기치 못한 트래픽 급증이 발생하면, 대량 알림 생성 작업(알림센터 적재 + 푸시 생성)이 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 단위 제어”를 제공했습니다.

<figure><img src="/files/6eA3R4ImT41JMI0uW0b4" alt=""><figcaption></figcaption></figure>

운영자가 할 수 있는 일:

* 실행 중 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은 단순히 느리게 만드는 게 아니라\
  \&#xNAN;**“한 번에 처리되는 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 선택과 일관된 방향이며,\
운영 안정성을 위해 “정확히 한 번”보다 “안전하게 다시 할 수 있음”을 선택한 설계입니다.
