개념
좋은 증거의 기준은 하나다 — 개발자가 검색할 수 있는가
결함 리포트에 로그를 붙이는 목적은 "열심히 조사했음을 보이는 것"이 아니다. 개발자가 자기 자리에서 그 요청을 다시 찾아낼 수 있게 하는 것 이다. 기준은 이 한 문장으로 충분하다.
이 리포트만 보고, 우리 로그 시스템에서 해당 요청을 검색어 하나로 찾아낼 수 있는가.
찾을 수 있으면 좋은 증거다. 못 찾으면 아무리 길게 써도 개발자는 결국 "재현 부탁드립니다"라고 답한다. 그리고 재현이 어려운 결함일수록 그 답은 곧 방치를 뜻한다.
검색을 가능하게 만드는 것은 세 가지다. 무엇으로 찾을 것인가(상관 ID), 언제인가(타임스탬프), 무엇이 터졌는가(스택 트레이스). 이 레슨은 이 셋을 다룬다. 모듈 6(레슨 2)에서 배운 재현 절차·기대 결과·실제 결과 위에 이 셋을 얹는다고 생각하면 된다.
상관 ID — 요청 하나를 끝까지 따라가는 열쇠
요즘 시스템은 요청 하나가 여러 서비스를 거친다. 주문 API 가 회원 서비스와 쿠폰 서비스와 결제 게이트웨이를 부른다. 로그도 그만큼 흩어진다. 상관 ID(correlation ID) 는 그 흩어진 로그를 하나로 묶는 값이다. 분산 추적에서는 보통 trace ID 라고 부른다.
표준은 W3C Trace Context 다. HTTP 요청에 traceparent 헤더로 실려 다닌다.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ trace-id (32자리 16진수) │ trace-flags
version parent-id(span-id, 16자리)QA 가 리포트에 적어야 하는 값은 가운데 trace-id 다. 요청이 어느 서비스를 거치든 이 값은 유지된다. tracestate 라는 짝 헤더도 있는데 벤더별 정보를 나르는 용도라 QA 가 쓸 일은 거의
없다.
현장에서는 표준 헤더 대신 회사 규칙을 쓰는 곳도 많다. 이름만 다를 뿐 역할은 같다.
| 이름 | 어디서 보이나 |
|---|---|
traceparent | W3C 표준. OpenTelemetry 계열이면 대부분 이것 |
x-request-id | Nginx·많은 프레임워크의 관행적 이름 |
x-correlation-id | 기업 내부 게이트웨이에서 흔함 |
x-amzn-trace-id | AWS ALB·API Gateway |
응답 본문의 errorId·referenceId | 오류 응답에 참조 코드를 담는 설계 |
찾는 순서는 이렇다.
- 개발자 도구 네트워크 탭에서 실패한 요청을 클릭한다
- 응답 헤더 를 먼저 본다. 서버가 되돌려 주는 경우가 가장 편하다
- 없으면 요청 헤더 를 본다. 프런트엔드나 브라우저 확장이 심었을 수 있다
- 그래도 없으면 응답 본문 을 본다. 오류 응답 JSON 에 참조 코드가 들어 있는 설계가 흔하다
- 오류 화면에 "오류 코드 A-2031" 같은 문구가 있으면 그것도 후보다
상관 ID 가 아예 없다면
없는 시스템이 실제로 많다. 그럴 때 할 일이 두 가지다.
당장은 대체 식별자를 쓴다. 로그를 좁힐 수 있는 조합이면 된다.
- 업무 키 — 주문번호·결제 승인번호·회원 ID. 가장 강력한 대체재 다
- 계정 식별자 + 정확한 시각 범위 + 엔드포인트 경로
- 세션 ID·기기 ID (앱이라면)
중기적으로는 요구한다. 이것은 테스트 가능성(testability) 개선 요구이고, QA 가 제기하기에 가장 정당한 종류의 요구다(모듈 9 레슨 9). 요구 문장은 구체적일수록 받아들여진다.
- 모든 API 응답에
x-request-id를 헤더로 내려 달라 - 오류 응답 본문에 그 값을
referenceId로 포함해 달라 - 사용자에게 보이는 오류 화면에 그 값의 앞 8자리를 표시해 달라 — 고객센터 문의에도 그대로 쓰인다
세 번째 항목은 QA 만의 이익이 아니라서 설득이 잘 된다. CS 팀이 "고객이 말한 오류 코드로 로그를 찾는" 능력을 갖게 된다.
타임스탬프 — 시간대 없는 시각은 증거가 아니다
"14시 3분쯤"은 증거가 아니다. 로그 시스템에서 검색 범위를 만들 수 없기 때문이다. 세 가지를 지킨다.
- 시간대를 명시한다.
2026-09-24 14:03:11 KST (+09:00)또는 ISO 8601 로2026-09-24T14:03:11+09:00. 서버 로그가 UTC 면05:03:11Z와 같은 시각이다 - 범위로 적는다. 초 단위까지 정확한 값을 알기 어려우므로
14:03:00 ~ 14:03:40 KST처럼 앞뒤 여유를 준다 - 기기 시계를 확인한다. 테스트 단말의 시계가 몇 분 틀어져 있으면 검색 범위가 통째로 빗나간다. 특히 에뮬레이터·오래 켜 둔 테스트 기기에서 흔하다
시각을 정확히 남기는 가장 쉬운 방법은 네트워크 탭에서 그 요청의 Date 응답 헤더를 복사하는 것 이다. 서버가 찍은 시각이므로 기기 시계와 무관하다.
스택 트레이스 — 어디까지 붙이나
전부 붙이면 리포트가 읽히지 않고, 첫 줄만 붙이면 정보가 없다. 기준은 이렇다.
- 예외 타입과 메시지 전체 (첫 줄)
- 우리 코드 프레임 전부 — 회사 패키지로 시작하는 줄들
- 최종
Caused by블록 전체 - 프레임워크·JDK 내부 프레임은
...로 줄인다 - 접기(details)나 첨부파일로 원문 전체도 같이 남긴다. 요약이 잘못 잘렸을 때를 대비한다
java.lang.NullPointerException: Cannot invoke "java.time.LocalDate.isBefore(...)"
because "coupon.validTo" is null
at com.example.shop.coupon.CouponService.validate(CouponService.java:87)
at com.example.shop.coupon.CouponService.apply(CouponService.java:54)
at com.example.shop.order.OrderService.createOrder(OrderService.java:212)
at com.example.shop.order.OrderController.create(OrderController.java:63)
... (framework frames omitted)붙이기 전에 마스킹 한다. 로그 한 줄에 회원 이메일·전화번호·카드번호가 섞여 있는 경우가 많다. 리포트는 사내 여러 사람이 보고 첨부는 오래 남는다. 값의 앞뒤만 남기고 가운데를 가린다 —
hong****@example.com, 5412-****-****-1234. 마스킹이 필요했다는 사실 자체도 별도 결함으로 등록 한다(레슨 3).
증거 여섯 묶음
좋은 결함 리포트의 증거는 대개 이 여섯으로 구성된다. 전부 있을 필요는 없지만, 무엇이 없는지는 알고 있어야 한다.
| # | 증거 | 무엇을 증명하나 |
|---|---|---|
| 1 | 상관 ID(또는 대체 식별자) | 개발자가 로그를 찾을 수 있다 |
| 2 | 시간대 포함 타임스탬프 범위 | 검색 범위를 만들 수 있다 |
| 3 | 요청·응답 원문(경로·본문·상태 코드) | 무엇을 보냈고 무엇을 받았는가 |
| 4 | 로그 발췌(스택 트레이스 포함) | 서버 내부에서 무엇이 터졌는가 |
| 5 | 화면 캡처 또는 짧은 영상 | 사용자에게 어떻게 보였는가 |
| 6 | 환경 정보 — 빌드 버전·환경(stg/prod)·계정·기기 | 어디서 재현되는가 |
여기에 이 모듈의 특기를 하나 더 얹을 수 있다. 정합성 조회 결과(레슨 2) 다. "이 증상이 발생한 주문이 지난 7일간 34건"은 우선순위를 바꾼다.
나쁜 리포트와 좋은 리포트
같은 결함을 두 가지로 써 본다.
[나쁨]
제목: 결제가 안 됨
내용: 쿠폰 넣고 결제하니까 오류납니다. 확인 부탁드립니다.
첨부: 스크린샷1.png[좋음]
제목: [stg] 종료일이 없는 쿠폰 적용 시 주문 생성 500 — NPE (CouponService.validate:87)
환경: stg / 빌드 2026.9.24-rc3 / Chrome 140 / 계정 qa-buyer-07 (memberId=10023)
발생 시각: 2026-09-24 14:03:00~14:03:40 KST (+09:00) / 서버 로그 UTC 기준 05:03
상관 ID: traceparent trace-id = 4bf92f3577b34da6a3ce929d0e0e4736
(응답 헤더 x-request-id 에도 동일 값)
재현 절차:
1. qa-buyer-07 로 로그인
2. 상품 2건을 장바구니에 담는다 (합계 52,000원)
3. 결제 화면에서 쿠폰 WELCOME10 적용
4. [결제하기] 클릭
기대 결과: 주문이 생성되고 주문 완료 화면으로 이동
실제 결과: "일시적인 오류" 토스트, 주문 미생성
POST /api/v1/orders → 500, 응답시간 2.4s
서버 로그 (발췌):
java.lang.NullPointerException: ... because "coupon.validTo" is null
at com.example.shop.coupon.CouponService.validate(CouponService.java:87)
at com.example.shop.order.OrderService.createOrder(OrderService.java:212)
데이터 확인:
SELECT coupon_id, code, valid_from, valid_to FROM coupons WHERE valid_to IS NULL;
→ 12건. 그중 3건이 현재 발급 중인 쿠폰
재현율: 3/3 (종료일이 NULL 인 쿠폰에서만. 종료일이 있는 쿠폰은 정상)
첨부: 네트워크탭_응답.png, 화면녹화.mp4, 로그원문.txt두 번째 리포트에서 개발자가 해야 할 일은 코드를 여는 것뿐 이다. 재현도, 로그 검색도, 원인 가설 수립도 이미 끝나 있다. 더 중요한 것은 마지막 두 줄이다 — 재현 조건이 valid_to IS NULL
로 좁혀져 있고, 같은 조건의 쿠폰이 12건 더 있다는 영향 범위까지 나와 있다.
로그에 접근할 수 없을 때
QA 에게 서버 로그 권한이 없는 조직도 있다. 그래도 할 수 있는 것이 남아 있다.
- 클라이언트 쪽 증거를 최대한 모은다 — 네트워크 탭의 요청·응답 원문(HAR 파일로 저장 가능), 콘솔 로그, 응답 헤더의 식별자
- 읽기 권한을 요청한다. 로그 시스템의 읽기 전용 계정 요구는 DB 와 같은 논리다. "결함 리포트의 왕복 횟수를 줄이기 위해"라는 근거를 붙인다
- 없으면 없다고 적는다. "서버 로그 미확인(권한 없음)"이라고 명시하면 개발자가 그 부분을 자기가 채워야 한다는 것을 안다. 비워 두면 "이미 봤는데 없더라"로 오해된다
HAR 파일을 쓸 때는 주의한다. HAR 에는 인증 토큰과 쿠키가 그대로 들어 있다. 공유 전에 민감 헤더를 지우거나, 저장 시 제공되는 "민감 데이터 제외" 옵션을 쓴다.
실무에서 왜 필요한가
결함 하나가 고쳐지기까지 걸리는 시간은 코드 수정 시간이 아니라 왕복 대기 시간 이 지배한다. 리포트 등록 → 개발자 확인 → 정보 요청 → QA 응답 → 재확인. 왕복 한 번이 보통 반나절에서 하루다. 증거가 갖춰진 리포트는 이 왕복을 0 으로 만든다. 릴리스 직전 하루가 급한 상황에서 이 차이는 결정적이다.
반려율도 달라진다. "재현 불가"로 돌아오는 결함의 대부분은 재현 조건이 리포트에 없는 것이지 진짜로 재현이 안 되는 것이 아니다. 위 예시에서 valid_to IS NULL 이라는 조건이 없으면
개발자는 아무 쿠폰으로나 시도하고 정상 동작을 보고 닫아 버린다. 조건을 좁히는 능력이 반려를 막는다.
조직 차원의 효과도 있다. 상관 ID 를 리포트에 꾸준히 적다 보면 그 값이 없는 곳이 드러난다. 어느 서비스는 헤더를 전파하지 않고, 어느 오류 화면은 참조 코드를 안 보여 준다. 이 목록이 쌓이면 관측성 개선 과제가 되고, 그것을 주도하는 QA 는 팀에서 다른 위치에 선다.