init: 클린 기반 auth 서버 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:30:18 +09:00
parent 471db0203d
commit 8a1ac1e769
3642 changed files with 275893 additions and 1 deletions
+400
View File
@@ -0,0 +1,400 @@
# Logging 아키텍처
> **정책 단일 출처**: 로깅과 관련된 정책 항목 (정책 6 / 9 / 11) 의 *현재 상태* 는
> [`docs/exception-handling-policy.md`](../../exception-handling-policy.md) 가 정식 정의입니다.
> 본 문서는 그 정책의 *이유와 흐름* 을 설명합니다.
> Sanitizer 회귀 게이트 (jqwik / 단위 테스트 / 96.3% 커버리지) 는
> [`docs/testing-coverage-policy.md`](../../testing-coverage-policy.md) 와 함께 봅니다.
## 1. Context & Scope
### 목적
이 문서는 인증 서버의 로깅 구조를 MDC 사용법, structured event field, 예외 처리 책임 분리, audit log 운영 계약 관점에서 설명합니다.
목표는 아래 질문에 답하는 것입니다.
-`traceId`를 로그 메시지에 직접 넣지 않고 Logback 패턴에서 소비해야 하는가?
-`eventType`, `emailMasked`, `provider`, `reason`, `status`, `durationMs`, `actorId` 같은 값은 message 문자열이 아니라 structured field여야 하는가?
- 왜 서비스 레이어의 실패 로그를 audit event 발행으로 바꿨는가?
- 왜 Kubernetes에서는 `audit.auth` 파일 출력을 기본값이 아니라 opt-in profile로 내려야 하는가?
-`TraceIdFilter`가 체인 최전방에 있어야 하고, 요청 시간은 `System.nanoTime()`으로 재야 하는가?
### Scope
- 포함:
- `TraceIdFilter`, `RequestAccessLogFilter`, `WebConfiguration`
- `ApplicationExceptionHandler`, `InfrastructureExceptionHandler`, `SecurityExceptionHandler`
- `AuthAuditEventPublisher`와 bootstrap audit listener
- `logback-spring.xml`의 console pattern, structured console, `audit.auth`, optional `AUDIT_FILE`
- `traceId`, `clientIp`, `userAgent` MDC 전파
- audit/access structured field 정책
- Tomcat trusted proxy 범위와 audit file opt-in 계약
- 제외:
- ELK, Loki, Datadog 같은 외부 수집기 설정
- OpenTelemetry, Micrometer Tracing 실제 도입
- 조직 차원의 장기 보존 정책
- W3C traceparent 전파와 span 생성
## 2. Why
- MDC는 "컨텍스트를 코드에서 분리"하려는 도구입니다. `MDC.get("traceId")`를 매번 로그 메시지에 다시 붙이면 MDC를 도입한 이유가 사라집니다.
- structured logging의 목적은 JSON 모양을 만드는 것이 아니라 검색과 집계 가능한 필드를 보장하는 것입니다. `LOGIN_FAILURE emailMasked=... reason=...` 같은 message 문자열은 로그 백엔드에서 `eventType`, `emailMasked`, `reason` 필드로 보장되지 않습니다.
- 서비스 레이어에서 실패를 직접 `audit.warn(...)`으로 남기고, 같은 예외를 글로벌 예외 핸들러가 다시 `warn`으로 남기면 운영 로그가 중복됩니다.
- 인증 서버의 audit log는 성공/실패 여부만으로 충분하지 않습니다. 사고 조사에는 요청자 IP, User-Agent, traceId가 같이 남아야 합니다.
- Kubernetes에서는 stdout/stderr를 cluster-level backend가 수집하는 구조가 기본 운영 모델입니다. 파일 appender를 항상 켜면 pod 로컬 디스크 유실, 중복 기록, shipper 계약 부재 문제가 생깁니다.
- 필터 최전방에서 MDC를 심지 않으면 Spring Security 안쪽에서 발생한 로그에 traceId가 빠질 수 있습니다.
- 요청 시간 측정은 시스템 시각 변경 영향을 받지 않아야 하므로 `System.currentTimeMillis()`보다 `System.nanoTime()`이 맞습니다.
결국 이번 변경의 핵심은 "로그를 더 많이 남기는 것"이 아니라, 같은 요청과 같은 보안 이벤트를 필드 기반으로 정확히 재구성할 수 있게 책임과 운영 계약을 다시 나누는 것입니다.
## 3. Goals & Non-Goals
### Goals
- traceId는 로그 메시지가 아니라 Logback 패턴에서 자동으로 붙입니다.
- audit/access의 검색 대상 값은 SLF4J key-value pair로 남겨 prod structured console에서 JSON top-level field가 되게 합니다.
- 요청 단위 메타데이터(`traceId`, `clientIp`, `userAgent`)를 정규화/축약한 뒤 MDC에 심어 access log, exception log, audit log가 같은 컨텍스트를 공유하게 합니다.
- 이메일은 `emailMasked`, 내부 사용자 식별자는 `userIdHash`, 인증 주체는 `actorId`로 축약해서 남깁니다.
- 서비스 레이어는 logger에 직접 의존하지 않고 audit event만 발행합니다.
- `audit.auth`는 기본적으로 콘솔에 기록하고, 파일 보존 계약이 있을 때만 `audit-file` profile로 롤링 파일을 추가합니다.
- `TraceIdFilter``RequestAccessLogFilter`의 순서를 명시적으로 보장합니다.
- proxy 뒤의 client IP는 `server.forward-headers-strategy=native``server.tomcat.remoteip.internal-proxies` 운영 설정을 통해 신뢰 범위를 제한합니다.
### Non-Goals
- 이번 변경에서 표준 분산 추적 라이브러리까지 바로 도입하는 것
- 모든 profile의 로그를 JSON 파일로 재설계하는 것
- audit event를 비동기 메시지 브로커로 내보내는 것
- 로그 수집 백엔드의 retention, index, dashboard 정책까지 결정하는 것
## 4. Architecture Overview
### 4.1 전체 구조
```mermaid
flowchart LR
Client["Client"] --> Trace["TraceIdFilter<br/>MDC(traceId, clientIp, userAgent)"]
Trace --> Access["RequestAccessLogFilter<br/>http.access"]
Access --> Security["Spring Security"]
Security --> Controller["Controllers / Presentation"]
Controller --> App["Application Services"]
App --> AuditPublisher["AuthAuditEventPublisher"]
AuditPublisher --> SpringEvents["ApplicationEventPublisher"]
SpringEvents --> AuditListener["AuthAuditEventListener"]
Controller -. business / infra exceptions .-> ExceptionHandlers["Global Exception Handlers"]
AuditListener -. audit.auth .-> AuditLogger["audit.auth logger"]
ExceptionHandlers -. root logger .-> RootLogger["root logger"]
Access -. http.access .-> AccessLogger["http.access logger"]
AuditLogger --> Console["CONSOLE appender<br/>source of truth"]
AuditLogger -. audit-file profile .-> AuditFile["AUDIT_FILE<br/>optional rolling file"]
RootLogger --> Console
AccessLogger --> Console
```
### 4.2 핵심 컴포넌트
- `TraceIdFilter`
- 32자리 lowercase hex traceId를 생성합니다.
- `traceId`, 축약된 `clientIp`, 정규화된 `userAgent`를 MDC에 넣습니다.
- `X-Trace-Id` 응답 헤더를 설정합니다.
- `RequestAccessLogFilter`
- 요청 종료 시 `eventType=HTTP_ACCESS`, `method`, `requestPath`, `status`, `durationMs`, `remoteIp`, `actorId`, `result``http.access` event field로 남깁니다.
- traceId는 메시지에 넣지 않고 패턴이 자동 출력합니다.
- `AuthAuditEventPublisher`
- 서비스 레이어가 성공/실패 audit event를 발행하는 포트입니다.
- bootstrap audit listener
- Spring event를 받아 `audit.auth`로 기록합니다.
- `eventType`과 event fields를 SLF4J key-value pair로 올립니다.
- 서비스는 logger를 몰라도 됩니다.
- `logback-spring.xml`
- 콘솔 패턴에 `%X{traceId}`를 넣습니다.
- non-prod plain console과 optional audit file에는 `%kvp`를 넣어 key-value field를 볼 수 있게 합니다.
- prod profile에서는 Spring Boot structured console appender를 사용합니다.
- `audit-file` profile에서만 `audit.auth``AUDIT_FILE`을 추가합니다.
## 5. How It Works
### 5.1 요청/데이터 흐름
```mermaid
sequenceDiagram
participant Client
participant Trace as TraceIdFilter
participant Access as RequestAccessLogFilter
participant App as Application Service
participant Events as Spring Events
participant Audit as audit.auth
participant Exception as Exception Handler
Client->>Trace: HTTP request
Trace->>Trace: normalize and put traceId/clientIp/userAgent into MDC
Trace->>Client: set X-Trace-Id header
Trace->>Access: continue
Access->>App: continue
App->>Events: publish AuthAuditEvent
Events->>Audit: write audit.auth
App-->>Exception: throw BusinessException (when needed)
Exception->>Audit: no direct audit write
Exception->>Client: ApiResult(traceId)
Access->>Audit: no-op
Access->>Client: response committed
Access->>Audit: no-op
Access->>Access: log ACCESS with event fields
```
### 5.2 상세 설계
#### MDC는 패턴에서 소비한다
`traceId`는 더 이상 `ApplicationExceptionHandler`, `RequestAccessLogFilter`, `SecurityExceptionHandler` 메시지 안에 직접 들어가지 않습니다.
- 일반 로그:
- `CONSOLE_LOG_PATTERN``%X{traceId:-}`를 출력
- audit 파일:
- `AUDIT_FILE_PATTERN``%X{traceId:-} %X{clientIp:-} %X{userAgent:-}``%kvp`를 출력
이 구조의 장점은 로그 메시지가 비즈니스 의미만 담고, 상관관계 컨텍스트는 로깅 인프라가 일관되게 관리한다는 점입니다.
##### MDC 누락 시 응답 traceId 는 sentinel `"-"`
`RequestBoundApiResultFactory``MDC.get("traceId")` 가 null/blank 이면 응답 `ApiResult.traceId``"-"` 를 넣습니다 (JSON `null` 로 가리지 않음).
이 sentinel 의 의도는 *TraceIdFilter 미설치/오설정* 같은 운영 결함을 응답에서 즉시 노출시키는 것입니다.
- JSON `null` 로 두면 클라이언트/CS팀이 traceId 가 없다는 사실을 인지하지 못한 채 운영 트리아지가 진행됩니다.
- `"-"` sentinel 은 대시보드/검색에서 즉시 눈에 띕니다 — "왜 traceId 가 `-` 로 나오지?" 가 정상적인 첫 질문이 됩니다.
테스트에서도 동일 정책을 단언합니다 (`RequestBoundApiResultFactoryPropertyTest` — jqwik 속성 6개).
#### 의미 필드는 SLF4J key-value pair로 올린다
prod profile의 `structured-console-appender.xml`은 Spring Boot structured logging encoder를 사용합니다.
이 encoder는 MDC와 SLF4J key-value pair를 JSON top-level member로 출력합니다.
따라서 access log는 message를 `ACCESS`로만 두고 아래 값을 event field로 남깁니다.
- `eventType=HTTP_ACCESS`
- `method`
- `requestPath`
- `status`
- `durationMs`
- `remoteIp`
- `actorId`
- `result`
audit log도 `LOGIN_FAILURE emailMasked=... reason=...`처럼 문자열을 합치지 않습니다.
`AuthAuditEventLogListener``eventType`과 event fields를 key-value pair로 추가하고, message는 event type만 둡니다.
민감 식별자는 아래 기준을 적용합니다.
- 이메일: 원문 대신 `emailMasked`로 기록합니다. 예: `te***@example.com`
- 내부 사용자 ID: 원문 대신 `userIdHash`로 기록합니다.
- HTTP/security principal: `actorId`로 기록하며 이메일이면 마스킹하고 그 외 값은 짧은 SHA-256 해시로 축약합니다.
- IP 주소: IPv4는 마지막 octet을 `0`으로 바꾸고, IPv6/기타 값은 짧은 SHA-256 해시로 축약합니다.
- User-Agent, path, reason: CR/LF, 공백, 제어문자, `=`, `|``_`로 치환하고 길이를 제한합니다.
이렇게 하면 운영 백엔드에서 아래 쿼리가 message parsing 없이 가능합니다.
```text
eventType = LOGIN_FAILURE AND reason = invalid_password
status >= 500 AND actorId = al***@example.com
provider = github AND eventType = OAUTH_LOGIN_SUCCESS
```
#### 서비스 레이어는 logger 대신 audit event를 발행한다
`LoginService`, `SignUpService`, `OAuthLoginService`는 더 이상 `slf4j-api`에 직접 의존하지 않습니다.
- 성공/실패 사실이 필요하면 `AuthAuditEventPublisher`로 semantic event를 발행합니다.
- 예외 자체는 그대로 던집니다.
- 글로벌 예외 핸들러는 root logger에서 한 번만 경고/오류를 남깁니다.
이렇게 나누면 같은 실패가 "서비스 logger + 예외 핸들러 logger"로 중복되지 않고, audit stream은 여전히 별도로 유지됩니다.
##### 보안 이벤트의 audit 채널은 *경로와 무관하게* 단일
인증/인가 거부는 두 경로로 도달할 수 있습니다.
1. 필터 단 — `SecurityExceptionHandler` (Spring Security `AuthenticationEntryPoint` / `AccessDeniedHandler`)
2. 컨트롤러 단 — `SecurityResponseExceptionHandler` (`@PreAuthorize` 등 메서드 보안에서 발생한 예외)
두 핸들러 모두 `SecurityAuditTrailWriter.record(...)` 한 곳을 통과해 `audit.auth` 로거에 적재합니다. 즉 같은 보안 이벤트가 어느 경로로 들어와도 audit 형식이 동일합니다 (`eventType`, `actorId`, `method`, `requestPath`).
audit 쓰기는 try/catch 로 격리됩니다 — audit 백엔드 장애가 응답 렌더링을 막지 않게 하기 위함입니다.
#### audit file은 opt-in 운영 계약으로 둔다
`audit.auth`의 기본 source of truth는 콘솔입니다.
prod profile에서는 콘솔이 structured JSON으로 나가고, Kubernetes에서는 이 stdout/stderr를 cluster-level logging backend가 수집하는 모델을 전제로 합니다.
파일 appender는 아래 조건이 있을 때만 `audit-file` profile로 추가합니다.
- persistent volume에 보존한다.
- 또는 file shipper가 `app.logging.audit.file` 경로를 수집한다.
- console + file 중복 기록 비용을 감수할 이유가 있다.
`audit-file` profile을 켜면 `AUDIT_FILE`은 아래 정책을 사용합니다.
- `app.logging.audit.file`
- `SizeAndTimeBasedRollingPolicy`
- 기본값 `./logs/audit/auth.log`
파일 패턴도 `%kvp`를 포함하므로 event fields는 눈으로 확인할 수 있습니다.
다만 파일은 JSON structured source가 아니라 운영 보조 채널입니다. Kubernetes에서 장기 보존과 검색의 기준은 structured console 수집 backend입니다.
#### 필터 순서와 시간 측정
`WebConfiguration`은 아래 순서를 사용합니다.
1. `TraceIdFilter`: `Ordered.HIGHEST_PRECEDENCE`
2. `RequestAccessLogFilter`: `Ordered.HIGHEST_PRECEDENCE + 1`
이 순서 덕분에 Spring Security, controller, exception handler, access log 모두 같은 MDC를 공유합니다.
요청 시간은 `System.nanoTime()`으로 측정합니다.
이 값은 시간 동기화나 시스템 시각 변경의 영향을 받지 않으므로 duration 계산에 적합합니다.
#### traceId 생성 방식
기존 16자리 hex custom 값 대신 128-bit trace id에 맞춘 32자리 lowercase hex 값을 생성합니다.
- `ThreadLocalRandom.current().nextLong()` 두 개를 사용합니다.
- `HexFormat.of().toHexDigits(...)`로 각 64-bit 값을 16자리 hex로 변환합니다.
- 두 값이 모두 0인 경우는 W3C trace id에서 허용되지 않으므로 다시 생성합니다.
현재 필터가 W3C `traceparent`를 전파하거나 span을 만들지는 않습니다.
다만 `traceId`라는 이름을 유지하는 이상, 나중에 Micrometer Tracing 또는 OpenTelemetry를 도입할 때 불필요한 포맷 이행을 줄이기 위해 32-hex 형식으로 맞춥니다.
#### remoteIp는 trusted proxy 설정에 의존한다
애플리케이션 코드는 `X-Forwarded-For`를 직접 읽지 않고 `request.getRemoteAddr()`만 사용합니다.
실제 client IP 정규화는 아래 설정에 맡깁니다.
- `server.forward-headers-strategy=native`
- `server.tomcat.remoteip.internal-proxies=${SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES:...}`
운영 환경은 ingress, service mesh, load balancer 대역을 `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES`로 명시해야 합니다.
이 값이 틀리면 access log의 `remoteIp`와 MDC의 `clientIp`는 ingress IP이거나 신뢰하면 안 되는 forwarded header 결과가 될 수 있습니다.
## 6. Alternatives Considered
### 대안 1
- 구조:
`MDC.get("traceId")`를 각 로그 메시지에 직접 삽입
- 장점:
눈에 바로 보여서 구현이 단순해 보입니다.
- 단점:
컨텍스트가 코드 전역에 침투하고, 메시지 형식이 제각각이 되며, MDC의 존재 이유가 사라집니다.
- 왜 선택하지 않았는가:
traceId는 패턴에서 일괄 처리하는 편이 더 일관되고 유지보수가 쉽습니다.
### 대안 2
- 구조:
audit/access 주요 값을 `message``key=value` 문자열로 합쳐 넣음
- 장점:
plain console에서 바로 보이고 구현량이 가장 적습니다.
- 단점:
로그 백엔드가 `eventType`, `reason`, `status`, `actorId`를 필드로 보장하지 못하고 regex parsing에 의존합니다.
- 왜 선택하지 않았는가:
인증 서버 운영에서 필요한 것은 문자열 검색보다 provider별 성공률, reason별 실패율, 5xx access log 같은 필드 기반 집계입니다.
### 대안 3
- 구조:
audit/access 주요 값을 모두 MDC에 넣고 로그 직후 제거
- 장점:
Spring Boot structured console이 MDC를 top-level field로 내보내므로 요구사항을 만족할 수 있습니다.
- 단점:
이벤트 한 건에만 속하는 값이 thread context에 섞입니다. 실수로 scope를 닫지 않으면 다음 로그로 새기 쉽습니다.
- 왜 선택하지 않았는가:
`traceId`, `clientIp`, `userAgent`처럼 요청 전체에 유효한 값은 MDC에 두고, `status`, `reason`, `provider`처럼 로그 이벤트에만 유효한 값은 SLF4J key-value pair로 두는 편이 책임이 분명합니다.
### 대안 4
- 구조:
서비스 레이어가 `audit.auth` logger를 직접 사용
- 장점:
구현량이 적습니다.
- 단점:
비즈니스 실패와 예외 처리 로그가 결합되고, 테스트도 logger 구현에 끌려갑니다.
- 왜 선택하지 않았는가:
audit event 발행으로 바꾸면 서비스는 의미만 표현하고, 실제 기록은 bootstrap이 담당할 수 있습니다.
### 대안 5
- 구조:
바로 `Micrometer Tracing`을 도입
- 장점:
표준 헤더 처리와 MDC 주입이 더 견고합니다.
- 단점:
이번 브랜치의 책임 범위를 크게 넓히고, 운영 도구 선택까지 함께 결정해야 합니다.
- 왜 선택하지 않았는가:
현재는 로그 책임 분리와 audit 영속성 정리가 먼저였습니다.
### 대안 6
- 구조:
`AUDIT_FILE`을 모든 profile에서 항상 켬
- 장점:
VM 또는 단일 서버 운영에서는 별도 파일을 곧바로 확인할 수 있습니다.
- 단점:
Kubernetes에서는 pod 재시작 시 파일 유실, console과 file 중복 기록, file shipper 부재 문제가 생깁니다.
- 왜 선택하지 않았는가:
현재 운영 기본값은 console 수집을 기준으로 두고, 파일 보존 계약이 있는 환경만 `audit-file` profile로 명시하는 편이 안전합니다.
## 7. Cross-cutting Concerns
- 성능:
- traceId 생성은 `UUID` 문자열 가공 대신 `ThreadLocalRandom + HexFormat`을 사용합니다.
- duration은 `System.nanoTime()`으로 계산합니다.
- 보안:
- audit/access structured field에 축약된 `clientIp`, 정규화된 `userAgent`, 축약된 `remoteIp`, 축약된 `actorId`를 기록합니다.
- 이메일은 `emailMasked`, 사용자 ID는 `userIdHash`로만 기록합니다.
- `request.getRemoteAddr()`는 컨테이너가 정규화한 값을 사용합니다.
- trusted proxy 범위는 `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES`로 운영 환경에서 명시합니다.
- 테스트:
- `TraceIdFilterTest`는 32-hex traceId, MDC 전파, User-Agent 정규화를 검증합니다.
- `RequestAccessLogFilterTest`는 access 값이 message가 아니라 key-value pair로 올라가고 principal이 `actorId`로 축약되는지 확인합니다.
- `LoggingConfigurationSmokeTest`는 prod structured encoder가 MDC와 key-value pair를 JSON top-level field로 출력하는지 확인합니다.
- 서비스 단위 테스트는 audit event가 `emailMasked`, `userIdHash`를 발행하는지 검증합니다.
- `LogSanitizerPropertyTest` (jqwik 속성 11개) + `LogSanitizerEdgeCaseTest` (단위) 가 마스킹 정책 (이메일/IP/path) 회귀를 자동 차단합니다 — `LogSanitizer` 라인 커버리지 96.3%, 잔여 2줄은 SHA-256 환경 의존이라 자연 미커버.
- `RequestBoundApiResultFactoryPropertyTest` (jqwik 속성 6개) 가 traceId sentinel `"-"` 폴백 정책을 자동 단언.
- 운영:
- Kubernetes에서는 structured console 수집 backend를 기준으로 확인합니다.
- audit file은 persistent volume 또는 file shipper 계약이 있을 때만 `audit-file` profile로 켭니다.
- dev actuator는 별도 runbook으로 검증합니다.
## 8. Result / Trade-offs
- 얻은 이점:
- traceId가 로그 메시지 포맷에 침투하지 않습니다.
- audit/access 핵심 값이 prod JSON 로그의 top-level structured field가 됩니다.
- email, principal, User-Agent 같은 식별자와 외부 입력이 원문으로 로그 포맷에 삽입되지 않습니다.
- 서비스 레이어의 실패 로깅과 글로벌 예외 로깅 책임이 분리됩니다.
- traceId가 32-hex 형식이 되어 추후 tracing 도입 비용이 줄어듭니다.
- audit file이 명시적 운영 계약이 있는 환경에서만 켜집니다.
- 요청자 IP, User-Agent가 audit/access 컨텍스트에 자동 포함됩니다.
- 감수한 비용:
- audit event 포트와 listener라는 중간 계층이 하나 추가되었습니다.
- `logback-spring.xml` 설정이 이전보다 복잡해졌습니다.
- plain console에서는 key-value pair가 `%kvp` 형식으로 보이고, 운영 집계 기준은 prod structured console입니다.
- 남은 리스크:
- `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES`가 실제 ingress/proxy 대역과 다르면 client IP 신뢰도가 떨어집니다.
- optional 로컬 파일은 외부 보안 로그 저장소를 완전히 대체하지 못합니다.
- 서비스 간 분산 추적이 필요해지면 표준 tracing 도입이 필요합니다.
## 9. References
- 관련 코드:
- `bootstrap/src/main/java/com/project/auth/config/web/TraceIdFilter.java`
- `bootstrap/src/main/java/com/project/auth/config/web/RequestAccessLogFilter.java`
- `bootstrap/src/main/java/com/project/auth/config/logging/AuthAuditLoggingConfiguration.java`
- `bootstrap/src/main/resources/logback-spring.xml`
- `application/src/main/java/com/project/auth/application/support/audit/AuthAuditEvent.java`
- `presentation/src/main/java/com/project/auth/presentation/support/exception/ApplicationExceptionHandler.java`
- 관련 문서:
- [02-runbook-log-correlation-and-dev-actuator.md](./02-runbook-log-correlation-and-dev-actuator.md)
- [../02-clean-architecture/02-error-handling.md](../02-clean-architecture/02-error-handling.md)
@@ -0,0 +1,187 @@
# Runbook: 로그 상관관계, structured field, dev actuator 검증
## 1. 목적
이 절차는 logger 리뷰 반영 후 아래 항목이 실제로 동작하는지 확인하기 위한 운영 점검 절차입니다.
- 응답 헤더, 응답 body, 일반 로그, access log, audit log가 같은 `traceId`로 연결되는가?
- `traceId`가 32자리 lowercase hex 형식인가?
- prod structured console에서 `eventType`, `reason`, `status`, `durationMs`, `actorId` 같은 값이 message가 아니라 JSON field로 보이는가?
- audit/access log에 축약된 `clientIp`, 정규화된 `userAgent`, 축약된 `remoteIp`, 축약된 `actorId`가 운영 가정에 맞게 남는가?
- access log가 noisy 경로를 제외하는가?
- audit file을 켜는 환경에는 persistent volume 또는 file shipper 계약이 있는가?
- dev actuator가 `127.0.0.1:9090`에서만 열리고, 허용한 endpoint만 노출되는가?
## 2. 사용 시점
- `TraceIdFilter`, `RequestAccessLogFilter`, `AuthAuditLoggingConfiguration`, `logback-spring.xml`을 수정한 직후
- `logging.structured.*`, `server.forward-headers-strategy`, `server.tomcat.remoteip.internal-proxies`를 바꾼 직후
- audit file 경로, 롤링 정책, `audit-file` profile 사용 여부를 바꾼 직후
- "traceId는 있는데 요청 재구성이 끊긴다" 같은 피드백이 나왔을 때
## 3. Preconditions
- 필요한 권한:
- 로컬에서 애플리케이션을 실행하고 콘솔 로그와 audit 파일을 볼 수 있어야 합니다.
- 필요한 환경 변수/도구:
- `curl`
- `rg`
- 선택 사항: `jq`, `ss`
- 사전 확인 사항:
- prod structured logging 점검은 `prod` profile 또는 운영 로그 backend에서 확인합니다.
- local/dev plain console은 `%kvp`로 key-value field를 보여주지만, JSON field 검증의 기준은 prod structured console입니다.
- Kubernetes에서는 console 수집 backend가 audit/access 로그의 source of truth입니다.
- audit file은 기본으로 켜지지 않습니다. 파일 검증을 하려면 `audit-file` profile과 `APP_LOGGING_AUDIT_FILE` 값을 먼저 확인합니다.
- proxy 뒤 운영 환경은 `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES`에 ingress/load balancer/service mesh 대역을 설정해야 합니다.
- `dev` profile 점검 시 management 포트 `9090`이 사용 가능해야 합니다.
## 4. Procedure
### Step 1
인증이 필요한 endpoint 를 Bearer token 없이 호출해 traceId 를 확보합니다.
```bash
curl -i http://localhost:8080/api/v1/auth/me
```
예상 결과:
- 응답은 `401 Unauthorized` (Resource Server 가 token 부재로 거절)
- `X-Trace-Id` 헤더가 존재
- 응답 body의 `traceId`가 헤더와 동일
- `traceId` 값은 32자리 lowercase hex
- 콘솔 로그에는 메시지 본문이 아니라 패턴 prefix에서 `traceId=...`가 보여야 함
### Step 2
prod structured console 또는 로그 backend에서 방금 받은 traceId를 조회합니다.
```text
traceId = <STEP1_TRACE_ID>
```
예상 결과:
- `AUTHENTICATION_REQUIRED` 또는 관련 audit event가 검색됨
- `traceId`, `clientIp`, `userAgent`, `eventType`, `actorId`, `method`, `requestPath`가 JSON field로 보임
- message는 `AUTHENTICATION_REQUIRED`처럼 event 이름만 담고, `actorId=... method=...` 문자열을 합친 형태가 아님
### Step 3
access log의 structured field를 확인합니다.
```bash
curl -i http://localhost:8080/api/v1/auth/me \
-H 'User-Agent: runbook-access-check'
```
예상 결과:
- `http.access` 로그의 message는 `ACCESS`
- `eventType=HTTP_ACCESS`, `status`, `durationMs`, `remoteIp`, `actorId`, `result`가 event field로 존재
- `status``durationMs`는 문자열 parsing 대상이 아니라 숫자 field로 집계 가능
### Step 4
가짜 `X-Forwarded-For` 를 넣어도 앱 코드가 raw header 를 직접 읽지 않는지 확인합니다. health endpoint 처럼 항상 응답하는 경로를 사용합니다.
```bash
curl -i http://localhost:8080/actuator/health \
-H 'X-Forwarded-For: 203.0.113.10' \
-H 'User-Agent: runbook-forwarded-check'
```
예상 결과:
- 응답 자체는 정상
- 로컬 직접 호출처럼 요청이 trusted proxy를 거치지 않으면 `remoteIp``clientIp`는 raw `X-Forwarded-For` 값이 아니라 실제 접속 IP를 축약한 값
- 운영 proxy 뒤에서는 `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES`가 실제 proxy 대역과 일치할 때만 forwarded client IP가 반영됨
- `userAgent`는 MDC field로 기록되며 CR/LF, 공백, 제어문자, `=`, `|``_`로 치환됨
### Step 5
noisy 경로가 access log 제외 대상인지 확인합니다.
```bash
curl -i http://localhost:8080/livez
curl -i http://localhost:8080/swagger-ui.html
```
예상 결과:
- 응답 자체는 정상
- 콘솔에는 위 요청에 대한 `ACCESS ...` 로그가 기본적으로 남지 않음
### Step 6
audit file은 명시적으로 opt-in 했을 때만 확인합니다.
```bash
SPRING_PROFILES_ACTIVE=local,audit-file ./gradlew :bootstrap:bootRun
rg 'eventType=' logs/audit/auth.log
```
예상 결과:
- `audit-file` profile을 켠 경우에만 `logs/audit/auth.log`가 생성됨
- 파일 로그에는 `traceId`, `clientIp`, `userAgent``%kvp` 기반 event fields가 함께 보임
- Kubernetes에서 이 profile을 켠다면 persistent volume 또는 file shipper가 있어야 함
### Step 7
dev actuator가 loopback 전용으로 열리고, 허용 대상 endpoint만 응답하는지 확인합니다.
```bash
ss -ltn | rg ':9090'
curl -s http://127.0.0.1:9090/actuator
curl -s http://127.0.0.1:9090/actuator/loggers
curl -i http://127.0.0.1:9090/actuator/env
```
예상 결과:
- `9090``127.0.0.1`에만 바인딩
- `/actuator`, `/actuator/loggers`는 응답
- `/actuator/env``403` 또는 `404`
## 5. Verification
- 같은 요청 하나를 기준으로 `X-Trace-Id`, body `traceId`, structured console 또는 로그 backend를 연결할 수 있어야 합니다.
- prod structured console에는 `traceId`, `clientIp`, `userAgent`, audit/access event fields가 JSON field로 보여야 합니다.
- audit file은 `audit-file` profile을 켠 환경에서만 생성되어야 합니다.
- access log 메시지 본문에는 `traceId=`가 직접 들어가지 않아야 합니다.
- audit/access 메시지 본문에는 `reason=`, `status=`, `actorId=` 같은 검색 필드가 직접 합쳐져 있지 않아야 합니다.
- 인증 audit event에는 이메일 원문이 아니라 `emailMasked`, 사용자 ID 원문이 아니라 `userIdHash`가 남아야 합니다.
- `/livez`, `/readyz`, `/swagger-ui`, `/actuator/**`는 기본 access log에서 빠져야 합니다.
- dev actuator는 로컬 loopback에서만 열려야 합니다.
## 6. Rollback / Recovery
- prod structured field가 사라지면 `logging.structured.format.console=logstash`, `structured-console-appender.xml`, SLF4J key-value logging 호출부를 함께 확인합니다.
- audit 파일 경로가 잘못되면 `APP_LOGGING_AUDIT_FILE` 또는 `app.logging.audit.file`을 되돌립니다.
- Kubernetes에서 file logging이 불필요하면 `audit-file` profile을 제거합니다.
- access log 제외가 과하면 `app.logging.access.excluded-path-prefixes`에서 경로를 제거합니다.
- 프록시 환경에서 remote IP가 비정상적이면 `server.forward-headers-strategy`, `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES`, ingress forwarded header 설정을 함께 검토합니다.
- dev actuator 접근이 필요 이상으로 막히면 `DevActuatorConfiguration` 허용 경로와 `management.server.address`를 함께 확인합니다.
## 7. Failure Modes
- 자주 발생하는 실수:
- audit/access 필드를 message 문자열에 `key=value`로 다시 합치는 방식으로 회귀함
- prod structured console이 아니라 local plain console만 보고 JSON field 검증을 끝냄
- `audit-file` profile만 켜고 persistent volume 또는 file shipper 계약을 빼먹음
- `X-Forwarded-For` 헤더가 곧바로 `remoteIp`가 되는 것을 정상이라고 오해함
- User-Agent, exception reason, path 같은 외부 입력을 정규화 없이 audit/access field로 넣음
- 위험한 포인트:
- audit 파일이 컨테이너 내부 로컬 디스크에만 있으면 장기 보존은 여전히 약합니다.
- trusted proxy 범위가 너무 넓으면 위조된 forwarded header를 믿을 수 있습니다.
- excluded 경로를 너무 넓게 잡으면 실제 필요한 인증 흐름도 access log에서 사라질 수 있습니다.
## 8. References
- 관련 대시보드:
- 현재 없음. 콘솔 로그와 audit file, 플랫폼 수집 로그를 기준으로 확인합니다.
- 관련 문서:
- [01-architecture.md](./01-architecture.md)
- [../02-clean-architecture/02-error-handling.md](../02-clean-architecture/02-error-handling.md)
+34
View File
@@ -0,0 +1,34 @@
# Logging
요청 상관관계 / 예외 처리 / 감사 로그 / 운영 수집 계약 4 축의 설계 결정과 운영 런북.
## 핵심 질문
- 왜 MDC 를 로그 메시지 안에서 직접 꺼내 쓰면 안 되는가?
- 왜 audit / access 의 의미 필드는 message 문자열이 아니라 structured field 여야 하는가?
- 왜 Kubernetes 운영에서는 audit file 을 기본값이 아니라 opt-in 계약으로 둬야 하는가?
## 현재 프로젝트 맥락
현재 로깅은 다섯 축으로 정리된다.
1. `TraceIdFilter` 가 32 자리 hex `traceId`, 축약된 `clientIp`, 정규화된 `userAgent` 를 MDC 에 넣고 `X-Trace-Id` 를 응답 헤더에 기록.
2. prod profile 은 Spring Boot structured console appender 를 사용하고, MDC 와 SLF4J key-value pair 를 JSON top-level field 로 출력.
3. `RequestAccessLogFilter` 는 noisy 경로를 제외한 요청을 `http.access` 로 요약하고, `status`, `durationMs`, `actorId`, `remoteIp` 등을 message 가 아니라 event field 로 기록.
4. 서비스 레이어는 logger 대신 `AuthAuditEventPublisher` 로 audit event 를 발행하고, bootstrap listener 가 `eventType`, `emailMasked`, `userIdHash`, `provider`, `reason` 등을 structured field 로 기록.
5. `audit.auth` 는 기본적으로 콘솔을 source of truth 로 사용. 롤링 파일 appender 는 `audit-file` profile 을 켰을 때만 추가.
## 문서
| 문서 | 내용 |
|------|------|
| [01-architecture.md](./01-architecture.md) | MDC, structured event fields, access log, exception log, optional audit file 의 역할 분리와 설계 이유 |
| [02-runbook-log-correlation-and-dev-actuator.md](./02-runbook-log-correlation-and-dev-actuator.md) | traceId 상관관계, structured JSON 확인, trusted proxy, audit file opt-in, dev actuator 검증 절차 |
## 핵심 키워드
`MDC` · `traceId` · `structured logging` · `SLF4J key-value pairs` · `http.access` · `audit.auth` · `audit-file profile` · `trusted proxy` · `clientIp` · `userAgent` · `actorId` · `emailMasked`
## 관련 문서
- 에러 처리와 traceId 배경: [../02-clean-architecture/02-error-handling.md](../02-clean-architecture/02-error-handling.md)