1. "실패"가 아니라 "소멸"이다
장애 대응하다 보면 제일 곤란한 게 에러 로그가 없는 문제다. 5xx가 찍히면 그래도 추적할 실마리가 있는데, 잡이 그냥 없어지면 시작점 자체가 없다. CS로 "환영 메일 안 왔어요" 티켓이 들어오고, 그제서야 DB를 뒤져보면 유저 row는 멀쩡히 있는데 잡 기록은 어디에도 없다. 큐 지표도 정상, 워커 에러율도 0, 배포 이력도 평범. 모든 시스템 관점에서는 아무 일도 없었다.
Dev.to에 올라온 "Four Ways Your Background Job Disappears"는 이 문제를 회원가입 + 환영 메일이라는 아주 흔한 시나리오로 풀어낸 글이다. 원문의 출발점이 좋다. 요청 핸들러 안에서 메일을 바로 보내는 코드다.
@app.post("/signup")
def signup(payload):
user = db.insert_user(payload) # 8ms
email.send_welcome(user.email) # 40ms? 2s? forever?
return {"ok": True, "user_id": user.id}
원문의 표현대로 두 번째 줄이 "당신의 가용성이 죽으러 가는 곳"이다. 99.9% 서비스 두 개를 직렬로 엮으면 99.8%가 된다. 메일 프로바이더를 "쓰는" 게 아니라 상속하는 셈이다. 그래서 큐로 뺀다. 여기까지는 다들 한다. 문제는 큐로 뺀 다음부터 잡이 사라지는 경로가 최소 네 갈래로 늘어난다는 것이다.
내 경험상 실무에서 만나는 소멸 경로는 이렇게 정리된다.
- 배포/SIGTERM: 롤링 업데이트 중 워커 파드가 잡을 들고 죽는다
- OOM Kill / 노드 축출: SIGKILL이라 정리할 틈조차 없다
- 가시성 타임아웃 + ack 전략: 유실 아니면 중복, 둘 중 하나
- 삼켜진 예외: try/except pass와 fire-and-forget
그리고 이 네 개 앞단에 하나 더 있다. 원문이 짚는 dual write 문제. 핸들러가 Postgres에 유저를 넣고 SQS에 메시지를 넣는다면, 이건 서로 다른 두 시스템에 대한 두 번의 쓰기고 둘을 묶는 트랜잭션은 존재할 수 없다. 그 사이에서 프로세스가 죽으면 계정은 있는데 잡은 없다. 순서를 바꾸면 존재하지 않는 유저에게 메일 보내는 잡이 남는다.
2. 원인 1·2 — 배포와 SIGTERM, 그리고 OOM Kill
쿠버네티스에서 파드를 지울 때 순서는 대략 이렇다. 엔드포인트에서 빠지고(서비스 라우팅 제외), preStop 훅이 있으면 실행되고, 컨테이너 PID 1에 SIGTERM이 간다. terminationGracePeriodSeconds(기본값 30초)가 지나도 안 죽으면 SIGKILL이 날아온다.
여기서 워커가 사라지는 패턴은 두 가지다.
패턴 A: SIGTERM을 아예 안 잡는다. 파이썬 기본 동작상 SIGTERM은 프로세스를 즉시 종료시킨다. 처리 중이던 잡은 ack도 못 하고, 정리 코드도 안 돈다. 큐 설정에 따라 재전달되거나(다행) 그대로 증발한다(불행).
패턴 B: SIGTERM은 잡는데 grace period보다 잡이 길다. 배치성 잡이 2분 걸리는데 grace period가 30초면 결국 SIGKILL이다. "우린 graceful shutdown 구현했는데요"라고 말하는 팀 절반은 여기에 걸린다.
최소 형태의 워커 종료 처리는 이렇다. 핵심은 새 잡을 안 받는 것과 진행 중 잡만 마무리하는 것을 분리하는 거다.
import signal, time
shutting_down = False
def on_term(signum, frame):
global shutting_down
shutting_down = True
print("SIGTERM received, draining...", flush=True)
signal.signal(signal.SIGTERM, on_term)
while not shutting_down:
msg = queue.receive(wait_seconds=5) # long polling
if not msg:
continue
try:
handle(msg)
queue.delete(msg) # 완료 후에만 삭제
except TransientError:
pass # 가시성 타임아웃 만료 후 재전달
print("drained, exiting", flush=True)
주의할 점 하나. long polling 대기 중에 SIGTERM이 오면 대기가 끝날 때까지 종료가 지연된다. 대기 시간을 grace period보다 충분히 짧게 잡아야 한다.
OOM Kill은 이야기가 다르다. 커널 OOM killer는 SIGKILL을 쓴다. 핸들러를 등록해도 소용없다. 그래서 대응은 "우아하게 죽기"가 아니라 "죽어도 잡이 살아남는 구조"여야 한다. 즉 완료 전 삭제 금지 원칙이 유일한 방어선이다.
운영 중 OOM 여부는 이렇게 확인한다.
$ kubectl describe pod worker-7d9f8c-2xk4l | grep -A5 "Last State"
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Mon, 03 Mar 2025 14:22:11 +0900
Finished: Mon, 03 Mar 2025 14:47:53 +0900
Exit Code 137은 128 + 9(SIGKILL)다. 143이 나오면 128 + 15(SIGTERM), 즉 grace period 안에 안 끝나서 강제 종료됐거나 정상 종료 경로다. 이 둘을 구분하는 것만으로 원인 분류가 절반은 끝난다.
워커가 자주 OOM으로 죽는다면 메모리 리밋을 올리기 전에 먼저 볼 것: 한 번에 prefetch하는 메시지 개수. 라이브러리 기본값이 크면 잡 페이로드를 수십~수백 개 메모리에 올려두고 있을 수 있다. 이건 리밋을 올려도 트래픽이 늘면 다시 터진다.
3. 원인 3·4 — 가시성 타임아웃, 그리고 삼켜진 예외
원문이 가장 명확하게 짚은 부분이 이거다. "delete는 영수증이지 결제가 아니다(The delete is the receipt, not the checkout)."
제대로 된 큐는 메시지를 워커에 넘길 때 지우지 않는다. 일정 시간 숨긴다. 이게 visibility timeout이다. 워커가 완료 후 명시적으로 delete하면 끝. 워커가 죽어서 delete를 못 하면 타이머 만료 후 메시지가 다시 보이고 다음 워커가 집는다.
여기서 실무 함정 두 개.
함정 1: 타임아웃이 잡보다 짧다. 45초 걸리는 잡에 30초 타임아웃을 걸면, 30초 시점에 재전달돼서 두 워커가 같은 잡을 동시에 돌린다. 둘 다 자기가 혼자라고 믿으면서. 메일이 두 통 나가는 정도면 다행이고, 크레딧 증가 같은 잡이면 사고다.
함정 2: 늦게 delete를 호출한다. 타임아웃이 이미 만료된 뒤 delete를 부르면 이런 에러를 본다.
An error occurred (ReceiptHandleIsInvalid) when calling the DeleteMessage
operation: The receipt handle has expired.
이 에러가 로그에 보이면 "잡 처리 시간 > 가시성 타임아웃"이 확정이다. 원인 파악에 아주 유용한 신호니까 절대 무시하지 말자. RabbitMQ 쪽에서는 비슷한 상황에서 PRECONDITION_FAILED - unknown delivery tag 1 같은 메시지가 나오고 채널이 닫힌다.
그런데 유실을 막으면 필연적으로 at-least-once가 된다. 메일 보내고 delete 직전에 죽으면 재전달되어 또 보낸다. 원문 표현대로 exactly-once 전달은 네트워크 너머로 살 수 있는 물건이 아니다. exactly-once "효과"는 큐가 아니라 워커에서 만든다. 즉 멱등성이다.
def handle(job):
# 유니크 인덱스가 대신 싸워준다
inserted = db.execute(
"INSERT INTO processed_jobs (job_id) VALUES (%s) ON CONFLICT DO NOTHING",
job.id,
)
if inserted.rowcount == 0:
return # 누가 이미 했다. 퇴근.
email.send_welcome(job.payload["user_id"])
원문이 짚는 두 가지 오해를 그대로 옮긴다. 첫째, 마커를 어디에 쓰는지가 중요하다. 부수효과와 다른 저장소에 마커를 쓰면 dual write 문제를 한 층 아래로 옮긴 것뿐이다. 둘째, 모든 잡에 이게 필요한 건 아니다. SET last_login = now()는 두 번 돌아도 아무 일 없다. INCREMENT credits BY 10은 전혀 아니다. 필요 없는 멱등성은 그냥 레이턴시다.
마지막 소멸 경로는 코드에 있다. 삼켜진 예외.
# 이 패턴이 잡을 조용히 죽인다
try:
send_email(user)
except Exception:
pass # 로그도 없고 재시도도 없다
# asyncio에서도 같은 일이 벌어진다
asyncio.create_task(send_email(user))
# 태스크 참조를 안 잡으면 GC 대상이 되고,
# 예외가 나도 "Task exception was never retrieved"로 끝날 수 있다
실제로 로그에서 이런 걸 보면 fire-and-forget 태스크가 조용히 죽고 있다는 뜻이다.
ERROR:asyncio:Task exception was never retrieved
future: <Task finished coro=<send_email() done, defined at app/mail.py:31>
exception=ConnectionResetError(104, 'Connection reset by peer')>
이건 에러 추적 툴에도 안 잡히는 경우가 많다. 요청 컨텍스트 밖이라 미들웨어가 못 잡기 때문이다.
4. 유실 제로에 가까워지는 조합: Outbox → 멱등성 → DLQ → 지표
앞의 dual write를 없애는 방법이 transactional outbox다. 이름이 무섭게 생겼지만 실체는 단순하다. 핸들러에서 큐에 쓰지 말고, 같은 DB의 테이블에, 같은 트랜잭션 안에서 쓴다.
BEGIN;
INSERT INTO users (id, email)
VALUES ('u_881', 'ada@example.com');
INSERT INTO outbox (id, type, payload)
VALUES ('j_204', 'send_welcome', '{"user_id":"u_881"}');
COMMIT;
이제 쓰기는 하나의 시스템에 대한 하나의 커밋이다. 유저와 잡이 둘 다 있거나 둘 다 없거나. 크래시가 끼어들 틈 자체가 사라진다. 이후 별도 relay 프로세스가 미발행 outbox row를 읽어 실제 큐로 publish한다. 테이블을 폴링하거나 Debezium 같은 걸로 WAL을 tailing하는 방식이다.
원문
'Tech_News' 카테고리의 다른 글
| Gemini 3.8 Flash와 3.8 Flash Cyber: 6주에 세 번 나온 Flash, 인프라 담당자는 뭘 봐야 하나 (1) | 2026.09.04 |
|---|---|
| Darling으로 Linux에서 macOS 바이너리 돌리기 — Wine과 뭐가 다르고, CI에 쓸 수 있을까 (0) | 2026.09.03 |
| Artifactory가 에이전트들의 비밀 게시판이 됐다 — AI 에이전트 시대의 사내 저장소 위협 모델 (1) | 2026.09.02 |
| 맥에서 진짜 iPhone을 부팅한다 — vphone-cli로 본 Virtualization.framework의 뒷문 (0) | 2026.09.01 |
| 윈도우 라이선스 환불받기: 노트북에 딸려온 OS 값, 실제로 돌려받을 수 있을까 (0) | 2026.08.31 |
