프로덕션에서 정확히 어디서 크래시가 났는지 압니다. stack trace가 invoice_service.py의 147행을 가리킵니다. 예외는 "customer_id"에 대한 KeyError입니다. 코드를 pull하고 테스트를 실행하면 모두 통과합니다. 샘플 페이로드로 endpoint를 수동으로 호출해도 잘 작동합니다.
버그는 실재합니다. 고객들이 이를 겪고 있습니다. 당신의 머신에서는 재현할 수 없습니다.
이것이 에러 보고서로부터 디버깅하는 표준적인 경험입니다. stack trace는 어디서인지 알려줍니다. 무엇이 그곳에 도달했는지는 알려주지 않습니다. 실패를 유발한 정확한 입력 없이는, 분필 윤곽 사진으로 범죄 현장을 재구성하는 것과 같습니다.
crash replay가 실제로 의미하는 것
crash replay는 프로덕션 실패를 유발한 완전한 입력 상태를 캡처하고, 로컬 환경에서 그 정확한 상태로 코드 경로를 재실행하는 실천입니다. 목표는 “147행에서 크래시가 났다”를 “147행을 매번 크래시시키는 테스트 케이스가 여기 있다”로 바꾸는 것입니다.
대부분의 개발자는 이미 수동 버전을 하고 있습니다. stack trace를 읽고, 어떤 요청이 원인인지 추측하고, 로그에서 페이로드를 재구성하려 하며, 로컬 데이터베이스에 비슷한 데이터가 있기를 바랍니다. 이는 점성술이 실패하는 같은 이유로 실패합니다: 충분한 정보 없이 패턴을 맞추고 있습니다.
추측과 replay의 차이는 직렬화입니다. 실패 순간의 정확한 함수 입력, 정확한 데이터베이스 응답, 정확한 외부 API 반환 값을 캡처해야 합니다. 그런 다음 이를 다시 입력으로 넣습니다.
replay를 위한 프로덕션 입력 캡처 방법
가장 간단하면서도 효과적인 패턴은 함수의 인자를 가로채 디스크에 직렬화하고 나중에 접근할 수 있는 곳으로 보내는 decorator입니다. 함수가 크래시하면 이를 만들어낸 세계의 동결된 스냅샷을 갖게 됩니다.
작동하는 Python 구현은 다음과 같습니다:
import json
import functools
import traceback
from pathlib import Path
from datetime import datetime, timezone
CAPTURE_DIR = Path("/var/crash-captures")
def capture_for_replay(func):
"""Decorator that captures inputs and outputs for replay debugging."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
capture = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"function": func.__qualname__,
"module": func.__module__,
"args": args,
"kwargs": kwargs,
"exception": None,
"traceback": None,
}
try:
result = func(*args, **kwargs)
capture["result"] = result
return result
except Exception as exc:
capture["exception"] = {
"type": type(exc).__name__,
"message": str(exc),
}
capture["traceback"] = traceback.format_exc()
# Write crash capture to disk
CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
filename = (
f"{func.__name__}_"
f"{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json"
)
capture_path = CAPTURE_DIR / filename
with open(capture_path, "w") as f:
json.dump(capture, f, default=str, indent=2)
raise # Re-raise so normal error handling continues
return wrapper
크래시가 나는 함수에 적용합니다:
@capture_for_replay
def generate_invoice(customer_data: dict, line_items: list) -> dict:
customer_id = customer_data["customer_id"] # This is line 147
# ... rest of invoice logic
return {"invoice_id": "INV-123", "total": 0}
프로덕션에서 KeyError: "customer_id"가 발생하면 다음과 같은 JSON 파일을 얻습니다:
{
"timestamp": "2026-08-15T14:32:11+00:00",
"function": "generate_invoice",
"module": "billing.invoice_service",
"args": [
{},
[{"sku": "PRO-1", "price": 99.0}]
],
"kwargs": {},
"exception": {
"type": "KeyError",
"message": "'customer_id'"
},
"traceback": "..."
}
첫 번째 인자는 빈 사전이었습니다. 이것이 전체 버그입니다. 상류 호출자가 고객 레코드 대신 {}를 전달했습니다.
이제 테스트 케이스가 생겼습니다:
def test_generate_invoice_with_empty_customer():
with pytest.raises(KeyError, match="customer_id"):
generate_invoice({}, [{"sku": "PRO-1", "price": 99.0}])
이 테스트는 수정 전에는 실패하고 입력 검증을 추가한 후에는 통과합니다. 더 중요한 것은 추측할 필요가 없었다는 것입니다. 크래시가 정확히 무엇을 테스트해야 하는지 알려줬습니다.
캡처가 보이지 않는 의존성을 놓치는 이유
이 패턴은 함수 인자를 캡처하지 글로벌 상태는 캡처하지 않습니다. generate_invoice가 데이터베이스에서 읽거나 외부 API를 호출하거나 환경 변수를 확인하면, 그 값들은 args와 kwargs에 없습니다. replay는 로컬 환경이 우연히 일치할 때에만 작동합니다.
decorator를 확장해 외부 의존성을 명시적으로 캡처할 수 있습니다:
@capture_for_replay
def generate_invoice(
customer_data: dict,
line_items: list,
db_conn
) -> dict:
tax_rate = db_conn.execute(
"SELECT rate FROM tax_rates WHERE region = ?",
(customer_data["region"],)
).fetchone()[0]
# ...
하지만 db_conn은 연결 객체입니다. JSON으로 직렬화할 수 없습니다. 직렬화할 수 있는 것은 쿼리와 결과입니다. 더 원칙적인 접근법은 순수 로직과 부작용을 분리하는 것입니다. 연결이 아닌 쿼리 결과를 인자로 전달합니다:
@capture_for_replay
def generate_invoice(
customer_data: dict,
line_items: list,
tax_rate: float
) -> dict:
# Pure function. All inputs are serializable.
total = sum(item["price"] for item in line_items)
total_with_tax = total * (1 + tax_rate)
return {
"invoice_id": "INV-123",
"total": round(total_with_tax, 2),
}
이것이 functional core, imperative shell입니다. 숨겨진 상태가 없으므로 캡처가 간단해집니다. 중요한 모든 입력은 인자 목록에 있습니다.
캡처로 해결할 수 없는 비결정성 문제
완벽한 입력 캡처가 있더라도 일부 크래시는 재현할 수 없습니다. race condition은 스레드 타이밍에 의존합니다. memory corruption은 할당기 상태에 의존합니다. 외부 API는 매 호출마다 다른 데이터를 반환합니다. 시드를 지정하지 않으면 난수 생성기는 다른 시퀀스를 생성합니다.
크래시가 race condition이라면, 단일 스레드 로컬 테스트에서 같은 입력을 replay해도 이를 유발하지 않습니다. 실제 동시성 패턴이 필요한데, 이는 원래 스레드를 실행한다는 의미이며, “replay”에서 “분산 추적과 부하 테스트”로 이동했다는 뜻입니다. 이는 다른 도구입니다.
대부분의 애플리케이션 수준 버그에 대해서는 입력 replay로 충분합니다. heisenbug에 대해서는 그렇지 않습니다. 타이밍 문제를 replay하려고 3시간을 쓰기 전에 어떤 유형인지 파악하세요.
프로덕션에서 캡처 운영화하기
위의 decorator는 로컬 디스크에 씁니다. 프로덕션에서는 이 캡처를 객체 저장소나 에러 추적기로 보내고 싶을 것입니다. 통합은 간단합니다: open(capture_path, "w") 호출을 S3 업로드나 Sentry 이슈의 첨부 파일로 바꾸면 됩니다.
대부분의 팀은 한 함수부터 시작해야 합니다. 가장 자주 크래시가 나는 서비스를 고르세요. decorator를 추가하세요. 다음 크래시를 기다리세요. 크래시가 오면 30분 추측 세션을 5분 테스트 작성으로 바꾸는 JSON 페이로드를 갖게 됩니다.
이미 Sentry를 사용 중이라면 Breadcrumbs 기능이 이런 맥락 일부를 자동으로 캡처합니다. 사용자 정의 접근법의 경우 이 패턴은 Python 30줄 안에 들어가며 decorator나 미들웨어를 지원하는 모든 런타임에서 작동합니다.
이번 주에 한 endpoint 캡처하기
이번 주에 가장 에러가 많은 endpoint에 캡처를 추가하세요. 모든 endpoint가 아닙니다. 모든 함수가 아닙니다. 하나입니다. 크래시가 나면 버그를 수정하기 전에 캡처에서 테스트 케이스를 작성하세요. 테스트를 실행하고 실패하는 것을 보세요, 수정을 적용하고 통과하는 것을 보세요.
프로덕션 크래시에서 재현 가능한 테스트 케이스까지의 이 루프가 디버깅과 고고학을 구분합니다.