워커와 큐를 쓰면 반드시 물어야 하는 질문 — 이 잡이 두 번 돌면?
백엔드 지식 지도 4편. 큐를 쓰는 이유와 함께 배달 보장, 멱등 소비자, 가시성 타임아웃, DLQ, 큐 격리를 실무 관점에서 정리한다.
Series
백엔드 지식 지도큐를 붙이면 요청이 가벼워진다. 사용자는 오래 기다리지 않고, 무거운 일은 뒤에서 처리된다.
그래서 큐는 자주 “성능 개선 도구”처럼 도입된다. 하지만 큐의 본질은 성능보다 실패 지점의 이동에 가깝다.
요청 안에서 하던 일을 요청 밖으로 빼면, 실패도 요청 밖으로 이동한다. 이제 사용자의 HTTP 응답만 봐서는 일이 정말 끝났는지 알 수 없다. 워커가 잡을 집었는지, 죽었는지, 재시도 중인지, 이미 처리했는데 ack만 유실됐는지 따로 봐야 한다.
큐를 쓰는 순간 가장 먼저 물어야 할 질문은 이것이다.
이 잡이 두 번 실행되면 무슨 일이 일어나는가?
이 질문에 대답하지 못하면 큐는 언젠가 데이터 중복 장치가 된다.
큐를 쓰는 이유는 세 가지다
큐를 쓰는 이유는 보통 셋이다.
첫째, 시간 분리다. 사용자를 오래 기다리게 하지 않는다. 이메일 발송, 이미지 변환, 리포트 생성, 외부 API 동기화 같은 일은 요청 안에서 끝낼 필요가 없을 때가 많다.
둘째, 부하 평탄화다. 순간적으로 요청이 몰려도 큐에 쌓아 두고 워커가 일정한 속도로 처리할 수 있다.
셋째, 재시도 지점이다. 실패한 일을 사라지게 하지 않고 다시 시도할 수 있는 자리를 만든다.
다만 세 장점은 동시에 단점이 된다.
시간을 분리하면 사용자가 결과를 바로 모른다. 부하를 흡수하면 큐가 밀리는 동안 지연이 숨어 있다가 한꺼번에 드러난다. 재시도를 넣으면 중복 실행과 재시도 폭주를 설계해야 한다.
큐는 하나가 아니다
“큐”라고 부르지만 실제 모델은 다르다.
작업 큐는 하나의 잡을 하나의 워커가 처리한다. 이메일 발송, 썸네일 생성, 결제 후처리 같은 데 어울린다.
pub/sub은 하나의 이벤트를 모든 구독자에게 전달한다. “주문 생성됨” 이벤트를 알림, 분석, 추천 시스템이 각각 받는 식이다.
로그나 스트림은 순서 있는 기록을 여러 소비자가 각자 오프셋으로 읽는다. Kafka가 대표적이다. 메시지를 없애는 큐라기보다 append-only log에 가깝다.
셋은 같은 문제가 아니다. Redis, RabbitMQ, Kafka, SQS를 고를 때도 “무엇이 더 빠른가”보다 “실패했을 때 무엇을 보장받고 싶은가”를 먼저 봐야 한다.
exactly-once보다 멱등 소비자가 중요하다
큐를 처음 쓰면 “정확히 한 번만 처리되면 좋겠다”고 생각한다.
하지만 네트워크 위에서 이 말은 생각보다 어렵다.
워커가 잡을 처리하고 DB에 커밋했다. 이제 브로커에 ack를 보내야 한다. 그런데 ack가 가는 길에 네트워크가 끊겼다. 브로커 입장에서는 워커가 처리했는지 알 수 없다. 다시 전달하면 중복이고, 버리면 유실일 수 있다.
응답이 없을 때 처리 실패와 응답 유실을 구별할 방법이 없다.
그래서 실무의 기본값은 보통 이렇다.
at-least-once delivery
+ idempotent consumer
= effectively-once중복 전달은 허용한다. 대신 소비자가 같은 일을 여러 번 해도 결과가 같도록 만든다.
방법은 데이터베이스에 있다.
- 멱등키 테이블을 둔다.
- 자연키에 unique constraint를 건다.
ON CONFLICT DO NOTHING또는ON CONFLICT DO UPDATE를 사용한다.- 처리 완료 기록과 비즈니스 변경을 같은 트랜잭션에 커밋한다.
- 버전 컬럼으로 조건부 업데이트를 한다.
중요한 것은 “잡 함수가 두 번 호출되지 않게 한다”가 아니다. 두 번 호출되어도 데이터가 한 번 처리된 것처럼 남게 만드는 것이다.
가시성 타임아웃은 회수를 지연시킨다
SQS 같은 큐에는 visibility timeout이라는 모델이 있다.
워커가 메시지를 집으면 그 메시지는 잠시 다른 워커에게 보이지 않는다. 워커가 정상 처리 후 삭제하면 끝난다. 하지만 워커가 죽거나 ack를 보내지 못하면, 정해진 시간이 지난 뒤 메시지가 다시 보인다.
여기서 중요한 것은 회수가 즉시 일어나지 않는다는 점이다.
워커가 죽은 순간부터 visibility timeout이 끝날 때까지, 잡은 애매한 상태로 남는다. 사용자에게는 진행 중처럼 보일 수 있고, 실제로는 아무도 처리하지 않을 수 있다.
워커 시스템마다 표현은 다르지만 본질은 같다.
실행 중 상태는 언젠가 회수되어야 한다.
하지만 회수는 항상 지연된다.그래서 긴 잡에는 하트비트가 필요하고, 진행 상태를 외부에서 볼 수 있어야 한다. 그리고 잡의 최대 실행 시간보다 회수 시간이 어떻게 긴지 짧은지 설계해야 한다.
강제 종료는 실패 기록 코드를 건너뛴다
워커에는 보통 job timeout이 있다. 잡이 너무 오래 걸리면 큐 시스템이 강제로 끊는다.
문제는 강제 종료가 애플리케이션의 실패 기록 코드를 건너뛸 수 있다는 점이다.
정상 예외라면 잡 함수가 except 블록에서 실패 사유를 저장할 수 있다. 하지만 프로세스가 SIGKILL로 죽으면 그 코드는 실행되지 않는다. 기록에는 “timeout”이나 “worker lost” 같은 외부 관찰만 남는다.
그래서 실패 기록 경로는 최소 세 가지를 덮어야 한다.
- 잡 함수 안에서 발생한 정상 예외
- 워커 프로세스 사망
- 실패 기록 코드 자체의 실패
셋 중 하나라도 비면 “왜 실패했는지”가 추측으로 남는다. 특히 이미지 처리, LLM 호출, 크롤링처럼 외부와 CPU를 많이 쓰는 잡은 프로세스 사망 경로를 별도로 봐야 한다.
재시도 가능 실패와 불가능 실패를 나눠야 한다
모든 실패를 재시도하면 큐는 쓰레기통이 된다.
일시적 실패는 재시도할 가치가 있다.
- 네트워크 timeout
- 일시적 5xx
- DB deadlock
- rate limit 이후 재시도 가능 응답
하지만 영구 실패는 재시도해도 소용없다.
- 잘못된 입력
- 권한 없음
- 존재하지 않는 리소스
- 파싱 불가능한 파일
- 비즈니스 규칙 위반
이 둘을 구분하지 않으면 영구 실패가 큐를 영원히 돈다. 워커 슬롯을 잡아먹고, 로그를 오염시키고, 진짜 장애 신호를 묻어 버린다.
그래서 DLQ가 필요하다. Dead Letter Queue는 끝내 처리하지 못한 잡을 버리지 않고 따로 모으는 곳이다. DLQ가 없으면 실패는 조용히 사라지거나 영원히 재시도된다. 둘 다 나쁘다.
긴 잡과 짧은 잡은 같은 큐에 두지 말자
큐에서 자주 생기는 장애가 head-of-line blocking이다.
워커가 10개 있고, 30분짜리 긴 잡이 10개 들어왔다고 해 보자. 그 뒤에 1초짜리 짧은 잡이 들어와도 처리되지 않는다. 워커 슬롯을 긴 잡이 모두 차지하고 있기 때문이다.
처리 시간 평균만 보면 괜찮아 보일 수 있다. 하지만 짧은 잡의 대기 시간은 폭발한다.
해법은 두 가지다.
첫째, 큐를 나누고 전용 워커를 붙인다.
short queue: 빠른 사용자 후처리
long queue: 리포트 생성, 대형 변환
external queue: 느린 외부 API 동기화둘째, 긴 잡을 체크포인트가 있는 작은 잡들로 쪼갠다. 큰 리포트 하나를 한 번에 만들기보다 페이지별, 계정별, 날짜별로 나누면 실패 재시도 비용도 작아진다.
대부분의 팀은 큐를 너무 늦게 나눈다. 문제가 보일 때는 이미 짧은 작업까지 같이 느려진 뒤다.
잡 페이로드에는 식별자만 넣자
잡 인자에 큰 객체를 넣고 싶을 때가 있다.
요청 시점의 데이터를 통째로 직렬화해서 큐에 넣으면 워커가 DB를 다시 읽지 않아도 된다. 편해 보인다.
하지만 대가가 크다.
- 직렬화와 역직렬화 비용이 커진다.
- 브로커 메모리를 압박한다.
- 재시도 시 낡은 데이터로 실행될 수 있다.
- 스키마 변경에 취약해진다.
정석은 claim check 패턴이다.
잡에는 ID만 넣는다.
워커가 시작할 때 현재 데이터를 읽는다.물론 항상 현재 값을 읽는 것이 맞지는 않다. 어떤 작업은 접수 시점의 스냅샷이 필요하다. 중요한 것은 그 선택을 의식적으로 하는 것이다. “그냥 편해서 객체를 넣었다”가 문제다.
큐 길이는 결과가 아니라 증상이다
큐 길이가 늘어난다는 것은 소비가 생산을 못 따라간다는 뜻이다.
처리 시간만 보면 놓칠 수 있다. 워커가 잡을 빠르게 처리하고 있어도 enqueue에서 시작까지 대기 시간이 길면 사용자는 느리게 느낀다.
큐에서 봐야 할 지표는 최소 다섯 가지다.
- queue depth: 얼마나 쌓였나
- enqueue to start latency: 시작 전 대기 시간
- processing time: 실제 처리 시간
- failure rate: 실패율
- retry rate: 재시도율
특히 대기 시간이 중요하다. “처리는 빠른데 아무도 서비스를 못 받는” 상태는 대기 시간에서 드러난다.
마무리
큐는 단순히 비동기 처리를 붙이는 도구가 아니다.
큐는 실패를 보존하고, 지연시키고, 다시 시도하게 만드는 장치다. 그래서 큐를 쓰는 순간 중복, 회수, 강제 종료, DLQ, 공정성, 관측 지표를 함께 설계해야 한다.
가장 중요한 질문은 계속 같다.
이 잡이 두 번 실행되면 무슨 일이 일어나는가?
이 질문에 자신 있게 답할 수 있을 때 큐는 안전한 도구가 된다.
다음 편에서는 요청과 서비스 사이의 계약인 HTTP와 API를 본다.
핵심 질문은 이것이다.
어떤 요청은 다시 보내도 되고, 어떤 요청은 다시 보내면 안 되는가.