Node.js로 개발할 때 에러 핸들링은 처음에는 너무 쉬워 보입니다. 라우트를 try-catch로 감싸고 500 상태 코드를 보내면, 클라이언트가 다음 행동을 결정하면 되니까요. HTTP에서는 이 모델이 잘 작동하지만, 백그라운드 작업으로 넘어가면 무너집니다. 큐 시스템에는 기다리는 클라이언트가 없습니다. 오직 워커, 페이로드, 그리고 Redis, RabbitMQ 또는 SQS 어딘가에서 올라가는 재시도 카운터만 있을 뿐입니다. 웹 요청 실패를 처리하는 것과 똑같은 방식으로 실패를 처리한다면, 단순히 트랜잭션 하나를 놓치는 것에 그치지 않을 것입니다. 전체 파이프라인이 중단되거나, 컴퓨팅 자원을 낭비하거나, 동일한 오염된 메시지 때문에 워커가 반복적으로 충돌하게 될 것입니다.
백그라운드 작업에서 HTTP 마인드셋이 무너지는 이유
요청-응답 사이클에서는 피드백 루프가 즉각적입니다. 사용자가 버튼을 클릭하면 서버가 에러를 던지고, 사용자는 실패 화면을 봅니다. 정리는 간단합니다. 큐 워커는 격리되어 존재합니다. 작업을 가져와서 몇 초 또는 몇 분 동안 처리한 뒤 성공을 승인(acknowledge)합니다. 중간에 문제가 생기면 큐는 왜 그런지 알 수 없습니다. 단지 승인이 도착하지 않았다는 것만 알 뿐입니다. 설정에 따라 큐는 아마도 영원히 재시도를 반복할 것입니다. 잘못된 형식의 페이로드 하나가 워커 사이를 수백 번 오가며 CPU를 낭비하고, 실제로 처리가 필요한 정상적인 작업들 뒤에 숨어버릴 수 있습니다.
두 가지 종류의 실패
탄력적인 큐를 만들기 위한 첫 번째 규칙은 모든 에러를 똑같이 취급하지 않는 것입니다. 에러가 발생하는 즉시 두 그룹으로 분류해야 합니다.
**재시도 가능한 실패(Retryable failures)**는 일시적입니다. 네트워크 타임아웃, 서드파티 API의 속도 제한(rate limits), 또는 커넥션 풀이 일시적으로 고갈되어 재설정된 데이터베이스 연결 등을 생각해보세요. 이는 압박을 받고 있는 살아있는 시스템의 증상입니다. 2분 뒤의 다음 시도에서는 성공할 수도 있습니다.
**영구적인 실패(Permanent failures)**는 포이즌 필(poison pills)입니다. 잘못된 형식의 페이로드, 스키마 검증 에러, 또는 업스트림 서비스의 계약(contract) 변경으로 인한 필수 필드 누락 등이 여기에 해당합니다. 이런 것들을 재시도하는 것은 순전한 낭비입니다. 100번째 시도에서도 똑같이 실패할 것이기 때문입니다.
만약 catch 블록이 이 둘을 구분하지 못한다면, 당신의 큐는 눈을 감고 운전하는 것과 같습니다.
패턴 1: catch 블록에서 에러 분류하기
워커의 catch 블록은 파일 내에서 가장 신중하게 작성된 코드가 되어야 합니다. 에러가 발생하면 즉시 검사하세요. 에러 코드가 ECONNRESET이거나 타임아웃인가요? 재시도를 위해 큐에 넣으세요. SyntaxError인가요, Joi 검증 거부인가요, 아니면 외래 키 제약 조건 누락인가요? 즉시 데드 레터 큐(DLQ)로 이동시키고 재시도 횟수에 포함하지 마세요.
BullMQ나 Bee Queue를 포함한 대부분의 Node.js 큐 라이브러리는 커스텀 백오프 전략과 에러 훅을 정의할 수 있게 해줍니다. 이를 활용하세요. 영구적인 에러는 기본적으로 세 번씩 잠자고 재시도해서는 안 됩니다. 나머지 작업들이 원활하게 흐를 수 있도록 메인 큐에서 즉시 격리해야 합니다. DLQ는 정확한 페이로드와 에러 컨텍스트를 보존하므로, 나중에 버그를 패치하거나 스키마를 수정한 뒤 작업을 다시 실행(replay)할 수 있게 해줍니다.
패턴 2: 지터를 포함한 지수 백오프(Exponential Backoff with Jitter)
즉시 재시도하는 것은 공격적입니다. 다운스트림 데이터베이스가 이미 부하로 인해 허덕이고 있다면, 50개의 워커가 2초마다 계속 요청을 보내는 것은 데이터베이스를 완전히 끝장낼 것입니다. 시스템이 회복할 수 있는 여유를 주기 위해 뒤로 물러나야 합니다.
지수 백오프를 사용하세요. 첫 번째 실패 시 1초를 기다립니다. 두 번째는 2초, 그다음은 4초, 8초... 최대 5분 정도의 합리적인 상한선까지 늘립니다. 하지만 타이밍만으로는 부족합니다. 모든 실패한 작업이 정확히 똑같은 간격을 사용한다면, 백오프가 만료될 때 모든 작업이 한꺼번에 충돌할 것입니다. 때때로 '썬더링 허드(thundering herd)'라고 불리는 이 동기화된 파도는 회복 중인 서비스에 과부하를 줄 수 있습니다.
지터(jitter)를 추가하세요. 계산된 지연 시간에 10~20% 정도의 무작위 비율을 더해 불규칙하게 만드세요. 4초가 4.2초나 4.7초가 되는 식입니다. 이 간단한 무작위성이 재시도 스파이크를 분산시켜 인프라가 파도처럼 몰아치는 충격을 받지 않도록 유지해 줍니다.
패턴 3: 멱등성(Idempotency)을 고려한 설계
여기서 큐 인프라와 비즈니스 로직이 만납니다. 결제 제공업체를 통해 고객에게 요금을 청구하는 작업을 상상해 보세요. 워커가 결제 요청을 성공적으로 보냈지만, 데이터베이스에 성공 기록을 남기거나 작업 승인을 하기 전에 연결이 끊어졌습니다. 큐는 이를 실패로 간주합니다. 그리고 재시도합니다. 고객은 요금을 두 번 결제하게 됩니다.
Node.js에서 모든 사이드 이펙트를 멱등(idempotent)하게 만들어 이를 방지하세요. 작업 ID나 비즈니스 특정 식별자로부터 멱등성 키(idempotency key)를 생성하세요. 결제를 생성하거나 이메일을 보내거나 재고를 조정하기 전에, 작업이 이미 완료되었는지 확인하세요. 해당 키를 데이터베이스와 이를 지원하는 모든 서드파티 API까지 전달하세요. 동일한 페이로드를 10번 실행해도 한 번 실행했을 때와 동일한 결과가 나오도록 작업을 구성하세요. 이 습관 하나만으로 금융 및 데이터 무결성 버그의 한 범주를 완전히 제거할 수 있습니다.
패턴 4: 데드 레터 큐(DLQ)를 대시보드처럼 다루세요
DLQ는 잘못된 작업들이 잊히기 위해 모이는 무덤이 아닙니다. 이는 운영 도구이며, 가장 주의 깊게 살펴봐야 할 지표 중 하나여야 합니다.
DLQ의 깊이(depth)가 증가할 때 알림이 발생하도록 설정하세요. DLQ에 메시지가 단 하나라도 있다는 것은 검증 로직이 깨졌거나, 업스트림 스키마가 변경되었거나, 다운스트림 서비스가 더 이상 인식할 수 없는 쓰레기 데이터를 보내고 있다는 의미일 때가 많습니다. 이것들이 바로 고객이 불만을 제기하기 전에 포착해야 할 신호들입니다. 원시 페이로드(raw payload), 스택 트레이스(stack trace), 타임스탬프를 검사할 수 있는 대시보드를 구축하세요. 런북(runbook)을 준비해 두세요. 실패 원인을 조사하고, 코드를 패치한 다음, 올바른 순서로 메시지를 재전송(replay)해야 합니다. 사용 중인 큐 구현체가 지원한다면, 절대적인 개수뿐만 아니라 증가율에 대해서도 알림을 설정하세요. 잘못된 배포 한 번으로 몇 분 만에 DLQ가 넘쳐날 수 있기 때문입니다.
패턴 5: 의심스러울 때는 프로세스를 종료(Crash)하세요
Node.js는 V8 isolate 내부의 단일 이벤트 루프에서 실행됩니다. 처리되지 않은 프로미스 거부(unhandled promise rejection)나
