정신을 잃지 않고 여러 단계에 걸친 비동기 작업(Async Job) 실패를 디버깅하는 방법
요약
다단계 비동기 작업(Async Job) 실패 시 맥락을 파악하기 어려운 문제를 해결하기 위한 7가지 디버깅 전략을 제시합니다. 상관관계 ID 부여, 구조화된 로깅, 분산 트레이싱 등을 통해 실패의 근본 원인을 추적하는 방법을 다룹니다.
핵심 포인트
- 상관관계 ID(Correlation ID)를 부여하여 모든 작업 실행을 추적함
- 단계별 경계에서 구조화된 로깅(Structured Logging) 수행
- OpenTelemetry를 활용한 분산 트레이싱 도입 권장
- 예외 전파 전 에러 컨텍스트를 캡처하는 로직 구현
- 로컬 디버깅 워크플로우와 모니터링 대시보드 구축
요약(TL;DR): 대부분의 팀을 괴롭히는 것은 실패 그 자체가 아닙니다. 작업이 1단계부터 3단계까지는 완벽하게 완료되었는데, 4단계 어딘가에서 죽어버렸고, 로깅 인프라가 당신에게 딱 한 줄의 메시지만 전달했을 때입니다: Job failed: undefined method 'id' for nil:NilClass. 맥락도 없고, 흔적도 없습니다. 작업이 폭발했을 때 무엇을 처리하고 있었는지에 대한 어떠한 표시도 없습니다.
📖 읽기 시간: 약 39분
이 글의 내용
- 실제 문제: 작업이 4단계에서 실패했지만, 당신은 실패했다는 사실만 알고 있음
- 1단계 — 다른 무엇을 하기 전에 모든 작업 실행(Job Run)에 상관관계 ID(Correlation ID) 부여하기
- 2단계 — 실패 시에만 기록하는 것이 아니라, 모든 단계의 경계(Step Boundary)에서 구조화된 로깅(Structured Logging) 수행하기
- 3단계 — 다단계 작업(Multi-Step Jobs)을 위한 분산 트레이싱(Distributed Tracing) (OpenTelemetry는 설정의 고통을 감수할 가치가 있음)
- 4단계 — 재시도 로직(Retry Logic)이 당신을 방해하는 것이 아니라 당신을 돕도록 만들기
- 5단계 — 에러 컨텍스트(Error Context): 예외(Exception)가 상위로 전파(Bubble Up)되기 전에 필요한 정보 캡처하기
- 6단계 — 운영 환경(Production) 접근 권한이 필요 없는 로컬 디버깅 워크플로우(Local Debugging Workflow)
- 7단계 — 사용자가 보고하기 전에 문제를 드러내는 대시보드(Dashboards) 및 알림(Alerting)
실제 문제: 작업이 4단계에서 실패했지만, 당신은 실패했다는 사실만 알고 있음
대부분의 팀을 괴롭히는 것은 실패 그 자체가 아닙니다. 작업이 1단계부터 3단계까지는 완벽하게 완료되었는데, 4단계 어딘가에서 죽어버렸고, 로깅 인프라가 당신에게 딱 한 줄의 메시지만 전달했을 때입니다: Job failed: undefined method 'id' for nil:NilClass. 맥락도 없고, 흔적도 없습니다. 작업이 폭발했을 때 무엇을 처리하고 있었는지에 대한 어떠한 표시도 없습니다.
어디에서나 흔히 볼 수 있는 전형적인 설정이 있습니다. 바로 fetch(가져오기) → transform(변환) → persist(저장) → notify(알림)로 이어지는 파이프라인입니다. 각 단계는 이전 단계의 출력값에 의존합니다. fetch 단계는 외부 API를 호출하고 응답을 구성합니다. transform 단계는 이를 내부 스키마 (Schema)에 맞게 가공합니다. persist 단계는 이를 Postgres에 기록합니다. notify 단계는 다운스트림 서비스 (Downstream service)로 웹훅 (Webhook)을 발송합니다. 이 과정이 정상적으로 작동할 때는 매우 우아합니다. 하지만 실패할 때는, 실제 버그가 transform 단계에서 발생하여 에러를 발생시키는 대신 조용히 nil을 반환했음에도 불구하고, persist 단계에서 nil 참조 에러 (Nil reference error)로 시작되는 스택 트레이스 (Stack trace)를 멍하니 바라보게 됩니다.
# 의도적인 단계 추적 (Step tracking)이 없을 때의 로그 모습
[2024-01-15 14:23:01] INFO: Starting InvoiceProcessingJob jid=abc123 invoice_id=9981
[2024-01-15 14:23:04] ERROR: Job failed jid=abc123 error="NoMethodError: undefined method 'amount' for nil"
...
비동기 실패 (Async failures)는 서로를 악화시키는 세 가지 이유로 인해 동기적 실패 (Synchronous failures)와 질적으로 다릅니다. 첫째, 연속적인 스택 트레이스 (Stack trace)가 없습니다. 작업이 실행될 때쯤이면 작업을 큐에 넣은 (Enqueued) 프레임은 이미 사라졌기 때문에, 호출자 컨텍스트 (Caller context)를 완전히 잃게 됩니다. 둘째, 작업은 요청 컨텍스트 (Request context) 없이 실행됩니다. 즉, HTTP 미들웨어 (Middleware)에서 제공하는 일반적인 상관관계 ID (Correlation IDs)가 자동으로 포함되지 않습니다. 셋째 — 그리고 이것이 정말로 혼란을 야기하는 부분인데 — 작업은 처음 실행되었을 때와는 다른 워커 (Worker), 다른 시간, 그리고 다른 애플리케이션 상태 (Application state)에서 재시도될 수 있습니다.
마지막 지점은 구체적인 예시가 필요합니다. 저는 주문 이행 (Order fulfillment)을 처리하는 Sidekiq 작업을 가지고 있었습니다. 첫 번째 시도는 제3자 배송 API (Third-party shipping API)에 접근하다가 Net::ReadTimeout이 발생하며 실패했습니다. 이는 완전히 이해할 수 있는 일시적인 (Transient) 오류였습니다. Sidekiq은 25분 후(기본 지수 백오프 (Exponential backoff) 적용)에 이를 재시도했습니다. 하지만 그 사이 백그라운드 데이터 마이그레이션 (Data migration)이 실행되어, 해당 작업이 읽으려던 고객 레코드 (Customer record)를 소프트 삭제 (Soft-delete)해 버렸습니다. 두 번째 시도는 ActiveRecord::RecordNotFound로 실패했습니다. 세 번째 재시도에서도 동일한 오류가 발생했습니다. 누군가 데드 잡 큐 (Dead job queue)를 확인했을 때, 가시적인 세 번의 재시도 모두에서 오류 메시지는 RecordNotFound였고, 원래의 ReadTimeout은 어디에서도 찾아볼 수 없었습니다. 작업의 예외 이력 (Exception history)에서 덮어씌워졌기 때문입니다. 이를 조사하던 개발자는 고객 레코드가 처음부터 없었다고 합리적으로 가정했고, 잘못된 방향으로 두 시간을 허비했으며, 이 모든 연쇄 반응을 시작한 타임아웃 (Timeout)은 끝내 찾지 못했습니다.
표준 애플리케이션 로깅 (Application logging)은 구조적인 이유로 여기서 실패합니다. 로깅은 요청/응답 (Request/response) 사이클을 중심으로 설계되었기 때문입니다. 로거가 한 줄을 기록하면 요청이 종료되고
비동기 디버깅을 감당할 수 있는 수준으로 만들어준 단 하나의 변화는 더 나은 로깅이나 더 화려한 도구가 아니었습니다. 그것은 바로 모든 작업 실행(job run)이 어디를 가든 함께 따라다니는 고유한 ID를 갖도록 보장하는 것이었습니다. 이것이 없다면, 파이프라인의 4단계에서 발생한 실패를 보고도 원래의 트리거(trigger), 입력 페이로드(input payload), 또는 1~3단계에서 무슨 일이 일어났는지 추적할 방법이 전혀 없습니다. 하지만 이것이 있다면, 하나의 ID를 grep하는 것만으로 전체 실행 이력이 쏟아져 나옵니다.
이것이 당신이 할 수 있는 가장 높은 ROI(투자 대비 효율)를 가진 변화인 이유
대부분의 팀은 이미 어딘가에 작업 ID(job ID)를 가지고 있습니다. Sidekiq은 jid를 제공하고, BullMQ는 job.id를 제공합니다. 문제는 이 ID들이 작업 경계(job boundary)에서 소멸된다는 점입니다. 작업 A가 작업 B를 인큐(enqueue)할 때, 작업 B는 A와의 연결 고리 없이 완전히 새로운 ID를 부여받습니다. 이제 당신에게는 서로 연결되지 않은 두 개의 로그 스트림(log streams)이 남게 되며, 타임스탬프(timestamp)나 입력 데이터를 수동으로 대조하지 않고서는 이들을 결합할 방법이 없습니다. 상관관계 ID(correlation ID, 작업 자체의 ID와는 별개)는 최초의 HTTP 요청이나 웹훅(webhook)부터 모든 대기열 단계(queued step)를 거쳐 최종적인 부수 효과(side effect)에 이르기까지 전체 체인에 꿰어 넣는 실과 같습니다.
Ruby/Sidekiq: 미들웨어(Middleware)가 올바른 주입 지점입니다
이 기능을 작업 베이스 클래스(job base class)에 넣지 마세요. 서버 미들웨어(server middleware)를 사용하여 서드파티(third-party) 작업을 포함한 모든 작업에서 자동으로 실행되도록 하세요. 제가 실제로 실행하는 코드는 다음과 같습니다:
class CorrelationIdMiddleware
def call(worker, job, queue)
# job['correlation_id']는 작업이 인큐될 때 설정되었습니다.
...
ensure 블록이 매우 중요합니다. Sidekiq은 스레드 풀(thread pool)을 사용하는데, 만약 스레드 로컬 상태(thread-local state)를 정리하지 않으면 동일한 스레드에서 실행되는 다음 작업이 오래된(stale) ID를 가져가게 됩니다. 저는 이로 인해 유령 상관관계(phantom correlations)가 발생하여 이를 해결하는 데 몇 시간이 걸리는 것을 본 적이 있습니다.
Node.js/BullMQ: 스레드 로컬(Thread Locals)이 존재하지 않으므로 페이로드를 사용하세요
Node는 Ruby와 같은 방식의 스레드 로컬 저장소(Thread-local storage)를 가지고 있지 않지만, Node의 async_hooks 모듈에서 제공하는 AsyncLocalStorage를 사용하면 동일한 의미론(Semantics)을 구현할 수 있습니다. 특히 BullMQ의 경우, 저는 이중 안전장치(Belt-and-suspenders) 접근 방식으로 correlationId를 job.data에 직접 전달하기도 합니다. 이는 해당 ID가 Redis로 직렬화(Serialization)된 후에도 살아남으며, 코드를 실행하지 않고도 언제든 검사할 수 있음을 의미합니다:
import { Worker, Queue } from 'bullmq';
import { AsyncLocalStorage } from 'async_hooks';
...
모두를 당황하게 만드는 함정: 자식 작업(Child Jobs)은 아무것도 상속받지 않습니다
이 부분은 그 어떤 README 파일에도 나와 있지 않습니다. 실행 중인 작업 내부에서 자식 작업(Child job)을 큐에 넣을 때, 새 작업은 완전히 백지 상태로 시작됩니다. 상관관계 ID(Correlation ID)는 전달되지 않습니다. Sidekiq의 jid를 통해서도, BullMQ의 작업 계보(Job lineage)를 통해서도, 그 무엇을 통해서도 전달되지 않습니다. 매번 명시적으로 전달해야만 합니다:
# Ruby — 부모 작업 내부
def perform(order_id, correlation_id:)
# ... 작업 수행 ...
...
// BullMQ — 부모 프로세서(Processor) 내부
await fulfillmentQueue.add('fulfill', {
orderId: job.data.orderId,
...
저는 한 걸음 더 나아가 제안합니다. correlationId를 작업 스키마(Job schema)의 필수 필드로 만들고, 큐 헬퍼(Queue helper)가 이 값이 누락되었을 때 에러를 던지도록 하세요. 그렇게 하면 누군가 호출 스택 깊은 곳에서 새로운 작업을 큐에 추가하면서 ID를 전달하는 것을 잊어버려 발생하는 부류의 버그를 제거할 수 있습니다.
ID를 방출(Emit)해야 하는 위치
상관관계 ID는 실패가 나타날 수 있는 모든 곳에 나타날 때만 유용합니다. 저의 체크리스트는 다음과 같습니다:
- 구조화된 로그 (Structured logs): 시작/종료 라인뿐만 아니라 모든 로그 라인 — 작업 시작 시
{ cid }가 바인딩된Rails.logger.tagged또는 Pino child logger를 사용하세요. - 에러 리포터 (Error reporters): 작업이 실행되기 전
Sentry.set_tag('correlation_id', cid)를 호출하세요 — 이렇게 하면 Sentry 이슈를 ID별로 그룹화하고 검색할 수 있습니다. - 외부 HTTP 호출 (Outbound HTTP): 작업이 수행하는 모든 외부 API 호출에
X-Correlation-ID를 설정하세요 — 벤더(Vendor) 지원 팀이 요청을 추적해야 할 때 스스로에게 고마워하게 될 것입니다. - 웹훅 페이로드 (Webhook payloads): 작업이 완료되거나 실패했을 때 웹훅을 전송한다면, 본문에
correlation_id를 포함하세요 — 이를 통해 고객이 자신의 측면 상호작용을 귀사의 로그로 역추적할 수 있습니다.
2단계 — 실패 시에만 하는 것이 아니라, 모든 단계의 경계에서 구조화된 로깅 수행
모든 단계의 경계에서 구조화된 로깅 수행
사람들을 가장 먼저 무너뜨리는 것은 로그의 부족이 아닙니다. 로그는 넘쳐나지만 실제로 무슨 일이 일어났는지 재구성할 수 없다는 점입니다. 5개의 워커(Worker)가 작업을 동시에 처리하며 각각 일반 텍스트(Plain-text) 라인을 쏟아내고 있고, 이제 당신은 40개의 서로 다른 작업에서 발생한 Processing step 2라는 문구가 서로 뒤섞여 있는 20만 줄의 로그를 grep으로 뒤지고 있습니다. 일반 텍스트는 동시성(Concurrency)이 개입되는 순간 무용지물이 됩니다. JSON 로그를 사용하면 job_id로 즉시 필터링할 수 있고, 어떤 로그 애그리게이터(Log aggregator, 예: Datadog, Loki, CloudWatch)에 전달하더라도 정규 표현식(Regex) 주문을 외울 필요 없이 작업별로 일관된 타임라인을 얻을 수 있습니다.
최소한의 실행 가능한 로그 라인은 단순히 메시지와 타임스탬프만 있는 것이 아닙니다. 프로덕션 환경에서 충분히 많은 작업 실패를 디버깅한 끝에, 저는 다음 필드 세트를 로그의 상한선이 아닌 하한선(Floor)으로 정했습니다:
- timestamp — 에포크(Epoch)가 아닌 밀리초를 포함한 ISO 8601 형식. 사람이 읽기 용이합니다.
- job_id — 재시도(Retry) 시에도 변하지 않는 큐(Queue)의 내부 ID.
- correlation_id — 이 작업을 트리거한 상위 HTTP 요청 또는 이벤트와 이 작업을 연결하는 ID. 새벽 2시에 당신을 구해줄 필드입니다.
- step_name — 예:
validate_payload,charge_card,send_confirmation. - duration_ms — 단계(Step) 종료 시 기록. 이 단계가 보통 80ms가 걸리는데 현재 4000ms가 걸리고 있다면, 그것이 바로 신호입니다.
- status —
started,completed,failed.true/false가 아닙니다.
semantic_logger를 사용하는 Sidekiq의 경우, 제가 실제로 사용하는 초기화(Initializer) 설정은 다음과 같습니다. 핵심은 개발 환경이 아닌 곳에서는 JSON 형식을 강제하고, 모든 로그에 작업 컨텍스트(Job context)를 자동으로 태깅하는 것입니다:
# config/initializers/semantic_logger.rb
SemanticLogger.default_level = :info
...
pino를 사용하는 BullMQ 워커(Worker)는 대부분의 튜토리얼이 틀리는 두 가지 특정 옵션이 필요합니다. 첫째, 프로덕션 환경에서는 prettyPrint: false로 설정해야 합니다. 프리티 프린팅(Pretty printing)은 각 라인을 파싱하고 다시 직렬화(Re-serialize)하여 지연 시간(Latency)을 추가하며, 줄 바꿈으로 구분된 JSON(Newline-delimited JSON)을 기대하는 모든 로그 애그리게이터(Log aggregator)를 망가뜨립니다. 둘째, level: 'info'를 명시적으로 설정하고 pino 인스턴스를 작업 핸들러(Job handler) 내부가 아닌 워커 시작 시점에 바인딩하십시오(그렇지 않으면 부모 컨텍스트를 잃게 됩니다):
// worker.js
import { Worker } from 'bullmq';
import pino from 'pino';
...
에러뿐만 아니라 단계의 진입(Entry)과 종료(Exit)를 모두 기록하십시오. 이 점을 아무리 강조해도 지나치지 않습니다. 에러 로그는 단계가 실패했음을 알려줍니다. 진입 로그는 단계가 시작되었음을 알려줍니다. duration_ms를 포함한 종료 로그는 단계가 정상적으로 완료되었으며 얼마나 걸렸는지를 알려줍니다. 이 세 가지가 모두 없다면, "단계가 시작되지 않음", "단계가 시작되었으나 멈춤(Hung)", "단계가 완료되었으나 다음 단계에서 충돌 발생"을 구분할 수 없습니다. 이들은 각각 세 가지 완전히 다른 문제이며 세 가지 다른 해결책을 필요로 합니다. 로그를 통해 재구성하는 타임라인은 당신이 실제로 방출(Emit)한 이벤트만큼만 완전할 수 있습니다.
제가 본 대부분의 팀은 이를 거꾸로 하고 있습니다. 작은 헬퍼 함수(helper function)가 호출될 때마다 INFO 로그를 남기지만(노이즈), 단계 전환(step transitions) 시점에는 로그를 남기지 않습니다(시그널). 결국 작업당 50개의 로그 라인이 쌓여 주요 작업의 순서에 대해서는 아무것도 알려주지 못하고, 메모리가 급증했을 때 어떤 단계가 활성 상태였는지 알려주는 라인은 단 하나도 없는 상황에 직면하게 됩니다. 지켜야 할 원칙은 다음과 같습니다. 모든 단계의 경계(step boundary)에는 INFO를, 단계 내부의 세부 사항에는 DEBUG를, 그리고 실제로 무언가를 포착했을 때만 ERROR를 남기는 것입니다. 만약 정상적으로 성공한 작업이 8~10개 이상의 INFO 라인을 생성한다면, 당신은 스팸 영역으로 빠진 것이며 결국 로그를 완전히 무시하게 될 것입니다. 이는 로그를 남기는 목적 자체를 무색하게 만듭니다.
3단계 — 다단계 작업(Multi-Step Jobs)을 위한 분산 트레이싱 (OpenTelemetry는 설정의 고통을 감수할 가치가 있습니다)
작업 파이프라인(job pipeline)에 6개 이상의 단계가 생기는 순간 — 특히 그 단계 중 일부가 병렬 자식 작업(parallel child jobs)으로 확장(fan out)되는 경우 — 로그는 고고학적 발굴 작업이 되어버립니다. 당신은 세 개의 서로 다른 서비스에 걸쳐 타임스탬프(timestamps)를 상관 분석하고, 무엇이 무엇보다 먼저 실행되었는지 머릿속으로 재구성하며, 작업이 큐에 삽입(enqueued)된 시점부터 단계 4에서 최종 실패하기 전 사이에 아무도 배포를 하지 않았기를 기도해야 합니다. 저도 그런 경험이 있습니다. 그리고 해결책은 더 나은 로그 형식이 아닙니다. 바로 트레이스(traces)입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기