22 KiB
Logging 아키텍처
정책 단일 출처: 로깅과 관련된 정책 항목 (정책 6 / 9 / 11) 의 현재 상태 는
docs/exception-handling-policy.md가 정식 정의입니다. 본 문서는 그 정책의 이유와 흐름 을 설명합니다. Sanitizer 회귀 게이트 (jqwik / 단위 테스트 / 96.3% 커버리지) 는docs/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,WebConfigurationApplicationExceptionHandler,InfrastructureExceptionHandler,SecurityExceptionHandlerAuthAuditEventPublisher와 bootstrap audit listenerlogback-spring.xml의 console pattern, structured console,audit.auth, optionalAUDIT_FILEtraceId,clientIp,userAgentMDC 전파- 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-fileprofile로 롤링 파일을 추가합니다.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 전체 구조
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.accessevent field로 남깁니다. - traceId는 메시지에 넣지 않고 패턴이 자동 출력합니다.
- 요청 종료 시
AuthAuditEventPublisher- 서비스 레이어가 성공/실패 audit event를 발행하는 포트입니다.
- bootstrap audit listener
- Spring event를 받아
audit.auth로 기록합니다. eventType과 event fields를 SLF4J key-value pair로 올립니다.- 서비스는 logger를 몰라도 됩니다.
- Spring event를 받아
logback-spring.xml- 콘솔 패턴에
%X{traceId}를 넣습니다. - non-prod plain console과 optional audit file에는
%kvp를 넣어 key-value field를 볼 수 있게 합니다. - prod profile에서는 Spring Boot structured console appender를 사용합니다.
audit-fileprofile에서만audit.auth에AUDIT_FILE을 추가합니다.
- 콘솔 패턴에
5. How It Works
5.1 요청/데이터 흐름
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_ACCESSmethodrequestPathstatusdurationMsremoteIpactorIdresult
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 없이 가능합니다.
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 채널은 경로와 무관하게 단일
인증/인가 거부는 두 경로로 도달할 수 있습니다.
- 필터 단 —
SecurityExceptionHandler(Spring SecurityAuthenticationEntryPoint/AccessDeniedHandler) - 컨트롤러 단 —
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.fileSizeAndTimeBasedRollingPolicy- 기본값
./logs/audit/auth.log
파일 패턴도 %kvp를 포함하므로 event fields는 눈으로 확인할 수 있습니다.
다만 파일은 JSON structured source가 아니라 운영 보조 채널입니다. Kubernetes에서 장기 보존과 검색의 기준은 structured console 수집 backend입니다.
필터 순서와 시간 측정
WebConfiguration은 아래 순서를 사용합니다.
TraceIdFilter:Ordered.HIGHEST_PRECEDENCERequestAccessLogFilter: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=nativeserver.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.authlogger를 직접 사용 - 장점: 구현량이 적습니다.
- 단점: 비즈니스 실패와 예외 처리 로그가 결합되고, 테스트도 logger 구현에 끌려갑니다.
- 왜 선택하지 않았는가: audit event 발행으로 바꾸면 서비스는 의미만 표현하고, 실제 기록은 bootstrap이 담당할 수 있습니다.
대안 5
- 구조:
바로
Micrometer Tracing을 도입 - 장점: 표준 헤더 처리와 MDC 주입이 더 견고합니다.
- 단점: 이번 브랜치의 책임 범위를 크게 넓히고, 운영 도구 선택까지 함께 결정해야 합니다.
- 왜 선택하지 않았는가: 현재는 로그 책임 분리와 audit 영속성 정리가 먼저였습니다.
대안 6
- 구조:
AUDIT_FILE을 모든 profile에서 항상 켬 - 장점: VM 또는 단일 서버 운영에서는 별도 파일을 곧바로 확인할 수 있습니다.
- 단점: Kubernetes에서는 pod 재시작 시 파일 유실, console과 file 중복 기록, file shipper 부재 문제가 생깁니다.
- 왜 선택하지 않았는가:
현재 운영 기본값은 console 수집을 기준으로 두고, 파일 보존 계약이 있는 환경만
audit-fileprofile로 명시하는 편이 안전합니다.
7. Cross-cutting Concerns
- 성능:
- traceId 생성은
UUID문자열 가공 대신ThreadLocalRandom + HexFormat을 사용합니다. - duration은
System.nanoTime()으로 계산합니다.
- traceId 생성은
- 보안:
- audit/access structured field에 축약된
clientIp, 정규화된userAgent, 축약된remoteIp, 축약된actorId를 기록합니다. - 이메일은
emailMasked, 사용자 ID는userIdHash로만 기록합니다. request.getRemoteAddr()는 컨테이너가 정규화한 값을 사용합니다.- trusted proxy 범위는
SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES로 운영 환경에서 명시합니다.
- audit/access structured field에 축약된
- 테스트:
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-fileprofile로 켭니다. - 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.javabootstrap/src/main/java/com/project/auth/config/web/RequestAccessLogFilter.javabootstrap/src/main/java/com/project/auth/config/logging/AuthAuditLoggingConfiguration.javabootstrap/src/main/resources/logback-spring.xmlapplication/src/main/java/com/project/auth/application/support/audit/AuthAuditEvent.javapresentation/src/main/java/com/project/auth/presentation/support/exception/ApplicationExceptionHandler.java
- 관련 문서: