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)