대부분의 Node.js 튜토리얼은 에러 처리를 뒷전으로 미룹니다. 라우트 핸들러를 try/catch 블록으로 감싸고, 스택 트레이스를 로그로 남긴 뒤 500 에러를 반환하곤 하죠. 이런 사고방식이 유지되는 이유는 HTTP 요청의 반대편에 실제 사람이 기다리고 있기 때문입니다. 백그라운드 작업은 다릅니다. 큐 시스템에는 응답을 기다리는 조급한 클라이언트도, 자동으로 새로고침되는 브라우저도 없습니다. 오직 워커(worker), 페이로드(payload), 그리고 조용히 올라가는 재시도 카운터만 존재할 뿐입니다. 문제가 발생하면 처음에는 천천히, 그러다 어느 순간 한꺼번에 터져 나옵니다. 잘못 분류된 에러 하나가 전체 파이프라인을 멈추게 하거나, 새벽 3시에 엔지니어를 깨울 수도 있습니다.

괴리는 단순합니다. 요청-응답 사이클은 실패가 빠르고 명확하게 드러납니다. 반면 큐의 실패는 조용합니다. 데이터베이스 연결이 끊기기 전까지 워커는 수백 개의 작업을 처리할 수도 있습니다. 명확한 처리 규칙이 없다면, 워커는 즉시 재시도를 반복하며 이미 과부하가 걸린 데이터베이스를 계속 두드리고 결국 크래시(crash)가 발생합니다. 워커를 직접 지켜보는 사람이 없기 때문에, 문제의 첫 징후는 종종 연쇄적인 백업(backup) 현상이나 로그 파일로 가득 찬 디스크로 나타납니다. 단순히 catch 블록만으로는 부족합니다. 서로 다른 실패를 다르게 취급하고, 단 하나의 잘못된 작업으로부터 시스템의 나머지 부분을 보호할 수 있는 전략이 필요합니다.

두 가지 종류의 실패

모든 에러를 두 가지 범주 중 하나로 나누는 것부터 시작하세요.

재시도 가능한(Retryable) 에러는 일시적입니다. 서드파티 API의 네트워크 타임아웃, 429 Rate-limit 응답, 또는 기본(primary) DB를 따라가지 못하는 일시적인 데이터베이스 레플리카 지연 등이 이에 해당합니다. 이것들은 버그가 아니라 압박의 증상입니다. 시스템은 30초 안에 스스로 회복될 수도 있습니다. 재시도 가능한 작업은 다시 시도할 가치가 있지만, 반드시 통제된 조건 하에서 이루어져야 합니다.

영구적인(Permanent) 에러는 실수입니다. 페이로드의 잘못된 JSON, 누락된 사용자 ID, 스토리지에 존재하지 않는 필수 파일 등이 있습니다. 이런 에러는 첫 번째 시도에서 실패했듯, 백 번째 시도에서도 똑같이 실패할 것입니다. 이를 재시도하는 것은 CPU 사이클을 낭비하고, 큐 슬롯을 점유하며, 정상적인 작업의 지연을 초래하는 독성 백프레셔(back-pressure)를 생성합니다. 영구적인 실패가 가야 할 유일하고 유용한 곳은 로그, 알림, 또는 데드 레터 큐(dead-letter queue)입니다. 재시도 루프에 포함되어서는 안 됩니다.

결정 엔진 구축하기

즉시 분류하세요. 이 결정을 큐 프레임워크에 맡기지 마세요. 에러를 잡는 순간, 그 운명을 결정해야 합니다.

실제로 이는 에러를 상위로 전파(bubbling up)하기 전에 실패 원인을 검사하는 커스텀 에러 클래스나 래퍼(wrapper) 함수를 만드는 것을 의미합니다. 데이터베이스 드라이버가 connection reset을 던지면 핸들러는 이를 재시도 가능으로 태깅해야 합니다. 페이로드 검증기가 스키마 불일치(schema mismatch)를 던지면 영구적 에러로 태깅해야 합니다. 많은 작업 프로세서가 모든 것을 재시도하는 것을 기본값으로 설정하는데, 이는 가장 비용이 많이 드는 선택입니다. 영구적인 작업은 즉시 거부하세요. 작업을 버리거나, 메인 파이프라인을 오염시키지 않도록 데드 레터 큐로 경로를 변경하세요. 이 작은 습관 하나가 그 어떤 인프라 변경보다 확실하게 스노우볼 효과(snowball effects)를 방지합니다.

더 똑똑하게 백오프(Back Off)하기

재시도를 할 때는 절대 즉시 하지 마세요. 데이터베이스가 다운된 상태에서 워커들이 매초 몰아치는 것은 내부에서 발생하는 서비스 거부(DoS) 공격처럼 보일 수 있습니다. 지수 백오프(exponential backoff)를 사용하세요. 1분, 5분, 15분 순으로 대기 시간을 늘리세요. 상위 시스템이 회복할 여유를 주어야 합니다.

하지만 지수 백오프만으로는 충분하지 않습니다. 서비스 재시작으로 인해 수천 개의 작업이 동시에 실패하면, 그들의 재시도 일정이 일치하게 됩니다. 서비스가 다시 온라인 상태가 되었을 때 이 작업들이 동시에 몰려들어 다시 서비스를 다운시킬 수 있습니다. 이때 지터(jitter), 즉 각 지연 시간에 작은 무작위 오프셋을 추가하세요. 재시도 시점을 몇 초 간격으로 분산시키면 동시다발적인 폭주(synchronized stampedes)를 막을 수 있습니다. 수학적으로는 간단하지만, 이를 통해 얻는 안정성은 엄청납니다.

증거 보존하기

데드 레터 큐(DLQ)는 쓰레기통이 아니라 감사 추적(audit trail)을 위한 곳입니다. 작업이 마지막 재시도 횟수를 모두 소진했을 때 단순히 삭제하지 마세요. 에러 컨텍스트 및 재시도 이력과 함께 전체 페이로드를 DLQ로 이동시키세요.

이렇게 하면 증거가 보존됩니다. 사람이 직접 작업을 검사하고, 버그를 수정하며, 필요한 경우 수동으로 다시 실행할 수 있습니다. 더 중요한 것은 DLQ의 깊이(depth)를 모니터링하는 것입니다. DLQ에 쌓이는 작업이 갑자기 급증한다면, 이는 잘못된 배포, 스키마 변경 오류, 또는 외부 벤더의 계약 위반을 알리는 가장 빠른 경고일 수 있습니다. DLQ의 증가를 후행 지표가 아닌 선행 지표로 취급하세요. DLQ가 차오르고 있다면 상위 시스템에 변화가 생긴 것이며, 백로그가 확산되기 전에 팀이 이를 인지해야 합니다.

재시도를 고려한 설계

모든 작업은 두 번 실행될 수 있다고 가정하고 설계하십시오. 실제로 그럴 수도 있기 때문입니다. 워커는 처리 도중 실패하여 다시 스케줄링되고 다시 실행될 수 있습니다. 만약 작업이 고객에게 비용을 청구하거나, 이메일을 보내거나, 재고 수량을 증가시키는 작업이라면, 단순한 재시도는 중복을 발생시킵니다.

해결책은 멱등성(idempotency)입니다. 사이드 이펙트를 수행하기 전에 이미 발생했는지 확인하십시오. 작업 페이로드의 고유 식별자를 멱등성 키로 사용하십시오. 해당 키를 수명이 짧은 캐시나 유일성 제약 조건이 있는 데이터베이스 테이블에 저장하십시오. 키가 이미 존재한다면 작업을 건너뛰고 성공을 반환합니다. 이렇게 하면 재시도가 위험 요소에서 무해한 no-op으로 바뀝니다. 코드 몇 줄이 더 필요하지만, 하룻밤 사이에 매출이 왜 두 배로 뛰었는지 재무팀에 설명해야 하는 상황을 막아줍니다.

프로세스 보호하기

제한 없는 Promise rejection과 예기치 않은 예외(stray exceptions)는 경고 없이 Node.js 프로세스를 종료시킬 수 있습니다. 워커에서 이는 작업 유실을 의미하며, 오케스트레이터는 컨테이너를 재시작하기 위해 분주하게 움직여야 합니다.

unhandledRejectionuncaughtException에 대한 글로벌 핸들러를 등록하십시오. 이들의 역할은 애플리케이션을 구출하는 것이 아닙니다. 필요한 최소한의 정리 작업을 수행한 뒤 종료하는 것입니다. Docker, Kubernetes 또는 systemd가 깨끗한 메모리 상태로 워커를 재시작하도록 맡기십시오. 글로벌 핸들러가 실행된 후에도 간신히 버티며 작동하는 것은 메모리 누수와 상태 오염을 초래합니다. 작업을 잘못 처리하는 느린 좀비 프로세스보다는 빠르고 깔끔하게 종료되는 것이 더 안전합니다. 오케스트레이터가 다시 살려줄 것을 믿으십시오. 손상된 런타임을 이겨내려 하지 마십시오.

시그널 존중하기

워커는 배포, 스케일링 이벤트, 노드 로테이션 중에 종료됩니다. 만약 프로세스가 SIGTERM을 받는 즉시 종료된다면, 현재 진행 중인 작업을 중단하게 됩니다. 해당 작업은 영원히 끝나지 않을 수도 있고, 재시도 횟수 카운터조차 아직 증가하지 않았을 수도 있습니다.

SIGTERM과 SIGINT를 수신 대기하십시오. 시그널이 도착하면 큐에서 새로운 작업을 가져오는 것을 중단하십시오. 가능하다면 현재 작업을 완료하십시오. 약 30초 정도의 하드 타임아웃을 설정하여, 그 시간이 지나면 무조건 종료되도록 합니다. 이러한 우아한 종료(graceful shutdown)는 큐를 존중하며 잘못된 실패를 방지합니다. 배포 파이프라인은 깔끔하게 종료된 워커는 정상(healthy)으로 간주해야 하며, 충돌(crash)한 워커는 경고를 발생시켜야 합니다.

핵심 요약

신뢰할 수 있는 큐 핸들링은 모든 에러를 잡아내는 것이 아닙니다. 각 실패 모드에 대해 의도적인 결정을 내리는 것입니다. 일시적인 오류는 인내심을 갖고 재시도하십시오. 영구적인 오류는 빠르게 처리하여 묻어버리십시오. 워커를 폭주(stampede)로부터 보호하고, 멱등성 키로 데이터를 보호하며, 종료되는 프로세스는 깔끔하게 종료되도록 하십시오. 모든 실패에 정의된 경로가 있다면, 새벽 3시는 그저 평범한 시간 중 하나가 될 것입니다. 파이프라인은 계속 작동하고, 팀원들은 계속 잠을 잘 수 있습니다.