init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user