# 에러 핸들링 아키텍처
> **정책 단일 출처**: 코드/테스트/게이트로 강제되는 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
(DomainException)"]
APP["Application Layer
(BusinessException)"]
INFRA["Infrastructure Layer
(InfrastructureException)"]
MVC["Spring MVC
(HttpMessageNotReadable 등)"]
SEC["Security Filter Chain
(AuthenticationException,
AccessDeniedException)"]
end
subgraph "예외 처리 출구"
GEH["Presentation Exception Handlers
(@RestControllerAdvice)"]
SEH["SecurityExceptionHandler
(EntryPoint + AccessDenied)"]
IEH["InfrastructureExceptionHandler
(bootstrap advice)"]
end
subgraph "횡단 관심사"
TF["TraceIdFilter
(MDC traceId)"]
end
DOM -->|"Application 경계에서
BusinessException 으로 번역"| APP
DOM -->|"Infrastructure 복원 경계에서
InfrastructureException 으로 번역"| INFRA
DOM -.->|"번역 누락 시
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> 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