Files
project-auth-server/docs/examples/spring/async-scheduler-retry.md
T

200 lines
5.2 KiB
Markdown

# 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를 붙임