init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
# 에러 핸들링 아키텍처
|
||||
|
||||
> **정책 단일 출처**: 코드/테스트/게이트로 강제되는 13개 정책의 *현재 상태*는
|
||||
> [`docs/exception-handling-policy.md`](../../exception-handling-policy.md) 가 정식 정의입니다.
|
||||
> 본 문서는 그 정책이 어떤 *문제 의식과 흐름*에서 왔는지를 설명하는 아키텍처 문서입니다.
|
||||
> 두 문서가 어긋나면 정책 문서가 우선합니다.
|
||||
|
||||
## 1. Context & Scope
|
||||
|
||||
### 목적
|
||||
|
||||
이 문서는 Project-Auth-Server 의 예외 처리 구조를 설명합니다. 핵심 질문은 *"예외가 어디서 발생하든, 사용자에게 보이는 응답 계약과 내부 책임 경계가 왜 흔들리면 안 되고, 어떻게 흔들리지 않는 상태를 유지하는가"* 입니다.
|
||||
|
||||
### Scope
|
||||
|
||||
- 포함: 예외 계층 구조, 계층별 예외 처리 책임, Security 필터 예외 처리, 로깅 전략
|
||||
- 제외:
|
||||
- 인증 / 인가 비즈니스 플로우 → [03-keycloak/](../03-keycloak/)
|
||||
- Validation 처리 / `ConstraintViolation` / `@ConfigurationProperties` 검증 → [02a-validation-deep-dive.md](./02a-validation-deep-dive.md)
|
||||
- traceId / MDC / structured logging → [04-logging/01-architecture.md](../04-logging/01-architecture.md)
|
||||
|
||||
## 2. Why
|
||||
|
||||
### 해결하려는 문제
|
||||
|
||||
리뷰 시점에서 에러 핸들링에 다음 6 가지 구조적 결함이 있었습니다.
|
||||
|
||||
| # | 문제 | 심각도 | 영향 |
|
||||
|---|------|--------|------|
|
||||
| 1 | 프로젝트 전체에 Logger 가 0 건 | HIGH | 500 에러 발생 시 원인 추적 불가. 프로덕션 장애 시 블라인드 |
|
||||
| 2 | Security 필터 예외가 JSON 이 아닌 Spring 기본 페이지로 응답됨 | HIGH | 인증 / 인가 실패 시 클라이언트가 파싱할 수 없는 응답을 받음 |
|
||||
| 3 | Spring 기본 예외 (malformed JSON, 잘못된 HTTP 메서드 등) 미처리 | MEDIUM | 클라이언트 잘못인데 500 (서버 오류) 으로 응답됨 |
|
||||
| 4 | 인프라에서 `IllegalStateException` 직접 throw | MEDIUM | 예외 계층 원칙 위반. 기술 예외가 비즈니스 예외로 번역되지 않음 |
|
||||
| 5 | `DomainException` 누수 방지 규칙 없음 | MEDIUM | presentation 이 도메인 메시지를 직접 외부 응답으로 노출할 수 있음 |
|
||||
| 6 | 응답에 `timestamp` 가 없고 요청 추적 수단이 없음 | LOW | 운영 디버깅 시 시점 특정과 로그 검색 어려움 |
|
||||
|
||||
### 왜 이 구조를 선택했는가
|
||||
|
||||
핵심 설계 판단은 *"모든 예외의 최종 출구를 하나의 JSON 계약으로 통일하되, 각 계층의 책임은 분리한다"* 입니다.
|
||||
|
||||
선택 근거:
|
||||
1. REST API 서버에서 클라이언트는 모든 응답을 동일한 파서로 처리해야 합니다. Security 필터 예외만 HTML 로 내려가면 클라이언트에 분기 로직이 필요해집니다.
|
||||
2. 예외의 발생 위치 (도메인, 인프라, Security 필터) 는 다르지만, 응답 형태는 같아야 합니다. 이를 위해 `ApiResult` 를 모든 출구에서 공유합니다.
|
||||
3. 예상 가능한 예외 (비즈니스 로직) 와 예상하지 못한 예외 (인프라 장애) 는 로그 레벨을 분리해야 운영 시 알림 설정이 가능합니다.
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
- 예외 발생 위치와 무관하게 `ApiResult` JSON 응답 계약을 보장한다 — `data` 와 `errors` 를 분리하여 OpenAPI 가 `data` 를 oneOf 로 모델링할 필요 없게 한다
|
||||
- 예상 예외 (4xx) 와 비예상 예외 (5xx) 의 로깅 정책을 분리한다
|
||||
- 요청별 `traceId` 로 로그와 응답을 연결할 수 있게 한다 — MDC 누락 시 sentinel `"-"` 노출
|
||||
- 내부 분류 코드 (`InfrastructureErrorCode`) 가 클라이언트 응답 경로에 *컴파일 단계에서* 닿지 못하게 한다
|
||||
- 새 client-facing ErrorCode 추가 시 매퍼/테이블 누락을 *classpath 스캔 가드 테스트* 로 즉시 검출한다
|
||||
- 익명 사용자의 `AccessDenied` 는 401, 인증된 사용자만 403 (SDK 토큰 재발급 흐름 보존)
|
||||
- advice 우회 경로(`sendError`, 이중 폴트, 컨테이너 라우팅 실패) 는 `/error` 가 안전망으로 받되 *원래 status 를 보존* 한다 — 404 가 500 으로 둔갑하지 않게
|
||||
- 정책 회귀를 JaCoCo Tier 1 95%+ / PIT mutation 게이트 / jqwik 속성 테스트로 자동 차단한다
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- RFC 7807 (Problem Details) 표준 도입 — 현재 `ApiResult` 계약이 충분히 일관적이므로 이관 비용 대비 이점이 낮음
|
||||
- 분산 추적 (Distributed Tracing) — 현재 단일 서비스이므로 MDC 기반 UUID 로 충분
|
||||
- i18n MessageSource 도입 — `ClientFacingErrorCode.message()` 는 한국어 하드코딩이며, 별도 사건으로 미룸
|
||||
|
||||
## 4. Architecture Overview
|
||||
|
||||
### 4.1 전체 구조
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "예외 발생 출처"
|
||||
DOM["Domain Layer<br/>(DomainException)"]
|
||||
APP["Application Layer<br/>(BusinessException)"]
|
||||
INFRA["Infrastructure Layer<br/>(InfrastructureException)"]
|
||||
MVC["Spring MVC<br/>(HttpMessageNotReadable 등)"]
|
||||
SEC["Security Filter Chain<br/>(AuthenticationException,<br/>AccessDeniedException)"]
|
||||
end
|
||||
|
||||
subgraph "예외 처리 출구"
|
||||
GEH["Presentation Exception Handlers<br/>(@RestControllerAdvice)"]
|
||||
SEH["SecurityExceptionHandler<br/>(EntryPoint + AccessDenied)"]
|
||||
IEH["InfrastructureExceptionHandler<br/>(bootstrap advice)"]
|
||||
end
|
||||
|
||||
subgraph "횡단 관심사"
|
||||
TF["TraceIdFilter<br/>(MDC traceId)"]
|
||||
end
|
||||
|
||||
DOM -->|"Application 경계에서<br/>BusinessException 으로 번역"| APP
|
||||
DOM -->|"Infrastructure 복원 경계에서<br/>InfrastructureException 으로 번역"| INFRA
|
||||
DOM -.->|"번역 누락 시<br/>generic 500"| GEH
|
||||
APP --> GEH
|
||||
INFRA --> IEH
|
||||
MVC --> GEH
|
||||
SEC --> SEH
|
||||
GEH -->|"ApiResult JSON"| CLIENT["Client"]
|
||||
SEH -->|"ApiResult JSON"| CLIENT
|
||||
IEH -->|"ApiResult JSON"| CLIENT
|
||||
TF -.->|"MDC + X-Trace-Id"| GEH
|
||||
TF -.->|"MDC + X-Trace-Id"| SEH
|
||||
TF -.->|"MDC + X-Trace-Id"| IEH
|
||||
```
|
||||
|
||||
### 4.2 핵심 컴포넌트
|
||||
|
||||
#### 예외 계층 구조
|
||||
|
||||
```
|
||||
RuntimeException
|
||||
├── DomainException (domain 모듈)
|
||||
│ ├── InvalidUserEmailException
|
||||
│ └── InvalidUserNameException
|
||||
├── BusinessException (application 모듈, ErrorCode 보유)
|
||||
│ ├── KeycloakUserNotFoundException (AUTH-004)
|
||||
│ ├── InvalidKeycloakClaimsException (AUTH-003)
|
||||
│ └── ...
|
||||
└── InfrastructureException (infrastructure 모듈, InfrastructureErrorCode 보유)
|
||||
├── PERSISTED_DATA_INVALID (INFRA-003) — 저장 데이터가 도메인 규칙에 맞지 않음
|
||||
└── EXTERNAL_SERVICE_ERROR (INFRA-999) — 외부 시스템 연동 오류
|
||||
```
|
||||
|
||||
- **DomainException**: 도메인 불변식 위반. ErrorCode 를 가지지 않음. 순수 도메인 규칙 표현용. `cause` 체이닝을 지원하여 원본 예외 스택 트레이스를 보존할 수 있음. 현재 정책은 이 타입이 presentation 까지 도달하지 않도록 application / infrastructure 경계에서 번역하는 것. 누수되면 generic 500 으로 처리하며, 도메인 메시지를 외부 응답에 직접 노출하지 않는다.
|
||||
- **BusinessException**: 비즈니스 유스케이스 예외. `ErrorCode` (code + message) 를 반드시 보유. HTTP status 매핑의 기준점. `cause` 체이닝을 지원하여 도메인 예외 변환 시 원본 스택 트레이스를 보존할 수 있음.
|
||||
- **InfrastructureException**: 기술 구현체의 장애. `InfrastructureErrorCode` (INFRA- 접두사) 를 보유하여 인프라 장애 유형을 구분. bootstrap 의 `InfrastructureExceptionHandler` 에서 전용 처리되며, 상세 메시지와 인프라 코드는 로그에만 기록하고 사용자에게는 `COMMON-999` 만 응답.
|
||||
|
||||
#### 예외-응답 매핑 흐름 (sealed 분리)
|
||||
|
||||
```
|
||||
ErrorCode (sealed)
|
||||
permits ClientFacingErrorCode, ExternalErrorCode
|
||||
|
||||
ClientFacingErrorCode (non-sealed marker) ExternalErrorCode (non-sealed marker)
|
||||
├── CommonErrorCode → mapCommon() ├── InfrastructureErrorCode (내부 분류 전용)
|
||||
├── AuthErrorCode → mapAuth() └── (다른 인프라 모듈도 여기에 추가)
|
||||
└── PresentationErrorCode → mapPresentation()
|
||||
```
|
||||
|
||||
##### 매퍼 시그니처 좁히기 — 정책을 *컴파일 단계에서* 강제
|
||||
|
||||
```java
|
||||
public static HttpStatus map(ClientFacingErrorCode errorCode) { ... }
|
||||
```
|
||||
|
||||
매퍼는 `ErrorCode` 가 아니라 `ClientFacingErrorCode` 만 받습니다. 그래서:
|
||||
|
||||
- `InfrastructureErrorCode` 를 매퍼에 인자로 넣는 모든 코드는 **`javac` 단계에서 컴파일 실패** 합니다.
|
||||
- `BusinessException.errorCode` 필드 타입도 `ClientFacingErrorCode` 로 좁혀, `BusinessException` 을 잘못된 코드로 *생성하는 것 자체* 가 불가능합니다.
|
||||
|
||||
이 정책의 의도는 두 가지를 코드 한 줄로 동시에 표현하는 것입니다.
|
||||
|
||||
- 내부 분류 코드 (`INFRA-003`, `INFRA-999` 등) 는 로그·모니터링·알람 라우팅용으로 *유지* 한다.
|
||||
- 동시에 클라이언트 응답 경로에는 *닿을 수 없다* — 우회로가 없다.
|
||||
|
||||
##### `ClientFacingErrorCode` 가 *non-sealed* 인 이유
|
||||
|
||||
다른 모듈에서 클라이언트 노출 코드를 추가할 수 있어야 하므로 `ClientFacingErrorCode` 자체는 `non-sealed` 입니다. 그러면 매퍼의 switch 가 비-exhaustive 가 되므로 다음 두 단계로 회귀를 막습니다.
|
||||
|
||||
1. `ApiErrorHttpStatusMapper.map(...)` 의 `default` 분기는 `WARN` 로그를 남기고 500 으로 폴백합니다 — 사일런트 500 방지.
|
||||
2. `ApiErrorHttpStatusMapperClientFacingCoverageTest` 가 *classpath 스캔* 으로 모든 `ClientFacingErrorCode` 구현을 찾아 매핑 테이블에 빠진 값이 있으면 테스트 실패시킵니다 — 신규 코드 누락 방지.
|
||||
|
||||
추가로 같은 테스트가 enum 값별 정확 status 매핑 (`each_client_facing_error_code_maps_to_its_exact_expected_http_status`) 과 코드 문자열 유일성 (`all_client_facing_error_codes_have_unique_string_codes`) 을 함께 단언합니다.
|
||||
|
||||
##### `InfrastructureErrorCode` 의 운영 가치는 그대로
|
||||
|
||||
매퍼에서 차단된다고 해서 `InfrastructureErrorCode` 가 무용한 것은 아닙니다.
|
||||
|
||||
- 로그에서 `INFRA-003`, `INFRA-999` 를 보고 운영자가 원인을 좁힙니다.
|
||||
- 알람 라우팅·대시보드·on-call 분기 모두 이 코드 기준입니다.
|
||||
- 클라이언트는 구현 세부사항이 제거된 `COMMON-999` 만 받습니다.
|
||||
|
||||
즉 같은 사건에 대해 *내부 분류* 와 *외부 노출* 을 분리해서 관리합니다.
|
||||
|
||||
## 5. How It Works
|
||||
|
||||
### 5.1 예외 처리 흐름
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant TF as TraceIdFilter
|
||||
participant SF as Security Filter
|
||||
participant DC as DispatcherServlet
|
||||
participant GEH as Presentation Exception Handlers
|
||||
participant SEH as SecurityExceptionHandler
|
||||
participant IEH as InfrastructureExceptionHandler
|
||||
|
||||
C->>TF: HTTP Request
|
||||
TF->>TF: MDC.put("traceId", hex32) + reduced clientIp + normalized userAgent
|
||||
TF->>SF: doFilter
|
||||
|
||||
alt 인증/인가 실패
|
||||
SF->>SEH: AuthenticationException
|
||||
SEH->>SEH: log.warn + ApiResult.failure()
|
||||
SEH-->>C: 401/403 JSON + X-Trace-Id
|
||||
else 인증 통과
|
||||
SF->>DC: doFilter
|
||||
alt Business 예외
|
||||
DC->>GEH: BusinessException
|
||||
GEH->>GEH: log.warn + ApiErrorHttpStatusMapper.map()
|
||||
GEH-->>C: 4xx JSON + X-Trace-Id
|
||||
else 인프라 예외
|
||||
DC->>IEH: InfrastructureException
|
||||
IEH->>IEH: log.error(code + stacktrace)
|
||||
IEH-->>C: 500 JSON + X-Trace-Id
|
||||
else 비예상 예외
|
||||
DC->>GEH: Exception (버그 / 미분류)
|
||||
GEH->>GEH: log.error(stacktrace)
|
||||
GEH-->>C: 500 JSON + X-Trace-Id
|
||||
end
|
||||
end
|
||||
|
||||
TF->>TF: MDC clear
|
||||
```
|
||||
|
||||
### 5.2 단계별 설계
|
||||
|
||||
#### 단계 1: 요청 추적 (TraceIdFilter)
|
||||
|
||||
모든 요청에 대해 Security 필터보다 먼저 실행됩니다. 자세한 동작과 운영 점검은 [04-logging/01-architecture.md](../04-logging/01-architecture.md) 참고.
|
||||
|
||||
핵심만:
|
||||
- `Ordered.HIGHEST_PRECEDENCE` 로 등록되어 Spring Security 안쪽 로그에도 traceId 가 남는다
|
||||
- 응답 헤더 `X-Trace-Id` 를 설정해 클라이언트 / CS 팀 / 개발자 경로 연결 가능
|
||||
|
||||
#### 단계 2: Security 예외 처리 (SecurityExceptionHandler)
|
||||
|
||||
Spring Security 예외는 `DispatcherServlet` 도달 전에 필터 체인에서 발생하기 때문에 `@RestControllerAdvice` 가 잡을 수 없습니다.
|
||||
|
||||
**이것이 `SecurityExceptionHandler` 를 별도로 둔 이유입니다.**
|
||||
|
||||
```java
|
||||
// SecurityExceptionHandler — AuthenticationEntryPoint + AccessDeniedHandler 동시 구현
|
||||
public class SecurityExceptionHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
|
||||
|
||||
@Override
|
||||
public void commence(..., AuthenticationException authException) throws IOException {
|
||||
log.warn("Authentication required. actorId={} method={} requestPath={}", ...);
|
||||
writeErrorResponse(response, HttpStatus.UNAUTHORIZED, AuthErrorCode.AUTHENTICATION_REQUIRED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(..., AccessDeniedException accessDeniedException) throws IOException {
|
||||
log.warn("Access denied. actorId={} method={} requestPath={}", ...);
|
||||
writeErrorResponse(response, HttpStatus.FORBIDDEN, AuthErrorCode.ACCESS_DENIED);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
운영 로그에서 바로 도움이 되도록 다음 정보를 함께 남깁니다.
|
||||
|
||||
- `traceId`: 응답 바디 / 헤더와 로그를 연결
|
||||
- `actorId`: 인증된 사용자가 있으면 원문 대신 마스킹 / 해시된 값으로 기록
|
||||
- `HTTP method + requestPath`: 어떤 요청에서 거부되었는지 확인
|
||||
|
||||
`ResourceServerSecurityConfiguration` 에서 연결합니다:
|
||||
|
||||
```java
|
||||
.oauth2ResourceServer(oauth2 -> oauth2
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))
|
||||
.authenticationEntryPoint(securityExceptionHandler)
|
||||
.accessDeniedHandler(securityExceptionHandler))
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
.authenticationEntryPoint(securityExceptionHandler)
|
||||
.accessDeniedHandler(securityExceptionHandler))
|
||||
```
|
||||
|
||||
#### 단계 3: Presentation 예외 처리 (5 + 1 계층)
|
||||
|
||||
초기에는 `GlobalExceptionHandler` 하나에 모든 예외가 모여 있었지만, 발생 *위치별* 로 책임을 분리하면서 현재는 **5계층 advice + 2개의 advice-밖 안전망 (= 5 + 1)** 으로 운영합니다.
|
||||
|
||||
```text
|
||||
@Order 위치 책임
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
HIGHEST_PRECEDENCE bootstrap InfrastructureExceptionHandler
|
||||
Infra/Domain 누수 → COMMON-999 정규화
|
||||
HIGHEST_PRECEDENCE + 5 bootstrap SecurityResponseExceptionHandler
|
||||
AuthenticationException / AccessDeniedException
|
||||
익명 → 401 · 인증 → 403 + audit
|
||||
HIGHEST_PRECEDENCE + 10 presentation ValidationExceptionHandler
|
||||
Bean Validation (Method/Constraint/HandlerMethod)
|
||||
→ errors 맵 (JSON Pointer 키, RFC 6901)
|
||||
HIGHEST_PRECEDENCE + 20 presentation RequestExceptionHandler
|
||||
Spring MVC 입력 11 종 + ResponseStatus / Upload
|
||||
LOWEST_PRECEDENCE presentation ApplicationExceptionHandler
|
||||
BusinessException + MessageNotWritable
|
||||
+ @ExceptionHandler(Exception.class) ← 안전망
|
||||
|
||||
(advice 밖) bootstrap SecurityExceptionHandler
|
||||
필터 단 AuthenticationEntryPoint /
|
||||
AccessDeniedHandler — advice 가 못 보는 경로
|
||||
audit + response.isCommitted() 체크
|
||||
|
||||
(/error 매핑) bootstrap ApiErrorController
|
||||
advice 우회 (sendError, 이중 폴트, 컨테이너 라우팅 실패)
|
||||
— 컨테이너가 결정한 status 그대로 보존
|
||||
— 본문만 ApiResult 로 정규화
|
||||
```
|
||||
|
||||
##### 왜 `SecurityResponseExceptionHandler` 가 bootstrap 에 있는가
|
||||
|
||||
ArchUnit 규칙(`presentation_must_not_read_security_context_directly`, `presentation_must_not_accept_raw_spring_security_authentication`) 이 presentation 모듈의 `org.springframework.security.core.*` 직접 의존을 막습니다. 익명/인증 분기는 `SecurityContextHolder` + `Authentication` 을 보아야 하므로, 이 advice 는 bootstrap 에 둡니다 — 룰 우회 없이 정직하게 분리.
|
||||
|
||||
##### `SecurityResponseExceptionHandler` 의 익명/인증 분기 정책
|
||||
|
||||
```java
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handle(AccessDeniedException ex, HttpServletRequest req) {
|
||||
if (isAnonymous(SecurityContextHolder.getContext().getAuthentication(), req)) {
|
||||
// 익명 → 401, 클라이언트 SDK 의 토큰 재발급 트리거 유지
|
||||
return failure(AUTHENTICATION_REQUIRED, ...);
|
||||
}
|
||||
// 인증된 사용자 → 403, 권한 부족 의미
|
||||
return failure(ACCESS_DENIED, ...);
|
||||
}
|
||||
|
||||
private static boolean isAnonymous(Authentication auth, HttpServletRequest req) {
|
||||
if (req.getUserPrincipal() != null) return false; // 비동기 컨텍스트 유실 방어
|
||||
return auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken;
|
||||
}
|
||||
```
|
||||
|
||||
이전에는 모든 `AccessDeniedException` 을 403 으로 응답했는데, 익명 사용자에게도 403 을 주면 SDK 가 토큰 재발급 흐름을 트리거하지 못합니다. `ExceptionTranslationFilter` 가 필터 단에서 하던 분기 (익명 → 401) 를 컨트롤러 단(`@PreAuthorize` 등) 에도 동일하게 적용한 것입니다. `getUserPrincipal()` 폴백은 비동기 컨트롤러로 SecurityContext 가 워커 스레드에 전파되지 않은 케이스를 방어합니다.
|
||||
|
||||
##### `ApiErrorController` 의 status 보존 정책
|
||||
|
||||
이전에는 `/error` 가 무조건 500 을 반환해 진짜 404 가 500 으로 둔갑하면서 LB 헬스체크 오작동·SDK 5xx 자동 재시도 폭주가 발생했습니다. 현재는 `RequestDispatcher.ERROR_STATUS_CODE` 를 그대로 보존하고, 5xx 만 `COMMON-999` 로 정규화, 4xx 는 `RESOURCE_NOT_FOUND` / `METHOD_NOT_ALLOWED` / `UNHANDLED_CLIENT_ERROR` 등으로 분류합니다.
|
||||
|
||||
##### `Exception.class` 안전망
|
||||
|
||||
`ApplicationExceptionHandler` 의 `@ExceptionHandler(Exception.class)` 가 advice 안의 마지막 안전망입니다. 매칭되지 않은 모든 `RuntimeException` 을 잡아 ERROR 레벨 풀스택 로깅 + `COMMON-999` 응답으로 정규화합니다. 안전망이 없으면 unmatched 예외가 `/error` 로 흘러가 `ApiResult` 계약이 깨질 수 있습니다.
|
||||
|
||||
##### 로깅 정책이 분리되는 지점
|
||||
|
||||
- 예상 예외 (비즈니스 / 도메인 / 클라이언트 잘못) → `log.warn` — 알림 불필요
|
||||
- 비예상 예외 (인프라 장애, 버그) → `log.error` — 즉시 알림 대상
|
||||
- 모든 핸들러 로그가 `method=`, `requestPath=`, `errorCode=` 필드를 일관되게 포함 (정책 11)
|
||||
|
||||
> Validation 예외 응답 정규화, `ConstraintViolation` / `MessageSourceResolvable` 의미, `@ConfigurationProperties` 검증과의 차이는 [02a-validation-deep-dive.md](./02a-validation-deep-dive.md) 에서 따로 다룹니다.
|
||||
|
||||
#### 단계 4: 인프라 예외 번역
|
||||
|
||||
인프라 계층에서는 기술 예외를 `InfrastructureException` 으로 감싸서 던집니다. `JpaUserRepositoryAdapter` 가 저장된 row 를 도메인으로 복원할 때의 패턴이 대표 예시입니다.
|
||||
|
||||
```java
|
||||
// Before — 예외 계층 원칙 위반
|
||||
try {
|
||||
return User.restoreFromRow(row);
|
||||
} catch (DomainException | IllegalArgumentException | NullPointerException exception) {
|
||||
throw new IllegalStateException("Invalid persisted user row.", exception);
|
||||
}
|
||||
|
||||
// After — 전용 예외 타입 + ErrorCode 로 번역
|
||||
try {
|
||||
return User.restoreFromRow(row);
|
||||
} catch (DomainException | IllegalArgumentException | NullPointerException exception) {
|
||||
throw new InfrastructureException(
|
||||
InfrastructureErrorCode.PERSISTED_DATA_INVALID,
|
||||
"Failed to restore User from persisted row.",
|
||||
exception);
|
||||
}
|
||||
```
|
||||
|
||||
`InfrastructureException` 은 `InfrastructureErrorCode` 를 보유합니다:
|
||||
|
||||
| 코드 | 의미 |
|
||||
|------|------|
|
||||
| `INFRA-003` | 저장된 데이터가 도메인 규칙에 맞지 않습니다 (`PERSISTED_DATA_INVALID`) |
|
||||
| `INFRA-999` | 외부 시스템 연동 중 오류가 발생했습니다 (`EXTERNAL_SERVICE_ERROR`) |
|
||||
|
||||
bootstrap 의 `InfrastructureExceptionHandler` (`@Order(HIGHEST_PRECEDENCE)`) 가 이를 전용 처리합니다:
|
||||
- `log.error` 로 에러 코드, 상세 메시지, 스택트레이스를 기록
|
||||
- 사용자에게는 항상 `COMMON-999` 와 그 메시지만 응답, 내부 기술 정보 노출 차단
|
||||
- presentation 이 infrastructure 에 의존하지 않도록 핸들러를 bootstrap 에 배치 (레이어 규칙 준수)
|
||||
- 즉 bootstrap 은 단순 조립만 하는 모듈이 아니라, presentation 이 직접 의존할 수 없는 기술 예외를 HTTP 경계에서 번역하는 소수의 adapter 도 포함합니다.
|
||||
- persistence adapter 는 저장소에서 읽은 데이터를 domain 으로 복원할 때 `DomainException`, `IllegalArgumentException`, `NullPointerException` 을 `InfrastructureException(INFRA-003)` 로 번역. 잘못된 저장 데이터는 비즈니스 검증 실패가 아니라 인프라 무결성 문제로 간주.
|
||||
|
||||
## 6. Alternatives Considered
|
||||
|
||||
### 대안 1: `@RestControllerAdvice` 에서 Security 예외까지 한 곳에서 처리
|
||||
|
||||
- 장점: 예외 핸들러가 한 파일에 모여 관리 편의성이 높아짐.
|
||||
- 단점: Security 예외는 `DispatcherServlet` 이전에 발생하므로 `@RestControllerAdvice` 가 도달하지 못함. `HandlerExceptionResolver` 를 위임하는 패턴이 있지만, 필터 체인과 서블릿 컨텍스트 사이의 경계를 인위적으로 넘겨야 하므로 복잡도가 올라감.
|
||||
- 왜 선택하지 않았는가: Spring Security 의 설계 의도에 맞게 `AuthenticationEntryPoint` / `AccessDeniedHandler` 를 분리하는 것이 유지보수와 프레임워크 호환성 측면에서 더 안정적.
|
||||
|
||||
### 대안 2: RFC 7807 Problem Details 표준 도입
|
||||
|
||||
- 장점: 업계 표준 (`application/problem+json`), Spring 6 의 `ProblemDetail` 네이티브 지원.
|
||||
- 단점: 기존 `ApiResult` 계약을 전면 교체해야 하고, 이미 클라이언트와 맞춘 응답 구조를 깨뜨림.
|
||||
- 왜 선택하지 않았는가: 현재 `ApiResult` 가 `success`, `code`, `message`, `data`, `timestamp` 필드로 충분히 일관적이며, RFC 7807 이관 비용이 이점보다 큼. 추후 API 버전업 시 고려 가능.
|
||||
|
||||
### 대안 3: Micrometer Tracing 으로 분산 추적
|
||||
|
||||
- 장점: OpenTelemetry 호환, Zipkin / Jaeger 연동, span 기반 성능 분석.
|
||||
- 단점: 현재 단일 서비스이므로 오버엔지니어링. 의존성과 설정 복잡도가 급증.
|
||||
- 왜 선택하지 않았는가: MDC 기반 UUID 필터가 현재 규모에 적합. 향후 MSA 전환 시 `TraceIdFilter` 를 Micrometer 로 교체하면 됨.
|
||||
|
||||
## 7. Cross-cutting Concerns
|
||||
|
||||
- **로깅**: SLF4J + MDC. 예상 예외 `warn`, 비예상 예외 `error`. `logback-spring.xml` 의 패턴이 `%X{traceId}` 를 출력. 자세히는 [04-logging/01-architecture.md](../04-logging/01-architecture.md).
|
||||
- **보안**: 비예상 예외 발생 시 내부 기술 정보가 응답에 노출되지 않도록 고정 메시지 (`COMMON-999`) 사용. 내부 정보는 로그에만 기록.
|
||||
- **테스트**: `ApiErrorHttpStatusMapper` 의 client-facing coverage 테스트가 새 ErrorCode 추가 시 기본 500 폴백 누락을 탐지. `ExceptionHandlingIntegrationTest` 가 `401/403/400/405/500` 실제 웹 흐름을 고정.
|
||||
- **운영**: 응답 헤더 `X-Trace-Id` 를 통해 클라이언트 → CS팀 → 개발자 경로로 장애 원인 특정 가능. `log.error` 기반으로 알림 시스템과 연동 가능.
|
||||
|
||||
## 8. Result / Trade-offs
|
||||
|
||||
### 얻은 이점
|
||||
|
||||
- 예외 발생 위치 (도메인, 인프라, Security 필터/컨트롤러, Spring MVC) 와 무관하게 `ApiResult` JSON 응답 보장
|
||||
- 내부 분류 코드 (`InfrastructureErrorCode`) 의 클라이언트 응답 경로 진입을 *컴파일 단계에서 차단* — 런타임 검사 X
|
||||
- 익명 사용자 401 분기로 클라이언트 SDK 의 토큰 재발급 흐름 보존
|
||||
- `/error` 안전망이 컨테이너 status 를 보존 — 진짜 404 가 500 으로 둔갑해 LB/SDK 정책이 깨지는 사고 차단
|
||||
- `ApiResult` 의 `data` / `errors` 분리로 OpenAPI 가 응답을 oneOf 로 모델링할 필요 없음
|
||||
- 예상 / 비예상 예외의 로깅 레벨 분리로 운영 알림 설정 가능
|
||||
- 요청별 `traceId` 로 로그-응답 연결 (응답 헤더 `X-Trace-Id` + 응답 바디 `traceId` 필드, MDC 누락 시 sentinel `"-"`)
|
||||
- 새 `ClientFacingErrorCode` 구현 추가 시 *classpath 스캔 가드 테스트* 가 매핑 테이블 누락을 즉시 검출
|
||||
- 정확값 매핑 테이블 + PIT mutation 게이트 (application 90%, presentation 75%) + jqwik 30+ 속성 테스트로 단언 강도 자동 검증
|
||||
- 검증 실패 응답 키를 *JSON Pointer (RFC 6901)* 로 통일 — 클라이언트가 두 가지 키 형식을 분기 처리할 필요 없음
|
||||
- `DomainException` 이 웹 계층까지 직접 올라오는 경로를 제거하여, presentation 이 순수 HTTP 번역 책임에만 집중할 수 있음
|
||||
- 5 advice + 2 안전망 책임 분리로 핸들러 확장 시 유지보수 부담을 줄임
|
||||
|
||||
### 감수한 비용
|
||||
|
||||
- `ApiResult` 에 `errors`, `traceId`, `timestamp` 필드가 추가되어 기존 응답 계약이 변경됨 (총 7 필드)
|
||||
- Security 예외 핸들링이 *세 곳* 에 분산: (1) 필터 단 `SecurityExceptionHandler`, (2) 컨트롤러 단 `SecurityResponseExceptionHandler`, (3) `/error` 안전망 `ApiErrorController`. 대신 `SecurityAuditTrailWriter` 단일 채널로 audit 만은 한 곳에서 받게 통일.
|
||||
- `InfrastructureExceptionHandler` / `SecurityResponseExceptionHandler` / `ApiErrorController` 가 bootstrap 에 있어서 예외 처리 코드가 presentation 과 bootstrap 두 모듈에 분산됨 (레이어 규칙 준수를 위한 트레이드오프)
|
||||
- 저장 데이터 복원 실패를 `INFRA-003` 으로 분류하면서, 일부 데이터 불일치가 곧바로 500 으로 처리됨
|
||||
- `ClientFacingErrorCode` 가 `non-sealed` 라 매퍼에 `default` 분기가 필요. 컴파일러가 exhaustiveness 를 강제하지 못하므로 *classpath 스캔 가드 테스트* + WARN 로그 두 가지로 보완
|
||||
|
||||
### 남은 리스크
|
||||
|
||||
- `TraceIdFilter` 가 심는 MDC 키와 `logback-spring.xml` 패턴이 어긋나면 traceId 상관관계가 끊길 수 있음
|
||||
- 익명/인증 분기는 ThreadLocal `SecurityContextHolder` 를 가정. 비동기 컨트롤러 도입 시 `getUserPrincipal()` 폴백만으로 충분한지는 도입 시점에 재검증 필요
|
||||
- `ClientFacingErrorCode.message()` 는 한국어 하드코딩 — i18n MessageSource 도입은 별도 사건으로 미룸
|
||||
- `DomainException` 이 누수되면 원본 메시지 대신 generic 500 으로 처리되므로, application / infrastructure 경계 번역 누락은 테스트로 계속 감시해야 함
|
||||
|
||||
## 9. References
|
||||
|
||||
### 관련 코드
|
||||
|
||||
- 예외 계층 기반: [DomainException.java](../../../domain/src/main/java/com/project/auth/domain/user/exception/DomainException.java), [BusinessException.java](../../../application/src/main/java/com/project/auth/application/support/exception/BusinessException.java), [InfrastructureException.java](../../../infrastructure/src/main/java/com/project/auth/infrastructure/support/exception/InfrastructureException.java)
|
||||
- 에러 코드 (sealed 분리): [ErrorCode.java](../../../application/src/main/java/com/project/auth/application/support/exception/ErrorCode.java), [ClientFacingErrorCode.java](../../../application/src/main/java/com/project/auth/application/support/exception/ClientFacingErrorCode.java), [ExternalErrorCode.java](../../../application/src/main/java/com/project/auth/application/support/exception/ExternalErrorCode.java), [CommonErrorCode.java](../../../application/src/main/java/com/project/auth/application/support/exception/CommonErrorCode.java), [AuthErrorCode.java](../../../application/src/main/java/com/project/auth/application/support/exception/AuthErrorCode.java), [PresentationErrorCode.java](../../../presentation/src/main/java/com/project/auth/presentation/support/exception/PresentationErrorCode.java), [InfrastructureErrorCode.java](../../../infrastructure/src/main/java/com/project/auth/infrastructure/support/exception/InfrastructureErrorCode.java)
|
||||
- 예외 핸들러 (5 advice): [ValidationExceptionHandler.java](../../../presentation/src/main/java/com/project/auth/presentation/support/exception/ValidationExceptionHandler.java), [RequestExceptionHandler.java](../../../presentation/src/main/java/com/project/auth/presentation/support/exception/RequestExceptionHandler.java), [ApplicationExceptionHandler.java](../../../presentation/src/main/java/com/project/auth/presentation/support/exception/ApplicationExceptionHandler.java), [InfrastructureExceptionHandler.java](../../../bootstrap/src/main/java/com/project/auth/config/web/InfrastructureExceptionHandler.java), [SecurityResponseExceptionHandler.java](../../../bootstrap/src/main/java/com/project/auth/config/web/SecurityResponseExceptionHandler.java)
|
||||
- advice 밖 안전망: [SecurityExceptionHandler.java](../../../bootstrap/src/main/java/com/project/auth/config/auth/security/SecurityExceptionHandler.java) (필터 단), [ApiErrorController.java](../../../bootstrap/src/main/java/com/project/auth/config/web/ApiErrorController.java) (`/error` 매핑)
|
||||
- 응답: [ApiResult.java](../../../presentation/src/main/java/com/project/auth/presentation/support/response/ApiResult.java) (7 필드 + `@JsonInclude(NON_NULL)`), [ApiErrorHttpStatusMapper.java](../../../presentation/src/main/java/com/project/auth/presentation/support/exception/ApiErrorHttpStatusMapper.java) (시그니처 좁히기)
|
||||
- 테스트: [ApiErrorHttpStatusMapperClientFacingCoverageTest.java](../../../bootstrap/src/test/java/com/project/auth/architecture/ApiErrorHttpStatusMapperClientFacingCoverageTest.java) (정확값 + classpath 가드), [ExceptionHandlingIntegrationTest.java](../../../bootstrap/src/test/java/com/project/auth/ExceptionHandlingIntegrationTest.java), [ApiErrorControllerIntegrationTest.java](../../../bootstrap/src/test/java/com/project/auth/ApiErrorControllerIntegrationTest.java), [ValidationExceptionHandlerIntegrationTest.java](../../../bootstrap/src/test/java/com/project/auth/ValidationExceptionHandlerIntegrationTest.java)
|
||||
|
||||
### 관련 문서
|
||||
|
||||
- [exception-handling-policy.md](../../exception-handling-policy.md) — 13개 정책 단일 출처 (정식 정의)
|
||||
- [testing-coverage-policy.md](../../testing-coverage-policy.md) — JaCoCo / PIT / jqwik Tier 분류와 임계치
|
||||
- [testing-history/](../../testing-history/README.md) — 사건 단위 before/after 비교 기록
|
||||
- [Architecture Overview](../../architecture/README.md) — 레이어 구조와 의존 방향
|
||||
- [03-adr-boundary-refactoring.md](./03-adr-boundary-refactoring.md) — 경계 재정렬 결정 기록
|
||||
- [02a-validation-deep-dive.md](./02a-validation-deep-dive.md) — Validation 예외 응답 정규화, ConstraintViolation 의미, ConfigurationProperties 검증
|
||||
- [04-logging/01-architecture.md](../04-logging/01-architecture.md) — TraceIdFilter, MDC, structured logging
|
||||
@@ -0,0 +1,126 @@
|
||||
# Validation 예외 처리 Deep-Dive
|
||||
|
||||
[02-error-handling.md](./02-error-handling.md) 의 단계 3 (`ValidationExceptionHandler`) 에서 다루지 않은 *세부 동작* 만 모은 문서. 핵심 architecture 는 02 가 source of truth.
|
||||
|
||||
## Why
|
||||
|
||||
기본 처리 (Bean Validation 실패 → 400) 만으로는 클라이언트가 *어느 필드가 왜 실패했는지* 알 수 없다. 또한 `ConstraintViolation` 같은 Jakarta Validation 타입은 HTTP 요청 검증과 `@ConfigurationProperties` 검증 두 곳에 모두 등장하지만 *언제, 어디서, 어떻게* 실패하는지가 다르다. 이를 한 데 묶어서 다루지 않으면 핸들러 구현이 일관성을 잃는다.
|
||||
|
||||
## Validation 예외를 응답 데이터로 정규화
|
||||
|
||||
validation 계열 예외는 단순히 `"요청 값이 올바르지 않습니다."` 한 줄만 주는 것이 아니라, 어떤 필드가 왜 실패했는지도 함께 내려준다. 이를 위해 `ValidationExceptionHandler` 는 `ApiResult<Map<String, List<String>>>` 를 사용한다.
|
||||
|
||||
```java
|
||||
Map<String, List<String>> errors = new LinkedHashMap<>();
|
||||
errors.computeIfAbsent(field, key -> new ArrayList<>())
|
||||
.add(message);
|
||||
```
|
||||
|
||||
이 구조의 의미는 다음과 같다.
|
||||
|
||||
- `String`: 실패한 필드명 또는 파라미터명. 예: `email`, `password`, `title`
|
||||
- `List<String>`: 해당 필드에서 발생한 검증 메시지 목록. 한 필드에 여러 제약이 동시에 실패할 수 있으므로 리스트로 보관
|
||||
- `LinkedHashMap`: 검증 오류가 수집된 순서를 최대한 유지해 응답이 매번 같은 모양으로 보이게 함
|
||||
|
||||
예를 들어 이메일과 비밀번호가 동시에 실패하면 응답 `data` 는 다음과 비슷해진다.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"code": "COMMON-001",
|
||||
"message": "요청 값이 올바르지 않습니다.",
|
||||
"data": {
|
||||
"email": [
|
||||
"유효한 이메일 형식이 아닙니다."
|
||||
],
|
||||
"password": [
|
||||
"비밀번호는 8자 이상 50자 이하여야 합니다."
|
||||
]
|
||||
},
|
||||
"traceId": "4f5c7b90a2de118c",
|
||||
"timestamp": "2026-04-10T13:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
한 필드에서 여러 제약이 동시에 실패하면 리스트가 길어진다.
|
||||
|
||||
```json
|
||||
{
|
||||
"password": [
|
||||
"비어 있을 수 없습니다.",
|
||||
"8자 이상이어야 합니다."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`computeIfAbsent` 는 *"키가 없으면 기본 컬렉션을 만들고, 있으면 기존 컬렉션을 재사용"* 하는 `Map` 인터페이스의 메서드. 즉 validation 응답에서는 *"처음 등장한 필드면 빈 리스트를 만들고, 이미 있으면 그 리스트에 메시지를 하나 더 추가"* 하는 역할.
|
||||
|
||||
## `ConstraintViolation`, `MessageSourceResolvable` 이 실제로 뜻하는 것
|
||||
|
||||
validation 예외 흐름에서 자주 보이는 타입은 아래처럼 역할이 다르다.
|
||||
|
||||
- `ConstraintViolation`: 검증 실패 1 건을 표현하는 객체. 실패한 경로, 메시지, 잘못된 값 같은 메타데이터를 가짐
|
||||
- `ConstraintViolationException`: `ConstraintViolation` 여러 건을 모아 던지는 예외
|
||||
- `MessageSourceResolvable`: Spring 이 메시지 코드, 치환 인자, 기본 메시지를 나중에 해석할 수 있도록 감싼 타입
|
||||
|
||||
현재 `ValidationExceptionHandler` 는 이 정보를 이렇게 사용한다.
|
||||
|
||||
- `ConstraintViolationException` 처리 시: 각 `ConstraintViolation` 에서 `propertyPath` 와 `message` 를 꺼내 `field → messages` 구조로 정규화
|
||||
- `HandlerMethodValidationException` 처리 시: Spring 이 준 `MessageSourceResolvable` 목록에서 `getDefaultMessage()` 를 꺼내 응답 메시지로 사용
|
||||
|
||||
즉 현재 구현은 다국어 메시지 해석까지는 하지 않고, Spring 이 계산한 기본 메시지를 API 응답에 그대로 실어 주는 쪽에 가깝다. 향후 다국어 응답이 필요해지면 `MessageSourceResolvable` 의 코드와 인자를 이용해 locale 별 메시지로 바꿀 수 있다.
|
||||
|
||||
## `ConstraintViolationException` 과 `@ConfigurationProperties` 검증의 관계
|
||||
|
||||
`ConstraintViolation` 자체는 HTTP 요청 전용 개념이 아니라 Jakarta Validation 의 공통 모델이다. 그래서 아래처럼 `@Validated` 와 `@NotBlank` 를 붙인 `@ConfigurationProperties` 에도 같은 검증 개념이 적용된다.
|
||||
|
||||
```java
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "app.docs")
|
||||
public record AppDocsProperties(
|
||||
@NotBlank String title,
|
||||
@NotBlank String description,
|
||||
@NotBlank String version
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
다만 **언제, 어디서 실패하느냐는 완전히 다르다.**
|
||||
|
||||
- HTTP 요청 검증: `DispatcherServlet` 이후에 발생하며 `ValidationExceptionHandler` 가 잡아 `400` JSON 응답으로 변환
|
||||
- `@ConfigurationProperties` 검증: 애플리케이션 시작 시점에 바인딩 / 검증 중 발생하며, 서버가 뜨기 전에 실패함
|
||||
|
||||
즉 `AppDocsProperties` 같은 설정 검증 실패는 *"잘못된 요청 값"* 이라기보다 *"잘못된 애플리케이션 설정"* 이다. 이 경우는 `CommonErrorCode.CONSTRAINT_VIOLATION` 으로 API 응답을 만드는 흐름이 아니라, 애플리케이션 부팅 실패로 이어지는 것이 일반적.
|
||||
|
||||
정리하면:
|
||||
|
||||
- 같은 Bean Validation 애노테이션 (`@NotBlank`, `@Pattern`, `@NotNull`) 을 써도
|
||||
- 웹 요청 검증은 클라이언트 입력 검증이고
|
||||
- `@ConfigurationProperties` 검증은 서버 설정 검증이다.
|
||||
|
||||
그래서 문구 *"요청 값이 제약 조건을 위반했습니다."* 는 `ValidationExceptionHandler` 가 다루는 웹 요청 검증 컨텍스트에만 정확하게 맞는 표현.
|
||||
|
||||
## Result / Trade-offs
|
||||
|
||||
### 얻은 이점
|
||||
|
||||
- 클라이언트가 어느 필드의 어느 제약이 실패했는지 알 수 있어 폼 UX 가능
|
||||
- `LinkedHashMap` 으로 응답 순서가 안정적이라 스냅샷 테스트 가능
|
||||
- 같은 Bean Validation 애노테이션이 *요청 검증* 과 *설정 검증* 두 컨텍스트에서 다르게 흐른다는 점이 문서화되어, 신입이 핸들러 추가 시 잘못된 가정을 줄일 수 있음
|
||||
|
||||
### 감수한 비용
|
||||
|
||||
- 응답 데이터 형태가 *단순 string* 이 아니라 *map of list of string* 이라 클라이언트 파서가 약간 복잡해짐
|
||||
- 다국어 메시지 해석은 안 함 (현재 단일 locale 가정) — 추후 i18n 시 `MessageSourceResolvable` 코드 / 인자 사용으로 확장 필요
|
||||
|
||||
### 남은 리스크
|
||||
|
||||
- validation 응답의 상세 필드 구조가 API 스펙으로 굳어질 경우, 향후 RFC 7807 등 다른 오류 포맷으로 바꿀 때 마이그레이션 비용이 생김
|
||||
- `@ConfigurationProperties` 검증 실패는 부팅 실패로 이어지므로 helm rollout 시 *"왜 Pod 가 안 뜨지"* 의 원인이 될 수 있음 — 운영 매뉴얼에 *"부팅 실패 시 application.yml 의 검증 항목 먼저 확인"* 추가 권장
|
||||
|
||||
## References
|
||||
|
||||
- [02-error-handling.md](./02-error-handling.md) — 에러 핸들링 핵심 architecture
|
||||
- 핸들러 코드: [ValidationExceptionHandler.java](../../../presentation/src/main/java/com/project/auth/presentation/support/exception/ValidationExceptionHandler.java)
|
||||
- 응답: [ApiResult.java](../../../presentation/src/main/java/com/project/auth/presentation/support/response/ApiResult.java)
|
||||
- 설정 검증 예시: [AppDocsProperties.java](../../../bootstrap/src/main/java/com/project/auth/config/openapi/AppDocsProperties.java)
|
||||
@@ -0,0 +1,73 @@
|
||||
# ADR-003: AGENTS 기준 레이어 경계 리팩토링
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-04-14
|
||||
|
||||
## Context
|
||||
|
||||
`AGENTS.md`와 standards/examples는 레이어별 소유 책임을 명확히 나누지만, 일부 구현은 편의상 경계 관심사를 직접 들고 있었다.
|
||||
|
||||
- `presentation` 응답 모델이 MDC와 현재 시각을 직접 읽었다.
|
||||
- OAuth2 callback controller와 mapper가 Spring Security 타입과 provider registration 해석을 직접 다뤘다.
|
||||
- repository adapter가 transaction boundary를 소유했다.
|
||||
- Vault Transit client가 ad-hoc JSON/URL/header 조립을 핵심 로직 안에 섞고 있었다.
|
||||
|
||||
이 결정은 기능을 바꾸기보다, 같은 인증 플로우를 유지하면서 경계 위반 가능성을 줄이기 위한 것이다.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 응답 DTO/model은 순수 transport 계약이어야 한다.
|
||||
- 현재 사용자와 provider registration 해석은 web/security boundary에서 끝나야 한다.
|
||||
- transaction은 application use case 작업 단위에서 소유해야 한다.
|
||||
- infrastructure adapter는 기술 번역을 담당하되 business workflow를 소유하지 않아야 한다.
|
||||
- 같은 위반이 재발하지 않도록 ArchUnit 테스트로 구조를 검증해야 한다.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: 기존 구조 유지 후 국소 수정
|
||||
|
||||
- 장점: 수정 파일 수가 적다.
|
||||
- 단점: `ApiResult`, controller, mapper, repository adapter에 섞인 책임이 남아 같은 문제가 반복된다.
|
||||
- 트레이드오프: 단기 변경은 작지만 standards/examples와 계속 어긋난다.
|
||||
|
||||
### Option 2: 경계별 소유자를 다시 지정
|
||||
|
||||
- 장점: `presentation`은 HTTP 계약, `bootstrap`은 request/security context, `application`은 transaction, `infrastructure`는 기술 번역에 집중한다.
|
||||
- 단점: 조립 코드와 테스트 fixture 수정이 필요하다.
|
||||
- 트레이드오프: 초기 변경량은 늘지만 이후 기능 추가 시 경계 판단 비용이 줄어든다.
|
||||
|
||||
## Decision
|
||||
|
||||
Option 2를 선택해 request metadata, OAuth2 provider mapping, transaction boundary, external integration DTO/translation의 소유 위치를 각 레이어 기준에 맞게 재배치한다.
|
||||
|
||||
## Consequences
|
||||
|
||||
### 긍정적 결과
|
||||
|
||||
- `ApiResult`는 MDC나 clock을 모르는 순수 envelope가 되었다.
|
||||
- OAuth2 controller는 Spring Security `Authentication` 대신 presentation 전용 principal을 받는다.
|
||||
- provider registration ID와 redirect path는 bootstrap security configuration에서 조립한다.
|
||||
- repository adapter의 transaction annotation을 제거하고 application collaborator가 transaction boundary를 가진다.
|
||||
- Vault Transit client는 typed request/response와 명시적 infrastructure exception translation을 사용한다.
|
||||
- ArchUnit이 presentation의 SecurityContext 직접 접근, ApiResult의 시간/MDC 의존, infrastructure transaction 소유를 막는다.
|
||||
|
||||
### 부정적 결과
|
||||
|
||||
- controller/advice 생성자에 `ApiResultFactory` 의존성이 추가된다.
|
||||
- use case 내부 collaborator가 늘어나면서 bootstrap bean wiring이 길어졌다.
|
||||
- OAuth2 callback 테스트는 Spring Security token 대신 전용 principal fixture를 사용한다.
|
||||
|
||||
### 위험 완화
|
||||
|
||||
- 외부 API 응답 shape와 endpoint path는 유지한다.
|
||||
- `./gradlew test`로 application, presentation, infrastructure, bootstrap 테스트를 모두 통과시킨다.
|
||||
- DB provider별 필수 필드는 Flyway constraint로 한 번 더 보호한다.
|
||||
|
||||
## 후속 정리
|
||||
|
||||
본 ADR 시점에 정리된 Vault Transit client 는 이후 [ADR-002 Keycloak 전환](../03-keycloak/02-adr-keycloak-resource-server.md) 에서 자체 JWT 발급 책임이 사라지면서 통째로 제거됨. 자세한 제거 범위는 [04-adr-token-ownership-cleanup.md](../03-keycloak/04-adr-token-ownership-cleanup.md).
|
||||
@@ -0,0 +1,20 @@
|
||||
# Clean Architecture
|
||||
|
||||
5 모듈 (`bootstrap` / `domain` / `application` / `presentation` / `infrastructure`) 의 경계 결정과 계층별 책임을 정리한다.
|
||||
|
||||
## 문서
|
||||
|
||||
| 문서 | 내용 |
|
||||
|------|------|
|
||||
| [02-error-handling.md](./02-error-handling.md) | 예외 처리 핵심 아키텍처 — `ErrorCode` 가 HTTP status 를 모름. 계층별 책임 분리 + Security 필터 / Infrastructure 번역 |
|
||||
| [02a-validation-deep-dive.md](./02a-validation-deep-dive.md) | Validation 응답 정규화 (`Map<String, List<String>>`) + `ConstraintViolation` 의미 + `@ConfigurationProperties` 검증과의 차이 |
|
||||
| [03-adr-boundary-refactoring.md](./03-adr-boundary-refactoring.md) | AGENTS 기준으로 응답 / 인증 / 트랜잭션 / 인프라 경계를 재정렬한 ADR-003 |
|
||||
|
||||
## 검증
|
||||
|
||||
이 폴더가 약속하는 모든 경계 규칙은 [`LayerDependencyArchitectureTest`](../../../bootstrap/src/test/java/com/project/auth/architecture/LayerDependencyArchitectureTest.java) 에서 ArchUnit 으로 PR 마다 자동 검증.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [Architecture Overview](../../architecture/README.md)
|
||||
- [README · Highlighted Engineering Decisions](../../../README.md#highlighted-engineering-decisions)
|
||||
@@ -0,0 +1,92 @@
|
||||
# Keycloak Resource Server 아키텍처
|
||||
|
||||
## Why
|
||||
|
||||
auth-server 가 직접 로그인, OAuth2 callback, JWT 발급, issuer / JWK 공개를 모두 맡으면 인증 프로토콜과 비즈니스 사용자 식별이 강하게 섞입니다. 이번 구조는 인증 주체를 Keycloak 으로 옮기고, auth-server 는 검증된 access token 을 받아 내부 비즈니스 로직만 수행하는 Resource Server 로 제한합니다.
|
||||
|
||||
## What
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client[Client] -->|login / token request| Keycloak[Keycloak Realm]
|
||||
Keycloak -->|broker login| Google[Google IdP]
|
||||
Keycloak -->|broker login| GitHub[GitHub IdP]
|
||||
Keycloak -->|access token| Client
|
||||
Client -->|Authorization Bearer| AuthServer[auth-server]
|
||||
AuthServer -->|JWKS fetch one-time + cached| Keycloak
|
||||
AuthServer -->|provider KEYCLOAK + sub| DB[(auth.users)]
|
||||
```
|
||||
|
||||
- Keycloak: 로그인, OAuth2 broker, issuer, token 발급, JWKS 공개를 소유합니다.
|
||||
- auth-server: Spring Security Resource Server 로 token signature, issuer, expiry 를 검증합니다.
|
||||
- application: 검증된 `sub`, `email`, `name` claim 으로 이미 연결된 내부 사용자를 식별합니다.
|
||||
- infrastructure: `provider=KEYCLOAK`, `provider_subject=sub` 기준으로 users row 를 조회합니다.
|
||||
|
||||
## How
|
||||
|
||||
### 1. ResourceServer 설정
|
||||
|
||||
[`ResourceServerSecurityConfiguration`](../../../bootstrap/src/main/java/com/project/auth/config/auth/ResourceServerSecurityConfiguration.java) 가 다음을 wiring 합니다.
|
||||
|
||||
```java
|
||||
http
|
||||
.csrf(CsrfConfigurer::disable)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/actuator/health", "/actuator/health/**",
|
||||
"/livez", "/readyz",
|
||||
"/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/auth/me").hasRole("user")
|
||||
.anyRequest().authenticated())
|
||||
.oauth2ResourceServer(oauth2 -> oauth2
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))
|
||||
.authenticationEntryPoint(securityExceptionHandler)
|
||||
.accessDeniedHandler(securityExceptionHandler));
|
||||
```
|
||||
|
||||
핵심 설정:
|
||||
- `spring.security.oauth2.resourceserver.jwt.issuer-uri=https://keycloak.dev.example.com/realms/platform`
|
||||
- 이 한 줄로 Spring Boot 가 자동으로 `JwtDecoder` Bean 을 만들고, **OIDC discovery endpoint** (`/.well-known/openid-configuration`) 에서 JWKS URI 를 조회합니다.
|
||||
- `NimbusJwtDecoder` 가 JWKS 를 가져와 캐시. 기본 캐시 TTL 5 분 (Spring Security 기본). Keycloak 키 회전이 일어나도 5 분 내 자동 반영.
|
||||
|
||||
### 2. Token 검증 체인
|
||||
|
||||
| 단계 | 검증 내용 | 실패 시 |
|
||||
|---|---|---|
|
||||
| 1 | Bearer header 형식 | 401, `WWW-Authenticate: Bearer` |
|
||||
| 2 | JWT 서명 (JWKS 공개키 매칭) | 401, `invalid_token` |
|
||||
| 3 | `iss` 가 설정된 issuer-uri 와 일치 | 401, `invalid_token` |
|
||||
| 4 | `exp` 미만료, `nbf`/`iat` 유효 | 401, `invalid_token` |
|
||||
| 5 | `aud` 가 허용 client 와 일치 (선택) | 401 |
|
||||
| 6 | `KeycloakJwtAuthenticationConverter` 가 claim → `AuthenticatedUser` 변환 | — |
|
||||
| 7 | `hasRole("user")` 권한 검사 | 403, `access_denied` |
|
||||
|
||||
1~5 는 Spring Security 가 자동, 6~7 은 본 프로젝트 코드.
|
||||
|
||||
### 3. Claim → Principal 변환
|
||||
|
||||
[`KeycloakJwtAuthenticationConverter`](../../../bootstrap/src/main/java/com/project/auth/config/auth/security/KeycloakJwtAuthenticationConverter.java) 가 다음 claim 을 프로젝트 전용 principal 로 변환합니다.
|
||||
|
||||
- `sub` → `AuthenticatedUser.subject`
|
||||
- `email` → `AuthenticatedUser.email`
|
||||
- `name` 또는 `preferred_username` → `AuthenticatedUser.name`
|
||||
- `scope` → `SCOPE_*`
|
||||
- `realm_access.roles` → `ROLE_*`
|
||||
|
||||
자세한 매핑 정책과 sample JWT payload 는 [03-claim-role-design.md](./03-claim-role-design.md).
|
||||
|
||||
### 4. 비즈니스 흐름
|
||||
|
||||
`GET /api/v1/auth/me` 는 `@CurrentUser AuthenticatedUser` 를 받아 `LoadKeycloakUserUseCase` 에 최소 claim 만 넘깁니다. 이 use case 는 `(KEYCLOAK, sub)` 로 기존 내부 사용자 id 를 조회하고, 연결된 사용자가 없으면 `AUTH-004` 404 를 반환합니다 — *자동 생성 / 자동 연결은 하지 않습니다.*
|
||||
|
||||
## Result
|
||||
|
||||
- auth-server 내부 JWT 발급기, 로컬 RSA key source, Vault Transit signer, 자체 OIDC discovery / JWKS endpoint 를 모두 제거했습니다 ([04-adr-token-ownership-cleanup.md](./04-adr-token-ownership-cleanup.md)).
|
||||
- `/api/v1/auth/login`, `/api/v1/auth/oauth2/keycloak/*`, `/oauth2/authorization/*`, `/login/oauth2/code/*` 는 더 이상 auth-server 의 로그인 경로가 아닙니다.
|
||||
- 클라이언트는 Keycloak 에서 token 을 받고 auth-server 에는 Bearer token 만 보냅니다.
|
||||
- 내부 사용자 검증은 token signature 검증이 아니라 비즈니스 식별 / 조회 문제로 분리됐습니다.
|
||||
|
||||
## 운영 고려사항
|
||||
|
||||
- **JWKS 회전**: Keycloak 측 회전 시 5 분 내 ResourceServer 가 새 키를 fetch. 회전 직후 발급된 token 이 캐시 만료 전 도달하면 `invalid_token` 가능 — Keycloak 측 grace period 또는 `NimbusJwtDecoder` cache refresh 정책 조정 가능.
|
||||
- **issuer-uri 변경**: prod 와 dev 가 서로 다른 hostname 이라 환경별 overlay 에서 주입. 이 값이 token 의 `iss` 와 한 글자라도 다르면 모든 token 이 거절됨 — 가장 흔한 운영 사고 패턴.
|
||||
- **Realm role 의존**: 기본 사용자에 `user` role 이 부여되지 않으면 401 이 아니라 *401 통과 후 403* 으로 떨어짐. 운영자는 Keycloak realm 의 default-roles-platform 설정에 `user` 가 있는지 확인해야 함.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-002: Keycloak을 인증 주체로 두고 auth-server를 Resource Server로 전환
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-04-17
|
||||
|
||||
## Context
|
||||
|
||||
기존 auth-server는 로컬 로그인, OAuth2 login callback, JWT 발급, issuer/JWKS 공개, Vault Transit 서명 위임까지 맡았습니다.
|
||||
Keycloak이 이미 OIDC Provider와 broker 역할을 수행할 수 있으므로, 인증 책임을 애플리케이션에 계속 남기면 보안 경계와 운영 책임이 중복됩니다.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 로그인·회원가입·token 발급 책임을 전문 IdP로 집중한다.
|
||||
- auth-server는 비즈니스 API와 내부 사용자 식별에 집중한다.
|
||||
- DB 트랜잭션과 외부 인증 호출을 섞지 않는다.
|
||||
- 자체 JWT signer와 Vault Transit dependency를 제거한다.
|
||||
- Keycloak claim 변경의 영향 범위를 security adapter와 application command에 제한한다.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: auth-server가 계속 JWT를 발급
|
||||
|
||||
- 장점: 기존 `/auth/login`과 OAuth2 callback 흐름을 유지할 수 있습니다.
|
||||
- 단점: issuer, signing key, JWKS, Vault Transit 운영 책임이 계속 남습니다.
|
||||
- 트레이드오프: 구현 변경은 적지만 Keycloak 도입 효과가 약합니다.
|
||||
|
||||
### Option 2: Keycloak이 인증/인가 token을 발급하고 auth-server는 검증만 수행 (Keycloak-first)
|
||||
|
||||
- 장점: 로그인·회원가입·token 발급·issuer·JWKS·broker 책임이 Keycloak으로 모입니다. 내부 DB는 Keycloak token이 들어올 때 lazy upsert.
|
||||
- 단점: 클라이언트 로그인 흐름과 테스트 데이터 준비 방식이 바뀝니다. 회원가입 UX는 Keycloak 테마로 흡수해야 합니다.
|
||||
- 트레이드오프: 초기 전환 비용은 있지만 장기 운영 경계가 단순해집니다.
|
||||
|
||||
### Option 3: auth-server가 회원가입을 받고 Keycloak Admin API로 미러링
|
||||
|
||||
- 장점: 기존 signup UX를 유지할 수 있습니다.
|
||||
- 단점: 이중 쓰기 실패 시 일관성 확보용 보상 로직(outbox/Saga)이 필요하고, password 원문이 auth-server를 통과해 보안 경계가 다시 넓어집니다. `no DB transaction held across remote call` 규칙 준수 비용도 큽니다.
|
||||
- 트레이드오프: UX 유지 대가가 구조적 복잡도 증가로 직결됩니다.
|
||||
|
||||
## Decision
|
||||
|
||||
Option 2를 채택합니다. Keycloak을 인증 주체로 두고 auth-server는 Spring Security Resource Server로 token을 검증하며, 회원가입 UX와 계정 lifecycle은 Keycloak realm에 위임합니다. auth-server는 `(provider=KEYCLOAK, provider_subject=sub)` 기준으로 내부 사용자를 식별합니다. 이후 내부 사용자 생성 정책은 [ADR-005](./05-adr-keycloak-user-auto-registration.md)에서 자동 등록으로 보강했습니다.
|
||||
|
||||
## Consequences
|
||||
|
||||
### 긍정적 결과
|
||||
|
||||
- 자체 JWT 발급 코드, 로컬 signup 컨트롤러, Vault Transit signer, password hasher가 모두 제거됩니다.
|
||||
- issuer와 JWKS source of truth가 Keycloak 하나로 고정됩니다.
|
||||
- Spring Security 타입은 `bootstrap`/`presentation` 경계에서 끝나고 application은 claim command만 받습니다.
|
||||
- 내부 DB 스키마에서 `encoded_password`, LOCAL provider 분기, social subject 관련 체크 제약이 모두 제거됩니다(`V5__keycloak_only_provider.sql`).
|
||||
|
||||
### 부정적 결과
|
||||
|
||||
- 클라이언트는 더 이상 auth-server 로그인/회원가입 endpoint를 사용할 수 없습니다.
|
||||
- 회원가입 UX가 Keycloak realm 설정과 테마에 묶입니다.
|
||||
- Keycloak realm 설정은 API 인증의 필수 운영 의존성이며, 운영자는 JWKS 회전과 issuer URI 고정을 책임져야 합니다.
|
||||
|
||||
### 위험 완화
|
||||
|
||||
- `GET /api/v1/auth/me`의 token 검증은 Resource Server에 맡깁니다.
|
||||
- 연결된 내부 사용자가 없으면 [ADR-005](./05-adr-keycloak-user-auto-registration.md)에 따라 email 충돌 검사 후 내부 사용자를 자동 등록합니다.
|
||||
- role/claim mapping은 `KeycloakJwtAuthenticationConverter` 한 곳에 둡니다.
|
||||
- 조회 흐름은 내부 DB lookup만 수행하므로 `GET`에 숨은 쓰기 부작용을 두지 않습니다.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Keycloak Claim / Role 설계
|
||||
|
||||
## Why
|
||||
|
||||
Resource Server는 token을 검증한 뒤 어떤 claim을 내부 principal로 신뢰할지 정해야 합니다.
|
||||
모든 claim을 그대로 비즈니스 계층에 넘기면 Keycloak 계약 변경이 내부 로직 전체로 번집니다.
|
||||
|
||||
## What
|
||||
|
||||
auth-server가 사용하는 최소 claim은 다음입니다.
|
||||
|
||||
| Claim | 용도 | 내부 모델 |
|
||||
|------|------|-----------|
|
||||
| `sub` | Keycloak 사용자 고정 식별자 | `provider_subject` |
|
||||
| `email` | 내부 사용자 신규 생성과 충돌 검사 | `UserEmail` |
|
||||
| `name` | 내부 사용자 표시 이름 | `UserName` |
|
||||
| `preferred_username` | `name`이 없을 때 fallback | `AuthenticatedUser.name` |
|
||||
| `scope` | OAuth2 scope authority | `SCOPE_*` |
|
||||
| `realm_access.roles` | Realm role authority | `ROLE_*` |
|
||||
|
||||
## Sample JWT payload
|
||||
|
||||
Keycloak realm `platform` 이 발급하는 access token 의 payload 는 다음 형태입니다 (값은 예시).
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "https://keycloak.dev.example.com/realms/platform",
|
||||
"sub": "1f7a3b2e-9c4d-4f81-a0e7-2b8f5c1d6a4b",
|
||||
"aud": "auth-server-ingress",
|
||||
"exp": 1735689600,
|
||||
"iat": 1735686000,
|
||||
"azp": "auth-server-ingress",
|
||||
"scope": "openid email profile",
|
||||
"email": "alice@example.com",
|
||||
"email_verified": true,
|
||||
"name": "Alice Kim",
|
||||
"preferred_username": "alice",
|
||||
"realm_access": {
|
||||
"roles": ["user"]
|
||||
},
|
||||
"resource_access": {
|
||||
"auth-server-ingress": { "roles": [] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`sub` 는 Keycloak 이 사용자에게 부여하는 immutable UUID 로, 내부 DB 의 `provider_subject` 와 1:1 로 묶입니다. `realm_access.roles` 는 위 표의 `ROLE_*` 매핑 대상입니다.
|
||||
|
||||
## How
|
||||
|
||||
`KeycloakJwtAuthenticationConverter`가 JWT claim을 `AuthenticatedUser`로 변환합니다.
|
||||
controller는 `@CurrentUser`로 이 principal을 받고, application에는 `KeycloakUserClaims(subject, email, name)`만 전달합니다.
|
||||
`KeycloakUserClaimsValidator`는 claim이 비어 있거나 `UserEmail`/`UserName` 도메인 규칙에 어긋나면 `InvalidKeycloakClaimsException(AUTH-003, HTTP 400)`으로 번역해 Keycloak 오염 클레임이 비즈니스 로직까지 흘러들지 않게 차단합니다.
|
||||
|
||||
권장 realm role:
|
||||
|
||||
| Keycloak role | Spring authority | 용도 |
|
||||
|---------------|------------------|------|
|
||||
| `user` | `ROLE_user` | 일반 인증 사용자 |
|
||||
| `admin` | `ROLE_admin` | 운영/관리 API |
|
||||
|
||||
역할 이름은 Keycloak realm에서 소유합니다. auth-server는 role 값을 새로 발급하거나 DB 값으로 권한을 덮어쓰지 않습니다.
|
||||
현재 `/api/v1/auth/me`는 `GET` 요청에 대해 `ROLE_user`가 필요합니다. 따라서 Keycloak realm의 self-service registration 기본 역할에는 `user`를 포함해야 하며, 역할이 없는 token은 인증은 성공하더라도 `AUTH-002`로 거절됩니다.
|
||||
|
||||
## Result / Trade-offs
|
||||
|
||||
- token 검증 결과는 신뢰하되, 내부 사용자 식별은 `(provider=KEYCLOAK, provider_subject=sub)` 기준으로만 수행합니다.
|
||||
- email은 자동 계정 연결 키로 쓰지 않습니다. 연결된 내부 사용자가 없으면 email 충돌 검사 후 새 내부 사용자를 만들고, 같은 email 이 이미 다른 subject 에 연결되어 있으면 `AUTH-005` 409를 반환합니다.
|
||||
- 실무 권장안은 “IdP subject를 immutable external identity로 삼고, email은 표시/연락/초기 등록 보조값으로만 다루는 것”입니다. email은 변경될 수 있고 재사용될 수 있으므로 권한 있는 내부 계정 연결 키로 쓰면 위험합니다.
|
||||
- 표시용 `email`/`name`은 token claim에서 왔더라도 DB에 저장된 값이 authoritative입니다. 따라서 `/api/v1/auth/me` 응답은 DB 값을 노출하고, 권한(`authorities`)은 token claim에서 그대로 가져옵니다.
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-004: Keycloak 전환 후 자체 인증 자산 일괄 제거
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-04-17 (ADR-002 와 동일 PR 묶음)
|
||||
|
||||
## Context
|
||||
|
||||
[ADR-002](./02-adr-keycloak-resource-server.md) 가 인증 주체를 Keycloak 으로 옮긴 직후, auth-server 내부에는 다음이 *기능적으로 무용해진* 상태로 남아 있었다.
|
||||
|
||||
- 자체 JWT 발급기 (`NimbusJwtTokenIssuerAdapter`, RSA key source, Vault Transit signer)
|
||||
- 로컬 회원가입 / 로그인 endpoint, BCrypt password hasher, password 도메인 규칙
|
||||
- 자체 OIDC discovery / JWKS endpoint
|
||||
- Multi-provider (LOCAL / GOOGLE / GITHUB) 분기
|
||||
|
||||
이걸 그대로 두면 *"어느 issuer 의 token 을 신뢰하는가"*, *"회원 식별자의 source of truth 는 어디인가"* 가 흐려지고, 운영 장애 가능 지점도 늘어난다 (예: 로컬 RSA key 파일 누락 시 부팅 실패, Vault Transit endpoint 변경 시 영향 등).
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Keycloak 으로 인증 주체가 옮겨졌으므로, auth-server 의 *모든* 인증 발급 / 키 관리 책임은 중복.
|
||||
- `provider` 컬럼이 `LOCAL/GOOGLE/GITHUB/KEYCLOAK` 4 종으로 분기되어 있으면 auth lookup 쿼리, audit event, error code 모두 분기 비용이 남는다.
|
||||
- password 가 디스크에 저장되는 한 *비밀번호 정책 / 해시 회전 / 누설 시 회전* 책임이 따라온다 — IdP 가 처리하는 게 정석.
|
||||
- 부분 제거 (deprecation 표기 후 점진 제거) 는 6~12 개월 dead code 가 portfolio 에 남는 비용이 큼.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: 점진적 deprecation (각 클래스에 `@Deprecated` + 주석)
|
||||
|
||||
- 장점: 기존 클라이언트가 일시적으로 호환됨.
|
||||
- 단점: dead code 가 PR diff 마다 노이즈가 됨. 보안 책임 (password 저장, 자체 키 보관) 이 *제거 전까지* 계속 살아 있음.
|
||||
- 트레이드오프: portfolio 관점에서 *"제거를 못 끝내는 사람"* 시그널.
|
||||
|
||||
### Option 2: 일괄 제거 + DB 스키마 정리 (V4 → V5 마이그레이션 2 단)
|
||||
|
||||
- 장점: 책임 경계가 한 PR 로 깨끗하게 정리됨. password / Vault Transit 운영 위험이 즉시 사라짐.
|
||||
- 단점: 기존 `/auth/login` / `/auth/oauth2/*` 클라이언트가 곧장 깨짐 (다만 Keycloak 으로 이미 옮겼으니 이 시점에 클라이언트는 없음).
|
||||
- 트레이드오프: 초기 변경량이 크지만, 이후 운영 표면이 작아짐.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 2 채택.** 자체 인증 발급 / 로컬 회원가입 / Vault Transit signing 자산을 일괄 제거하고, DB 스키마는 `V4__add_keycloak_provider.sql` (KEYCLOAK 값 허용) → `V5__keycloak_only_provider.sql` (encoded_password 컬럼 제거 + LOCAL/social 체크 제약 제거 + `provider` 체크를 `KEYCLOAK` 단일값으로 고정) 두 단계로 정리.
|
||||
|
||||
## 제거된 책임
|
||||
|
||||
| 이전 사용처 | 역할 | 처리 |
|
||||
|------------|------|------|
|
||||
| `AuthLoginController` | `/api/v1/auth/login` 로컬 로그인 endpoint | 제거 |
|
||||
| `AuthOAuth2Controller` | Keycloak broker 시작 / 완료 endpoint | 제거 |
|
||||
| `OAuth2SecurityConfiguration` | OAuth2 login filter chain | `ResourceServerSecurityConfiguration` 으로 대체 |
|
||||
| `UserSignUpController` / `SignUp*` | 로컬 회원가입 use case 와 DTO | 제거 (회원가입은 Keycloak 담당) |
|
||||
| `BcryptPasswordEncoderAdapter` / `PasswordHasherPort` | bcrypt 비밀번호 해싱 | 제거 (auth-server 는 password 를 다루지 않음) |
|
||||
| `EncodedPassword` / `UserPasswordPolicy` / `InvalidUserPasswordException` | 비밀번호 도메인 규칙 | 제거 |
|
||||
| `User.registerLocal` | LOCAL provider 등록 경로 | 제거 (`registerKeycloak` 만 남음) |
|
||||
| `AuthProvider.LOCAL/GOOGLE/GITHUB` | social/local provider 분기 값 | 제거 (`KEYCLOAK` 만 남음) |
|
||||
| `NimbusJwtTokenIssuerAdapter` | 자체 RS256 JWT 발급 | 제거 |
|
||||
| `JwtKeyConfiguration` / `ConfiguredJwtSigningKeySource` | 로컬 RSA signer 구성 | 제거 |
|
||||
| `VaultTransitClient` / `VaultTransitJwtSigner` | Vault Transit 서명 API 호출 | 제거 |
|
||||
| `ConfiguredOpenIdDiscoveryDocumentProvider` / `OpenIdDiscoveryController` | 자체 issuer / JWKS 공개 | 제거 |
|
||||
| `auth-login.html` | auth-server 로그인 페이지 | 제거 |
|
||||
| `AuthAuditEventType.LOGIN_*` / `OAUTH_LOGIN_*` / `TOKEN_ISSUED` / `SIGNUP_*` | 로컬 인증 / 발급 감사 이벤트 | `KEYCLOAK_USER_NOT_FOUND` 로 축소 |
|
||||
| `AuthErrorCode.INVALID_CREDENTIALS` / `OAUTH_*` | 로컬 로그인 에러 코드 | `KEYCLOAK_CLAIMS_INVALID` / `KEYCLOAK_ACCOUNT_CONFLICT` 로 교체 |
|
||||
| `UserErrorCode.*` / `ApiSuccessCode.USER_SIGNED_UP` | 로컬 회원가입 에러 / 성공 코드 | 제거 |
|
||||
|
||||
## 남은 책임
|
||||
|
||||
| 현재 사용처 | 역할 |
|
||||
|------------|------|
|
||||
| `ResourceServerSecurityConfiguration` | Keycloak issuer 기반 Bearer token 검증 |
|
||||
| `KeycloakJwtAuthenticationConverter` | claim / role → project principal 매핑 |
|
||||
| `AuthenticatedUserController` | 현재 사용자 조회 진입점 |
|
||||
| `KeycloakUserLoader` | `(KEYCLOAK, sub)` 기준 내부 사용자 식별 |
|
||||
| `KeycloakUserClaimsValidator` | 검증된 JWT 에서 올라온 claim 의 내부 도메인 적합성 확인 |
|
||||
|
||||
## DB 스키마 변화
|
||||
|
||||
- `V4__add_keycloak_provider.sql`: `KEYCLOAK` 값을 허용하는 중간 단계 migration
|
||||
- `V5__keycloak_only_provider.sql`: `encoded_password` 컬럼과 LOCAL / social 체크 제약을 제거하고, `provider_subject` 를 NOT NULL 로, `provider` 체크를 `KEYCLOAK` 단일값으로 고정
|
||||
|
||||
## Consequences
|
||||
|
||||
### 긍정적 결과
|
||||
|
||||
- auth-server 는 더 이상 token 을 만들거나 공개키를 배포하거나 password 를 저장하지 않습니다.
|
||||
- issuer / JWKS source of truth 가 Keycloak 한 곳으로 고정.
|
||||
- DB 스키마에서 `encoded_password`, LOCAL provider 분기, social subject 체크 제약이 모두 제거 — 코드 분기뿐 아니라 *데이터 모델* 도 단순해짐.
|
||||
- Vault Transit endpoint 의존성이 사라져 dev 환경에서 Vault 가 secret store 역할만 하면 됨.
|
||||
|
||||
### 부정적 결과
|
||||
|
||||
- 클라이언트는 더 이상 auth-server 로그인 / 회원가입 endpoint 를 사용할 수 없음 (이 시점에 그런 클라이언트는 없었음 — pre-emptive 제거).
|
||||
- 회원가입 UX 가 Keycloak realm 설정 / 테마에 묶임.
|
||||
- Keycloak realm 설정은 API 인증의 필수 운영 의존성 — 운영자는 JWKS 회전과 issuer URI 고정을 책임.
|
||||
|
||||
### 위험 완화
|
||||
|
||||
- `GET /api/v1/auth/me` 의 token 검증은 ResourceServer 가 담당.
|
||||
- 연결된 내부 사용자가 없으면 [ADR-005](./05-adr-keycloak-user-auto-registration.md)에 따라 email 충돌 검사 후 내부 사용자 자동 등록.
|
||||
- role / claim mapping 은 `KeycloakJwtAuthenticationConverter` 한 곳에 모임.
|
||||
- 조회 흐름은 내부 DB lookup 만 수행하므로 `GET` 에 숨은 쓰기 부작용 없음.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [02-adr-keycloak-resource-server.md](./02-adr-keycloak-resource-server.md) — 인증 주체 이전 결정 (ADR-002)
|
||||
- [05-adr-keycloak-user-auto-registration.md](./05-adr-keycloak-user-auto-registration.md) — Keycloak 인증 사용자 내부 자동 등록 결정 (ADR-005)
|
||||
- [01-architecture.md](./01-architecture.md) — 현재 ResourceServer 아키텍처
|
||||
- [03-claim-role-design.md](./03-claim-role-design.md) — claim / role 매핑 정책
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-005: Keycloak 인증 사용자 내부 자동 등록
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-05-12
|
||||
|
||||
## Context
|
||||
|
||||
Keycloak 이 로그인과 계정 lifecycle 을 소유하면 auth-server 는 검증된 access token 으로 내부 사용자 레코드를 식별해야 합니다. 기존 결정은 내부 사용자가 없으면 `AUTH-004` 404 를 반환하는 방식이었지만, 이 방식은 Keycloak self-service registration 과 auth-server 의 내부 사용자 테이블을 별도 운영 절차로 동기화해야 했습니다.
|
||||
|
||||
자동 등록을 도입하더라도 email 을 계정 연결 키로 쓰면 안 됩니다. email 은 변경되거나 재사용될 수 있으므로 내부 권한 식별자는 계속 Keycloak `sub` 와 `(provider=KEYCLOAK, provider_subject=sub)` 조합이어야 합니다.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Keycloak self-service registration 이후 첫 API 호출에서 내부 사용자 레코드를 준비한다.
|
||||
- 인증 주체와 token 발급 책임은 계속 Keycloak 에 둔다.
|
||||
- email 은 충돌 검사에만 사용하고 내부 계정 연결 키로 사용하지 않는다.
|
||||
- 내부 사용자 생성은 application use case 의 트랜잭션 경계 안에서 수행한다.
|
||||
- Resource Server 부팅은 Keycloak discovery endpoint 가 떠 있어야만 성공하는 구조를 피한다.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: 내부 사용자가 없으면 계속 404 반환
|
||||
|
||||
- 장점: `GET /api/v1/auth/me` 가 순수 조회로 남고 쓰기 부작용이 없다.
|
||||
- 단점: Keycloak 사용자와 내부 사용자 테이블을 별도 배치나 운영 절차로 맞춰야 한다.
|
||||
- 트레이드오프: HTTP 의미는 단순하지만 운영 동기화 비용이 생긴다.
|
||||
|
||||
### Option 2: 첫 인증 요청에서 내부 사용자 자동 등록
|
||||
|
||||
- 장점: Keycloak 에서 계정을 만든 사용자가 첫 API 호출 시 바로 내부 사용자 레코드를 얻는다.
|
||||
- 단점: `GET /api/v1/auth/me` 가 missing user 경로에서 쓰기를 수행한다.
|
||||
- 트레이드오프: 사용자 온보딩은 단순해지지만 transaction, 충돌 처리, 감사 이벤트가 필요하다.
|
||||
|
||||
### Option 3: Keycloak Admin/Event API 로 사전 동기화
|
||||
|
||||
- 장점: API 요청 경로의 쓰기 부작용을 줄일 수 있다.
|
||||
- 단점: 외부 API 호출, retry, idempotency, 실패 보상 흐름이 필요하다.
|
||||
- 트레이드오프: 운영 복잡도가 커지고 Keycloak 이벤트 전달 신뢰성에 의존한다.
|
||||
|
||||
## Decision
|
||||
|
||||
Option 2 를 채택합니다. `GET /api/v1/auth/me` 는 검증된 Keycloak claim 의 `sub` 로 내부 사용자를 조회하고, 없으면 `email` 충돌을 먼저 검사한 뒤 내부 사용자를 자동 등록합니다. 동일 email 이 이미 다른 subject 에 연결되어 있으면 자동 연결하지 않고 `AUTH-005` 409 를 반환합니다.
|
||||
|
||||
## Consequences
|
||||
|
||||
### 긍정적 결과
|
||||
|
||||
- Keycloak self-service registration 과 내부 사용자 생성이 첫 API 호출에서 자연스럽게 이어진다.
|
||||
- 내부 사용자 식별 기준은 여전히 immutable `sub` 이며, email 기반 자동 연결은 금지된다.
|
||||
- 자동 등록 성공은 `KEYCLOAK_USER_AUTO_REGISTERED` 감사 이벤트로 남는다.
|
||||
|
||||
### 부정적 결과
|
||||
|
||||
- `GET /api/v1/auth/me` 는 missing user 경로에서 DB write 를 수행한다.
|
||||
- 동시 첫 요청에서는 DB unique 제약과 충돌 처리 정책을 함께 고려해야 한다.
|
||||
- 운영 환경은 `APP_SECURITY_KEYCLOAK_ISSUER_URI` 를 제공해야 한다.
|
||||
|
||||
### 위험 완화
|
||||
|
||||
- application use case 에 transaction boundary 를 둔다.
|
||||
- 같은 email 이 이미 존재하면 `AUTH-005` 409 로 중단하고 새 subject 에 자동 연결하지 않는다.
|
||||
- `(provider, provider_subject)` unique 제약으로 subject 중복 생성을 방지한다.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Keycloak
|
||||
|
||||
Keycloak 을 OAuth2 / OIDC Provider 겸 Identity Broker 로 두고 auth-server 를 ResourceServer 로 축소한 구조의 결정 기록과 설계 문서.
|
||||
|
||||
## 현재 프로젝트 맥락
|
||||
|
||||
- auth-server 는 Keycloak realm 이 발급한 access token 을 ResourceServer 로 검증한다.
|
||||
- 로그인, OAuth2 broker, token 발급, issuer / JWKS 공개 책임은 Keycloak 이 가진다.
|
||||
- auth-server 는 검증된 claim 을 바탕으로 내부 사용자를 식별하고, 처음 보는 Keycloak `sub` 는 email 충돌 검사 후 내부 사용자로 자동 등록한다.
|
||||
|
||||
## 문서
|
||||
|
||||
| 문서 | 내용 |
|
||||
|------|------|
|
||||
| [01-architecture.md](./01-architecture.md) | Keycloak 중심 인증 구조 + ResourceServer 검증 체인 + JWKS 캐시 / issuer-uri 운영 |
|
||||
| [02-adr-keycloak-resource-server.md](./02-adr-keycloak-resource-server.md) | 인증 주체를 Keycloak 으로 이전한 ADR-002 |
|
||||
| [03-claim-role-design.md](./03-claim-role-design.md) | Keycloak claim / role 설계 + sample JWT payload |
|
||||
| [04-adr-token-ownership-cleanup.md](./04-adr-token-ownership-cleanup.md) | 자체 JWT / Vault Transit / 로컬 회원가입 일괄 제거 ADR-004 |
|
||||
| [05-adr-keycloak-user-auto-registration.md](./05-adr-keycloak-user-auto-registration.md) | Keycloak 인증 사용자 내부 자동 등록 ADR-005 |
|
||||
|
||||
## 역할 분리
|
||||
|
||||
- 심화 설계 문서: 이 폴더
|
||||
- 로컬 설정 절차: [docs/development/keycloak/LOCAL_SETUP.md](../../development/keycloak/LOCAL_SETUP.md)
|
||||
- 로컬 realm import 자산: [docs/development/keycloak/realm/project-auth-realm-local.json](../../development/keycloak/realm/project-auth-realm-local.json)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,19 @@
|
||||
# Topic Docs
|
||||
|
||||
토픽별 아키텍처 결정 기록 (ADR) 과 운영 런북 / 트러블슈팅을 모은 폴더.
|
||||
|
||||
## 토픽 인덱스
|
||||
|
||||
| 주제 | 핵심 내용 |
|
||||
|------|------|
|
||||
| [02-clean-architecture](./02-clean-architecture/README.md) | 5 모듈 레이어 경계 ADR + 예외 처리 5+1 계층 + sealed `ClientFacingErrorCode` 정책 + Validation deep-dive |
|
||||
| [03-keycloak](./03-keycloak/README.md) | Keycloak ResourceServer 전환 ADR + claim / role 설계 + 자체 인증 자산 제거 ADR |
|
||||
| [04-logging](./04-logging/README.md) | MDC traceId (+ sentinel `-` 폴백), structured logging, audit 단일 채널, LogSanitizer 회귀 게이트 |
|
||||
|
||||
## 정책/테스트 단일 출처
|
||||
|
||||
상위 hub 에 모인 *정책 단일 출처* 문서는 토픽 문서가 참조합니다 (어긋날 경우 정책 문서가 우선).
|
||||
|
||||
- [`exception-handling-policy.md`](../exception-handling-policy.md) — 13개 예외 처리 정책
|
||||
- [`testing-coverage-policy.md`](../testing-coverage-policy.md) — JaCoCo / PIT / jqwik Tier + 임계치
|
||||
- [`testing-history/`](../testing-history/README.md) — 사건 단위 before/after 비교 기록
|
||||
Reference in New Issue
Block a user