본문으로 건너뛰기
중급약 35분

로그 읽기 — 애플리케이션·액세스·브라우저 콘솔에서 원인 좁히기

"안 돼요"에서 멈추지 않으려면 로그를 직접 읽어야 한다. 애플리케이션 로그·웹 서버 액세스 로그·브라우저 콘솔이 각각 무엇을 답해 주는지 구분하고, 로그 레벨의 의미와 구조화 로그(JSON)의 필드를 읽는다. 실제 샘플을 놓고 스택 트레이스를 다섯 단계로 해석하고, 시각에서 레이어로 요청으로 좁혀 가는 절차를 익힌다.

  • #로그
  • #스택트레이스
  • #구조화로그
  • #디버깅
  • #브라우저콘솔

개념

로그는 개발자만 보는 것이 아니다

결함 리포트의 품질이 가장 크게 갈리는 지점은 원인 근처까지 갔는가 다. "결제가 안 됩니다"와 "결제 요청이 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

스택 트레이스는 다섯 단계 로 읽는다.

  1. 맨 윗줄의 예외 타입과 메시지 — NullPointerException, 그리고 메시지가 "coupon.validTo" is null 이라고 원인을 그대로 말해 준다. 최근 JVM 은 어느 값이 null 인지까지 알려 주므로 이 한 줄이 종종 결론이다
  2. 우리 코드의 첫 줄을 찾는다 — 위에서 아래로 내려가며 회사 패키지(com.example.…)로 시작하는 첫 줄을 찾는다. 여기서는 CouponService.validate(CouponService.java:87). 프레임워크 줄(org.springframework.…, java.base/…)은 대개 정보가 없다
  3. 호출 경로를 거꾸로 읽는다 — 아래에서 위로: 컨트롤러 → 주문 서비스 → 쿠폰 적용 → 쿠폰 검증. 어느 기능의 어느 단계인지가 나온다
  4. Caused by 사슬을 끝까지 본다 — 여러 겹으로 감싸인 예외에서는 맨 아래 Caused by 가 진짜 원인 이다. 위쪽은 그것을 감싼 포장지다
  5. 같은 줄의 업무 값을 챙긴다 — 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)로 확인해 가설을 검증한다

②에서 흔적이 아예 없으면 그 자체가 정보다 — 요청이 서버에 도달하지 않았다는 뜻이고, 원인은 클라이언트·네트워크·게이트웨이 쪽으로 좁혀진다.

로그에서 보면 안 되는 것

로그를 읽다 보면 개인정보나 비밀정보가 그대로 찍혀 있는 경우 를 만난다. 주민등록번호, 카드번호 전체, 비밀번호, 액세스 토큰, 이메일 원문.

이것은 도구 문제가 아니라 결함 이다. 개인정보보호법과 대부분의 내부 보안 지침이 로그의 민감정보 마스킹을 요구한다. 발견하면 두 가지를 한다.

  1. 결함으로 등록한다. "결제 로그에 카드번호 16자리가 평문으로 기록됨"은 보안 결함이고 대개 높은 우선순위다
  2. 그 로그를 증거로 복사해 붙이지 않는다. 결함 리포트·메신저에 붙이는 순간 노출 범위가 더 넓어진다. 마스킹한 뒤 붙이거나 위치만 지목한다

실무에서 왜 필요한가

로그를 읽는 QA 와 읽지 않는 QA 의 차이는 왕복 횟수 로 나타난다. 읽지 않으면 "안 됩니다" → 개발자가 로그 확인 → "어떤 계정이었나요" → 다시 확인 → 재현 요청으로 이어진다. 읽으면 첫 리포트에 예외 타입·발생 위치·업무 값이 들어가고, 개발자는 코드를 바로 연다.

판정 능력도 달라진다. 화면이 정상으로 보이는데 ERROR 가 찍히는 경우, 알림 발송이 실패했는데 사용자에게는 "발송됨"이라고 뜨는 경우 — 이런 것들은 로그를 보지 않으면 통과 판정을 내리고 넘어가게 된다. 특히 비동기 처리(메일·푸시·정산 배치)가 있는 시스템에서는 화면이 알려 주는 정보가 거의 없다.

그리고 로그는 재현되지 않는 결함의 유일한 단서 다. 한 번 보고 사라진 오류, 특정 사용자에게만 나는 오류는 재현 시도로 못 잡는다. 남은 흔적에서 조건을 역산하는 수밖에 없고, 그 작업을 할 수 있는 QA 는 팀에서 눈에 띈다.

실습 과제

과제 1 — 세 층의 로그를 한 요청으로 잇기 (30분)

아무 웹 서비스나 하나 열고(자기 제품이면 가장 좋다) 실패하는 요청을 일부러 하나 만든다. 없는 페이지 접근, 잘못된 형식의 입력, 로그인 없이 보호된 API 호출 중 하나면 된다.

  1. 개발자 도구를 열고 Preserve log 를 켠 뒤 재현한다
  2. 콘솔에 무엇이 찍혔는지, 네트워크 탭에서 그 요청의 상태 코드·응답 본문·응답 시간이 무엇인지 적는다
  3. 요청 헤더에서 요청 ID 계열 헤더(x-request-id, traceparent, x-amzn-trace-id 등)가 있는지 찾아 값을 적는다
  4. 서버 로그 접근 권한이 있다면 그 값으로 검색해 본다. 없다면 어떤 검색어로 요청하겠는지 를 문장으로 적는다

3번에서 아무 헤더도 없다면 그것이 이번 주의 발견이다 — 추적 가능한 상관 ID 가 없다는 뜻이고, 레슨 4에서 이 문제를 다룬다.

과제 2 — 스택 트레이스 해부 (20분)

아래 트레이스를 다섯 단계 절차로 읽고 표를 채운다. 모르는 프레임워크라도 절차는 같다.

2026-09-24 09:12:44.109 ERROR [order-api] OrderBatchRunner
  : 정산 배치 실패 batchId=STL-20260924 targetDate=2026-09-23
org.springframework.dao.DataIntegrityViolationException: could not execute statement
    at com.example.shop.settlement.SettlementRepository.save(SettlementRepository.java:41)
    at com.example.shop.settlement.SettlementService.close(SettlementService.java:158)
    at com.example.shop.batch.OrderBatchRunner.run(OrderBatchRunner.java:77)
Caused by: java.sql.SQLIntegrityConstraintViolationException:
    Duplicate entry 'STL-20260924-10023' for key 'settlement.uk_settlement_member'
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:118)
항목답
겉으로 드러난 예외
진짜 원인(최종 Caused by)
우리 코드의 첫 줄
어떤 기능의 어느 단계인가
확인해 볼 조회(레슨 2 형식)

마지막 칸이 핵심이다. 이 트레이스는 레슨 2의 중복 패턴 을 그대로 가리키고 있다.

과제 3 — 로그 레벨 점검 (20분)

담당 제품(또는 오픈소스 프로젝트 아무거나)의 로그 설정 파일을 찾는다(logback.xml, log4j2.xml, application.yml, winston 설정 등). 다음을 확인해 표로 남긴다.

  • 운영·스테이징의 기본 레벨은 각각 무엇인가
  • 실패 경로(예외 처리 블록)가 ERROR 로 남는가, WARN 이나 DEBUG 로 남는가
  • 로그에 요청 식별자가 들어가는가
  • 민감정보 마스킹 규칙이 설정에 있는가

네 항목 중 하나라도 비어 있으면 테스트 가능성(testability) 개선 요청 으로 정리한다. 개발팀에 요구할 근거가 되는 문서가 이 표다.

자가 체크리스트

  • 브라우저 콘솔·액세스 로그·애플리케이션 로그가 각각 답해 주는 질문을 구분할 수 있다
  • 원인 추적을 바깥(클라이언트)에서 안(서버)으로 진행하는 이유를 설명할 수 있다
  • 로그 레벨 여섯 단계의 의미와, 화면이 정상이어도 ERROR 를 확인해야 하는 이유를 말할 수 있다
  • 액세스 로그 한 줄에서 상태 코드·응답 시간·시간대를 읽을 수 있다
  • 스택 트레이스에서 우리 코드의 첫 줄과 최종 Caused by 를 찾아낼 수 있다
  • 구조화 로그의 traceId·service·env·업무 필드가 왜 필요한지 설명할 수 있다
  • jq 또는 로그 검색 도구로 레벨·식별자로 로그를 좁힐 수 있다
  • 콘솔의 TypeError·CORS·Mixed Content·404 가 각각 어느 담당의 문제인지 구분할 수 있다
  • Preserve log 를 켜고 페이지 이동이 있는 흐름의 증거를 남길 수 있다
  • 로그의 민감정보 노출을 결함으로 등록하고, 그 로그를 그대로 복사하지 않는다

흔한 실수

  • 서버 로그부터 뒤진다. 요청이 나가지도 않았는데 서버를 뒤지면 아무것도 안 나온다. 콘솔·네트워크 탭이 먼저다.
  • 시간대를 확인하지 않는다. 로그가 UTC 면 한국 시각과 9시간 차이다. "그 시각에 로그가 없다"의 절반은 이 착각이다.
  • 스택 트레이스의 맨 윗줄만 보고 프레임워크를 탓한다. 우리 코드의 첫 줄과 최종 Caused by 가 실제 정보다.
  • WARN 을 무시한다. 대부분의 장애는 며칠 전부터 WARN 으로 예고돼 있다.
  • 콘솔을 안 켜고 재현한 뒤 새로고침한다. 증거가 사라진다. 재현 전에 열고 Preserve log 를 켠다.
  • 운영 로그와 스테이징 로그를 섞는다. env 필드를 확인하지 않으면 다른 환경의 오류를 결함으로 등록한다.
  • 민감정보가 찍힌 로그를 그대로 결함 리포트에 붙인다. 노출을 키우는 행동이고, 그 노출 자체가 별도 결함이다.

참고 자료

  • MDN — CORS 오류 — 콘솔에 뜨는 CORS 메시지별 원인
  • MDN — Mixed content — HTTPS 페이지에서 HTTP 자원이 차단되는 규칙
  • jq 매뉴얼 — JSON 로그를 거르고 뽑는 문법
  • 레슨 s3-m09-l12 — QA 를 위한 Chrome DevTools(콘솔·네트워크 탭 기본)
  • 레슨 s3-m61-l02 — 정합성 조회(로그에서 세운 가설을 데이터로 확인한다)
  • 레슨 s3-m61-l04 — 로그를 결함 증거로 바꾸기
  • 레슨 s4-m14-l06 — 관측성으로 CI 실행 실패 추적하기(제품 로그가 아니라 파이프라인 쪽)

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

CI — 지속적 통합
코드를 올릴 때마다 자동으로 합쳐서 빌드하고 시험까지 돌리는 방식.
API — 응용 프로그램 인터페이스
프로그램끼리 주고받는 창구. 사람이 화면 버튼을 누르는 대신, 프로그램이 정해진 주소로 요청을 보내 답을 받는다.
QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
IP — 아이피 주소
인터넷에 연결된 기기마다 붙는 번호.
HTTP — 하이퍼텍스트 전송 규약
웹에서 요청과 응답을 주고받는 약속. "무엇을 달라"고 보내면 "여기 있다" 또는 "그런 건 없다"가 돌아온다.
UTC — 협정 세계시
세계 공통 기준 시각. 한국 시간보다 9시간 느리다.
JVM — 자바 가상 머신
자바로 만든 프로그램을 돌려 주는 바탕 프로그램.
JSON — 제이슨
프로그램끼리 값을 주고받을 때 쓰는 글자 형식. 괄호와 따옴표로 이름과 값을 짝지어 적는다.
CORS — 교차 출처 자원 공유
웹 화면이 다른 주소의 창구를 부를 수 있는지를 정하는 규칙. 허용되지 않으면 브라우저가 막는다.
HTTPS — 암호화된 웹 전송
웹 통신을 암호로 감싼 것. 중간에서 누가 들여다봐도 내용을 못 읽는다.
ID — 식별자
무엇 하나를 다른 것과 구별하기 위해 붙인 이름이나 번호.

용어집 전체 보기

이 레슨의 실습

실습은 별도 저장소에서 진행합니다. 새 탭으로 열립니다.