개념
로그는 개발자만 보는 것이 아니다
결함 리포트의 품질이 가장 크게 갈리는 지점은 원인 근처까지 갔는가 다. "결제가 안 됩니다"와 "결제 요청이 500 으로 떨어지고 서버 로그에 쿠폰 만료일이 비어 있을 때 발생하는 예외가 찍힙니다"는 같은 결함이지만 처리 속도가 다르다. 후자를 쓰려면 로그를 볼 줄 알아야 한다.
주의할 것은 이 레슨이 다루는 로그가 제품이 돌면서 남기는 로그 라는 점이다. CI 에서 테스트가 실패했을 때 실행 로그·아티팩트·대시보드로 추적하는 일은 모듈 14(레슨 6)가 다룬다. 여기서는 사용자 요청 하나가 시스템을 통과하면서 남긴 흔적 을 읽는다.
세 곳에서 나온다 — 무엇을 묻느냐가 다르다
| 출처 | 어디서 보나 | 답해 주는 질문 |
|---|---|---|
| 브라우저 콘솔·네트워크 | 개발자 도구(F12) | 요청이 나가긴 했는가, 프런트엔드에서 터졌는가 |
| 웹 서버·게이트웨이 액세스 로그 | Nginx·ALB·API Gateway | 요청이 서버까지 도달했는가, 상태 코드와 응답 시간은 |
| 애플리케이션 로그 | 서버 파일·로그 수집 시스템 | 처리 중 어디서 무엇이 터졌는가, 업무 값은 무엇이었는가 |
순서가 중요하다. 바깥에서 안으로 들어간다. 콘솔에 아무 요청도 안 보이면 서버 로그를 뒤질 이유가 없고(프런트엔드 문제), 액세스 로그에 요청이 없으면 애플리케이션 로그도 비어 있다(라우팅·인증 계층 문제). 이 순서만 지켜도 헛수고의 절반이 없어진다.
로그 레벨 — 무엇을 보고 무엇을 무시하나
거의 모든 로깅 프레임워크가 같은 계단을 쓴다.
| 레벨 | 뜻 | QA 가 볼 때 |
|---|---|---|
TRACE / DEBUG | 개발용 상세 흔적 | 운영에서는 대개 꺼져 있다. 테스트 환경에서 켜 달라고 요청할 수 있다 |
INFO | 정상 동작 기록 | 흐름 확인용. "여기까지는 왔다"를 알려 준다 |
WARN | 이상하지만 처리는 계속됨 | 가장 자주 무시되고 가장 자주 결함의 전조 다 |
ERROR | 처리 실패 | 여기부터 본다. 사용자 화면이 멀쩡해도 ERROR 가 찍혔으면 결함 후보다 |
FATAL / CRITICAL | 프로세스 수준 실패 | 즉시 보고 |
QA 가 기억할 것은 두 가지다.
첫째, 화면이 정상이어도 ERROR 가 찍힐 수 있다. 서버가 실패를 잡아 기본값으로 처리하고 화면에는 성공을 보여 주는 코드가 흔하다. 테스트 중 ERROR 가 찍혔다면 화면과 무관하게 물어볼
가치가 있다.
둘째, 로그가 안 보인다고 안 일어난 것이 아니다. 운영 환경은 보통 INFO 이상만 남긴다. 테스트 환경에서 DEBUG 를 켜 달라고 요청하는 것은 정당한 요구이며, 반대로 중요한 실패가
DEBUG 로만 남고 있다면 그것 자체가 결함 이다(로그 레벨 오설정).
액세스 로그 읽기
가장 단순하고 가장 빨리 답을 주는 로그다.
203.0.113.24 - - [24/Sep/2026:14:03:11 +0900] "POST /api/v1/orders HTTP/1.1" 500 173 "https://shop.example.com/checkout" "Mozilla/5.0 (Macintosh; ...) Chrome/140.0.0.0" 2.418왼쪽부터 클라이언트 IP, 시각(시간대 포함), 요청 메서드와 경로, HTTP 버전, 상태 코드(500), 응답 본문 크기, 리퍼러, User-Agent, 그리고 마지막이 응답 시간(2.418초) 이다. 마지막 두 값은 설정에 따라 있을 수도 없을 수도 있다.
여기서 읽어 낼 것.
- 상태 코드가 4xx 인가 5xx 인가 — 4xx 는 요청이 잘못됐다는 서버의 주장(클라이언트 결함 또는 검증 메시지 결함), 5xx 는 서버가 스스로 실패한 것
- 응답 시간 — 정상 응답인데 2초가 넘으면 그 자체로 결함 후보다
- 시각과 시간대 —
+0900이 붙어 있는지 확인한다. UTC 로 남는 서버가 흔하고, 그러면 내 테스트 시각과 9시간 차이다
한 건이 아니라 흐름으로 보면 더 많이 보인다.
# 최근 로그에서 상태 코드 분포
awk '{print $9}' access.log | sort | uniq -c | sort -rn | head
# 특정 엔드포인트의 5xx 만
grep '"POST /api/v1/orders' access.log | awk '$9 ~ /^5/' | tail -20애플리케이션 로그와 스택 트레이스
2026-09-24 14:03:11.482 ERROR 1 --- [http-nio-8080-exec-7] c.e.shop.order.OrderController
: 주문 생성 실패 orderRequestId=REQ-8f3c21 memberId=10023
java.lang.NullPointerException: Cannot invoke "java.time.LocalDate.isBefore(java.time.LocalDate)"
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)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(...)
at org.springframework.web.servlet.mvc.method.AnnotationMethodHandlerAdapter...
... 48 common frames omitted스택 트레이스는 다섯 단계 로 읽는다.
- 맨 윗줄의 예외 타입과 메시지 —
NullPointerException, 그리고 메시지가"coupon.validTo" is null이라고 원인을 그대로 말해 준다. 최근 JVM 은 어느 값이null인지까지 알려 주므로 이 한 줄이 종종 결론이다 - 우리 코드의 첫 줄을 찾는다 — 위에서 아래로 내려가며 회사 패키지(
com.example.…)로 시작하는 첫 줄을 찾는다. 여기서는CouponService.validate(CouponService.java:87). 프레임워크 줄(org.springframework.…,java.base/…)은 대개 정보가 없다 - 호출 경로를 거꾸로 읽는다 — 아래에서 위로: 컨트롤러 → 주문 서비스 → 쿠폰 적용 → 쿠폰 검증. 어느 기능의 어느 단계인지가 나온다
Caused by사슬을 끝까지 본다 — 여러 겹으로 감싸인 예외에서는 맨 아래Caused by가 진짜 원인 이다. 위쪽은 그것을 감싼 포장지다- 같은 줄의 업무 값을 챙긴다 —
orderRequestId=REQ-8f3c21 memberId=10023. 이 값이 레슨 4에서 결함 증거의 핵심이 된다
여기까지 읽으면 결함 가설이 선다. "쿠폰의 종료일(valid_to)이 비어 있으면 검증에서 터진다." 그리고 이 가설은 레슨 2의 조회로 바로 확인할 수 있다 — SELECT * FROM coupons WHERE valid_to IS NULL. 로그와 조회가 만나는 지점이 여기다.
구조화 로그(JSON)
요즘 서버는 사람이 읽는 한 줄 대신 JSON 한 줄을 남긴다. 로그 수집 시스템에서 필드로 검색·집계 할 수 있기 때문이다.
{"timestamp":"2026-09-24T05:03:11.482Z","level":"ERROR","service":"order-api","env":"stg",
"traceId":"8f3c21a04b7e9d12","spanId":"b7e9d1204a","userId":"10023",
"http":{"method":"POST","path":"/api/v1/orders","status":500,"durationMs":2418},
"message":"주문 생성 실패","error":{"type":"NullPointerException",
"detail":"coupon.validTo is null","at":"CouponService.validate:87"},
"order":{"requestId":"REQ-8f3c21","couponCode":"WELCOME10","itemCount":2}}QA 가 눈여겨볼 필드는 정해져 있다.
| 필드 | 왜 중요한가 |
|---|---|
timestamp | 끝의 Z 는 UTC 다. 한국 시각과 9시간 차이. 화면 시각과 대조할 때 반드시 확인 |
level | 필터의 1순위 |
service, env | 마이크로서비스에서 어느 서비스의 어느 환경 인지. 운영 로그를 스테이징 결함 증거로 붙이는 실수를 막는다 |
traceId | 요청 하나를 여러 서비스에 걸쳐 잇는 열쇠. 레슨 4의 주인공 |
http.status, http.durationMs | 액세스 로그를 따로 안 봐도 되는 경우가 많다 |
업무 필드(order.*) | 재현 조건이 여기 들어 있다 |
파일로 받았다면 jq 로 바로 좁힐 수 있다.
# ERROR 만 골라 시각·메시지·traceId 만 본다
jq -r 'select(.level=="ERROR") | [.timestamp, .traceId, .message] | @tsv' app.log
# 특정 요청의 전체 흐름
jq 'select(.traceId=="8f3c21a04b7e9d12")' app.log
# 예외 타입별 건수
jq -r 'select(.error) | .error.type' app.log | sort | uniq -c | sort -rn로그 수집 시스템(Kibana·Grafana Loki·CloudWatch 등)에서도 같은 일을 검색어로 한다. 화면은 달라도 "레벨로 거르고, traceId 로 잇고, 업무 필드로 확인한다"는 순서는 같다.
브라우저 콘솔
프런트엔드 쪽 실패는 서버 로그에 아무것도 안 남는다. 개발자 도구 콘솔에서만 보인다(모듈 9 레슨 12).
Uncaught TypeError: Cannot read properties of undefined (reading 'discountRate')
at applyCoupon (checkout.bundle.js:2:18442)
at onClick (checkout.bundle.js:2:19103)
Access to fetch at 'https://api.example.com/v1/coupons' from origin
'https://shop.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Mixed Content: The page at 'https://shop.example.com/checkout' was loaded over HTTPS,
but requested an insecure resource 'http://cdn.example.com/track.js'. This request has been blocked.
GET https://cdn.example.com/img/banner_v2.png 404 (Not Found)네 줄이 각각 다른 성격의 결함이다.
TypeError/undefined읽기 — 서버 응답에 기대한 필드가 없다. 대개 API 계약 불일치 이고, 네트워크 탭에서 실제 응답 JSON 을 확인하면 바로 증명된다(모듈 12)- CORS 차단 — 서버 설정 문제다. 프런트엔드 코드를 고칠 일이 아니므로 리포트에 "서버 CORS 헤더 누락"이라고 적으면 담당이 바로 정해진다
- Mixed Content — HTTPS 페이지에서 HTTP 자원을 불렀다. 분석 스크립트가 이렇게 막히면 레슨 5의 수집 누락 으로 이어진다
- 리소스 404 — 이미지·스크립트 경로 오류. 화면에는 깨진 이미지 하나로만 보인다
콘솔은 새로고침하면 지워진다. 재현 전에 콘솔을 열어 두고, Preserve log(로그 보존) 옵션을 켠다. 페이지 이동이 일어나는 결제·로그인 흐름에서는 이 옵션이 없으면 증거가 사라진다.
원인을 좁히는 순서
① 언제 실패 시각을 초 단위로 확정한다 (화면 시계 캡처·요청 시각)
↓
② 어느 층 콘솔 → 액세스 로그 → 애플리케이션 로그 순으로 흔적을 찾는다
↓
③ 어느 요청 traceId·요청 ID·주문번호 중 하나로 그 요청만 뽑는다
↓
④ 어느 줄 스택 트레이스에서 우리 코드 첫 줄과 최종 Caused by 를 지목한다
↓
⑤ 왜 로그의 업무 값을 조회(레슨 2)로 확인해 가설을 검증한다②에서 흔적이 아예 없으면 그 자체가 정보다 — 요청이 서버에 도달하지 않았다는 뜻이고, 원인은 클라이언트·네트워크·게이트웨이 쪽으로 좁혀진다.
로그에서 보면 안 되는 것
로그를 읽다 보면 개인정보나 비밀정보가 그대로 찍혀 있는 경우 를 만난다. 주민등록번호, 카드번호 전체, 비밀번호, 액세스 토큰, 이메일 원문.
이것은 도구 문제가 아니라 결함 이다. 개인정보보호법과 대부분의 내부 보안 지침이 로그의 민감정보 마스킹을 요구한다. 발견하면 두 가지를 한다.
- 결함으로 등록한다. "결제 로그에 카드번호 16자리가 평문으로 기록됨"은 보안 결함이고 대개 높은 우선순위다
- 그 로그를 증거로 복사해 붙이지 않는다. 결함 리포트·메신저에 붙이는 순간 노출 범위가 더 넓어진다. 마스킹한 뒤 붙이거나 위치만 지목한다
실무에서 왜 필요한가
로그를 읽는 QA 와 읽지 않는 QA 의 차이는 왕복 횟수 로 나타난다. 읽지 않으면 "안 됩니다" → 개발자가 로그 확인 → "어떤 계정이었나요" → 다시 확인 → 재현 요청으로 이어진다. 읽으면 첫 리포트에 예외 타입·발생 위치·업무 값이 들어가고, 개발자는 코드를 바로 연다.
판정 능력도 달라진다. 화면이 정상으로 보이는데 ERROR 가 찍히는 경우, 알림 발송이 실패했는데 사용자에게는 "발송됨"이라고 뜨는 경우 — 이런 것들은 로그를 보지 않으면 통과 판정을 내리고
넘어가게 된다. 특히 비동기 처리(메일·푸시·정산 배치)가 있는 시스템에서는 화면이 알려 주는 정보가 거의 없다.
그리고 로그는 재현되지 않는 결함의 유일한 단서 다. 한 번 보고 사라진 오류, 특정 사용자에게만 나는 오류는 재현 시도로 못 잡는다. 남은 흔적에서 조건을 역산하는 수밖에 없고, 그 작업을 할 수 있는 QA 는 팀에서 눈에 띈다.