init: 클린 기반 auth 서버 설계

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