배경 작업은 실패하지 않고 사라진다
이 글은 사용자 요청 안에서 이메일, 이미지 렌더링, LLM 호출까지 직접 처리하는 1인 개발자와 소규모 SaaS 팀을 위한 것이다. 읽고 나면 얻는 것이 하나 있다. 배경 작업의 진짜 위험은 조용한 소실이다. 그리고 사라진 작업을 잡아내는 큐 운영의 규율.
결론부터 말한다. 큐는 빠르게 만드는 도구가 아니다. 큐는 지금 당장 하지 않아도 되는 일을 요청 밖으로 내보내는 장치이고, 그 순간 시스템은 사용자에게 새로운 계약을 맺게 된다. 언제 끝나는가, 실패하면 어떻게 되는가. 배경 작업 시스템의 성패는 작업이 얼마나 빨리 도는지로 재지 않는다. 사라진 작업을 얼마나 신뢰할 수 있게 잡아내느냐로 재는다.
반복되는 장면이 있다. 보고서 버튼을 누르면, 하나의 요청 안에서 이메일을 보내고 이미지를 렌더링하고 LLM을 호출한다. 사용자가 30초를 기다렸다가 브라우저가 포기하면, 사용자는 다시 누른다. 이메일 두 통, 이미지 두 장, LLM 두 번. 로그를 보면 모든 단계가 성공을 반환했다. 아무것도 깨지지 않았다는 기록뿐이다. 배경 작업 문제가 시작되는 모습은 대개 이런 형태이고, 그 원인은 거의 언제나 경계가 잘못된 데 있다. 이 글은 그 경계부터 시작해, 작업의 모양, 실패의 사각지대, 관측까지 순서대로 논증한다.
글의 핵심 개념을 형상화했습니다.
요청 안에 뭘 두고 밖으로 뭘 내보내는가
동기 경로는 단순하다. 요청이 들어와, 처리되고 결과가 돌아간다. 그 사이 사용자의 화면은 막혀 있다. 조회, 검색, 결제 승인은 이 경로에 잘 맞는 일이다. 사용자가 그 자리에서 결과를 기다리고 처리가 빠르고 결정을 내리기 쉽다.
비동기 경로는 “받았다”는 사실만 즉시 돌려준다. 일은 나중에 일어나고 결과는 이메일이나 상태 변경, 또는 푸시로 도착한다. 사용자의 대기 시간은 거의 0이 된다. 대신 계약이 생긴다. 큐가 지켜야 할 계약은 둘이다. 언제 끝나는가, 실패하면 어떻게 되는가.
모던하냐는 질문은 여기서 물러난다. 남는 질문은 둘이다. 사용자가 얼마나 기다려줄 수 있는가, 그리고 서비스가 일을 늦게 해도 되는가. 사용자가 화면을 보며 기다리고 있다면 동기다. 2초가 걸려도 그렇다. 비동기는 사용자가 자리를 뜬 뒤 알아서 처리해도 되는 일에만 쓰인다.
새로운 일이 생겼을 때, 경로에 넣기 전에 몇 가지를 묻는다. 사용자가 지금 결과를 봐야 하는가. 얼마나 걸리는가. 대략적인 가이드라인은 이렇다. 100ms 이하는 동기여도 문제없고 1초를 넘으면 큐 후보다 [추정]. 이 라인 자체보다 중요한 것은, 처리 시간이 내 손에서 벗어난다는 사실이다. DB 락, 외부 API, 불안정한 네트워크. 예상보다 실행을 늘릴 수 있는 요인이 보이면, 그 일부터 요청 경로에서 빼는 것이 안전하다.
경계를 흐리게 하는 유혹은 속도다. 보고서가 빠르면 동기에서 처리해도 괜찮을 것이라는 계산이 먼저 나온다. 그러나 기준은 사용자의 대기 자세다. 화면 앞에서 결과를 보는 일이라면 2초여도 동기다. 사용자가 자리를 뜬 뒤 도착해도 되는 일이라면 30초여도 큐다. 빠르다는 사실 하나가 경계를 움직이지 않는다. 경계는 도구 고르기 전에, 어떤 계약으로 사용자를 대할지를 정한 후에야 선다.
NotebookLM이 소스를 종합해 생성한 인포그래픽입니다.
작업은 다시 돌 수 있는 최소 단위로
경계가 정해지면 다음 질문은 모양이다. 큐는 결국 목록인데, 배경 작업 파이프라인의 품질을 두 가지가 정한다. 들어가는 작업의 모양, 그리고 그 작업을 꺼내서 돌리는 워커의 루프. 이 섹션에서 다룰 것은 전자다.
작업의 크기가 실패의 파급 범위를 정한다. 작업은 안전하게 다시 실행할 수 있는 한 단위의 조작이어야 한다. 세 가지 조건이 있다. 입력이 명확하고 조작이 하나이며 완료를 검증할 수 있어야 한다.
흔한 반례는 묶어 넣기다. 주문 처리라는 작업에 결제 확인, 발송, 이메일, 적립을 모두 담은 경우. 마지막에 이메일이 실패하면 재시도 시 전체가 다시 돌고, 발송은 두 번 일어난다. 두 번째 박스가 창고에서 나가는 순간, 시스템은 정확히 설계대로 동작한 결과다. 사이에 의존성이 있으면, 조정은 별도의 부모 작업이나 상태 기계가 맡는다. 자식들은 각자 재시도 가능하게 만든다.
좋은 작업은 목적어가 하나인 동사다. 이메일 1통 전송은 좋은 작업이고 고객에게 연락하기는 나쁜 작업이다. 무엇을, 어떤 식으로, 어떻게 완료가 되는지 알 수 없기 때문이다. 아래 표는 같은 파이프라인 안에서 두 모양이 공존하는 모습을 비교한다.
| 좋은 작업 | 나쁜 작업 |
|---|---|
| 이메일 1통 전송 | 주문 전체 처리 |
| 이미지 리사이즈 1개 | 결제 + 발송 + 적립 |
| LLM 호출 1회 | 보고서 전체 생성 |
| 해지 절차 1단계 | 고객에게 연락하기 |
페이로드는 참조를 실어 나른다. 작업에 사용자 기록의 복사본을 담아 두면 안 된다. user_id만 넣고 실행할 때 DB에서 최신으로 가져온다. 큐 속에 고인 복사본과 DB의 상태가 다른 것은, 찾기 힘든 버그의 원천이다. 사용자의 주소가 바뀌었어도, 큐에 고여 있는 옛 주소로 메일이 나간다. 큐는 무엇을 할 일을 전하는 곳이고 지금은 어떤 상태라는 사실은 DB가 담는다.
엔큐는 가볍게 만든다. 엔큐는 작업을 큐에 쓰는 행위다. 엔큐 시점에 파일을 가져오거나 API를 부르는 일이 필요하다면, 그 일 자체를 별도 작업으로 만든다. 엔큐는 빠른 기록이어야 한다. 무거워진 엔큐는 동기 경로에 또다시 긴 블록을 만들어, 이 섹션의 전제인 경계 설정을 무너뜨린다.

재실행은 막을 수 없고 무해하게만 만들 수 있다
워커는 죽는다. 배포, OOM, 패닉. 워커가 작업을 실행하다가 사라지면, 그 작업은 ack되지 못한다. 가시성 타임아웃이 지나면 작업은 다시 돌아오고 다른 워커가 다시 실행한다. 이 회복은 필수다. 워커가 죽어도 일이 돌아오게 해 주는 것이 큐의 존재 이유이기 때문이다. 대가는 중복 실행이다. 사이드 이펙트가 두 번 일어날 수 있다.
그래서 최소 한 번과 멱등성이 파이프라인의 핵심이 된다. 재실행을 막을 수 있는 방법은 없다. 만들어지는 순간, 멱등성이 없는 작업은 재실행의 위험을 품은 상태다. 멱등성이란 같은 조작이 여러 번 돌아가도, 결과가 한 번 실행한 것과 같은 성질이다.
자연스럽게 멱등인 조작이 있고 그렇지 않은 조작이 있다. 주문 상태를 취소로 바꾸는 일은 몇 번을 돌려도 같은 상태가 생긴다. 포인트를 올리는 일, 이메일을 보내는 일, 카드를 청구하는 일은 그렇지 않다. 매번 실행할 때마다 새로운 결과가 쌓인다. 멱등이 아닌 단계 하나하나가, 재시도가 사용자에게 보일 실수가 되는 자리다.
기술은 몇 가지가 있다. 멱등 키다. 사용자가 버튼을 두 번 눌러도 같은 키로 들어오는 작업은 하나로 병합된다. 액션 전에 검사다. 보고서 행이 이미 존재하면 확인으로 끝낸다. DB의 유니크 제약이다. 중복 키 오류는 이미 완료했다는 증거로 읽는다. 앞의 더블클릭 사례는 멱등 키 하나로 두 번째 이메일을 멈출 수 있다.
그러나 멱등성을 재시도해서 성공할 때까지 돌리는 정책과 섞으면 안 된다. 그건 별개의 병이다. 영원히 재시도되는 작업은 워커를 차지하고 진짜 실패를 끊임없는 시도 사이에 숨긴다. 재시도 횟수에 상한을 두고 상한에 닿으면 작업은 데드레터로 이동한다. 데드레터는 실패한 작업이 누군가 볼 때까지, 세어지며 기다리는 곳이다.

워커의 죽음과, 멈춰 버린 작업
배경 파이프라인은 조용하다. 아무 일이 없으면 존재를 느끼지 못한다. 그래서 진짜 실력은 무언가 죽는 순간에 드러난다. 일은 네 가지 사각지대에서 사라진다. 워커가 죽는 것, 작업이 실패하지 않고 멈추는 것, 작업이 영원히 재시도되는 것, 그리고 ack과 사이드 이펙트가 어긋나는 것. 사각지대마다 붙잡는 자리가 다르다.
워커 사망은 가시성 타임아웃이 잡는다. 다만 가시성 타임아웃은 범위다. 작업의 최대 실행 시간보다 길어야 한다. 타임아웃이 30초이고 작업이 40초가 걸리면, 멀쩡한 워커의 작업이 다른 워커한테 탈취당해 이유 없이 두 번 돈다. 동시에 잃을 수 있는 시간보다 짧아야 한다. 타임아웃이 1시간이면, 죽은 작업을 1시간 동안 찾지 못한다. 합리적인 시작점은 p95 실행 시간의 2에서 3배다 [추정]. 관측하면서 점차 조인다.
사망에는 또 다른 종류가 있다. 메모리다. 큰 이미지를 풀거나 LLM 응답을 통째로 메모리에 올리는 작업에서 워커가 한계에 닿으면, 프로세스는 에러를 남기지 않고 끝난다. 작업은 ack되지 않은 채 사라졌으므로, 가시성 타임아웃이 다시 붙잡아온다. 잡는 사각지대는 같고 원인이 다를 뿐이다.
1인 팀에는 한 줄을 더 보탠다. 워커를 다시 시작하는 것은 누가 하는가. 워커 하나가 죽으면, 그 워커를 살리는 일도 누군가의 몫이다. systemd 같은 프로세스 매니저가 죽은 워커를 다시 띄워 주도록 해 두는 것은, 큐 운영의 기본 장비다. 매니저가 없다면, 일정 간격으로 워커가 살아있는가를 확인하는 워치독 크론 하나다. 둘 다 없으면, 워커 사망은 사람 눈으로 알아차리는 것뿐이다. 그리고 사람들은 새벽에 깨어 있지 않다.
가장 까다로운 사각지대는 실패하지 않고 멈추는 작업이다. 외부 API 호출에 걸린 작업은 에러를 던지지 않고, 단순히 반환하지 않는다. 재시도 로직은 발동조차 못 한다. 멈췄다는 사실을 로그가 기록하는 것도 없다. 이 사각지대를 잡는 것은 가시성 타임아웃뿐이다. 앞 섹션에서 값을 정한 것이, 사실은 정류 감지 장치이기도 했던 셈이다.

ack과 사이드 이펙트, 데드레터까지
네 번째 사각지대는 ack과 사이드 이펙트의 어긋남이다. ack은 사이드 이펙트가 끝난 후에만 쓴다. ack을 먼저 쓰면 일은 끝난 것으로 처리되어, 다시 돌아오지 않는다. 그런데 사이드 이펙트가 여러 외부 호출로 이루어져 있으면, 호출 사이의 크래시는 일부를 남긴 채 멈추게 된다.
이럴 때 순서가 살림을 한다. 되돌리기 힘든 호출은 마지막에 두고 앞의 호출들은 멱등으로 만든다. 이메일을 보내고 상태를 기록한 뒤 마지막에 카드를 청구한다. 크래시가 어디서 터져도, 재실행이 안전해지는 순서다.
영원히 재시도되는 작업은 앞 섹션의 상한이 끊는다. 재시도 횟수에 상한이 없으면, 실패한 작업이 계속 돌아다니며 워커를 차지한다. 큐는 정지한 것이 아니라 돌아가는 것처럼 보이며, 진짜 실패는 그 움직임 뒤에 숨는다. 상한을 두면, 실패한 작업은 데드레터에서 세어지며 기다린다.
데드레터는 아침에 읽는 곳이다. 같은 오류로 한꺼번에 쌓인 데드레터가 있다면, 그것은 N건의 실패가 아니라 한 건의 사고다. 외부 API가 내려간 날이면, 계속 실패하던 작업들이 상한에 닿아 데드레터로 몰려 온다. 읽는 순서도 같다. 먼저 외부 쪽을 확인하고 그 다음 내 코드다. 데드레터 수가 1을 넘은 순간, 사용자의 일이 일어나지 않았다는 증거가 생긴 것이다.
붙잡는 자리를 정리하면, 이렇다. 워커의 죽음은 가시성 타임아웃과 워커 부활이 잡는다. 정류는 가시성 타임아웃이 잡는다. 무한 재시도는 상한과 데드레터가 잡는다. ack과 사이드 이펙트의 어긋남은 순서와 멱등성이 잡는다. 사각지대마다 붙잡는 자리가 다르지만 모든 죽음이 어딘가에 기록되도록 만드는 일은 같다.

1인 팀이 볼 지표는 네 개면 충분하다
1인 팀에는 24시간 온콜이 없다. 새벽 3시에 큐가 막히면, 일어날지 말지를 그때서야 결정하게 된다. 컨텍스트 없이, 어둠 속에서. 그래서 관측의 설계 목표는 명확하다. 조치할 일이 있을 때만 깨우는 것, 그리고 낮에 5분이면 건강을 확인할 수 있는 것.
지표는 많이 필요 없다. 네 개면 충분하고 각자의 역할이 다르다. 첫째는 최고령 작업의 대기 시간이다. 정체의 진짜 계기다. 길이가 500개여도 1분 안에 비워진다면 괜찮고, 5개여도 3시간을 기다린 작업이 있다면 사고다. 몇 개가 쌓였는가보다, 쌓인 것 중 가장 오래된 것이 몇 시간을 기다렸는가를 본다.
둘째는 처리 시간의 p95다. 열화의 징조다. DB가 느려지고 외부 서비스가 느려지고 워커가 늙으면 여기부터 커진다. 작업 유형별로 보는 것이 적절하다. 이메일의 p95와 LLM의 p95를 합쳐서 보면 아무 의미가 없다. 각 유형은 각자의 모양을 가지고 있다. 같은 지표라도, 다른 스케일에서 읽어야 한다.
셋째는 재시도율이다. 외부 불안정의 징조다. LLM API나 SMTP가 그날 컨디션이 나쁘면, 여기가 가장 먼저 반응한다. 재시도율이 갑작스레 오르면 대개 외부 쪽이 아픈 신호다. 내 코드보다는 그쪽을 먼저 확인하게 된다. 조사 순서를 정해 주는 지표다.
넷째는 데드레터 수다. 조용한 실패다. 1이 의미 있는 유일한 지표다. 사용자가 요청한 일이 일어나지 않았다는 증거가 하나 생겼기 때문이다. 0이 정상이고 1이 넘는 순간 사용자에게 미안한 일이 생긴 증거다. 나머지 세 개는 추세이고 이것만은 사건이다. 경계값은 시작점이다. 최고령 대기 1시간 초과, 처리 p95가 통상의 2배, 재시도율 5퍼센트 초과, 데드레터 1건 이상 [추정]. 제품 리듬에 맞게 조인다. 밤에만 도는 LLM 배치와 분 단위 이메일의 통상은, 같은 지표라도 다르다. 경계값은 법이 아니라, 새벽에 일어나도 5분이면 상황을 읽을 수 있게 해 주는 출발선이다.
큐는 나중에를 안전하게 만드는 장치다. 일은 요청 밖으로 나가고 시스템은 끝낼 것과, 실패하면 말할 것과, 조용히 사라지지 않을 것을 약속한다. 경계, 작은 멱등 작업, 범위로 정한 가시성 타임아웃, 데드레터, 네 개의 지표. 이것이 한꺼번에 처리하지 않는 규율의 전체다. 더 깊게 파고들고 싶은 분은, 이 글과 함께 준비된 전자책 큐의 기술: 배경 작업을 보시면 된다. 경계의 질문부터 데드레터 운영까지, 이 글이 간략히 한 지점들을 예시와 함께 펼쳐 쓴 책입니다.
참고 자료
본문의 최소 한 번 전달과 멱등성, 가시성 타임아웃의 범위, 워커 부활, 재시도 상한과 데드레터는 아래 자료와 대조해 보실 수 있습니다. 본문에 [추정]으로 표시한 경계값과 지수 배수는 저자의 운영 시작점입니다.
- Amazon SQS FAQ: 가시성 타임아웃, 데드레터, 메시지 전달 (AWS)
- Idempotent requests (Stripe API 문서)
- Idempotence (Wikipedia)
- systemd.service(5): Restart= 옵션 (man7.org)
- BullMQ: 재시도와 데드레터가 있는 메시지 큐 (GitHub)
NotebookLM이 소스를 종합해 생성한 인포그래픽입니다.