오류 로그의 빈칸에는 이유가 있어야 한다
목차
400 응답과 Trace ID가 남아 있어도 어떤 입력이 거절됐는지 알 수 없는 경우가 있다. 상세 화면에 필요한 값이 비어 있으면 요청을 찾는 데서 조사가 멈춘다. 이번 주 요청 진단 작업에서 다룬 문제도 이 간격이었다.
이번 주에는 이 간격을 줄이는 작업을 했다. 요청과 응답을 무조건 복사하는 방식은 쓸 수 없었다. 진단 저장소에 인증정보나 파일 내용이 쌓이면 문제 하나를 고치면서 다른 문제를 만들기 때문이다.
아래 경로와 값은 설명을 위해 재구성한 예시다. 실제 서비스의 식별자와 로그 원문은 사용하지 않았다.
빈 객체 하나로는 설명할 수 없는 상태
요청 본문이 {}로 보일 때 가능한 이유는 여러 가지다. 클라이언트가 빈 JSON을 보냈을 수도 있고 인증 단계에서 요청이 끝나 본문을 읽지 않았을 수도 있다. 파일 업로드라서 JSON 수집 대상이 아니었을 수도 있다.
이 상태들을 모두 빈 객체로 바꾸면 보는 사람은 빈칸의 의미를 추측해야 한다. 그래서 값과 수집 상태를 함께 다뤘다.
| 상태 예시 | 해석 |
|---|---|
captured | 허용된 범위에서 값을 수집했다 |
not_read | handler가 본문을 읽지 않았거나 끝까지 읽기 전에 처리가 종료됐다 |
too_large | 크기 제한을 넘어 수집하지 않았다 |
non_json | JSON 수집 대상이 아닌 형식이었다 |
policy_omitted | 정책에 따라 값을 남기지 않았다 |
상태는 요청 전체에 하나만 붙이면 부족하다. 경로와 query는 읽었지만 본문은 수집하지 않은 요청도 있기 때문이다. 예를 들어 진단 정보를 아래처럼 해석할 수 있어야 한다. 실제 API 응답을 복사한 형식은 아니다.
{
"path": {"state": "captured", "values": {"id": "document-example"}},
"query": {"state": "captured", "values": {"tag": ["guide", "draft"]}},
"body": {"state": "policy_omitted"}
}query의 반복값도 배열로 남긴다. ?tag=guide&tag=draft를 문자열 하나로 줄이면 서버에 들어온 입력과 화면에서 읽는 입력이 달라진다.
요청에 적힌 ID는 처리한 객체의 ID가 아니다
경로 값을 보여주고 나면 다음 문제가 생긴다. /documents/document-example로 요청했다고 해서 서버가 그 문서를 찾았거나 접근을 허용했다는 뜻은 아니다.
요청 경로의 ID → 클라이언트가 보낸 주장
조회·권한 확인 후 ID → 서버가 확인한 처리 대상실패한 요청의 경로 파라미터는 진단용 입력으로 남길 수 있다. 하지만 그 값을 그대로 처리 객체 필드에 넣으면 존재하지 않는 문서를 처리한 것처럼 보인다. 권한 검사 전에 거절된 요청도 같은 문제가 생긴다.
입력과 확인된 객체를 분리하면 같은 Trace ID 안에서도 해석이 명확해진다. 무엇을 요청했는지와 무엇을 실제로 처리했는지가 각각 드러난다. 사용자 입력을 사실로 승격하지 않는 경계다.
진단을 위해 요청을 더 읽을 때의 비용
본문을 수집하려고 handler보다 먼저 스트림을 끝까지 읽으면 업무 요청의 동작 자체를 바꿀 수 있다. 느린 클라이언트가 보내는 본문을 기다리느라 오류 응답이 늦어질 수도 있다.
이번 구현은 인증된 요청 중 크기가 알려진 작은 JSON에 한해 제한된 시간 동안 관측 가능한 본문을 보완해서 읽었다. 길이가 불명확한 전송, 초과하거나 미완성인 본문, Expect: 100-continue, multipart와 바이너리는 값 대신 수집 상태로 설명했다.
multipart 안에 JSON 이벤트가 들어 있는 경로는 handler가 엄격한 decode를 끝낸 뒤 논리 이벤트를 등록했다. 업무 값 검증에서 거절됐다면 decode된 필드를 볼 수 있지만 JSON 문법이나 타입부터 틀렸다면 부분적으로 채워진 객체를 정상 입력인 것처럼 기록하지 않는다.
진단 코드도 요청 처리의 일부다. 수집량과 대기 시간에 상한이 있어야 오류를 설명하는 기능 때문에 원래 오류 응답이 더 늦어지는 일을 피할 수 있다.
정제는 저장 전과 조회 후에 필요했다
관리자만 보는 화면이어도 인증정보를 저장할 이유가 생기지는 않는다. OWASP Logging Cheat Sheet도 접근 토큰과 비밀번호 같은 값을 로그에 직접 기록하지 않도록 안내한다.
이번 작업에서는 생산 단계에서 민감 값을 정제하고 저장된 값을 관리자 응답으로 바꾸는 경계에서도 다시 정제했다. 이전 버전이 저장한 데이터나 예상과 다른 저장 형식이 들어오는 경우까지 같은 화면이 처리하기 때문이다. 다운로드용 서명 URL과 파일 본문도 그대로 옮기지 않았다.
필드 이름만 보는 규칙으로는 부족한 경우가 있다. 설정 목록의 value가 공개 설정인지 비밀 설정인지는 같은 객체의 key와 schema가 결정한다. schema 조회에 실패했는데 공개 값으로 간주하면 단순한 문자열 마스킹으로는 막기 어렵다. 공개 여부를 확인하지 못한 값은 숨기는 쪽으로 처리했다.
상세 조회 자체가 다시 활동 기록을 만든다는 점도 고려했다. 조회 응답을 다음 진단 본문에 복사하면 민감한 상세가 계속 중첩될 수 있다. 따라서 상세 조회 경로는 조회 사실을 남기되 query와 본문 등 재귀 수집 대상은 정책상 생략했다.
무엇을 검증했는가
로컬 회귀 검사는 성공·실패 요청의 path와 반복 query, 읽을 수 있는 JSON뿐 아니라 본문 읽기가 지연되는 경우와 수집하지 않는 경우도 다뤘다. 관리자 응답과 화면에서는 수집 상태가 값 옆에 남고 민감 값은 노출되지 않는지 확인했다.
당시 서버의 저장소 검증 명령과 관리자 화면의 전체 검증 명령은 통과했다. 다만 이것은 로컬 코드와 테스트의 결과다. 배포된 producer에서 메시지 전달과 저장을 거쳐 화면까지 같은 정보가 도착하는지는 별도로 확인할 일이다. Trace ID가 있다고 다른 서비스까지 전파됐다고 단정할 수도 없다.
자기 서비스에 적용할 때는 같은 API에 두 요청을 보내보면 좋다. 하나는 작은 정상 JSON, 다른 하나는 인증 단계에서 거절되는 요청이다. 두 상세가 모두 빈 본문으로만 보인다면 아직 수집 상태를 구분하지 못한다. 정상 입력과 숨긴 값, 읽지 않은 값을 화면에서 서로 다르게 설명할 수 있어야 한다.