init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- 실패를 정상값처럼 숨긴다
|
||||
- 디버깅과 계약을 깨뜨린다
|
||||
@@ -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를 붙임
|
||||
@@ -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 같은 예외 케이스를 고려하지 않았다
|
||||
@@ -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 구조 검토
|
||||
@@ -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이 시그니처 제약 뒤에 숨어 버린다
|
||||
Reference in New Issue
Block a user