Devin.KR

구조화 로그와 요청 맥락

70분 안팎

학습 목표

요청 ID를 남기고 개인정보와 비밀을 제외합니다.

개념

장애 신고를 검색 가능한 기록으로 바꿉니다

안내판이 가끔 실패한다는 신고만으로 어느 배포의 어떤 요청인지 찾기 어렵습니다. 앞 모듈에서 프록시를 되돌렸어도 실패 시점의 상태와 처리 시간이 남아 있어야 이유를 설명할 수 있습니다. 이번에는 문장을 이어 붙인 로그를 JSON 한 줄로 바꾸고 클라이언트가 받은 요청 ID를 검색 키로 사용합니다. 로그는 사건 하나의 맥락을 보존하는 자료입니다. 전체 서비스의 성공률은 다음 레슨에서 계산하며 한 줄의 성공만으로 배포를 승인하지 않습니다.

실습을 여는 방법

이 모듈의 zip은 압축을 푼 루트에서 실행합니다. Java는 JDK 17과 Spring Boot 3.1.5이며 Maven wrapper가 들어 있습니다. 학습자 명령은 ./mvnw test입니다. Python 3 파일 실습은 bash check.sh, 브라우저 Python은 표준 입력으로 JSON을 받습니다. Docker가 필요한 실습은 verify external로 실행 검증 대기입니다. 실제 운영 자원이나 계정은 필요하지 않습니다. Java 테스트는 임시 파일과 Mock 요청을 사용하므로 실행 서버를 만들지 않습니다. 자기 프로젝트 디렉터리와 제공된 교육 fixture만 사용합니다.

JSONL의 한 줄은 한 사건입니다

JSONL은 JSON 객체를 줄마다 저장하는 형식입니다. 전체 파일이 하나의 JSON 배열은 아닙니다. 읽을 때 줄 단위로 파싱하고 각 줄의 필드 형식을 검사합니다. timestamp는 UTC 시각, request_id는 검색 키, method와 route는 작업 종류, status는 결과, duration_ms는 이 필터 안에서 잰 경과 시간입니다. 숫자를 문자열로 저장하면 비교와 정렬이 어긋날 수 있습니다. ObjectMapper로 직렬화하면 따옴표와 개행의 이스케이프를 직접 구현할 필요가 없습니다.

요청 ID는 신뢰할 수 없는 입력입니다

클라이언트가 보낸 X-Request-ID를 그대로 붙이면 개행으로 가짜 로그 줄을 만들거나 긴 문자열로 기록량을 늘릴 수 있습니다. 실습은 영문·숫자·하이픈으로 1~64자인 값만 재사용하고 다른 값은 UUID로 대체합니다. 유효한 ID를 응답 헤더에도 보내 신고 자료와 로그를 연결합니다. UUID로 바꾼 뒤 원래 잘못된 값을 따로 기록하면 제외 정책이 깨집니다. 이 검사는 문자 형태를 제한할 뿐 ID의 전역 유일성이나 사용자의 신원을 보증하지 않습니다.

검색 키와 인증 키를 구분합니다

요청 ID는 요청을 찾기 위한 표시이며 인증 권한을 주지 않습니다. 같은 ID를 가진 두 요청이 들어올 수도 있으므로 시각·경로·배포 버전도 함께 조사합니다. 요청 ID를 비밀 토큰 대신 사용하거나 ID가 같다는 이유로 동일 사용자라고 판단하지 않습니다. 미션에서는 trace_id와 span_id를 추가하여 구간 기록에 연결합니다. 지금 실습의 계약은 여섯 필드이고, 뒤에서 필드를 확장할 때 소비 측 파서와 테스트도 함께 바꿉니다.

비밀을 지우는 대신 필요한 값만 고릅니다

Authorization, X-Lab-Token, 쿠키, 요청 본문, 쿼리 문자열과 예외 메시지는 저장하지 않습니다. 토큰이라는 이름만 지우는 방식은 다른 필드명이나 URL에 들어간 값을 놓칩니다. 허용 목록으로 필요한 필드만 새 객체에 넣습니다. 사용자 이메일이 경로 일부에 들어갈 가능성도 있어 모르는 경로는 other로 기록합니다. 응답 본문에 있는 안내문 제목도 이 로그에 넣지 않습니다. 값이 없어도 요청 결과를 추적할 수 있는지 먼저 설계하는 것이 제외 정책의 출발점입니다.

경로를 일정한 범주로 남깁니다

/notices와 /health처럼 알려진 경로는 그대로 분류합니다. /users/어떤값 같은 임의 경로는 other가 됩니다. 쿼리 문자열은 URI 경로와 별도이므로 request.getQueryString을 로그에 추가하지 않습니다. method도 알려진 HTTP 메서드 목록 이외에는 other입니다. 이 방식은 같은 작업의 로그를 묶고 민감값을 줄이지만 알려지지 않은 주소의 세부 원인을 잃습니다. 새 기능을 추가할 때는 개인 식별값 없는 route 템플릿을 정의하고 테스트를 추가하여 조사 가능 범위를 넓힙니다.

벽시계와 경과 시간의 목적이 다릅니다

사건 정렬에는 Instant의 UTC 시각을 쓰고 처리 시간에는 System.nanoTime의 차이를 사용합니다. 벽시계가 보정되면 현재 시각 두 개의 차이는 음수가 되거나 갑자기 커질 수 있습니다. nanoTime 자체는 달력 날짜가 아니므로 다른 호스트의 값과 직접 비교하지 않습니다. 이 필터의 시간은 chain 호출 앞뒤이며 브라우저의 DNS·연결·응답 전송 전체와 경계가 다릅니다. duration_ms를 사용자 전체 대기 시간이라고 이름 붙이면 이후 트레이스 비교에서 잘못된 차이를 만듭니다.

예외 경로에서도 결과를 남깁니다

chain.doFilter가 정상 반환한 경우 응답의 status를 기록합니다. IOException·ServletException·RuntimeException이 밖으로 전달되면 이 필터의 결과를 500으로 분류하고 원래 예외를 다시 던집니다. finally에 기록을 두면 실패 경로의 사건도 남습니다. 단, 뒤쪽 컨테이너가 최종 응답을 바꿀 수 있어 이 값은 필터의 관측 결과입니다. 예외 메시지를 로그에 더하지 않아도 상태와 요청 ID로 실패를 찾을 수 있습니다. 성공만 남기는 구현은 사용자 실패를 통계에서 사라지게 만듭니다.

쓰기 실패의 정책도 알아 둡니다

예제 write는 synchronized로 같은 인스턴스의 줄 쓰기를 직렬화하고 부모 디렉터리를 만든 뒤 APPEND합니다. 여러 프로세스가 하나의 파일에 쓰는 문제와 로그 회전·디스크 용량 관리는 해결하지 않습니다. 현재 IOException은 호출자에게 전달되어 응답에 영향을 줄 수 있습니다. 실제 서비스에는 로그 전용 큐·수집 실패 경보·용량 제한 같은 별도 정책이 필요합니다. 이 교육 코드를 그대로 무제한 운영 로그로 적용하지 말고 어떤 실패가 사용자 경로에 영향을 주는지 README에 남깁니다.

starter 실패를 계약으로 읽습니다

starter는 유효한 ID도 새로 만들고 모든 상태를 200으로 저장합니다. 정상 시간과 민감값 제외 검사는 이미 통과하지만 ID 재사용과 오류 상태 검사가 실패해야 합니다. expected 500 but was 200은 HTTP 성공 여부를 테스트가 잘못 판정한 것이 아니라 기록의 결과 필드를 잘못 만든 것입니다. boundary64는 최대 길이 허용, tooLong은 65자 대체를 검사합니다. 실패한 메서드와 임시 입력을 함께 읽고 구현을 고칩니다. 기대값을 200으로 바꾸면 과제를 해결한 것이 아닙니다.

한 줄 이상을 파싱해 봅니다

multipleJsonLines는 두 요청이 배열이나 합쳐진 객체 대신 두 줄로 남는지 검사합니다. 개행 포함 ID와 민감값 fixture도 통과해야 형식과 정보 제외를 함께 보장합니다. 실제 읽기 도구에서 Extra data가 나면 파일 전체를 하나의 JSON으로 읽었는지 확인합니다. Expected true but was false는 해당 메서드의 검증 조건을 찾아 어떤 필드가 누락됐는지 좁힙니다. Maven 의존성 오류는 JUnit assertion과 다르므로 테스트를 삭제하지 않고 Java 버전과 wrapper 환경부터 확인합니다.

제출할 증거를 고릅니다

완료한 결과는 여섯 필드의 JSONL 계약, 유효 ID 재사용·잘못된 ID 대체·상태·시간·제외 정책을 설명하는 코드, 통과한 테스트입니다. 본문이나 실제 토큰을 붙인 스크린샷은 제출하지 않습니다. 로그를 열 때도 필요한 줄만 선택하고 공유 대상과 보관 기간을 정합니다. 이 레슨을 마치면 특정 실패 ID의 결과를 찾아 읽을 수 있어야 합니다. 한 요청의 구간 분해와 클라이언트 관측 경계는 더 읽기로 이어가고 여기서는 필터가 책임지는 사건 기록을 완성합니다.

따라하기

JSONL을 줄 단위로 읽기

다음 코드를 Python 3로 실행하여 두 사건의 ID와 상태를 읽습니다. 입력은 교육용 고정 문자열입니다.

import json
text='\n'.join([json.dumps({"request_id":"demo-1","status":200}),json.dumps({"request_id":"demo-2","status":500})])
for line in text.splitlines():
    row=json.loads(line)
    print(row["request_id"],row["status"])

실행 결과

demo-1 200
demo-2 500

ID 길이와 개행 경계 확인

Java의 허용 조건과 같은 문자·길이 계약을 작은 Python 정규식으로 확인합니다. raw 값은 교육용 예제입니다.

import re
for label,value in [("64","a"*64),("65","a"*65),("newline","bad\nforged")]:
    print(label, bool(re.fullmatch(r"[A-Za-z0-9-]{1,64}",value)))

실행 결과

64 True
65 False
newline False

필터의 실패를 재현하기

starter.zip 루트에서 테스트를 실행합니다. contract·boundary64·thrownFailure의 기대값을 읽고 RequestLogFilter의 ID 선택과 결과 분류를 완성합니다. 테스트 기대값은 유지합니다.

./mvnw test
cat src/test/java/lab/LogTest.java

성공과 제외 정책을 확인하기

완성한 프로젝트 또는 solution.zip 루트에서 실행합니다. target/surefire-reports의 LogTest는 7개가 실패·오류 없이 끝나고 이전 회귀 검사도 통과해야 합니다. 같은 파일의 여러 줄과 민감값 제외를 함께 확인합니다.

./mvnw test

확인 문제

실습

RequestLogFilter에서 허용 ID 재사용·잘못된 ID 대체와 오류 상태 분류를 완성합니다. status·duration_ms는 숫자, timestamp는 UTC 문자열입니다. 로그는 여섯 필드만 가진 JSONL이며 원시 헤더·본문·쿼리·예외 메시지는 제외합니다. LogTest는 길이 64·65·개행·모르는 경로·예외·여러 줄·비밀 및 이메일 제외를 검사합니다. 테스트 기대값을 유지하고 ./mvnw test로 확인합니다.

시작 코드·테스트 내려받기

실행 명령

./mvnw test

기대 결과

LogTest 7개와 이전 회귀 검사 모두 통과합니다.

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 지표·로그·트레이스로 확인하는 정보를 설명합니다.