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
+171
View File
@@ -0,0 +1,171 @@
# AOP 예시
## 좋은 예시 1: 실행 시간 측정
```java
@Aspect
@Component
public class TimingAspect {
@Pointcut("execution(public * com.project.auth.application..*(..))")
public void applicationOperation() {}
@Around("applicationOperation()")
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
try {
return joinPoint.proceed();
} finally {
long elapsed = System.nanoTime() - start;
log.info("method={} elapsedNanos={}", joinPoint.getSignature(), elapsed);
}
}
}
```
**왜 좋은가:**
- 횡단 관심사인 timing만 다룬다
- 비즈니스 로직을 바꾸지 않는다
- pointcut이 이름 있는 작은 단위다
## 좋은 예시 2: 예외 기록
```java
@Aspect
@Component
public class ExceptionLoggingAspect {
@Pointcut("execution(public * com.project.auth.application..*(..))")
public void applicationOperation() {}
@AfterThrowing(pointcut = "applicationOperation()", throwing = "exception")
public void logFailure(Exception exception) {
log.error("application failure", exception);
}
}
```
**왜 좋은가:**
- 예외를 숨기지 않고 기록만 한다
- 비즈니스 의미를 변경하지 않는다
## 좋은 예시 3: annotation 기반 감사
```java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String action();
}
@Aspect
@Component
public class AuditAspect {
@Around("@annotation(audited)")
public Object audit(ProceedingJoinPoint joinPoint, Audited audited) throws Throwable {
Object result = joinPoint.proceed();
auditLog.record(audited.action(), joinPoint.getSignature().toShortString());
return result;
}
}
```
**왜 좋은가:**
- annotation으로 의도를 드러낸다
- 횡단 concern만 수행한다
- service 흐름을 숨기지 않는다
## 좋은 예시 4: named pointcut 조합
```java
@Aspect
@Component
public class CommonPointcuts {
@Pointcut("execution(public * *(..))")
public void publicMethod() {}
@Pointcut("within(com.project.auth.application..*)")
public void inApplicationLayer() {}
@Pointcut("publicMethod() && inApplicationLayer()")
public void applicationPublicOperation() {}
}
```
**왜 좋은가:**
- 작은 pointcut을 조합한다
- 범위를 읽고 설명하기 쉽다
## 나쁜 예시 1: 핵심 비즈니스 로직을 AOP로 이동
```java
@Around("execution(* ..LoginService.login(..))")
public Object issueTokenAndSaveAudit(ProceedingJoinPoint joinPoint) throws Throwable {
...
}
```
**문제:**
- 핵심 use case 흐름이 숨는다
- 코드 추적이 어려워진다
- 서비스가 해야 할 결정을 aspect가 가져간다
## 나쁜 예시 2: self-invocation 기대
```java
@Service
public class SampleService {
public void foo() {
this.bar(); // aspect 기대
}
public void bar() {
...
}
}
```
**문제:**
- proxy를 통과하지 않아 advice가 적용되지 않을 수 있다
## 나쁜 예시 3: 너무 넓은 pointcut
```java
@Before("execution(* *(..))")
public void logEverything() {
...
}
```
**문제:**
- 범위가 지나치게 넓다
- 성능/디버깅/예측 가능성 모두 나빠질 수 있다
- 어떤 코드가 영향을 받는지 설명하기 어렵다
## 나쁜 예시 4: @Around로 예외 숨김
```java
@Around("execution(* ..*(..))")
public Object swallow(ProceedingJoinPoint joinPoint) {
try {
return joinPoint.proceed();
} catch (Throwable ex) {
return null;
}
}
```
**문제:**
- 실패를 정상값처럼 숨긴다
- 디버깅과 계약을 깨뜨린다
+175
View File
@@ -0,0 +1,175 @@
# ApplicationEvent 예시
## 좋은 예시 1: 핵심 작업 후 후속 반응 분리
```java
public record UserRegisteredEvent(
Long userId,
String email,
Instant occurredAt
) {}
@Service
public class RegisterUserService {
private final ApplicationEventPublisher eventPublisher;
private final Clock clock;
public RegisterUserService(ApplicationEventPublisher eventPublisher, Clock clock) {
this.eventPublisher = eventPublisher;
this.clock = clock;
}
@Transactional
public Long register(CreateUserCommand command) {
User user = ...;
userRepository.save(user);
eventPublisher.publishEvent(
new UserRegisteredEvent(user.getId(), user.getEmail(), Instant.now(clock))
);
return user.getId();
}
}
```
**왜 좋은가:**
- 핵심 등록 작업과 후속 반응을 분리한다
- 이벤트 payload가 필요한 상태를 직접 담는다
- publisher가 listener 구현을 모른다
## 좋은 예시 2: commit 후에만 처리
```java
@Component
public class UserRegisteredAuditListener {
@TransactionalEventListener
public void handle(UserRegisteredEvent event) {
auditLog.record("USER_REGISTERED", event.userId(), event.occurredAt());
}
}
```
**왜 좋은가:**
- 기본 AFTER_COMMIT 의미를 활용한다
- rollback된 작업에 대해 잘못된 후속 기록을 남기지 않는다
## 좋은 예시 3: listener는 짧고 부가적
```java
@Component
public class WelcomeMetricListener {
@EventListener
public void handle(UserRegisteredEvent event) {
metrics.counter("user.registered").increment();
}
}
```
**왜 좋은가:**
- 짧고 효율적이다
- 핵심 비즈니스 흐름을 숨기지 않는다
## 좋은 예시 4: 테스트에서 이벤트 검증
```java
@RecordApplicationEvents
@SpringBootTest
class RegisterUserServiceTest {
@Test
void publishes_user_registered_event(ApplicationEvents events) {
service.register(command);
assertThat(events.stream(UserRegisteredEvent.class)).hasSize(1);
}
}
```
**왜 좋은가:**
- 이벤트 발행 사실을 테스트로 확인할 수 있다
- “어딘가에서 되겠지” 상태를 줄인다
## 나쁜 예시 1: 핵심 오케스트레이션을 이벤트에 숨김
```java
@Service
public class LoginService {
public LoginResponse login(LoginCommand command) {
eventPublisher.publishEvent(new LoginRequestedEvent(command));
return LoginResponse.pending();
}
}
```
**문제:**
- 핵심 로그인 흐름이 listener들 뒤로 숨어버린다
- 메인 결과가 이벤트 체인에 의존하게 된다
## 나쁜 예시 2: payload가 너무 빈약함
```java
public record UserRegisteredEvent(Long userId) {}
```
**문제:**
- 모든 listener가 다시 조회를 강요받을 수 있다
- 필요한 최소 상태가 누락되면 결합과 조회 비용이 커진다
**개선:**
- 정말 필요한 상태를 payload에 포함
## 나쁜 예시 3: 무거운 작업을 listener에 직접 넣음
```java
@Component
public class HeavyListener {
@EventListener
public void handle(UserRegisteredEvent event) {
externalApi.call(...);
fileExporter.export(...);
Thread.sleep(5000);
}
}
```
**문제:**
- listener가 너무 무겁고 느리다
- event hand-off의 장점을 해친다
- 장애 반경이 커진다
## 나쁜 예시 4: listener 순서에 핵심 의존
```java
@Component
class FirstListener {
@Order(1)
@EventListener
void handle(UserRegisteredEvent event) { ... }
}
@Component
class SecondListener {
@Order(2)
@EventListener
void handle(UserRegisteredEvent event) { ... } // 첫 번째가 반드시 먼저 돌 것을 기대
}
```
**문제:**
- 이벤트 기반 구조가 사실상 숨은 절차형 흐름이 된다
- 순서 의존이 커질수록 명시적 호출이 더 낫다
@@ -0,0 +1,199 @@
# async / scheduler / retry 예시
## 좋은 예시
### 예시 1. 스케줄 트리거는 얇게 두고, 재시도는 외부 경계에 둔다
```java
@Component
@RequiredArgsConstructor
public class ExpiredSessionCleanupJob {
private final ExpiredSessionCleanupUseCase expiredSessionCleanupUseCase;
@Scheduled(cron = "${auth.session.cleanup-cron}")
public void run() {
expiredSessionCleanupUseCase.cleanUpExpiredSessions();
}
}
@Service
@RequiredArgsConstructor
public class ExpiredSessionCleanupUseCase {
private final SessionRepository sessionRepository;
private final TokenRevocationGateway tokenRevocationGateway;
private final CleanupAuditAsyncPublisher cleanupAuditAsyncPublisher;
public void cleanUpExpiredSessions() {
List<ExpiredSession> expiredSessions = sessionRepository.findExpiredSessions();
for (ExpiredSession expiredSession : expiredSessions) {
tokenRevocationGateway.revoke(expiredSession.tokenId());
}
cleanupAuditAsyncPublisher.publish(expiredSessions.size());
}
}
@Component
public class TokenRevocationGateway {
@Retryable(
retryFor = {
ResourceAccessException.class,
SocketTimeoutException.class,
ConnectException.class
},
noRetryFor = {
IllegalArgumentException.class
},
maxAttempts = 3,
backoff = @Backoff(delay = 500, maxDelay = 2_000, multiplier = 2.0)
)
public void revoke(String tokenId) {
// 외부 인증/폐기 시스템 호출
}
@Recover
public void recover(Exception ex, String tokenId) {
throw new ExternalDependencyException("Token revocation failed after retries. tokenId=" + tokenId, ex);
}
}
@Component
@RequiredArgsConstructor
public class CleanupAuditAsyncPublisher {
@Async("auditAsyncExecutor")
public CompletableFuture<Void> publish(int cleanedCount) {
// 감사 로그/알림 전송
return CompletableFuture.completedFuture(null);
}
}
```
**좋은 이유:**
- scheduler는 트리거만 담당
- retry는 외부 호출 경계에만 존재
- async는 비핵심 후속 처리로 분리
- 각 책임이 bean 경계로 나뉘어 프록시 적용 여부가 명확함
### 예시 2. executor / scheduler를 명시적으로 분리한다
```java
@Configuration
@EnableAsync
@EnableScheduling
@EnableRetry
public class TaskExecutionConfig implements AsyncConfigurer {
@Bean(name = "auditAsyncExecutor")
public ThreadPoolTaskExecutor auditAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("audit-async-");
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.initialize();
return executor;
}
@Bean(name = "maintenanceTaskScheduler")
public ThreadPoolTaskScheduler maintenanceTaskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setThreadNamePrefix("maintenance-scheduler-");
scheduler.setPoolSize(2);
scheduler.initialize();
return scheduler;
}
@Override
public Executor getAsyncExecutor() {
return auditAsyncExecutor();
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("Async error in method={}, params={}", method.getName(), Arrays.toString(params), ex);
}
}
```
**좋은 이유:**
- async executor와 scheduler를 분리
- thread prefix로 운영 추적 가능
- void @Async 예외를 방치하지 않음
## 나쁜 예시
### 예시 1. 한 메서드에 스케줄/비동기/재시도를 다 겹친다
```java
@Service
public class BadCleanupService {
@Scheduled(fixedRate = 1000)
@Async
@Retryable
public void run() {
// 핵심 업무 + 외부 호출 + 후속 처리까지 한곳에 몰아넣음
}
}
```
**나쁜 이유:**
- 실행 경계가 불명확함
- 실패 전파/관측/재시도 범위가 애매함
- 어떤 책임 때문에 실패했는지 읽기 어려움
- 기본 retry 정책에 의존하기 쉬움
### 예시 2. self-invocation으로 @Async / @Retryable 효과를 기대한다
```java
@Service
@RequiredArgsConstructor
public class BadNotificationService {
public void sendAll(List<String> ids) {
for (String id : ids) {
this.sendOne(id); // 프록시를 거치지 않음
}
}
@Async
public void sendOne(String id) {
// ...
}
}
```
**나쁜 이유:**
- 같은 클래스 내부 호출이라 프록시 적용을 기대하면 안 됨
### 예시 3. retry를 결정적 실패에 건다
```java
@Component
public class BadMapper {
@Retryable(maxAttempts = 5)
public UserId map(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("raw must not be blank");
}
return new UserId(raw);
}
}
```
**나쁜 이유:**
- 입력 검증 실패는 재시도로 해결되지 않음
- retry 대상 예외를 좁히지 않음
- domain/value 생성 로직에 retry를 붙임
+138
View File
@@ -0,0 +1,138 @@
# bean registration 예시
## 좋은 예시 1: application service는 stereotype 등록
```java
@Service
public class RegisterUserService implements RegisterUserUseCase {
...
}
```
**왜 좋은가:**
- 애플리케이션 주 컴포넌트라는 역할이 드러난다
- scanning 기반 등록에 자연스럽다
## 좋은 예시 2: external client는 configuration + bean
```java
@Configuration
public class VaultClientConfiguration {
@Bean
public VaultTransitClient vaultTransitClient(
VaultProperties properties,
ObjectMapper objectMapper
) {
return new VaultTransitClient(
properties.address(),
properties.token(),
HttpClient.newHttpClient(),
objectMapper
);
}
}
```
**왜 좋은가:**
- 외부 라이브러리/인프라 객체 조립이 한 곳에 모인다
- 생성 로직이 명시적이다
## 좋은 예시 3: security/filter wiring은 configuration에 둠
```java
@Configuration
public class WebConfiguration {
@Bean
public FilterRegistrationBean<TraceIdFilter> traceIdFilter() {
FilterRegistrationBean<TraceIdFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new TraceIdFilter());
return registration;
}
}
```
**왜 좋은가:**
- framework wiring 성격이 분명하다
- business component와 분리된다
## 좋은 예시 4: domain object는 bean으로 등록하지 않음
```java
public record UserEmail(String value) {}
```
**왜 좋은가:**
- value object는 container 관리 대상이 아니다
- 생성/검증 책임은 domain에 남는다
## 나쁜 예시 1: domain entity를 bean으로 등록
```java
@Component
public class User {
...
}
```
**문제:**
- domain object 생명주기를 container가 소유하게 된다
- 의미가 맞지 않는다
## 나쁜 예시 2: @Component 안에 습관적 @Bean
```java
@Component
public class UserFactoryComponent {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
```
**문제:**
- full @Configuration 대신 lite mode가 된다
- configuration 역할과 component 역할이 섞인다
**개선:**
- 별도 @Configuration 클래스로 이동
## 나쁜 예시 3: 의미 없는 잡다한 config
```java
@Configuration
public class CommonConfig {
@Bean ...
@Bean ...
@Bean ...
}
```
**문제:**
- 어떤 조립을 담당하는지 이름만 보고 알기 어렵다
- 변경 이유가 다른 bean이 섞이기 쉽다
## 나쁜 예시 4: 단순 helper까지 bean으로 올림
```java
@Component
public class StringMaskingHelper {
public String mask(String input) { ... }
}
```
**문제:**
- lifecycle/DI 이득이 작다
- plain helper로 둘 수 있다면 굳이 bean일 필요가 없다
@@ -0,0 +1,150 @@
# @ConfigurationProperties 예시
## 좋은 예시 1: 의미 있는 설정 집합을 타입으로 묶음
```java
@ConfigurationProperties(prefix = "auth.jwt")
@Validated
public record JwtProperties(
@NotNull Duration accessTokenTtl,
@NotNull Duration refreshTokenTtl,
@NotBlank String issuer
) {}
```
**왜 좋은가:**
- 관련 설정이 하나의 계약으로 묶인다
- 타입 안전성과 검증이 있다
- scattered @Value를 줄인다
## 좋은 예시 2: configuration properties scanning 사용
```java
@SpringBootApplication
@ConfigurationPropertiesScan
public class AuthApplication {
}
```
**왜 좋은가:**
- 애플리케이션 내부 properties 타입을 명시적으로 스캔한다
- @Component에 기대지 않는다
## 좋은 예시 3: 조건부/명시 등록은 EnableConfigurationProperties
```java
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(VaultProperties.class)
public class VaultConfiguration {
}
```
**왜 좋은가:**
- 어떤 설정 타입을 활성화하는지 명확하다
- auto-configuration/조건부 wiring에 잘 맞는다
## 좋은 예시 4: third-party bean에 바인딩
```java
@Configuration(proxyBeanMethods = false)
public class ClientConfiguration {
@Bean
@ConfigurationProperties("app.http.client")
public HttpClientProperties httpClientProperties() {
return new HttpClientProperties();
}
}
```
**왜 좋은가:**
- 외부 타입/서드파티 설정을 명시적 config 안에 가둔다
- prefix와 등록 위치가 분명하다
## 나쁜 예시 1: 산발적 @Value 남발
```java
@Service
public class JwtIssuer {
@Value("${auth.jwt.access-token-ttl}")
private Duration accessTokenTtl;
@Value("${auth.jwt.refresh-token-ttl}")
private Duration refreshTokenTtl;
@Value("${auth.jwt.issuer}")
private String issuer;
}
```
**문제:**
- 관련 설정이 흩어진다
- 타입 집합과 검증이 약해진다
- 재사용/문서화가 어려워진다
**개선:**
- JwtProperties로 묶어서 주입
## 나쁜 예시 2: Optional 필드 사용
```java
@ConfigurationProperties("vault")
public record VaultProperties(
Optional<String> namespace
) {}
```
**문제:**
- Spring Boot 공식 문서가 권장하지 않는다
- 값이 없으면 empty Optional이 아니라 null이 바인딩될 수 있다
**개선:**
- nullable String
- 명시적 기본값
- 별도 default 처리
## 나쁜 예시 3: properties class에 business logic 포함
```java
@ConfigurationProperties("auth.jwt")
public class JwtProperties {
private Duration accessTokenTtl;
public String issueToken(User user) {
...
}
}
```
**문제:**
- 설정 계약과 비즈니스 로직이 섞인다
- 테스트/책임 분리가 흐려진다
## 나쁜 예시 4: CommonProperties dump zone
```java
@ConfigurationProperties("app")
public class AppProperties {
private String jwtIssuer;
private Duration retryDelay;
private String vaultAddress;
private String mailFrom;
}
```
**문제:**
- 소유 기능이 다 다르다
- prefix와 책임이 너무 넓다
- 기능별 변경이 서로 얽힌다
@@ -0,0 +1,165 @@
# dependency injection 예시
## 좋은 예시 1: 필수 의존성은 생성자 주입
```java
@Service
public class RegisterUserService implements RegisterUserUseCase {
private final UserReader userReader;
private final UserAppender userAppender;
private final PasswordHasher passwordHasher;
public RegisterUserService(
UserReader userReader,
UserAppender userAppender,
PasswordHasher passwordHasher
) {
this.userReader = userReader;
this.userAppender = userAppender;
this.passwordHasher = passwordHasher;
}
}
```
**왜 좋은가:**
- 필수 의존성이 시그니처에 드러난다
- final field를 사용할 수 있다
- 객체가 완전한 상태로 생성된다
## 좋은 예시 2: single constructor면 @Autowired 생략
```java
@Component
public class JwtTokenIssuer {
private final Clock clock;
public JwtTokenIssuer(Clock clock) {
this.clock = clock;
}
}
```
**왜 좋은가:**
- Spring은 단일 생성자를 자동으로 사용할 수 있다
- annotation noise를 줄인다
## 좋은 예시 3: 선택 의존성은 setter/config method 검토
```java
@Component
public class AuditClient {
private RetryTemplate retryTemplate = RetryTemplate.defaultInstance();
@Autowired(required = false)
public void setRetryTemplate(RetryTemplate retryTemplate) {
this.retryTemplate = retryTemplate;
}
}
```
**왜 좋은가:**
- 선택 의존성이라는 점이 드러난다
- reasonable default가 있다
## 좋은 예시 4: 다중 구현은 qualifier로 명시
```java
@Service
public class OAuthLoginService {
private final OAuthClient googleOAuthClient;
public OAuthLoginService(@Qualifier("googleOAuthClient") OAuthClient googleOAuthClient) {
this.googleOAuthClient = googleOAuthClient;
}
}
```
**왜 좋은가:**
- 여러 구현체 중 무엇을 주입받는지 명확하다
- 우연한 후보 선택에 기대지 않는다
## 나쁜 예시 1: production field injection
```java
@Service
public class RegisterUserService {
@Autowired
private UserReader userReader;
@Autowired
private UserAppender userAppender;
}
```
**문제:**
- 필수 의존성이 시그니처에 안 드러난다
- final field 사용이 어렵다
- plain unit test가 불편하다
## 나쁜 예시 2: service locator 사용
```java
@Service
public class RegisterUserService {
@Autowired
private ApplicationContext applicationContext;
public void register(...) {
UserAppender userAppender = applicationContext.getBean(UserAppender.class);
...
}
}
```
**문제:**
- DI가 아니라 lookup으로 퇴행한다
- 숨은 의존성이 생긴다
## 나쁜 예시 3: 생성자 인자 과다를 setter로 숨김
```java
@Service
public class ComplexService {
@Autowired
public void setA(A a) { ... }
@Autowired
public void setB(B b) { ... }
@Autowired
public void setC(C c) { ... }
@Autowired
public void setD(D d) { ... }
@Autowired
public void setE(E e) { ... }
@Autowired
public void setF(F f) { ... }
}
```
**문제:**
- 책임이 큰 문제를 주입 방식으로 숨긴다
- 객체의 필수/선택 의존성이 흐려진다
**개선:**
- collaborator 분리
- orchestration 재설계
- 설정 묶기 검토
@@ -0,0 +1,495 @@
# Filter / Interceptor / Resolver / Advice 예시
## 좋은 예시
### 예시 1. request/response 수준의 공통 처리만 filter에 둔다
```java
@Component
public class RequestIdFilter extends OncePerRequestFilter {
public static final String REQUEST_ID_ATTRIBUTE = "requestId";
public static final String REQUEST_ID_HEADER = "X-Request-Id";
@Override
protected boolean shouldNotFilterErrorDispatch() {
return false;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String requestId = request.getHeader(REQUEST_ID_HEADER);
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId);
response.setHeader(REQUEST_ID_HEADER, requestId);
filterChain.doFilter(request, response);
}
}
```
**좋은 이유:**
- HTTP request/response concern만 다룬다
- controller 이전에 처리되어도 자연스럽다
- business/service/repository에 의존하지 않는다
### 예시 2. handler 전후의 가벼운 공통 처리는 interceptor에 둔다
```java
@Component
@RequiredArgsConstructor
public class AuditActorInterceptor implements HandlerInterceptor {
private final AuditContextHolder auditContextHolder;
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) {
String actorId = (String) request.getAttribute(RequestAttributes.AUTHENTICATED_ACTOR_ID);
if (actorId != null) {
auditContextHolder.bind(actorId);
}
return true;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) {
auditContextHolder.clear();
}
}
public final class RequestAttributes {
public static final String AUTHENTICATED_ACTOR_ID = "authenticatedActorId";
private RequestAttributes() {
}
}
```
**좋은 이유:**
- handler 실행 전후의 공통 처리라는 interceptor 책임에 맞는다
- 인증 자체를 구현하지 않고, 인증 이후 컨텍스트 연결만 수행한다
- 핵심 business logic을 수행하지 않는다
### 예시 3. resolver는 낮은 수준의 예외만 제한적으로 변환한다
```java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestBindingExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) throws IOException {
if (!(ex instanceof HttpMessageNotReadableException)) {
return null;
}
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{
"success": false,
"code": "MALFORMED_JSON_REQUEST",
"message": "Malformed request body"
}
""");
return new ModelAndView();
}
}
```
**좋은 이유:**
- resolver를 “전역 business exception 처리기”가 아니라 저수준 예외 처리 지점으로 제한한다
- null 반환으로 다른 예외는 다음 resolver/advice에 넘긴다
- resolver 사용 이유가 명확하다
**주의:**
- 실제 프로젝트에서는 이조차도 가능하면 advice/기본 처리로 흡수할 수 있는지 먼저 검토하는 편이 낫다
- 이 예시는 “resolver가 허용되는 좁은 자리”를 보여주기 위한 예시다
### 예시 4. 에러 정책은 ErrorCode로 중앙 관리한다
```java
public enum ErrorCode {
DOMAIN_RULE_VIOLATION(HttpStatus.CONFLICT, "DOMAIN_RULE_VIOLATION", "Domain rule violation"),
EXTERNAL_DEPENDENCY_FAILURE(HttpStatus.BAD_GATEWAY, "EXTERNAL_DEPENDENCY_FAILURE", "Temporary external dependency failure"),
REQUEST_VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "REQUEST_VALIDATION_FAILED", "Request validation failed");
private final HttpStatus httpStatus;
private final String code;
private final String message;
ErrorCode(HttpStatus httpStatus, String code, String message) {
this.httpStatus = httpStatus;
this.code = code;
this.message = message;
}
public HttpStatus httpStatus() {
return httpStatus;
}
public String code() {
return code;
}
public String message() {
return message;
}
}
```
**좋은 이유:**
- 에러 코드, 메시지, 상태값이 분산되지 않는다
- advice가 문자열 조립 대신 매핑 책임에 집중할 수 있다
### 예시 5. 전역 예외 응답은 @RestControllerAdvice에서 ApiResult로 통일한다
```java
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(DomainRuleViolationException.class)
public ResponseEntity<ApiResult<Void>> handleDomainRuleViolation(
HttpServletRequest request
) {
ErrorCode errorCode = ErrorCode.DOMAIN_RULE_VIOLATION;
Map<String, String> metadata = requestMetadata(request);
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(
errorCode,
null,
metadata
));
}
@ExceptionHandler(ExternalDependencyException.class)
public ResponseEntity<ApiResult<Void>> handleExternalDependency(
HttpServletRequest request
) {
ErrorCode errorCode = ErrorCode.EXTERNAL_DEPENDENCY_FAILURE;
Map<String, String> metadata = requestMetadata(request);
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(
errorCode,
null,
metadata
));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResult<Map<String, String>>> handleValidation(
MethodArgumentNotValidException ex,
HttpServletRequest request
) {
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toUnmodifiableMap(
FieldError::getField,
DefaultMessageSourceResolvable::getDefaultMessage,
(first, second) -> first
));
ErrorCode errorCode = ErrorCode.REQUEST_VALIDATION_FAILED;
Map<String, String> metadata = requestMetadata(request);
return ResponseEntity.status(errorCode.httpStatus())
.body(ApiResult.fail(
errorCode,
errors,
metadata
));
}
private Map<String, String> requestMetadata(HttpServletRequest request) {
Object requestId = request.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE);
if (!(requestId instanceof String value) || value.isBlank()) {
return Map.of();
}
return Map.of("requestId", value);
}
}
```
**좋은 이유:**
- business exception, validation exception을 한곳에서 다룬다
- 응답 포맷이 ApiResult로 일관된다
- advice는 예외를 ErrorCode로 매핑하는 책임만 가진다
### 예시 6. 성공 응답 공통 래핑은 ResponseBodyAdvice에서 처리한다
```java
@RestControllerAdvice
public class ApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<?> converterType) {
Class<?> parameterType = returnType.getParameterType();
return !ApiResult.class.isAssignableFrom(parameterType)
&& !ResponseEntity.class.isAssignableFrom(parameterType)
&& !Resource.class.isAssignableFrom(parameterType);
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response
) {
if (body == null) {
return ApiResult.success(null);
}
if (body instanceof ApiResult<?>) {
return body;
}
return ApiResult.success(body);
}
}
```
**좋은 이유:**
- 성공 응답 공통화 위치가 명확하다
- controller가 반복해서 ApiResult.success(...)를 만들지 않아도 된다
- 이미 래핑된 응답은 다시 감싸지 않는다
### 예시 7. controller는 정상 흐름만 표현한다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sessions")
public class SessionQueryController {
private final SessionQueryUseCase sessionQueryUseCase;
@GetMapping("/{sessionId}")
public ApiResult<SessionResponse> getSession(@PathVariable String sessionId) {
SessionResponse response = sessionQueryUseCase.getSession(sessionId);
return ApiResult.success(response);
}
}
```
**좋은 이유:**
- controller가 예외 정책까지 떠안지 않는다
- 정상 흐름과 예외 흐름이 분리된다
## 나쁜 예시
### 예시 1. filter에서 business/service를 직접 호출한다
```java
@Component
@RequiredArgsConstructor
public class BadAuthenticationFilter extends OncePerRequestFilter {
private final UserRepository userRepository;
private final LoginPolicyService loginPolicyService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String userId = request.getHeader("X-User-Id");
User user = userRepository.findById(userId).orElseThrow();
loginPolicyService.validate(user);
filterChain.doFilter(request, response);
}
}
```
**나쁜 이유:**
- filter에 repository/business validation이 들어갔다
- HTTP concern과 business concern이 섞였다
### 예시 2. interceptor를 보안의 주 레이어로 사용한다
```java
@Component
public class BadAuthorizationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) throws Exception {
if (request.getHeader("Authorization") == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
return true;
}
}
```
**나쁜 이유:**
- 인증/인가의 중심을 interceptor에 두고 있다
- security/filter chain과 역할이 충돌한다
### 예시 3. resolver를 business exception 처리의 기본 수단으로 사용한다
```java
@Component
public class BadBusinessExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) throws IOException {
if (ex instanceof DomainRuleViolationException) {
response.setStatus(HttpStatus.CONFLICT.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{
"success": false,
"code": "DOMAIN_RULE_VIOLATION",
"message": "Domain rule violation"
}
""");
return new ModelAndView();
}
return null;
}
}
```
**나쁜 이유:**
- business exception 처리의 중심이 resolver로 내려갔다
- advice보다 의도가 덜 드러난다
- 응답 정책이 저수준 구현으로 흩어진다
### 예시 4. advice에서 에러 코드 문자열을 직접 하드코딩한다
```java
@RestControllerAdvice
public class BadApiExceptionHandler {
@ExceptionHandler(DomainRuleViolationException.class)
public ResponseEntity<ApiResult<Void>> handleDomainRuleViolation(
DomainRuleViolationException ex
) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResult.fail(
"DOMAIN_RULE_VIOLATION",
ex.getMessage()
));
}
}
```
**나쁜 이유:**
- 에러 코드 문자열이 advice에 박혀 있다
- 메시지 정책과 예외 메시지가 섞인다
- 코드/메시지/상태값 정책이 중앙화되지 않는다
### 예시 5. controller가 예외를 직접 잡아 ApiResult를 만든다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class BadUserController {
private final UserRegisterUseCase userRegisterUseCase;
@PostMapping
public ResponseEntity<ApiResult<Void>> register(@RequestBody RegisterUserRequest request) {
try {
userRegisterUseCase.register(request.email(), request.password());
return ResponseEntity.ok(ApiResult.success(null));
} catch (DuplicateEmailException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResult.fail(ErrorCode.DOMAIN_RULE_VIOLATION));
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResult.fail(ErrorCode.EXTERNAL_DEPENDENCY_FAILURE));
}
}
}
```
**나쁜 이유:**
- controller마다 예외 처리 로직이 중복된다
- 전역 예외 처리 규약이 깨진다
### 예시 6. ResponseBodyAdvice에서 무조건 이중 래핑한다
```java
@RestControllerAdvice
public class BadApiResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<?> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response
) {
return ApiResult.success(body);
}
}
```
**나쁜 이유:**
- 이미 ApiResult인 응답도 다시 감싼다
- file response, streaming response 같은 예외 케이스를 고려하지 않았다
+171
View File
@@ -0,0 +1,171 @@
# @Transactional 위치 예시
## 좋은 예시 1: use case 경계에 transaction
```java
@Service
public class RegisterUserService implements RegisterUserUseCase {
private final UserReader userReader;
private final UserAppender userAppender;
private final PasswordHasher passwordHasher;
public RegisterUserService(
UserReader userReader,
UserAppender userAppender,
PasswordHasher passwordHasher
) {
this.userReader = userReader;
this.userAppender = userAppender;
this.passwordHasher = passwordHasher;
}
@Transactional
public UserId register(CreateUserCommand command) {
if (userReader.findByEmail(UserEmail.from(command.email())).isPresent()) {
throw new DuplicateUserException();
}
User user = User.create(
UserEmail.from(command.email()),
UserName.from(command.name()),
passwordHasher.hash(command.password())
);
return userAppender.append(user);
}
}
```
**왜 좋은가:**
- 비즈니스 작업 단위가 transaction 경계와 일치한다
- repository 호출들이 하나의 원자적 작업으로 묶인다
- controller나 repository에 흩어지지 않는다
## 좋은 예시 2: 조회 use case는 readOnly
```java
@Service
public class GetUserProfileService implements GetUserProfileUseCase {
private final UserReader userReader;
public GetUserProfileService(UserReader userReader) {
this.userReader = userReader;
}
@Transactional(readOnly = true)
public UserProfileResult get(UserId userId) {
User user = userReader.findById(userId).orElseThrow(UserNotFoundException::new);
return UserProfileResult.from(user);
}
}
```
**왜 좋은가:**
- 순수 조회라는 의도가 드러난다
- 쓰기 작업과 구분된다
## 좋은 예시 3: 별도 확정 단위가 필요한 경우만 REQUIRES_NEW
```java
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(LoginAuditCommand command) {
...
}
}
```
**왜 좋은가:**
- 본 작업과 독립된 commit 단위를 의도적으로 분리한다
- 예외적 사용이라는 점이 분명하다
## 나쁜 예시 1: controller에 transaction
```java
@RestController
public class UserController {
@Transactional
@PostMapping("/users")
public UserResponse create(@RequestBody CreateUserRequest request) {
...
}
}
```
**문제:**
- HTTP 경계와 transaction 경계가 섞인다
- web layer가 persistence 세부를 과도하게 끌어안는다
## 나쁜 예시 2: repository마다 습관적 transaction
```java
@Repository
public class JpaUserRepository {
@Transactional
public UserJpaEntity save(UserJpaEntity entity) {
...
}
}
```
**문제:**
- 상위 use case 경계가 아니라 하위 collaborator에 transaction이 흩어진다
- 작업 단위가 잘게 찢어진다
## 나쁜 예시 3: self-invocation 기대
```java
@Service
public class UserService {
public void doWork() {
this.saveAudit(); // transactional 기대
}
@Transactional
public void saveAudit() {
...
}
}
```
**문제:**
- proxy mode에서는 self-invocation이 interception 되지 않는다
- 기대한 transaction이 실제로 열리지 않을 수 있다
**개선:**
- 클래스를 분리하거나 public entry boundary를 다시 설계
## 나쁜 예시 4: 긴 외부 API 호출을 transaction 안에 유지
```java
@Transactional
public void completeLogin(LoginCommand command) {
userRepository.save(...);
externalOAuthClient.fetchProfile(...); // 긴 네트워크 호출
tokenRepository.save(...);
}
```
**문제:**
- DB 자원/잠금을 오래 붙잡을 수 있다
- 실패 반경과 지연 시간이 커진다
**개선 방향:**
- 외부 호출과 DB transaction 경계를 재설계
- 후속 작업/event/outbox 구조 검토
+311
View File
@@ -0,0 +1,311 @@
# Validation Location 예시
## 좋은 예시
### 예시 1. request DTO 구조 검증은 presentation에서 처리한다
```java
public record CreateSessionRequest(
@NotBlank String email,
@NotBlank String password,
@NotNull LoginType loginType
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sessions")
public class SessionCommandController {
private final CreateSessionUseCase createSessionUseCase;
@PostMapping
public ApiResult<CreateSessionResponse> create(@Valid @RequestBody CreateSessionRequest request) {
CreateSessionResponse response = createSessionUseCase.create(
request.email(),
request.password(),
request.loginType()
);
return ApiResult.success(response);
}
}
```
**좋은 이유:**
- request shape 검증이 web boundary에 있다
- controller는 transport DTO를 domain object와 분리한다
- business rule 판단은 use case로 넘긴다
### 예시 2. path variable / request param 제약은 메서드 파라미터에 직접 둔다
```java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserQueryController {
private final UserQueryUseCase userQueryUseCase;
@GetMapping("/{userId}")
public ApiResult<UserResponse> getUser(
@PathVariable @NotBlank String userId,
@RequestParam(defaultValue = "1") @Min(1) int page
) {
UserResponse response = userQueryUseCase.getUser(userId, page);
return ApiResult.success(response);
}
}
```
**좋은 이유:**
- scalar input 제약이 controller boundary에 명확히 드러난다
- request DTO가 필요 없는 단순 입력을 과하게 감싸지 않는다
### 예시 3. application은 조회가 필요한 정책 검증을 담당한다
```java
@Service
@RequiredArgsConstructor
public class CreateSessionUseCase {
private final UserRepository userRepository;
public CreateSessionResponse create(CreateSessionCommand command) {
if (!userRepository.existsActiveUserByEmail(command.email())) {
throw new UserNotFoundException(command.email());
}
if (command.loginType() == LoginType.PASSWORDLESS
&& command.credential() instanceof PasswordCredential) {
throw new InvalidLoginRequestException();
}
// 실제 세션 생성
return new CreateSessionResponse(...);
}
}
public record CreateSessionCommand(
String email,
LoginCredential credential,
LoginType loginType
) {
public CreateSessionCommand {
Objects.requireNonNull(email, "email must not be null");
Objects.requireNonNull(credential, "credential must not be null");
Objects.requireNonNull(loginType, "loginType must not be null");
}
}
public sealed interface LoginCredential permits PasswordCredential, PasswordlessCredential {
}
public record PasswordCredential(String value) implements LoginCredential {
public PasswordCredential {
Objects.requireNonNull(value, "value must not be null");
if (value.isBlank()) {
throw new InvalidLoginRequestException();
}
}
}
public record PasswordlessCredential() implements LoginCredential {
}
```
**좋은 이유:**
- DB 조회가 필요한 규칙을 controller validation에 두지 않았다
- use case 전제조건 검증이 application에 있다
- nullable password를 application 내부로 전파하지 않고 명시적 credential 타입으로 표현한다
### 예시 4. domain은 자기 불변식을 스스로 보장한다
```java
public final class Email {
private final String value;
private Email(String value) {
if (value == null || value.isBlank()) {
throw new DomainRuleViolationException("Email must not be blank");
}
if (!value.contains("@")) {
throw new DomainRuleViolationException("Email format is invalid");
}
this.value = value;
}
public static Email of(String value) {
return new Email(value);
}
public String value() {
return value;
}
}
```
**좋은 이유:**
- domain invariant를 controller에 의존하지 않는다
- 어디서 생성되더라도 유효한 상태만 허용한다
### 예시 5. web 전용 복잡한 입력 검증은 @InitBinder + custom Validator로 제한적으로 둔다
```java
public class ChangePasswordRequestValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return ChangePasswordRequest.class.equals(clazz);
}
@Override
public void validate(Object target, Errors errors) {
ChangePasswordRequest request = (ChangePasswordRequest) target;
if (request.newPassword() != null
&& request.newPasswordConfirm() != null
&& !request.newPassword().equals(request.newPasswordConfirm())) {
errors.rejectValue("newPasswordConfirm", "password.confirm.mismatch");
}
}
}
@RestController
@RequestMapping("/api/password")
public class PasswordController {
@InitBinder("changePasswordRequest")
void initBinder(WebDataBinder binder) {
binder.addValidators(new ChangePasswordRequestValidator());
}
@PostMapping("/change")
public ApiResult<Void> changePassword(
@Valid @RequestBody ChangePasswordRequest changePasswordRequest
) {
return ApiResult.success(null);
}
}
```
**좋은 이유:**
- request-object 내부의 web 입력 규칙만 binder validator에 둔다
- business rule 전체를 validator에 몰아넣지 않는다
## 나쁜 예시
### 예시 1. entity를 request binding 대상으로 직접 노출한다
```java
@Entity
public class User {
@Id
private Long id;
private String email;
private String role;
}
@PostMapping("/users")
public ApiResult<Void> create(@Valid @RequestBody User user) {
...
}
```
**나쁜 이유:**
- web input model과 domain/persistence model이 섞였다
- 바인딩 범위가 불필요하게 넓다
- request schema 변경이 domain/persistence 모델에 직접 번진다
### 예시 2. controller validation만 믿고 domain에서 아무 것도 보장하지 않는다
```java
public final class Email {
private final String value;
public Email(String value) {
this.value = value;
}
}
```
**나쁜 이유:**
- 다른 진입 경로에서 잘못된 값이 들어오면 막지 못한다
- domain이 자기 불변식을 보장하지 못한다
### 예시 3. controller 클래스에 @Validated를 붙여 구식 proxy 방식에 기대한다
```java
@Validated
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{userId}")
public ApiResult<UserResponse> get(@PathVariable @NotBlank String userId) {
...
}
}
```
**나쁜 이유:**
- Spring MVC 6.1+ built-in method validation 대신 class-level AOP proxy 경로로 흐를 수 있다
- 이 프로젝트의 controller 규칙과 맞지 않는다
### 예시 4. filter / interceptor에서 business validation을 수행한다
```java
@Component
@RequiredArgsConstructor
public class BadLoginValidationFilter extends OncePerRequestFilter {
private final LoginPolicyService loginPolicyService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
loginPolicyService.validateLoginWindow();
filterChain.doFilter(request, response);
}
}
```
**나쁜 이유:**
- business validation이 web infrastructure 훅으로 새어 나갔다
- 요청 바운더리 검증과 use case 규칙이 섞였다
### 예시 5. service method validation만 믿고 복잡한 정책을 숨긴다
```java
@Service
@Validated
public class BadCreateSessionService {
public void create(
@NotBlank String email,
@NotBlank String password,
@NotNull LoginType loginType
) {
// 복잡한 도메인 정책을 전부 메서드 시그니처 제약에 기대함
}
}
```
**나쁜 이유:**
- method validation은 보조 수단이지 핵심 정책 엔진이 아니다
- proxy 기반 동작 특성 때문에 경계가 흐려질 수 있다
- business rule이 시그니처 제약 뒤에 숨어 버린다