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
+168
View File
@@ -0,0 +1,168 @@
# AOP 사용 기준
## 목적
AOP는 비즈니스 로직을 숨기는 우회 수단이 아니라,
여러 타입과 객체를 가로지르는 **cross-cutting concern** 을 모듈화할 때만 사용한다.
## 공식 의미
- Spring AOP는 OOP를 보완하는 방식이다.
- AOP의 핵심 단위는 class가 아니라 aspect다.
- Spring AOP는 proxy-based다.
- proxy-based AOP에서는 proxy를 통과하는 외부 호출만 advice가 적용된다.
- self-invocation은 기본적으로 advice가 적용되지 않는다.
- Spring은 AOP의 대표적 용도로 declarative transaction 같은 cross-cutting concern을 든다.
- pointcut은 더 작은 named pointcut으로 조합하는 것이 권장된다.
- 대부분의 경우 static pointcut이 충분하고 더 낫다.
## 기본 규칙
### 1. AOP는 cross-cutting concern에만 사용
다음은 AOP 후보가 된다.
- 공통 로깅
- 메트릭/트레이싱
- 권한 체크의 반복 경계
- 재시도/타이밍 측정
- 공통 감사(audit)
- 선언적 트랜잭션
다음은 AOP로 풀지 않는다.
- 핵심 비즈니스 흐름
- 상태 전이 정책
- 도메인 규칙
- 복잡한 분기 로직
- 외부 API orchestration
### 2. 기본값은 “명시적 코드”, 예외적으로 AOP
같은 기능을 명시적 코드로 더 잘 읽을 수 있으면 AOP를 쓰지 않는다.
기본:
- use case/service 안에서 명시적으로 보이는 흐름 우선
- 반복되는 횡단 관심사만 AOP로 추출
### 3. proxy-based 한계를 항상 전제로 한다
Spring AOP는 proxy 기반이므로 다음을 전제로 설계한다.
- proxy를 통과하는 외부 호출만 interception
- self-invocation은 적용되지 않음
- “같은 클래스 안에서 호출되면 aspect가 붙겠지”를 금지
### 4. self-invocation 해결을 위해 AOP 남용 금지
self-invocation 문제를 해결하려고 다음을 기본 금지한다.
- 자기 자신 proxy 주입
- `AopContext.currentProxy()` 의존
- 구현을 proxy semantics에 강하게 묶는 설계
기본 대응:
- 경계를 다시 분리
- 클래스를 분리
- 더 명시적인 구조로 변경
### 5. AOP는 경계가 뚜렷한 곳에만 적용
좋은 적용 지점:
- service/use-case public method
- controller 경계
- repository 경계
- 명시된 package/bean naming convention
지양:
- 너무 넓은 전체 패키지
- “일단 다 잡고 보자” 식 표현식
- private/internal 세부 구현까지 얽는 pointcut
### 6. pointcut은 작고 이름 있게 조합
공식 권장대로 pointcut은 작은 named pointcut을 조합해 만든다.
기본:
- package 범위 pointcut
- role 기반 pointcut
- public method pointcut
- bean naming 기반 pointcut
를 분리하고 조합한다.
### 7. 대부분 static pointcut 우선
동적 조건보다 static pointcut이 충분하면 static 쪽을 우선한다.
성능/이해도/예측 가능성이 더 좋다.
### 8. `@Around`는 최소화
`@Around`는 가장 강력하지만 가장 위험하다.
반환값/예외/호출 자체를 제어할 수 있으므로 꼭 필요할 때만 쓴다.
기본 우선순위:
- 단순 전처리 -> `@Before`
- 정상 반환 후 후처리 -> `@AfterReturning`
- 예외 기록/번역 -> `@AfterThrowing`
- 무조건 정리 -> `@After`
- 호출 제어/타이밍/재시도 등 정말 필요할 때만 `@Around`
### 9. advice 안에서 비즈니스 의미를 새로 만들지 않는다
advice는 보조 concern을 수행해야 한다.
금지:
- 상태 전이 결정
- 비즈니스 실패를 성공처럼 바꾸기
- 핵심 정책 우회
- controller/service가 해야 할 결정을 aspect에서 대신하기
### 10. 예외를 숨기지 않는다
AOP에서 예외를 잡더라도 기본은:
- 기록
- 문맥 추가
- 그대로 전파
중 하나다.
금지:
- 예외 삼키기
- 정상값으로 은폐
- 실패를 조용히 무시
### 11. 트랜잭션 대체 수단으로 일반 AOP를 남용하지 않는다
선언적 트랜잭션은 Spring이 제공하는 표준 메커니즘을 우선 사용한다.
일반 custom aspect로 transaction semantics를 흉내 내지 않는다.
### 12. AOP는 observability / policy enforcement에 더 적합
프로젝트에서 AOP는 아래 유형에 더 적합하다.
- 실행 시간 측정
- 공통 로깅
- 감사 기록
- annotation 기반 정책 강제
- 공통 예외 기록
복잡한 use case orchestration에는 부적합하다.
### 13. pointcut 범위는 문서화 가능해야 한다
pointcut을 보고 아래를 설명할 수 있어야 한다.
- 어디에 적용되는가
- 왜 거기에만 적용되는가
- 새 코드가 추가되면 어떤 naming/package 규칙으로 포함되는가
설명하기 어려우면 범위가 너무 넓거나 모호한 것이다.
### 14. bean naming / package convention을 설계와 함께 쓴다
Spring 공식 문서가 bean PCD나 package 기반 pointcut 예시를 드는 것처럼,
AOP를 쓸 거면 package 구조나 bean naming convention이 일정해야 한다.
즉:
- `*Service`
- `..application..`
- `..infrastructure..`
같은 규칙은 pointcut과 함께 관리한다.
## 프로젝트 기준 요약
- AOP는 cross-cutting concern에만 사용
- 기본값은 명시적 코드
- Spring AOP는 proxy-based라는 점을 전제로 설계
- self-invocation 기대 금지
- pointcut은 작고 이름 있게 조합
- 대부분 static pointcut 우선
- `@Around` 최소화
- advice에서 비즈니스 의미를 만들지 않음
- 예외를 숨기지 않음
+175
View File
@@ -0,0 +1,175 @@
# ApplicationEvent 사용 기준
## 목적
Application Event는 같은 애플리케이션 내부에서 **느슨하게 결합된 후속 반응**을 분리하기 위해 사용한다.
핵심 비즈니스 오케스트레이션을 숨기는 수단으로 사용하지 않는다.
## 공식 의미
- `ApplicationEventPublisher`는 이벤트 발행 기능을 제공한다.
- `publishEvent(Object)`는 일반 객체도 이벤트로 발행할 수 있으며, 필요 시 `PayloadApplicationEvent`로 감싸진다.
- 이벤트 발행은 multicaster로의 hand-off일 뿐, 그 자체로 synchronous/asynchronous 또는 immediate execution을 보장하지 않는다.
- 리스너는 가능한 한 효율적이어야 하며, 오래 걸리거나 blocking 가능한 작업은 개별적으로 비동기 실행을 고려한다.
- 트랜잭션 결과와 묶어 처리해야 하면 `@TransactionalEventListener`를 사용한다.
- `@TransactionalEventListener`의 기본 phase는 `AFTER_COMMIT`이다.
- 트랜잭션 밖에서 발행된 이벤트는 기본적으로 discard되며, `fallbackExecution = true`일 때만 예외적으로 처리된다.
## 기본 규칙
### 1. Application Event는 “후속 반응”에만 사용
다음은 이벤트 후보가 된다.
- 감사 로그 기록
- 메트릭/알림 발행
- 후속 캐시 정리
- 읽기 모델 갱신
- 부가적인 notification
- core use case 이후의 느슨한 반응
다음은 이벤트로 풀지 않는다.
- 핵심 비즈니스 흐름 자체
- 반드시 순서대로 수행되어야 하는 오케스트레이션
- 즉시 실패/성공 여부가 핵심인 주 경로
- 도메인 규칙 판정
- controller/service가 직접 보여줘야 하는 결과 계산
### 2. 이벤트는 같은 애플리케이션 내부 경계로 본다
기본적으로 Spring Application Event는 in-process 이벤트다.
즉:
- 다른 시스템과의 통합 이벤트 브로커 대체제가 아니다
- Kafka/RabbitMQ 같은 외부 메시징과 같은 의미로 쓰지 않는다
- 프로세스 내부의 느슨한 반응 분리에 한정한다
### 3. 발행은 “hand-off”일 뿐, 실행 모델을 가정하지 않는다
`publishEvent(...)`를 호출했다고 해서 아래를 가정하지 않는다.
- 반드시 동기적으로 끝난다
- 반드시 즉시 실행된다
- 반드시 같은 스레드에서 다 처리된다
즉 발행자(publisher)는 listener의 실행 방식에 의존하지 않는다.
### 4. listener는 짧고 효율적으로 유지
공식 문서 취지대로 listener는 가능한 한 짧고 효율적으로 유지한다.
기본 금지:
- 긴 블로킹 작업
- 대규모 외부 API 호출
- 무거운 batch 처리
- 여러 단계 오케스트레이션
정말 오래 걸리면 별도 비동기/후속 처리 구조를 검토한다.
### 5. 트랜잭션 결과가 중요하면 `@TransactionalEventListener`
다음은 `@TransactionalEventListener`를 우선 검토한다.
- DB commit 성공 후에만 실행되어야 하는 후속 처리
- rollback되면 수행하면 안 되는 반응
- 저장 완료 이후에만 의미가 있는 알림/감사/후속 처리
기본 phase:
- 특별한 이유가 없으면 `AFTER_COMMIT`
### 6. `fallbackExecution = true`는 예외적으로만
트랜잭션이 없을 때도 listener를 실행해야 하는 경우가 정말 명확할 때만 사용한다.
기본값:
- 트랜잭션 경계가 없는 발행은 discard되어도 괜찮다고 본다
### 7. 이벤트 payload는 처리에 필요한 상태를 포함
공식 문서상 reactive/async hand-off에서는 thread-local 상태를 기대하면 안 된다.
따라서 이벤트 객체에는 listener가 처리하는 데 필요한 최소 상태를 자체적으로 담는다.
금지:
- listener가 `SecurityContext`, MDC, thread-local만 믿고 동작
- payload 없이 “가서 다시 다 조회해라” 식으로 과도하게 빈약한 이벤트
기본:
- 식별자
- 필요한 시점 정보
- 필요한 타입/상태
를 명시적으로 포함
### 8. payload는 작고 안정적으로
이벤트는 무거운 객체 그래프 전체보다, listener가 필요한 최소 데이터만 담는다.
기본:
- entity 전체보다 id/필수 상태 우선
- JPA lazy proxy를 payload로 넘기지 않음
- 직렬화/로그에 취약한 대형 객체를 그대로 넘기지 않음
### 9. 이벤트 이름은 business fact 또는 completed action으로 짓는다
좋은 방향:
- `UserRegisteredEvent`
- `LoginSucceededEvent`
- `PublicKeyRotatedEvent`
지양:
- `DoSomethingEvent`
- `CommonEvent`
- `UserProcessEvent`
이름만 보고 무슨 일이 일어났는지 보여야 한다.
### 10. 발행자는 listener 존재를 몰라야 한다
publisher는 listener가 몇 개인지, 누가 듣는지, 어떤 순서인지에 기대지 않는다.
금지:
- “이 이벤트를 쏘면 저 listener가 반드시 먼저 실행된다”는 설계
- 이벤트 발행으로 핵심 결과를 암묵적으로 완성하는 구조
### 11. listener 순서 의존 최소화
`@Order`를 줄 수는 있지만, 가능하면 listener 간 순서 의존을 설계하지 않는다.
정말 필요할 때만:
- 같은 phase 안에서 우선순위 조정
- 매우 명확한 부가 처리 순서
기본은 서로 독립적으로 동작해야 한다.
### 12. listener 안에서 핵심 business decision 금지
listener는 후속 반응을 수행해야 한다.
금지:
- 핵심 상태 전이 결정
- 메인 use case 성공/실패를 뒤집는 판단
- 여러 하위 흐름을 연결한 복잡한 오케스트레이션
### 13. listener 예외는 의도를 분명히
listener에서 예외가 나면 어떤 영향을 기대하는지 명확해야 한다.
기본:
- 주 흐름과 강결합이면 이벤트보다 명시적 호출이 더 적합
- 후속 반응이면 실패 처리/재시도/로그 정책을 분리해서 설계
- 예외를 조용히 삼키지 않는다
### 14. 이벤트는 남발하지 않는다
“느슨하게 연결하고 싶다”는 이유만으로 이벤트를 남발하지 않는다.
다음 질문 중 여러 개가 “예”일 때만 검토한다.
- 발행자와 반응자를 분리할 가치가 큰가?
- 반응자가 하나가 아닐 수 있는가?
- 후속 반응이 핵심 흐름이 아닌가?
- 트랜잭션 완료 후 처리로 분리하는 이점이 큰가?
### 15. 테스트에서 이벤트를 검증할 수 있어야 한다
Spring 테스트는 `ApplicationEvents`를 기록하고 검증할 수 있다.
기본:
- 이벤트를 발행하는 use case는 발행 여부를 테스트 가능하게 설계
- listener 동작도 별도 테스트 가능하게 유지
- “이벤트가 어딘가에서 되겠지”를 금지
## 프로젝트 기준 요약
- Application Event는 내부 후속 반응 분리 수단
- 핵심 오케스트레이션에는 기본 금지
- 발행은 hand-off일 뿐 실행 모델을 가정하지 않음
- listener는 짧고 효율적으로
- commit 결과가 중요하면 `@TransactionalEventListener`
- payload는 작고 필요한 상태를 명시적으로 포함
- publisher는 listener 순서/존재를 몰라야 함
- 이벤트 남발 금지
@@ -0,0 +1,280 @@
# Async / Scheduler / Retry 기준
## 1. 목적
이 문서는 Spring의 비동기 실행(@Async), 스케줄 실행(@Scheduled), 재시도(@Retryable / RetryTemplate)를 프로젝트에서 언제, 어디에, 어떤 방식으로 사용할지 정의한다.
이 문서의 목적은 다음과 같다.
- 실행 경계를 명확히 한다.
- 프록시 기반 동작의 함정을 피한다.
- 스레드 풀/스케줄러를 암묵적 기본값에 맡기지 않는다.
- 재시도를 “일시적 실패”에만 제한한다.
- 핵심 비즈니스 로직이 비동기/스케줄/재시도 애노테이션 뒤에 숨지 않게 한다.
## 2. 근거 수준
- Official: Spring Framework / Spring Boot / Spring Retry 공식 문서에서 직접 확인되는 내용
- Official + Practice: 공식 제약 위에 일반적인 실무 운영 원칙을 결합한 내용
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
## 3. 기본 원칙
### 3.1 동기 실행을 기본값으로 둔다
비동기, 스케줄, 재시도는 기본 선택지가 아니라 명시적 필요가 있을 때만 도입한다.
- @Async는 호출자가 즉시 반환되어도 되는 후속 작업에만 사용한다.
- @Scheduled는 요청-응답 흐름이 아닌 주기성/지연 실행 작업에만 사용한다.
- retry는 외부 시스템 호출 등 일시적 실패 가능성이 있는 작업에만 사용한다.
- 검증 오류, 도메인 규칙 위반, 매핑 오류, 프로그래밍 오류에 retry를 걸지 않는다.
Spring Retry 공식 문서도 “항상 같은 예외가 발생하는 결정적 실패는 재시도해도 도움이 되지 않으며, 모든 예외 타입에 대해 재시도하지 말라”고 설명한다.
### 3.2 애노테이션은 도메인 규칙이 아니라 실행 메커니즘이다
@Async, @Scheduled, @Retryable은 모두 실행 방식에 대한 인프라 성격의 도구다. 따라서 이 프로젝트에서는 다음을 기본으로 한다.
- domain 레이어에는 사용하지 않는다.
- 주 사용 위치는 application / infrastructure 레이어로 제한한다.
- controller, entity, value object, mapper에 실행 메커니즘 애노테이션을 붙여 책임을 섞지 않는다.
이 규칙은 프로젝트 아키텍처 정렬을 위한 Project Recommendation이다.
## 4. @Async 표준
### 4.1 사용 기준
@Async는 호출 즉시 반환해도 되는 작업에만 사용한다.
허용 예:
- 알림 전송
- 감사 로그 전송
- 비핵심 후속 연산
- 핵심 트랜잭션 완료 뒤의 독립 작업
비허용 예:
- 핵심 비즈니스 결과를 결정하는 로직
- 호출자가 반드시 성공/실패를 알아야 하는 로직
- 트랜잭션 경계를 우회하려는 용도
- 초기화 콜백(@PostConstruct)에 직접 붙이는 방식
Spring 공식 문서상 @Async는 호출 시 작업을 TaskExecutor에 제출해 비동기로 실행하며, @PostConstruct 같은 lifecycle callback과 함께 사용할 수 없다.
### 4.2 프록시 경계 규칙
@Async는 기본적으로 proxy mode로 처리되므로 같은 클래스 내부 호출(self-invocation) 에는 적용되지 않는다. 따라서:
- this.someAsyncMethod() 형태를 금지한다.
- @Async가 필요한 로직은 별도 bean 으로 분리한다.
- 프록시 경계가 드러나게 설계한다.
Spring 공식 문서도 @Async의 기본 advice mode는 proxy이며 같은 클래스 내부 로컬 호출은 가로채지 못한다고 명시한다.
### 4.3 메서드 시그니처 규칙
Spring 공식 기준에서 @Async 메서드는 파라미터는 자유롭지만 반환형은 void 또는 Future 계열이어야 하며, CompletableFuture 사용이 가능하다. 또한 @Configuration 클래스 내부 메서드에는 지원되지 않는다.
프로젝트 규칙은 다음과 같다.
- 호출자가 결과를 관찰해야 하면 CompletableFuture<T>를 사용한다.
- 단순 fire-and-forget이면 void를 사용할 수 있다.
- 새 코드에서 구식 Future는 특별한 호환 요구가 없으면 사용하지 않는다.
- @Configuration 내부 메서드에 @Async를 사용하지 않는다.
### 4.4 예외 처리 규칙
void 반환 @Async 메서드의 예외는 호출자에게 전달되지 않고, 기본적으로는 로깅만 된다. 호출자가 실패를 알아야 하는 경우 CompletableFuture를 사용하고, void를 쓰는 경우에는 AsyncUncaughtExceptionHandler 또는 메서드 내부 명시적 예외 처리 전략을 둔다.
프로젝트 규칙:
- void @Async는 실패를 호출자에게 전달할 필요가 없는 작업에만 사용한다.
- void @Async를 도입하면 예외 처리/로그/모니터링 전략을 같이 정의한다.
- 실패가 비즈니스적으로 중요하면 @Async로 숨기지 않는다.
## 5. Executor / Scheduler 구성 표준
### 5.1 기본 자동 구성을 무심코 공유하지 않는다
Spring Boot는 AsyncTaskExecutor를 자동 구성하고, 그 실행기는 @EnableAsync뿐 아니라 MVC 비동기 요청 처리, WebFlux blocking 지원, GraphQL, JPA bootstrap, background initialization 등 여러 통합 지점에서 사용될 수 있다. 또한 스케줄러도 자동 구성되며, 가상 스레드를 쓰지 않을 때 기본 ThreadPoolTaskScheduler는 기본 스레드 수 1로 동작한다.
따라서 프로젝트 규칙은 다음과 같다.
- @Async용 executor와 @Scheduled용 scheduler를 개념적으로 분리한다.
- 무거운 업무성 비동기 작업을 web 요청 처리와 우연히 같은 executor에 태우지 않는다.
- 스레드 이름 prefix를 명시한다.
- pool size / queue capacity / rejection 정책을 의도적으로 설정한다.
- 운영에서 식별 가능한 bean 이름을 사용한다.
### 5.2 가상 스레드 사용 시 주의
Spring Framework는 SimpleAsyncTaskScheduler가 가상 스레드 정렬 옵션으로 동작할 수 있지만, fixed-delay 작업은 단일 scheduler thread에서 동작하므로 이 경우 fixed-rate나 cron을 권장한다고 설명한다. Spring Boot도 가상 스레드 활성화 시 scheduler가 SimpleAsyncTaskScheduler가 되며 pooling 관련 설정을 무시한다고 설명한다.
프로젝트 규칙:
- 가상 스레드를 켠다고 해서 scheduler 설계를 생략하지 않는다.
- fixed-delay 중심 작업이 많다면 가상 스레드 scheduler를 무비판적으로 선택하지 않는다.
## 6. @Scheduled 표준
### 6.1 사용 기준
@Scheduled는 주기 작업/지연 작업의 진입점으로만 사용한다.
Spring 공식 기준에서:
- 주기 작업에는 cron, fixedDelay, fixedRate 중 정확히 하나를 지정해야 한다.
- initialDelay는 선택 사항이다.
- 메서드는 인자를 받을 수 없다.
- 반환값은 일반적으로 무시된다.
- 같은 메서드에 여러 스케줄 선언을 둘 수 있으며, 이 경우 서로 독립적으로 실행되어 겹칠 수 있다.
프로젝트 규칙:
- 이 프로젝트의 @Scheduled 메서드는 반드시 void 로 작성한다.
- @Scheduled 메서드는 얇은 트리거(thin trigger) 로 유지하고 실제 업무는 application service/use case로 위임한다.
- 하나의 메서드에 여러 @Scheduled를 붙이지 않는다.
- 각 스케줄 작업은 재실행 가능(idempotent) 하고 중복 실행/겹침에 안전해야 한다.
### 6.2 fixedDelay와 fixedRate 선택 기준
Spring 공식 문서 기준:
- fixedDelay는 이전 실행 완료 시점 기준
- fixedRate는 이전 실행 시작 시점 기준 으로 간격이 계산된다.
프로젝트 규칙:
- 이전 실행이 끝난 뒤 다음 실행을 시작해야 하면 fixedDelay
- 일정 간격 기준으로 계속 트리거되어도 괜찮으면 fixedRate
- 업무 시간이 분명한 배치성 작업은 cron
- timezone 의미가 중요한 cron은 zone을 명시하는 방향을 우선 검토한다
### 6.3 스케줄러 풀 크기 규칙
Spring Boot의 기본 scheduler는 단일 스레드일 수 있다. 따라서 둘 이상의 작업이 있거나, 하나라도 오래 걸리는 작업이 있으면 pool size를 명시적으로 설계한다.
프로젝트 규칙:
- scheduler bean 또는 spring.task.scheduling.* 설정을 명시한다.
- “기본값 1개 스레드”에 의존한 채 운영에 올리지 않는다.
- 긴 작업과 짧은 작업이 섞이면 분리 가능성도 검토한다.
## 7. Retry 표준
### 7.1 retry를 사용할 수 있는 위치
Retry는 다음 같은 경우에만 사용한다.
- 외부 HTTP/gRPC/API 호출
- 메시지 브로커 일시 실패
- 네트워크 일시 장애
- 잠깐 후 재시도하면 회복될 수 있는 외부 의존성 오류
Spring Retry는 @EnableRetry로 @Retryable bean에 프록시를 만들며, RetryTemplate 기반의 프로그래밍 방식도 제공한다.
프로젝트 규칙:
- retry는 외부 경계(adapter/client/gateway) 에 가깝게 둔다.
- controller / domain / entity / mapper / validation 로직에는 두지 않는다.
- “왜 재시도 가능한가?”를 설명할 수 없는 경우 retry를 두지 않는다.
### 7.2 기본값을 그대로 쓰지 않는다
Spring Retry의 현재 API 기준:
- maxAttempts 기본값은 3
- retryFor와 noRetryFor를 비워두면 기본적으로 모든 예외가 재시도 대상이 될 수 있다
- backoff는 지정 가능하며 기본은 단순 Backoff 사양이다
- noRetryFor, notRecoverable 같은 세밀한 제어가 가능하다.
프로젝트 규칙:
- @Retryable에는 반드시 retryFor를 명시한다.
- 필요하면 noRetryFor 또는 notRecoverable도 함께 명시한다.
- maxAttempts를 명시한다.
- backoff 전략을 명시한다.
- “기본적으로 모든 예외 재시도” 형태를 금지한다.
### 7.3 @Recover 사용 기준
Spring Retry 공식 문서상 recovery method는:
- @Retryable 메서드와 같은 클래스 에 있어야 하고
- @Recover 로 표시해야 하며
- 반환형이 @Retryable 메서드와 맞아야 한다.
프로젝트 규칙:
- 재시도 소진 뒤 대체 경로가 의미 있을 때만 @Recover를 둔다.
- recover는 “실패를 조용히 삼키는 메서드”가 아니라, 대체 동작 또는 명시적 실패 변환 역할이어야 한다.
- recover가 있어도 관측 가능성(log/metric/alert)을 잃지 않는다.
### 7.4 @Retryable vs RetryTemplate
프로젝트 규칙:
애노테이션 기반이 더 읽기 쉬운 경우: @Retryable
다음 경우에는 RetryTemplate을 우선 검토:
- 한 메서드 전체가 아니라 일부 코드 블록만 재시도해야 하는 경우
- 루프 내부 각 항목마다 다른 retry 문맥이 필요한 경우
- 정책을 동적으로 조합해야 하는 경우
- 테스트에서 retry 경계를 더 명시적으로 다루고 싶은 경우
이 구분은 @Retryable이 bean method 경계의 선언적 방식이고, RetryTemplate은 임의 코드 블록의 프로그래밍 방식이라는 공식 구조에 맞춘 Official + Practice 규칙이다.
## 8. 조합 규칙
### 8.1 한 메서드에 다 몰아넣지 않는다
프로젝트 기본 규칙:
- @Scheduled + @Async + @Retryable를 같은 메서드에 겹쳐 붙이는 것을 기본 금지한다.
- 트리거, 업무 오케스트레이션, 외부 재시도, 후속 비동기 작업은 서로 다른 bean / 메서드 경계 로 나눈다.
이 규칙의 이유는 @Async와 @Retryable 모두 프록시 기반이며, 경계가 흐려질수록 self-invocation·예외 전파·관측 가능성 문제가 커지기 때문이다.
권장 구조:
- @Scheduled → application use case
- application use case → retry가 붙은 external gateway/client
- 비핵심 후속 작업 → 별도 async bean 또는 event listener
### 8.2 트랜잭션과의 결합
프로젝트 규칙:
- 핵심 트랜잭션 오케스트레이션은 application service에 둔다.
- retry 대상 외부 호출과 async 후속 처리까지 같은 메서드에 한꺼번에 섞지 않는다.
- 프록시 경계가 필요한 경우 메서드 분리가 아니라 bean 분리를 우선한다.
이 항목은 기존 transaction 문서와 맞물리는 Project Recommendation이다.
## 9. 체크리스트
다음 질문에 “예”로 답할 수 있어야 도입 가능하다.
@Async:
- 호출자가 즉시 반환되어도 되는가?
- 실패가 호출자에게 반드시 전달될 필요가 없는가, 또는 CompletableFuture로 전달되는가?
- 같은 클래스 내부 호출이 아닌가?
- 전용 executor와 예외 처리 전략이 있는가?
@Scheduled:
- 요청 흐름이 아닌 주기 작업인가?
- 메서드가 얇은 트리거인가?
- 중복 실행/겹침에 안전한가?
- scheduler pool 설정이 의도적으로 잡혀 있는가?
Retry:
- 실패가 일시적이라는 근거가 있는가?
- retryFor, maxAttempts, backoff가 명시되어 있는가?
- validation/domain/programming 오류는 제외되어 있는가?
- recover 또는 최종 실패 경로가 분명한가?
+166
View File
@@ -0,0 +1,166 @@
# bean registration 기준
## 목적
Spring bean 등록은 “컨테이너가 생명주기와 의존성을 관리해야 하는 객체”에만 사용한다.
아무 객체나 bean으로 올리지 않고, stereotype scanning과 `@Configuration` + `@Bean`을 역할에 따라 구분한다.
## 공식 의미
- Spring IoC container는 configuration metadata를 읽어 bean definition을 만들고 객체를 관리한다.
- 설정 메타데이터는 주로 annotation-based component class, `@Configuration` + `@Bean`, 또는 외부 설정으로 표현할 수 있다.
- `@Component`와 그 특수화(`@Repository`, `@Service`, `@Controller`)는 classpath scanning 대상이다.
- `@Bean`은 객체를 생성·설정·초기화하는 factory method를 bean definition으로 등록한다.
- `@Bean``@Configuration` 클래스에서 사용하는 것이 기본 권장 방식이다.
- bean overriding은 일반적으로 권장되지 않으며 설정 가독성을 해친다.
- bean/runtime registration은 초기 단계가 아니라 live access 중에는 공식적으로 지원되지 않는다.
- 일반적으로 fine-grained domain object는 Spring container에 등록하지 않는다.
## 기본 규칙
### 1. bean은 “컨테이너 관리 가치”가 있는 객체만 등록
다음은 bean 등록 후보이다.
- application service / use case entry object
- repository adapter
- external API client
- configuration / security / filter / interceptor
- shared infrastructure object
- framework integration object
다음은 기본적으로 bean 등록하지 않는다.
- domain entity
- value object
- request / response DTO
- command / result DTO
- 단순 임시 helper object
- 매 요청/매 호출마다 새로 만들어도 되는 순수 data object
### 2. 애플리케이션 주 컴포넌트는 stereotype annotation 우선
다음은 stereotype을 우선 검토한다.
- `@Service`
- `@Repository`
- `@Controller` / `@RestController`
- generic component면 `@Component`
즉 프로젝트 코드 안의 “주된 역할 객체”는 scanning 기반 등록을 기본값으로 한다.
### 3. `@Bean`은 명시적 조립이 필요할 때 사용
다음은 `@Configuration` + `@Bean`을 우선 검토한다.
- 외부 라이브러리 타입 등록
- 생성자가 복잡하거나 factory method가 필요한 경우
- 조건부 조립이 필요한 경우
- 여러 collaborator를 엮어 명시적으로 wiring해야 하는 경우
- infrastructure object / client / encoder / formatter / strategy bean 생성
- 같은 config 안에서 inter-bean wiring을 명시적으로 보여주고 싶은 경우
### 4. `@Bean`은 기본적으로 `@Configuration` 안에서만
`@Bean` method는 기본적으로 `@Configuration` 클래스 안에 둔다.
이유:
- full configuration mode가 inter-bean dependency를 더 안전하게 다룬다
- lite mode의 subtle bug 가능성을 줄일 수 있다
기본 금지:
- 일반 `@Component` 안에 습관적으로 `@Bean` method 두기
예외:
- 아주 제한된 factory-style component가 필요하고, inter-bean dependency 호출을 하지 않는 경우
### 5. bean 등록 이유가 이름만 보고 드러나야 한다
- scanning bean이면 stereotype이 역할을 드러내야 한다
- `@Configuration` 클래스는 조립 목적이 이름에 드러나야 한다
좋은 방향:
- `SecurityConfiguration`
- `WebConfiguration`
- `VaultClientConfiguration`
- `OAuth2SecurityConfiguration`
지양:
- `CommonConfig`
- `AppBeans`
- `GeneralConfiguration`
### 6. domain object를 bean으로 등록하지 않는다
Spring 공식 문서도 fine-grained domain object는 보통 container가 아니라 repository/business logic이 만들고 로드한다고 설명한다.
기본 금지:
- `User`, `Money`, `UserEmail`, `CreateUserCommand`를 bean으로 등록
- domain 생성 책임을 container로 넘기기
### 7. bean 이름은 기본 규칙을 따르고, 명시적 이름은 정말 필요할 때만
Spring은 scanning bean의 이름을 일반적으로 decapitalize된 simple class name으로 만든다.
기본:
- 이름 충돌이 없으면 기본 이름 사용
- qualifier/alias/explicit name은 실제 필요가 있을 때만 사용
무분별한 명시적 이름 지정 지양:
- `"mySpecialUserServiceBean"`
- `"appMainPrimaryService"`
### 8. bean overriding 기본 금지
같은 이름의 bean을 덮어쓰는 방식으로 조립하지 않는다.
이유:
- 설정 가독성이 나빠진다
- 어떤 bean이 실제로 쓰이는지 추적이 어려워진다
테스트에서만 예외적으로 필요하면 별도 테스트 설정/지원 메커니즘을 사용한다.
### 9. 런타임 동적 bean 등록 금지
애플리케이션 실행 중 live container에 새 bean을 동적으로 등록하는 방식은 기본 금지한다.
기본:
- bean definition은 startup 시점에 확정
- 동적 확장이 필요하면 registry/plugin/factory 전략을 따로 설계
### 10. bean은 역할 단위로 등록하고, 잡동사니 helper를 bean으로 올리지 않는다
container가 관리할 필요가 없는 순수 helper는 bean 대신:
- static utility
- package-private helper
- mapper instance
- plain object 생성
을 우선 검토한다.
### 11. configuration class는 “조립”만 하고 business logic은 넣지 않는다
`@Configuration` 클래스는 bean wiring과 설정 소유만 담당한다.
금지:
- 비즈니스 흐름
- 상태 전이
- 외부 호출 오케스트레이션
- 의미 있는 계산 로직
### 12. stereotype는 의미에 맞게 쓴다
- persistence adapter면 `@Repository`
- use case/application service면 `@Service`
- web adapter면 `@Controller` / `@RestController`
- 그 외 일반 Spring-managed component면 `@Component`
의미 없는 전부 `@Component` 관성 사용은 지양한다.
### 13. public API 역할이 없는 내부 구현은 과도한 bean 분해를 피한다
container bean 수를 늘리는 것이 곧 좋은 설계는 아니다.
질문:
- lifecycle 관리가 필요한가?
- 외부에서 주입받아야 하는가?
- 테스트 seam 가치가 있는가?
- 명시적 wiring으로 읽기 쉬워지는가?
아니면 plain object가 더 낫다.
## 프로젝트 기준 요약
- bean은 컨테이너 관리 가치가 있는 객체만 등록
- application 주 컴포넌트는 stereotype scanning 우선
- 외부 라이브러리/명시적 조립은 `@Configuration` + `@Bean`
- `@Bean`은 기본적으로 `@Configuration` 안에서만
- domain object / DTO / value object는 bean 등록 금지
- bean overriding, 런타임 동적 등록 기본 금지
- configuration class는 조립만 담당
@@ -0,0 +1,180 @@
# @ConfigurationProperties 사용 기준
## 목적
외부 설정은 산발적인 문자열 주입이 아니라, **의미 있는 설정 객체**로 묶어 관리한다.
설정은 business object가 아니라 **configuration contract** 로 취급한다.
## 공식 의미
- `@ConfigurationProperties`는 externalized configuration을 타입 안전하게 바인딩하기 위한 애노테이션이다.
- 클래스 또는 `@Configuration` 안의 `@Bean` 메서드에 붙일 수 있다.
- 바인딩은 setter 또는 생성자 인자를 통해 수행될 수 있다.
- `@EnableConfigurationProperties` 또는 `@ConfigurationPropertiesScan`으로 등록할 수 있다.
- `@ConfigurationProperties``@Value`보다 relaxed binding, metadata 지원에 유리하다.
- `@ConfigurationProperties`는 SpEL을 평가하지 않는다.
- `@ConfigurationPropertiesScan``@Component`가 붙은 클래스를 스캔 대상으로 잡지 않는다.
- `@Validated`로 properties validation을 수행할 수 있다.
- `Optional``@ConfigurationProperties`에서 권장되지 않는다.
## 기본 규칙
### 1. 의미 있는 설정 그룹은 `@ConfigurationProperties` 우선
다음은 `@ConfigurationProperties`를 우선 검토한다.
- 같은 prefix 아래 여러 설정값이 함께 움직임
- 계층형/nested 설정이 있음
- 설정 검증이 중요함
- 여러 bean이 같은 설정 집합을 참조함
- 운영 문서와 IDE metadata 지원이 중요함
예:
- Vault 설정
- OAuth client 설정
- JWT 설정
- scheduler/retry 설정
- feature toggle 묶음
### 2. 단발성 한두 값만 필요하면 `@Value`를 제한적으로 허용
다음은 `@Value`를 허용할 수 있다.
- 단일 상수성 설정값
- 로컬 config class 안에서만 쓰는 매우 작은 값
- SpEL이 실제로 필요한 경우
단, 애플리케이션 자체 설정 키 집합이라면 `@ConfigurationProperties`를 우선한다.
### 3. properties class는 설정 계약만 표현
`@ConfigurationProperties` 클래스의 책임은:
- 설정값 구조 표현
- 타입 안전 바인딩
- 검증
- 합리적 기본값 표현
다음은 넣지 않는다.
- business logic
- 외부 API 호출
- repository/service 호출
- 큰 계산 로직
- runtime mutable state
### 4. prefix는 명확하고 안정적으로 설계
prefix는 기능/도메인 경계를 드러내야 한다.
좋은 방향:
- `auth.jwt`
- `auth.oauth.google`
- `vault.transit`
- `app.retry`
지양:
- `config`
- `common`
- `misc`
- 의미가 너무 넓은 prefix
### 5. 등록 방식은 스캔과 명시 등록을 구분
기본 선택:
- 애플리케이션 내부 일반 설정 타입 -> `@ConfigurationPropertiesScan`
- 조건부 등록/auto-configuration/명시적 wiring 필요 -> `@EnableConfigurationProperties` 또는 `@Bean` + `@ConfigurationProperties`
### 6. `@Component`와 `@ConfigurationProperties`를 습관적으로 같이 쓰지 않는다
properties class는 설정 바인딩 타입이지 일반 component가 아니다.
기본:
- scanning 대상 properties -> `@ConfigurationPropertiesScan`
- 명시 등록이 필요하면 `@EnableConfigurationProperties`
`@Component`를 붙여 일반 bean처럼 다루는 패턴은 지양한다.
### 7. 가능한 한 immutable 구조를 선호
설정은 보통 startup 후 바뀌지 않는 계약이다.
기본 방향:
- 생성자 기반 바인딩 또는 immutable한 구조 선호
- 변경 가능한 setter-only bag object를 기본값으로 삼지 않음
- 필수 설정은 생성 시점에 확정되게 설계
### 8. `Optional` 필드 금지
Spring Boot 공식 문서상 `Optional``@ConfigurationProperties`에서 권장되지 않는다.
기본:
- nullable field
- 기본값
- nested object
- 명시적 default object
중 하나로 표현한다.
### 9. validation은 startup에서 최대한 실패하게
설정이 잘못되면 런타임 깊은 지점에서 터지지 않게, 바인딩 시점 검증을 우선한다.
기본:
- `@Validated`
- Bean Validation annotation (`@NotNull`, `@Min`, `@Pattern` 등)
- nested properties는 필요 시 `@Valid`
### 10. 기본값 정책을 숨기지 않는다
기본값은 다음 중 하나로 명시한다.
- 필드 기본값
- 생성자 기본값
- 명시적 nested default object
- 문서화된 운영 기본값
“값이 없으면 나중에 어딘가에서 처리”를 금지한다.
### 11. Environment 직접 조회보다 properties bean 주입 우선
application/service/infrastructure 코드에서 `Environment#getProperty(...)`를 흩뿌리지 않는다.
기본:
- 관련 설정은 properties 객체로 묶고
- 필요한 bean에 주입한다
예외:
- truly dynamic property lookup
- framework/bootstrap 초기화 특수 상황
### 12. 설정 객체는 소유 모듈 가까이에 둔다
properties class는 그것을 사용하는 기능/모듈 옆에 둔다.
예:
- `vault` 설정은 vault adapter/config 근처
- `jwt` 설정은 jwt/token 모듈 근처
`CommonProperties`, `AppProperties`처럼 전역 잡동사니 설정 객체는 지양한다.
### 13. third-party bean 바인딩도 가능하지만, 범위를 제한
외부 라이브러리 객체를 `@Bean` 메서드 + `@ConfigurationProperties`로 바인딩할 수 있다.
단, 그 경우도:
- 명확한 prefix
- 명확한 config class
- 외부 라이브러리 설정 범위 제한
을 지킨다.
### 14. 설정 계약과 business meaning을 혼동하지 않는다
예:
- `token-expiration-seconds`는 설정 계약
- `TokenTtl`은 도메인 의미일 수 있다
필요하면 properties -> domain config/value object 변환 단계를 둔다.
설정 타입을 그대로 domain everywhere에 흘려보내지 않는다.
### 15. 문서와 metadata를 함께 고려
`@ConfigurationProperties`의 장점 중 하나는 metadata/IDE 지원이다.
애플리케이션이 제공하는 설정 키는:
- prefix 일관성
- 이름 일관성
- 설명/문서화
를 함께 고려한다.
## 프로젝트 기준 요약
- 의미 있는 설정 집합은 `@ConfigurationProperties` 우선
- 한두 개 단발성 값만 `@Value` 제한 허용
- properties class는 설정 계약만 표현
- `@Component`와 습관적 결합 금지
- immutable 구조 선호
- `Optional` 필드 금지
- validation은 startup에서 최대한 실패하게
- `Environment` 직접 조회보다 properties bean 주입 우선
- 설정 객체는 owning module 가까이에 둔다
@@ -0,0 +1,166 @@
# dependency injection 기준
## 목적
의존성 주입은 객체가 자신의 협력 객체를 직접 생성하거나 찾지 않게 하여,
- 결합도를 낮추고
- 테스트를 쉽게 하며
- 초기화 상태를 더 명확하게 만드는 데 사용한다.
## 공식 의미
- Spring DI는 객체가 의존성을 생성자 인자, factory method 인자, 또는 setter/config method로 선언하면 컨테이너가 주입하는 방식이다.
- Spring은 constructor-based DI와 setter-based DI를 지원한다.
- Spring 팀은 일반적으로 constructor injection을 권장한다.
- constructor injection은 필수 의존성의 non-null 보장과 fully initialized state를 더 쉽게 만든다.
- setter injection은 주로 optional dependency 또는 재설정 가능한 dependency에 적합하다.
- field injection은 production code에서는 권장되지 않는다.
- 생성자가 하나뿐인 경우 Spring은 `@Autowired` 없이도 그 생성자를 사용할 수 있다.
## 기본 규칙
### 1. 기본값은 constructor injection
application code의 기본 DI 방식은 생성자 주입이다.
이유:
- 필수 의존성이 명확하다
- 객체가 생성 직후 완전한 상태가 된다
- final field 사용이 가능하다
- 테스트에서 plain constructor 호출이 쉽다
### 2. 필수 의존성은 생성자로만 받는다
다음은 생성자로만 주입한다.
- business collaborator
- repository / port
- external client
- policy / strategy
- configuration object
- mapper / validator / assembler 중 필수 협력 객체
필수 의존성을 setter/field로 받지 않는다.
### 3. 선택 의존성만 setter/config method injection 검토
setter 또는 config method injection은 아래일 때만 검토한다.
- optional dependency
- reasonable default가 있는 경우
- 재설정/reconfiguration 가능성이 실제로 필요한 경우
- legacy / third-party class 구조상 생성자 주입이 적합하지 않은 경우
기본값은 아니다.
### 4. production code field injection 금지
production code에서는 field injection을 사용하지 않는다.
이유:
- 의존성이 시그니처에 드러나지 않는다
- plain unit test가 불편해진다
- final field 사용이 어렵다
- partially initialized state 위험을 키운다
예외:
- 테스트 클래스
- framework가 직접 관리하는 극히 제한적 특수 케이스
### 5. 단일 생성자면 `@Autowired` 생략 가능
생성자가 하나뿐인 bean class는 `@Autowired`를 굳이 붙이지 않아도 된다.
기본:
- single constructor -> annotation 생략 가능
- 여러 생성자면 의도를 분명히 해야 한다
### 6. 의존성은 lookup하지 않는다
bean은 자신의 dependency를 직접 찾지 않는다.
금지:
- `applicationContext.getBean(...)`
- service locator 패턴
- static holder 통해 bean 가져오기
예외:
- 아주 제한된 framework integration
- truly dynamic lookup이 필요한 infrastructure 경계
기본은 constructor/setter 주입이다.
### 7. 생성자 인자가 많으면 DI 스타일이 아니라 책임 분해 문제를 먼저 본다
Spring 공식 문서도 constructor parameter가 많으면 code smell로 본다.
기본 판단:
- 5~7개 이상으로 커지면 책임 과다를 의심
- 하위 collaborator 분리
- policy object 분리
- orchestration 분리
- config object 묶기
를 먼저 검토한다
“setter로 바꿔서 숨기기”로 해결하지 않는다.
### 8. final field 우선
constructor injection을 쓴다면 의존성 필드는 가능한 한 final로 둔다.
이유:
- 불변성 강화
- 초기화 상태 명확화
- 재주입/변경 여지 축소
### 9. optional dependency는 명시적으로 표현
optional dependency는 다음 방식 중 하나를 명시적으로 선택한다.
- setter injection
- `ObjectProvider<T>`
- nullable/optional parameter를 가진 config method
- reasonable default를 가진 생성자/팩토리 구성
필수와 선택을 섞어 모호하게 만들지 않는다.
### 10. 컬렉션/다중 구현 주입은 의도를 분명히
여러 bean이 한 인터페이스를 구현할 때는:
- `List<T>`
- `Map<String, T>`
- `@Qualifier`
- `@Primary`
등을 통해 의도를 분명히 한다.
“우연히 하나만 있으니까 된다”에 기대지 않는다.
### 11. configuration properties는 raw value보다 객체로 주입
관련 설정값이 여러 개면 primitive/string 여러 개를 직접 주입하지 말고, configuration properties 객체로 묶어 주입하는 쪽을 우선 검토한다.
### 12. bean 간 순환 의존은 기본 금지
Spring은 constructor circular dependency를 문제로 보고, setter injection으로 우회는 가능하지만 권장하지 않는다.
기본:
- 순환 구조를 리팩터링으로 제거
- 책임 재배치
- 이벤트/포트/분리된 collaborator 도입 검토
setter로 억지 우회하지 않는다.
### 13. framework 관리 대상과 plain object를 구분
모든 객체가 DI 대상은 아니다.
기본:
- Spring bean 협력은 DI
- value object / domain entity / DTO / 단순 계산 객체는 plain object 생성 유지
### 14. 테스트도 같은 원칙을 따르되, 테스트 클래스 field injection은 허용 가능
Spring 공식 문서는 테스트에서는 field injection이 자연스러울 수 있다고 설명한다.
기본:
- production code -> constructor injection
- test class -> Spring test fixture에서는 field injection 허용 가능
- 하지만 application code 자체는 계속 constructor injection 유지
## 프로젝트 기준 요약
- 기본 DI 방식은 constructor injection
- 필수 의존성은 생성자
- optional dependency만 setter/config method 검토
- production code field injection 금지
- single constructor면 `@Autowired` 생략 가능
- dependency lookup 금지
- 생성자 인자 과다는 책임 분해 신호
- final field 우선
- circular dependency 우회보다 구조 수정 우선
@@ -0,0 +1,317 @@
# Filter / Interceptor / Resolver / Advice 기준
## 1. 목적
이 문서는 Spring MVC 기반 서버에서 요청/응답 경계의 공통 처리 로직을 어디에 둘지 정의한다.
대상은 다음 네 가지다.
- Servlet Filter
- Spring MVC HandlerInterceptor
- HandlerExceptionResolver
- @ControllerAdvice / @RestControllerAdvice / @ExceptionHandler / ResponseBodyAdvice
목표는 다음과 같다.
- HTTP/Servlet 수준 관심사와 MVC/controller 수준 관심사를 분리한다.
- 예외 처리와 응답 포맷 표준화를 한 곳에 모은다.
- business rule, validation, transaction, domain mapping이 web infrastructure 훅 안으로 새어 들어가지 않게 한다.
- 같은 문제를 filter / interceptor / advice 어디에든 중복 구현하는 일을 막는다.
## 2. 근거 수준
- Official: Spring Framework / Spring Boot 공식 문서에서 직접 확인되는 내용
- Official + Practice: 공식 제약 위에 일반적인 실무 운영 원칙을 결합한 내용
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
## 3. 기본 원칙
### 3.1 계층이 낮을수록 더 일반적인 HTTP 관심사만 둔다
기본 원칙:
- Filter: Servlet/HTTP 인프라 수준 공통 처리
- Interceptor: handler/controller 실행 전후 공통 처리
- @RestControllerAdvice: controller 계층의 예외 응답/공통 응답 규약 처리
- HandlerExceptionResolver: 정말 낮은 수준의 resolver chain 커스터마이징이 필요할 때만 사용
Spring 공식 문서 기준으로 Filter는 filter chain과 target Servlet 전후에 interception-style logic을 적용하는 용도이고, HandlerInterceptor는 handler 실행 전후 callback이며, 예외는 DispatcherServlet이 HandlerExceptionResolver 체인으로 위임합니다. @ControllerAdvice / @RestControllerAdvice는 전역 @ExceptionHandler 적용 지점입니다.
### 3.2 더 위 레벨 도구로 해결 가능한 일은 아래 레벨로 내리지 않는다
프로젝트 규칙:
- controller 예외 응답은 우선 @RestControllerAdvice
- 응답 body 표준화는 우선 ResponseBodyAdvice
- handler 관련 공통 전처리는 우선 HandlerInterceptor
- Servlet container 전체에 걸친 공통 처리만 Filter
즉, 아래 레벨 훅을 “더 강력하니까” 먼저 선택하지 않는다.
### 3.3 이 프로젝트의 에러 응답 기본 포맷은 ApiResult다
Spring은 @ExceptionHandler 또는 @RequestMapping에서 ProblemDetail이나 ErrorResponse를 반환해 RFC 9457 응답을 렌더링할 수 있고, ResponseEntityExceptionHandler도 공식 제공한다. 하지만 이 프로젝트는 공식 확장 지점은 따르되, 응답 본문 포맷의 기본값은 custom ApiResult 로 둔다.
프로젝트 규칙:
- 에러 응답의 프로젝트 기본 표준은 ProblemDetail이 아니라 ApiResult
- 전역 예외 처리의 기본 위치는 @RestControllerAdvice
- built-in MVC 예외와 business exception 모두 프로젝트 공통 ApiResult 규약으로 변환
- 외부 표준 계약이나 특정 연동에서 RFC 9457이 명시적으로 필요할 때만 ProblemDetail 사용을 예외적으로 허용
이 규칙은 Official + Practice + Project Recommendation 이다.
즉, 공식 문서가 제공하는 entry point는 사용하되, 실제 payload shape은 프로젝트 표준으로 통일한다.
## 4. Filter 표준
### 4.1 Filter의 책임
Filter는 Servlet 체인 수준의 공통 처리에 사용한다. Spring 공식 문서에서 Filter는 processing chain과 target Servlet 전후에 interception-style logic을 적용하는 용도다.
허용 예:
- request/response wrapping
- forwarded header 처리
- correlation id / trace id의 very-early binding
- MDC 진입/해제
- Spring Security filter chain에 통합되는 보안 전처리
- controller mapping 이전에 처리되어야 하는 공통 HTTP concern
비허용 예:
- 도메인 검증
- use case 오케스트레이션
- transaction 시작/종료
- repository/JPA 직접 호출을 전제로 한 핵심 업무 처리
- DTO ↔ domain 변환
- 공통 API 에러 응답 본문 구성의 주 책임
### 4.2 custom filter는 OncePerRequestFilter를 우선 검토한다
Spring 공식 문서 기준으로 OncePerRequestFilter는 request 시작 시 단일 호출을 지원하고, ASYNC/ERROR dispatch 관여 여부 제어를 제공한다.
프로젝트 규칙:
- 새 custom filter는 기본적으로 OncePerRequestFilter를 우선 사용한다.
- shouldNotFilterAsyncDispatch, shouldNotFilterErrorDispatch 필요 여부를 명시적으로 검토한다.
- “왜 filter여야 하는가?”를 설명할 수 없으면 interceptor 또는 advice로 올린다.
### 4.3 Filter에는 무거운 의존성을 직접 물지 않는다
Spring Boot 공식 문서는 filter bean이 application lifecycle 초기에 설치되므로 너무 많은 bean의 eager initialization을 유발하지 않게 주의하라고 하며, DataSource나 JPA configuration 의존은 좋지 않다고 설명한다.
프로젝트 규칙:
- filter에서 repository, entity manager, transaction-heavy service 직접 의존을 기본 금지한다.
- filter가 복잡한 서비스 계층을 호출해야 한다면 설계를 다시 검토한다.
- 인증/인가 체계는 개별 custom filter 남발보다 security/filter chain 구조에 맞춘다.
### 4.4 Filter 등록과 순서는 명시적 필요가 있을 때만 건드린다
Spring Boot는 Filter bean을 자동 등록하고, 필요하면 FilterRegistrationBean으로 매핑과 order를 제어할 수 있다. dispatcher type 미지정 시 기본은 REQUEST다.
프로젝트 규칙:
- 순서 의존이 없으면 순서 지정 최소화
- 순서가 필요하면 이유를 주석 또는 문서에 남긴다
- request body를 읽거나 wrapping하는 filter는 더 이른 순서 배치를 신중히 검토한다
## 5. HandlerInterceptor 표준
### 5.1 Interceptor의 책임
HandlerInterceptor는 handler/controller 실행 전후의 가벼운 공통 처리에 사용한다. Spring 공식 문서도 interceptor를 handler 관련 fine-grained preprocessing 용도로 설명한다.
허용 예:
- 인증 완료 이후의 요청자 정보 추출
- audit context 세팅/해제
- handler 기반 lightweight access policy
- locale/theme 같은 MVC handler 관련 전처리
- controller 호출 전후의 가벼운 메타데이터 기록
비허용 예:
- 보안의 주 진입점
- request/response wrapping
- body 읽기/변형
- transaction 제어
- 핵심 비즈니스 검증
- domain object 생성/조립
### 5.2 Interceptor를 보안 레이어의 중심으로 사용하지 않는다
Spring 공식 문서는 interceptor가 annotated controller path matching과 mismatch 가능성이 있어 security layer로는 이상적이지 않으며, 일반적으로 Spring Security 또는 Servlet filter chain에 통합된 접근을 더 이르게 적용하라고 권장한다.
프로젝트 규칙:
- 인증/인가의 중심은 interceptor가 아니라 security/filter chain
- interceptor는 보안 체계가 끝난 뒤 controller 실행에 가까운 공통 처리에만 사용
### 5.3 @ResponseBody / ResponseEntity 응답 변경 지점으로 쓰지 않는다
Spring 공식 문서 기준으로 @ResponseBody와 ResponseEntity 응답은 HandlerAdapter 내부에서 body가 쓰이고 커밋된 뒤 postHandle이 호출되므로, 이 시점은 응답 변경 지점으로 늦다. 이 경우 ResponseBodyAdvice를 사용해야 한다.
프로젝트 규칙:
- JSON 응답 envelope 적용
- 공통 body 필드 삽입
- 에러 응답 본문 공통 보강
- ApiResult 응답 래핑/정규화
이런 작업은 interceptor가 아니라 ResponseBodyAdvice 또는 @RestControllerAdvice에 둔다.
### 5.4 async controller가 있으면 interceptor lifecycle을 단순 가정하지 않는다
프로젝트 규칙:
- thread-local 정리 책임이 interceptor에 있다면 async request 존재 여부를 반드시 점검한다
- async endpoint가 있다면 interceptor cleanup이 sync 요청과 동일하게 호출된다고 가정하지 않는다
- async lifecycle이 중요하면 별도 async 처리 훅 설계를 검토한다
이 항목은 Spring MVC async 처리 모델을 반영한 Official + Practice 규칙이다.
## 6. HandlerExceptionResolver 표준
### 6.1 Resolver는 저수준 exception chain 확장 지점이다
Spring 공식 문서 기준으로 여러 HandlerExceptionResolver를 체인으로 둘 수 있고, order에 따라 순서가 정해지며, resolver는 ModelAndView, empty ModelAndView, 또는 null을 반환할 수 있다. null이면 다음 resolver가 계속 처리한다.
프로젝트 규칙:
- 일반 애플리케이션 예외 응답의 기본 수단으로 custom HandlerExceptionResolver를 만들지 않는다
- 기본 선택은 @RestControllerAdvice + @ExceptionHandler
- custom resolver는 framework integration이나 아주 낮은 수준의 예외 변환이 필요할 때만 허용
### 6.2 resolver는 ApiResult 표준화의 주 도구가 아니다
프로젝트 규칙:
- business exception → HTTP 응답 매핑은 @RestControllerAdvice
- ApiResult.fail(...) 생성도 기본적으로 advice에서 수행
- resolver는 “정말 advice보다 아래 레벨에서 처리해야 하는 상황”에만 사용
즉, ApiResult를 도입한다고 해서 resolver 쪽으로 내려가지 않는다.
응답 포맷 표준화의 중심은 여전히 advice다.
## 7. @ControllerAdvice / @RestControllerAdvice 표준
### 7.1 전역 예외 처리는 @RestControllerAdvice를 기본으로 한다
Spring 공식 문서 기준으로 @RestControllerAdvice는 @ControllerAdvice + @ResponseBody의 shortcut이며, 전역 @ExceptionHandler는 local controller의 @ExceptionHandler 뒤에 적용된다. 기본적으로 모든 controller에 적용된다.
프로젝트 규칙:
- REST API 서버의 전역 예외 처리 기본값은 @RestControllerAdvice
- controller별 local @ExceptionHandler 남발을 피한다
- 공통 에러 정책은 소수의 advice에 집중시킨다
### 7.2 이 프로젝트의 전역 에러 응답은 ApiResult로 통일한다
프로젝트 규칙:
- business exception, validation exception, built-in MVC exception 모두 가능한 한 ApiResult 규약으로 변환한다
- 에러 응답 구조는 프로젝트 전역에서 일관되게 유지한다
- controller가 직접 에러 body를 조립하지 않는다
- advice가 HTTP status와 ApiResult body를 함께 결정한다
권장 방향 예시:
- HTTP status는 표준 의미를 유지
- body는 ApiResult의 실패 형식으로 통일
- 내부 stack trace, framework class name, 구현 세부사항은 노출 금지
- 외부 노출용 에러 코드와 메시지는 분리 가능하게 설계
이 규칙은 Official + Practice 에 가깝다.
Spring은 ProblemDetail을 공식 지원하지만, 실제 프로젝트에서는 별도의 공통 envelope를 유지하는 경우가 많고, Spring의 공식 advice/exception handler 확장 지점은 그런 custom body에도 그대로 사용할 수 있다.
### 7.3 ProblemDetail은 기본이 아니라 예외적 옵션이다
Spring은 ProblemDetail, ErrorResponse, ResponseEntityExceptionHandler를 공식 지원한다. 또한 Spring Boot는 spring.mvc.problemdetails.enabled를 통해 built-in exception의 problem details 처리도 자동 구성할 수 있다.
프로젝트 규칙:
- 프로젝트 기본 에러 포맷은 ApiResult
- ProblemDetail은 다음 경우에만 예외적으로 허용
- 외부 표준 계약이 RFC 9457을 요구하는 경우
- 특정 API만 공개 표준 준수가 더 중요한 경우
- 타 시스템과의 호환성 때문에 application/problem+json이 필요한 경우
- 프로젝트 내부/일반 REST API에는 ProblemDetail과 ApiResult를 혼용하지 않는다
### 7.4 built-in MVC 예외도 프로젝트 포맷으로 흡수한다
Spring 공식 문서 기준으로 ResponseEntityExceptionHandler는 Spring MVC 예외와 ErrorResponseException을 다루는 편의 base class다.
프로젝트 규칙:
- built-in MVC 예외가 많고 이를 일관된 ApiResult로 바꿔야 하면 ResponseEntityExceptionHandler 확장을 검토한다
- built-in 예외를 ProblemDetail 그대로 노출하는 방향은 프로젝트 기본값이 아니다
- business exception과 framework exception이 서로 다른 응답 형식을 가지지 않게 한다
## 8. ResponseBodyAdvice 표준
### 8.1 응답 body 공통 가공은 ResponseBodyAdvice를 사용한다
Spring 공식 문서 기준으로 ResponseBodyAdvice는 @ResponseBody 또는 ResponseEntity controller method 실행 후, HttpMessageConverter가 body를 쓰기 전에 응답을 커스터마이징하는 확장 지점이다.
프로젝트 규칙:
- 성공 응답의 공통 envelope 적용
- ApiResult.success(...) 형태의 일관화
- 에러 응답 body의 공통 필드 보강
- trace id, timestamp, request id 같은 공통 값 삽입
이런 책임은 ResponseBodyAdvice에 둘 수 있다.
### 8.2 다만 전역 래핑은 “명확한 규약”이 있을 때만 사용한다
프로젝트 규칙:
- 모든 성공 응답을 ApiResult로 래핑할지 여부는 프로젝트 API 계약에 맞춰 일관되게 정한다
- file download, streaming, SSE, 이미 포맷이 고정된 응답에는 전역 wrapping을 피한다
- supports(...) 조건을 좁게 잡아 surprise를 줄인다
- controller가 이미 ApiResult를 반환하는 프로젝트라면 ResponseBodyAdvice로 이중 래핑하지 않는다
즉, ResponseBodyAdvice는 강력하지만 “마법처럼 몰래 바꾸는 곳”이 아니라 공식적인 응답 규약 적용 지점이어야 한다.
## 9. 위치별 금지 규칙
다음은 기본 금지다.
Filter:
- transaction
- repository/JPA 직접 호출
- business validation
- DTO/domain mapping
- ApiResult 에러 본문 생성의 주 수단
Interceptor:
- 인증/인가의 주 구현
- body wrapping
- @ResponseBody 응답 변형
- transaction
- 핵심 use case 호출
HandlerExceptionResolver:
- 일반 business exception 처리의 기본 수단
- 팀 공통 ApiResult 포맷의 주 진입점
ControllerAdvice / ResponseBodyAdvice:
- domain 규칙 실행
- repository 접근
- 핵심 오케스트레이션
- endpoint별 business branching 누적
## 10. 선택 기준 요약
다음 질문으로 결정한다.
- request/response를 Servlet 수준에서 감싸거나 아주 이른 시점에 처리해야 하는가? Filter
- 특정 handler/controller 실행 전후의 공통 처리인가? HandlerInterceptor
- controller 예외를 HTTP 응답으로 바꾸는가? @RestControllerAdvice + @ExceptionHandler
- 성공/실패 body를 프로젝트 공통 ApiResult 규약에 맞게 가공해야 하는가? ResponseBodyAdvice 또는 @RestControllerAdvice
- 정말 resolver chain 자체를 커스터마이징해야 하는가? HandlerExceptionResolver
+182
View File
@@ -0,0 +1,182 @@
# @Transactional 위치 기준
## 목적
트랜잭션은 “아무 데나 붙이는 애노테이션”이 아니라,
**하나의 비즈니스 작업 단위를 원자적으로 끝내야 하는 경계**에 둔다.
이 문서의 핵심은:
- 트랜잭션을 어디에 둘지
- 어디에 두면 안 되는지
- 프록시 기반 동작 때문에 어떤 함정이 있는지
를 명확히 하는 것이다.
## 공식 의미
- Spring의 선언적 트랜잭션은 기본적으로 AOP proxy 기반이다.
- proxy mode에서는 프록시를 통해 들어오는 외부 메서드 호출만 interception 된다.
- self-invocation은 기본적으로 transactional interception을 일으키지 않는다.
- `@Transactional` 기본값은 `PROPAGATION_REQUIRED`, `ISOLATION_DEFAULT`, read-write, 기본 timeout, unchecked exception rollback이다.
- Spring 팀은 인터페이스보다 구체 클래스의 메서드에 `@Transactional`을 두는 것을 권장한다.
- `PROPAGATION_REQUIRED`는 같은 스레드에서 service facade가 여러 repository 호출을 묶는 일반적인 호출 구조에 적절한 기본값이다.
- `REQUIRES_NEW`는 별도 물리 트랜잭션/자원을 더 잡으므로 커넥션 풀 고갈 위험이 있을 수 있다.
## 기본 규칙
### 1. 기본 위치는 application service / use case
트랜잭션 경계의 기본 위치는 application 계층의 service/use case 메서드다.
이유:
- 하나의 비즈니스 작업 단위를 가장 잘 표현한다
- 여러 repository/adapter 호출을 하나의 경계로 묶기 쉽다
- controller / repository / adapter에 흩어지는 것을 막는다
기본 예:
- 회원 가입
- 로그인 완료 처리
- 사용자 상태 변경
- 토큰 발급 + 저장
- 주문 생성
- 재시도 가능한 import 단위
### 2. controller / filter / interceptor / resolver에는 기본 금지
web adapter는 HTTP 경계를 담당한다.
트랜잭션 경계를 web layer에 두지 않는다.
금지 대상 기본값:
- `@Controller`
- `@RestController`
- `Filter`
- `HandlerInterceptor`
- `HandlerMethodArgumentResolver`
- exception handler
이유:
- HTTP concern과 transaction concern이 섞인다
- web boundary가 persistence 세부를 과도하게 끌어안게 된다
- 요청 전체를 너무 넓은 transaction으로 감쌀 위험이 커진다
### 3. repository / adapter에는 기본 금지
repository나 infrastructure adapter는 “트랜잭션 경계 소유자”가 아니라
상위 경계 안에서 참여하는 collaborator를 기본값으로 한다.
기본 금지:
- repository 메서드마다 습관적으로 `@Transactional`
- external API client에 `@Transactional`
- mapper/assembler/helper에 `@Transactional`
예외:
- 그 컴포넌트가 독립적인 transaction boundary를 실제로 소유해야 하는 경우
- 프레임워크/기술 통합상 별도 transaction semantics가 정말 필요한 경우
### 4. 읽기 전용 use case는 `readOnly = true` 검토
순수 조회 유스케이스라면 `@Transactional(readOnly = true)`를 우선 검토한다.
적용 후보:
- 상세 조회
- 목록 조회
- 검색
- 통계용 읽기 작업
단, readOnly 안에서 실제 write가 섞이면 안 된다.
### 5. 쓰기 작업은 기본 read-write
상태 변경, 저장, 삭제, 발급, 전이 같은 작업은 기본 read-write transaction으로 둔다.
readOnly를 습관적으로 붙이지 않는다.
### 6. self-invocation을 믿지 않는다
같은 클래스 안에서 `this.someTransactionalMethod()`처럼 호출하면
proxy mode에서는 transactional interception이 일어나지 않는다.
기본 대응:
- transaction boundary를 public entry method로 둔다
- 필요하면 클래스를 분리한다
- self-invocation을 전제로 설계하지 않는다
### 7. `@PostConstruct` / 초기화 코드에서 transaction에 기대지 않는다
Spring 공식 문서상 proxy가 완전히 준비되기 전에는 기대한 동작이 보장되지 않는다.
초기화 코드에서 transactional behavior를 전제로 하지 않는다.
### 8. 인터페이스보다 구체 클래스 메서드에 둔다
Spring 팀 권장에 따라 `@Transactional`은 기본적으로 구체 클래스의 메서드에 둔다.
이유:
- AspectJ weaving 등에서 interface annotation이 조용히 무시될 수 있는 함정을 줄인다
- annotation 위치가 더 직접적이고 명확하다
### 9. 클래스 레벨보다 메서드 레벨 우선 검토
클래스 전체가 거의 같은 transaction semantics를 가지면 클래스 레벨 선언을 허용할 수 있다.
하지만 readOnly/read-write, propagation, timeout이 섞이면 메서드별로 명시한다.
기본:
- 모든 public method가 같은 semantics면 class-level 가능
- 그렇지 않으면 method-level로 명확히 분리
### 10. 트랜잭션은 가능한 한 짧게 유지
트랜잭션은 DB 자원/잠금/연결을 붙잡을 수 있으므로 가능한 짧게 유지한다.
기본 금지:
- 긴 계산
- sleep
- 재시도 루프
- 사용자 입력 대기
- 네트워크 왕복 다수 포함
- 파일 업로드/다운로드 전체를 transaction 안에 유지
### 11. 외부 API 호출을 긴 DB transaction 안에 넣지 않는다
공식 문서가 직접 “외부 API 호출 금지”라고 쓰지는 않지만, transaction 자원과 propagation semantics 설명을 보면
DB transaction이 열려 있는 동안 원격 네트워크 호출까지 길게 포함시키는 것은 프로젝트 기본값으로 금지하는 것이 안전하다.
기본 방향:
- DB update 전/후로 외부 호출을 분리
- 정말 필요하면 outbox/event/후속 작업 구조 검토
- 원격 호출 때문에 DB connection/lock을 오래 잡지 않는다
### 12. `REQUIRES_NEW`는 예외적으로만
`REQUIRES_NEW`는 기본값이 아니다.
허용 후보:
- 독립 감사 로그 저장
- 실패해도 본 작업과 분리되어야 하는 후속 기록
- 별도 확정 단위가 명확한 경우
주의:
- 별도 물리 트랜잭션/자원을 사용한다
- 커넥션 풀 크기와 deadlock 가능성을 고려해야 한다
### 13. propagation/isolation/timeout은 필요할 때만 명시
기본값은 `REQUIRED`, `ISOLATION_DEFAULT`, 시스템 timeout이다.
즉 의미가 명확할 때만 덮어쓴다.
금지:
- 습관적으로 모든 메서드에 propagation/isolation 지정
- 이유 없는 `REQUIRES_NEW`
- 이유 없는 custom timeout
### 14. rollback 규칙은 예외 설계와 함께 본다
기본적으로 unchecked exception만 rollback된다.
checked exception도 rollback해야 하면 `rollbackFor` 등을 명시한다.
즉:
- 예외 타입 설계
- rollback 정책
- business failure 의미
를 같이 설계한다.
### 15. method visibility와 proxy 제약을 의식
proxy mode에서는 인터페이스 기반 프록시의 transactional 메서드는 public이어야 한다.
class-based proxy에서는 protected/package-visible 메서드도 가능하지만,
프로젝트 기본값은 **외부 진입 public method를 transaction boundary로 두는 것**이다.
## 프로젝트 기준 요약
- 기본 위치는 application service / use case
- controller / filter / resolver / interceptor에는 기본 금지
- repository / external client / mapper에는 기본 금지
- 순수 조회는 `readOnly = true` 검토
- self-invocation 믿지 않음
- `@Transactional`은 구체 클래스 메서드에 우선
- transaction은 짧게 유지
- 외부 API 호출을 긴 DB transaction 안에 넣지 않음
- `REQUIRES_NEW`는 예외적으로만
- rollback 규칙은 예외 타입과 함께 설계
@@ -0,0 +1,246 @@
# Validation Location 기준
## 1. 목적
이 문서는 “무엇을 어디서 검증할 것인가”를 정의한다.
이 문서의 목표는 다음과 같다.
- request shape 검증과 business rule 검증을 구분한다.
- web validation, application validation, domain invariant enforcement의 위치를 명확히 한다.
- Bean Validation 애노테이션을 어디에 써야 하고 어디에 의존하면 안 되는지 정한다.
- validation이 filter, interceptor, advice 같은 잘못된 지점으로 새어 나가지 않게 한다.
## 2. 근거 수준
- Official: Spring Framework 공식 문서에서 직접 확인되는 내용
- Official + Practice: 공식 문서의 제약 위에 실무적으로 널리 쓰이는 구조를 결합한 내용
- Project Recommendation: 이 프로젝트 구조에 맞춘 규칙
## 3. 기본 원칙
### 3.1 검증은 웹 계층 전용이 아니다
Spring은 validation이 웹 계층에 묶여 있어서는 안 되며, Spring Validator는 애플리케이션의 모든 계층에서 사용할 수 있다고 설명한다. 따라서 이 프로젝트에서도 validation을 “controller에서 끝나는 일”로 보지 않는다.
프로젝트 규칙:
- web validation은 입력 경계 검증
- application validation은 use case 실행 전제 검증
- domain validation은 도메인 불변식 보장
- 외부 연동 validation은 외부 계약 준수 확인
으로 나눈다.
### 3.2 한 곳의 검증만 믿지 않는다
프로젝트 규칙:
- controller validation은 잘못된 HTTP 입력을 빨리 거르는 역할이다.
- 그러나 domain invariant를 controller validation에 의존하지 않는다.
- 핵심 규칙은 domain 또는 application에서 다시 보장한다.
즉, “controller에서 @Valid 붙였으니 충분하다”를 금지한다.
### 3.3 request model과 domain model을 분리한다
Spring 공식 문서는 웹 데이터 바인딩에서 전용 model object를 사용하는 것이 좋고, JPA/Hibernate entity 같은 domain model을 직접 노출하지 말라고 권장한다. 또한 constructor binding 또는 allowedFields 같은 바인딩 통제를 검토하라고 안내한다.
프로젝트 규칙:
- request binding 대상은 전용 request DTO / form / command model이다.
- entity, aggregate, domain object를 @RequestBody / @ModelAttribute 바인딩 대상으로 쓰지 않는다.
- “웹 입력용 객체”와 “도메인 객체”는 분리한다.
## 4. 위치별 검증 규칙
### 4.1 Presentation(Web) 레이어
무엇을 여기서 검증하나
controller에서는 다음을 검증한다.
- 필수값 존재 여부
- 문자열 길이, blank 여부, 숫자 범위
- enum/format/basic pattern
- request DTO의 필드 단위 구조 검증
- 단일 요청 객체 안에서 해결 가능한 단순 cross-field 검증
- path variable / request param / header의 기본 제약
Spring MVC는 @RequestBody, @ModelAttribute, @RequestPart에 @Valid 또는 @Validated를 붙여 개별 객체 검증을 수행할 수 있고, 메서드 파라미터에 직접 @NotBlank, @Min 같은 constraint를 두면 method validation이 적용된다. 상황에 따라 MethodArgumentNotValidException 또는 HandlerMethodValidationException이 발생할 수 있다.
무엇을 여기서 검증하지 않나
controller에서는 다음을 검증하지 않는다.
- DB 조회가 필요한 business rule
- 인증 이후 사용자 상태 기반 정책
- aggregate 간 일관성
- 도메인 불변식의 최종 보장
- 외부 시스템 현재 상태에 의존하는 검증
이런 검증은 application 또는 domain으로 올린다.
controller 규칙
프로젝트 규칙:
- @RequestBody DTO에는 Bean Validation을 적극 사용한다.
- request param / path variable / request header의 단순 제약은 메서드 파라미터에 직접 건다.
- controller는 validation 실패를 ApiResult 규약으로 변환만 하고, 검증 정책 자체를 많이 품지 않는다.
- request DTO는 transport schema를 표현하는 객체이지 domain object가 아니다.
### 4.2 Application 레이어
무엇을 여기서 검증하나
application service / use case에서는 다음을 검증한다.
- use case 실행 전제조건
- 여러 입력 조합에 대한 정책 검증
- repository 조회나 외부 상태 조회가 필요한 검증
- “이 요청을 지금 수행해도 되는가”에 대한 오케스트레이션 수준 판단
Spring은 validation이 웹 계층에만 속하지 않는다고 설명하므로, 이런 종류의 검증을 application 계층으로 올리는 것은 Spring 철학과도 맞다.
프로젝트 규칙:
- application validation은 보통 명시적 코드로 작성한다.
- 복잡한 business rule을 Bean Validation 애노테이션으로 숨기지 않는다.
- application validation 실패는 domain/application 예외로 표현하고, web layer는 이를 ApiResult로 번역한다.
@Validated on service 사용 기준
Spring은 Bean Validation의 method validation을 MethodValidationPostProcessor와 @Validated를 통해 Spring bean에 적용할 수 있으며, 이는 AOP proxy에 의존한다고 설명한다. 따라서 proxy 한계가 있다.
프로젝트 규칙:
- service의 @Validated는 재사용 가능한 bean 경계 검증에 한해 제한적으로 허용한다.
- self-invocation, proxy bypass, 내부 private method 호출에 기대지 않는다.
- 핵심 business rule enforcement를 service method validation 하나에만 맡기지 않는다.
- 단순 null/size/precondition 보조 수단으로는 쓸 수 있지만, 복잡한 도메인 정책의 주 수단으로는 쓰지 않는다.
### 4.3 Domain 레이어
무엇을 여기서 검증하나
domain에서는 다음을 보장한다.
- value object 생성 조건
- entity/aggregate invariant
- 도메인 행위 수행 가능 조건
- “이 상태의 도메인 객체가 존재해도 되는가”에 대한 규칙
이 항목은 프로젝트 아키텍처 규칙이다.
프로젝트 규칙:
- domain invariant는 생성자, factory method, 도메인 메서드 안에서 보장한다.
- “controller에서 이미 검증했으니 domain에서는 생략”을 금지한다.
- domain rule 실패는 명시적 domain exception 또는 명시적 실패 모델로 표현한다.
- domain 객체에 web concern(BindingResult, @RequestBody, @ModelAttribute)를 들이지 않는다.
Bean Validation 애노테이션 사용 기준
Spring은 Bean Validation이 domain model 속성에도 선언될 수 있다고 설명하지만, 이 프로젝트에서는 domain의 핵심 규칙을 애노테이션만으로 표현하는 방식을 기본값으로 두지 않는다. Bean Validation은 보조 표현일 수 있으나, 최종 규칙 enforcement는 도메인 코드가 담당한다. 이는 Official + Practice + Project Recommendation 이다.
### 4.4 Infrastructure 레이어
프로젝트 규칙:
- 외부 API client, messaging adapter 등에서는 외부 계약에 필요한 검증을 할 수 있다.
- 다만 infrastructure validation은 외부 포맷/프로토콜/계약 확인용이지, 핵심 business rule의 본진이 아니다.
- 외부 요청 DTO와 내부 domain object 사이의 매핑 전에 필요한 최소 검증은 허용한다.
- domain 규칙을 infrastructure adapter에 중복 구현하지 않는다.
## 5. Bean Validation / Spring Validator 사용 규칙
### 5.1 @Valid / @Validated in controller
Spring MVC는 @Valid / @Validated로 command object 검증을 수행할 수 있고, method parameter constraint가 있으면 method validation이 개별 parameter validation을 대체한다. 또한 @Valid 자체는 constraint annotation이 아니며, @NotNull 같은 constraint와 함께 있을 때 method validation으로 이어질 수 있다.
프로젝트 규칙:
- request DTO 내부 필드 검증에는 @Valid
- validation group이 실제로 필요한 경우에만 @Validated
- scalar parameter 제약은 파라미터에 직접 constraint 부여
- controller 클래스 레벨 @Validated는 기본 금지
### 5.2 controller 클래스 레벨 @Validated 금지
Spring Framework 6.1+에서 MVC built-in method validation을 제대로 쓰려면 controller 클래스 레벨 @Validated를 제거해야 한다고 공식 문서가 명시한다. 클래스 레벨 @Validated를 두면 AOP proxy 방식이 적용된다.
프로젝트 규칙:
- controller 클래스에는 @Validated를 붙이지 않는다.
- 필요한 경우 메서드 파라미터에 직접 constraint를 선언해 MVC built-in method validation을 사용한다.
### 5.3 Spring Validator와 @InitBinder
Spring은 global validator를 MVC config로, local validator를 @InitBinder를 통해 controller 또는 @ControllerAdvice에 등록할 수 있다고 설명한다. @InitBinder는 binder 초기화, 변환, 포맷팅, controller-local customization에 쓰인다.
프로젝트 규칙:
- Bean Validation으로 충분하면 custom Spring Validator를 추가하지 않는다.
- 특정 web input에만 필요한 로컬 검증은 @InitBinder + custom Validator를 검토할 수 있다.
- @InitBinder는 web binding 전용 커스터마이징 지점으로 사용한다.
- application/domain business rule을 @InitBinder validator에 넣지 않는다.
### 5.4 BindingResult 사용 기준
Spring MVC는 Errors / BindingResult를 method parameter 바로 뒤에 두면 일부 validation error를 controller 안에서 직접 다룰 수 있다고 설명한다. 그러나 다른 파라미터에 validation error가 있으면 HandlerMethodValidationException이 발생할 수 있다.
프로젝트 규칙:
- 일반 REST API에서는 BindingResult를 광범위하게 사용하지 않는다.
- 기본 경로는 예외 발생 → @RestControllerAdvice에서 ApiResult 변환이다.
- HTML form 처리처럼 controller가 오류를 직접 조합해야 하는 경우에만 제한적으로 사용한다.
## 6. 이 프로젝트의 기본 배치
이 프로젝트의 기본 배치는 다음과 같다.
presentation:
- request DTO 구조 검증
- request param/path/header 기본 제약
- transport-level parsing/format validation
application:
- use case 전제조건
- 조회/상태 의존 정책
- 여러 입력의 조합 규칙
domain:
- value object/entity/aggregate invariant
- 생성/변경 가능 조건
- 핵심 business rule
infrastructure:
- 외부 시스템 계약 검증
- protocol/adapter 수준 포맷 검증
## 7. 금지 규칙
다음은 기본 금지다.
- filter / interceptor / resolver / advice 에 business validation 작성
- entity를 @RequestBody / @ModelAttribute로 직접 바인딩
- controller validation만 믿고 domain invariant 생략
- service method validation proxy에 business rule enforcement를 전부 위임
- ApiResult 에러 메시지 생성을 validator 안에서 직접 수행
- validation group을 명확한 이유 없이 남발
- 단순 request DTO 검증까지 repository 조회 기반 custom validator로 만드는 것
## 8. 체크리스트
다음 질문으로 위치를 결정한다.
- HTTP 입력 형식, nullability, 길이, 범위, 단순 필드 검증인가? presentation
- 여러 입력 조합, 현재 상태, 조회 결과에 따라 use case 수행 가능 여부를 판단하는가? application
- 이 객체가 존재하거나 이 행위를 수행할 수 있는지의 핵심 규칙인가? domain
- 외부 API 계약이나 adapter 포맷 문제인가? infrastructure
- 특정 request binding 방식, formatter, web-only validator가 필요한가? @InitBinder / local validator