백그라운드 큐 배치: 100건씩 나눠 복구 가능하게
전체 대상을 한 번에 처리하던 작업을 100건 단위로 나누고 claim·lease·멱등성·재시도로 중단 뒤에도 이어 가는 설계를 설명한다.
이 글의 목차‘전부 처리’라는 한 줄을 작은 묶음으로 바꿨다7
백그라운드 작업은 사용자가 화면에서 기다리지 않아도 서버가 뒤에서 수행하는 일이다.
당시에는 목적이 다른 여러 정기 작업이 각자 대상을 찾고, 한 번에 전체를 순회했다.
작업이 길어지면 지금 몇 번째를 처리하는지 알기 어려웠다.
중간에 멈췄을 때 이미 끝낸 대상을 다시 처리하는지도 작업마다 달랐다.
제한시간이나 재시도 횟수를 늘리면 같은 큰 작업을 더 오래 반복할 뿐이었다.
예를 들어 매일 새벽 만 명에게 정산서를 만드는 작업이 8,000번째에서 멈췄다고 해 보자.
완료 위치가 없다면 만 명을 처음부터 다시 확인하거나, 남은 2,000명을 사람이 찾아 따로 실행해야 한다.
100건씩 소유권과 완료 상태를 남기면 “전체 정산”이라는 막연한 작업을 복구 가능한 작은 단위로 바꿀 수 있다.
다만 모든 정기 작업에 큐가 필요한 것은 아니다.
파일 하나를 교체하는 짧고 원자적인 작업은 단순 스케줄러로 충분할 수 있다.
항목별 처리시간이 다르고 일부 실패만 다시 실행해야 하거나 여러 워커로 나눠야 할 때 큐의 상태 관리가 값을 한다.
| 작업의 성격 | 먼저 검토할 방식 | 이유 |
|---|---|---|
| 짧고 한 번에 성공하거나 실패해야 한다 | 단일 정기 작업 | 중간 상태를 추가할 이득이 작다 |
| 항목별 성공을 나눌 수 있고 일부만 재시도한다 | 배치 큐 | 완료 범위와 재시도 대상을 분리할 수 있다 |
| 여러 단계가 오래 걸리고 사람 승인·대기가 끼어 있다 | 워크플로 엔진 | 단계별 상태와 시간 제한을 명시하기 쉽다 |
| 외부 API가 초당 호출 수를 제한한다 | 큐와 처리 속도 제한 | 유입과 실행 속도를 분리할 수 있다 |
API(Application Programming Interface)는 우리 서버가 결제·메일 같은 다른 서비스와 정해진 형식으로 데이터를 주고받는 접점이다.
‘전부 처리’라는 한 줄을 작은 묶음으로 바꿨다
도서관 책 만 권을 “전부 정리”라는 한 가지 일로 맡기면 담당자가 멈췄을 때 어디까지 끝났는지 알기 어렵다.
상자별로 나누고 끝난 상자에 표시하면 다음 담당자가 이어서 할 수 있다.
백그라운드 작업도 처리할 대상을 공통 목록에 먼저 적고, 워커가 최대 100건씩 가져가도록 바꿨다.
워커는 대기 중인 일을 꺼내 실제 코드를 실행하는 프로세스다.
큐는 아직 처리하지 않은 일을 보관하는 대기 목록이고, 한 번에 꺼낸 묶음을 배치라고 부른다.
당시 적용한 핵심 흐름은 다음과 같다.
- 정기 트리거가 처리 대상을 공통 목록에 적재한다.
- 워커가 목록에서 최대 100건을 읽어 실제 작업을 실행한다.
- 성공을 확인한 대상만 목록에서 제거한다.
- 100건을 가득 읽었다면 다음 실행을 예약한다.
대상을 찾는 일과 처리하는 일을 분리하자 여러 정기 작업이 같은 방식으로 남은 양을 표현할 수 있었다.
실제 변경으로 확인되는 범위는 공통 목록과 최대 100건 단위 실행까지다.
성공한 항목만 제거하는 흐름을 두더라도, 작업 결과 반영과 목록 변경 사이에서 워커가 멈추거나 여러 워커가 동시에 읽는 경우는 별도 상태 설계로 막아야 한다.
한 실행이 100건을 다룬다는 사실만으로 37번째 실패 뒤 나머지 64건이 자동 보존되지는 않는다.
여러 워커 중 하나만 가져가도록 소유권 획득을 원자적으로 처리하고, 실제 작업이 끝난 뒤에만 완료 상태를 기록해야 한다.
워커가 죽으면 처리 중 소유권이 만료돼 다시 대기로 돌아와야 이 계산이 성립한다.
따라서 100건 배치로 확정할 수 있는 효과는 한 실행이 읽는 후보의 상한을 100건으로 제한한 것이다.
100건은 빠르다는 뜻이 아니라 책임 범위였다
100은 성능 시험에서 찾은 최적값이 아니었다.
한 워커가 한 번에 책임질 작업량과, 실패했을 때 다시 살펴볼 범위에 상한을 둔 운영값이었다.
묶음이 크면 큐를 조회하고 다음 실행을 등록하는 횟수는 줄어든다.
대신 워커가 오래 붙잡히고 실패했을 때 확인할 범위가 커진다.
묶음이 작으면 다시 시작하기는 편하지만 실행을 준비하는 비용이 늘어난다.
적절한 값은 대상 하나의 처리시간, 워커 메모리, 외부 API의 속도 제한을 함께 재고 정해야 한다.
이 변경의 목표는 처리 속도를 보장하는 것이 아니었다.
워커 한 번이 책임지는 범위를 최대 100건으로 제한하고, 성공이 확인된 대상만 목록에서 제거하는 것이었다.
처리량을 높이는 일과 실패 뒤 안전하게 이어 가는 일은 별도의 문제라서, 배치 크기만으로 큐가 완성됐다고 보지는 않았다.
배치 크기를 다시 정한다면 처리시간의 평균만 보지 않는다.
항목 하나가 대부분 100ms에 끝나도 일부가 10초씩 걸리면 100건 묶음의 종료 시각은 크게 흔들릴 수 있다.
그래서 다음 값을 작은 수의 워커로 먼저 측정한다.
- 항목 처리시간의 p50과 p95
- 배치 하나가 사용하는 최대 메모리
- 외부 API의 초당 허용 호출 수와 HTTP 429 응답
- 배치 중단 뒤 다시 확인해야 한 항목 수
p50은 측정값의 가운데에 해당하는 일반적인 처리시간이고, p95는 느린 일부까지 포함해 95%가 그 안에 끝나는 경계다.
배치 크기 × 항목 처리시간 p95가 워커 제한시간이나 lease에 너무 가까우면 크기를 줄인다.
항목을 병렬로 처리한다면 단순 곱셈과 달라지므로 실제 배치 시간과 외부 API 동시 요청 수를 함께 본다.
100은 이 작업에서 운영 범위를 나눈 시작값이었다.
관측 결과에 따라 20이나 200으로 바꿀 수 있어야 하며, 코드 곳곳에 숫자를 복사하지 않고 설정 한 곳에서 관리하는 편이 낫다.
운영에서 완성하려면 ‘가져갔다’는 표시가 필요하다
배치 크기는 실패 범위를 작게 만들지만 여러 워커가 같은 항목을 동시에 읽는 문제까지 해결하지 않는다.
운영용 큐에서는 소유권과 만료, 멱등성과 영구 실패 격리를 함께 둔다.
아래는 제품에 상관없이 필요한 상태 전이를 보여 주는 개념 의사코드다.
실제 구현에서는 여러 워커가 동시에 접근해도 하나만 소유권을 얻도록 원자적 갱신을 사용해야 한다.
대상 = 대기 목록에서 최대 100건을 claim한다
claim에는 만료되는 lease를 붙인다
각 대상에 대해:
같은 대상을 다시 받아도 결과가 달라지지 않게 처리한다
성공하면:
완료 상태로 바꾼다
실패하면:
실패 횟수를 기록한다
한도 미만이면 백오프 뒤 다시 대기시킨다
한도 이상이면 격리 목록으로 옮긴다
남은 대상이 있으면 다음 배치를 예약한다
claim은 “이 대상은 지금 이 워커가 처리한다”는 소유권 표시다.
lease는 워커가 죽어도 소유권이 영원히 남지 않도록 만료시간을 둔 임대다.
실패가 반복될수록 다음 시도까지 더 기다리는 방식을 백오프라고 하며, 계속 실패하는 대상은 별도 격리 목록으로 보내야 정상 대상의 진행을 막지 않는다.
관리형 큐에서는 이 격리 목록을 DLQ(Dead-Letter Queue)라고 부르기도 한다.
DLQ는 실패를 해결하는 자동 쓰레기통이 아니라, 반복 처리하지 못한 메시지를 원인 조사와 수동 복구를 위해 분리해 두는 큐다.
재시도에는 멱등성도 필요하다.
같은 대상을 여러 번 실행해도 최종 결과가 같아지는 성질이다.
“포인트를 1,000점 더한다”는 두 번 실행하면 2,000점이 되지만, “이 주문의 포인트 상태를 완료로 맞춘다”는 중복 실행에 상대적으로 안전하다.
lease는 중복 실행 가능성을 낮추지만 한 번만 실행된다고 보장하지 않는다.
워커가 외부 작업을 끝낸 직후 완료 상태를 쓰기 전에 죽으면 같은 항목이 다시 전달될 수 있다.
주문 ID 같은 멱등 키와 데이터베이스 유일성 제약을 함께 둬야 두 번째 실행이 추가 부작용을 만들지 않는다.
재시도도 모든 오류에 적용하지 않는다.
네트워크 시간 초과(timeout), 호출 횟수가 너무 많다는 HTTP 429, 서버가 일시적으로 처리하지 못했다는 5xx 응답처럼 회복 가능성이 있는 오류만 제한 횟수 안에서 다시 시도한다.
잘못된 입력처럼 반복해도 결과가 달라지지 않는 오류는 바로 격리한다.
백오프에는 작은 무작위 지연인 jitter를 더해 여러 워커가 같은 시각에 다시 몰리지 않게 한다.
100건이 바꾼 것은 처리 속도보다 한 번의 실행 크기였다
실제로 적용한 해결은 여러 정기 작업의 대상을 공통 목록에 넣고, 한 번의 워커 실행이 최대 100건만 처리하도록 나눈 것이었다.
이 구조가 직접 줄인 것은 실행 시간이 아니라 한 프로세스가 한 번에 읽고 처리하는 후보의 수다.
중단 뒤 안전하게 이어 가는 성질은 아래에서 구분한 claim, lease, 완료 상태와 멱등 처리가 함께 구현돼야 생긴다.
수치로 확정할 수 있는 효과는 한 번의 실행이 책임지는 후보의 상한이 100건이 됐다는 점이다.
항목마다 원자적으로 성공을 기록하고 lease가 만료된 미완료 항목을 회수하는 조건이라면, 100건 중 36건을 끝내고 37번째에서 멈췄을 때 성공이 확정되지 않은 최대 64건을 다시 확인한다.
반대로 배치가 끝난 뒤 한꺼번에 완료 처리하면 최악의 경우 100건 전체가 재시도 대상이 된다.
이전 일괄 작업의 전체 대상이 N건이었다면, 워커 한 번이 읽는 후보의 상한은 N건에서 min(N, 100)건으로 줄어든다.
예를 들어 대상이 1만 건일 때 100건은 전체의 1%지만, 이것은 처리시간이 99% 줄었다는 뜻이 아니다.
한 실행이 책임질 후보의 계산상 상한이 1%가 됐다는 뜻이다.
실제 중복 처리량과 재시작 범위는 claim, lease, 항목별 완료 기록이 제대로 구현돼야 이 범위 안에 머문다.
이 구조는 대상 하나의 처리시간이 길거나 편차가 크고, 외부 API·네트워크 문제 때문에 일부만 실패할 수 있는 작업에 적용할 만하다.
모든 대상을 하나의 트랜잭션으로 처리해야 하거나 항목 하나가 매우 작아 큐 조회 비용이 더 큰 작업에는 맞지 않을 수 있다.
배치 크기는 100을 관습처럼 복사하지 말고 배치 시간, 메모리, 외부 API 제한, 실패 후 남은 건수를 측정해 조정해야 한다.
후속 구현의 기준은 분명하다.
항목별 멱등 키와 완료 상태를 남기고, 만료되는 소유권인 lease로 죽은 워커의 작업을 회수하며, 일시 오류만 백오프로 재시도하고 반복 실패는 격리한다.
AWS는 멱등 API를 이용한 안전한 재시도에서 같은 요청을 다시 보내도 추가 부작용이 없도록 요청 식별자를 설계해야 한다고 설명한다.
Amazon SQS의 visibility timeout 문서도 처리 중 메시지를 잠시 숨겨도 최소 한 번 전달 모델에서는 중복 가능성이 사라지지 않는다고 밝힌다.
Retry with backoff pattern 역시 백오프 재시도에는 멱등성이 필요하며, 재시도가 과하면 서비스 부하를 키울 수 있다고 안내한다.
운영에서는 대기 건수와 가장 오래된 작업의 나이, 처리시간 p95, 재시도·격리 건수, lease 만료 회수와 멱등 키 충돌을 한 화면에 놓는다.
p95는 처리시간 100개를 짧은 순서로 놓았을 때 약 95번째 값으로, 느린 일부 작업까지 살피기 위한 지표다.
이 값이 lease보다 충분히 짧지 않다면 정상 워커의 작업도 중복될 수 있으므로 배치 크기를 줄이거나 살아 있는 워커가 lease를 연장하게 한다.
같은 작업에 적용할 체크리스트
- 작업 하나를 다시 실행해도 결과가 중복되지 않는가?
- 여러 워커 중 하나만 항목의 소유권을 얻는가?
- 워커가 죽으면
lease가 만료돼 다시 처리할 수 있는가? - 실제 작업이 성공한 뒤에만 완료 상태를 기록하는가?
- 재시도할 오류와 바로 격리할 오류를 구분했는가?
- 반복 실패 항목이 정상 항목의 진행을 막지 않는가?
- 배치 크기와
lease를 처리시간 p95로 다시 조정할 수 있는가? - 대기 건수뿐 아니라 가장 오래 기다린 작업의 나이를 보고 있는가?
결론
실제로 적용한 변화는 전체 대상을 한 번에 순회하던 여러 정기 작업을 공통 목록과 최대 100건 단위 실행으로 나눈 것이다.
이 변경으로 한 워커가 한 번에 읽는 후보 수에는 상한이 생겼다.
처리 속도가 몇 퍼센트 빨라졌다는 측정값은 남아 있지 않아 성과로 만들지 않았다.
운영에서 안전하게 이어 가려면 배치 크기만으로는 부족하다.
소유권을 원자적으로 얻고, 완료를 항목별로 기록하며, 만료된 작업을 회수하고, 중복 실행이 추가 부작용을 만들지 않게 해야 한다.
핵심은 100이라는 숫자가 아니라, 실패해도 소유권과 완료 상태를 남겨 작은 범위에서 다시 시작할 수 있게 만드는 데 있다.