160 lines
3.8 KiB
Markdown
160 lines
3.8 KiB
Markdown
# Retry 예시
|
|
|
|
## 좋은 예시
|
|
|
|
### 예시 1. retry 대상 예외와 backoff를 명시한다
|
|
|
|
```java
|
|
@Component
|
|
@RequiredArgsConstructor
|
|
public class ExternalTokenGateway {
|
|
|
|
private final ExternalTokenClient externalTokenClient;
|
|
|
|
@Retryable(
|
|
retryFor = {
|
|
SocketTimeoutException.class,
|
|
ConnectException.class,
|
|
ResourceAccessException.class
|
|
},
|
|
noRetryFor = {
|
|
IllegalArgumentException.class,
|
|
ExternalAuthenticationRejectedException.class
|
|
},
|
|
maxAttempts = 3,
|
|
backoff = @Backoff(delay = 300, maxDelay = 2_000, multiplier = 2.0, random = true)
|
|
)
|
|
public TokenResult issueToken(TokenCommand command) {
|
|
return externalTokenClient.issueToken(command);
|
|
}
|
|
}
|
|
```
|
|
|
|
**좋은 이유:**
|
|
|
|
- retry 대상을 좁혔다
|
|
- business rejection은 제외했다
|
|
- backoff + jitter 성격(random = true)을 명시했다.
|
|
|
|
### 예시 2. 최종 실패만 내부 예외로 번역한다
|
|
|
|
```java
|
|
@Component
|
|
@RequiredArgsConstructor
|
|
public class KeycloakTokenClient {
|
|
|
|
private final RestClient restClient;
|
|
|
|
public TokenResult issue(TokenCommand command) {
|
|
try {
|
|
return doIssue(command);
|
|
} catch (HttpServerErrorException | ResourceAccessException ex) {
|
|
throw new ExternalAuthTemporaryFailureException(ex);
|
|
} catch (HttpClientErrorException.Unauthorized ex) {
|
|
throw new ExternalAuthRejectedException(ex);
|
|
}
|
|
}
|
|
|
|
private TokenResult doIssue(TokenCommand command) {
|
|
return restClient.post()
|
|
.uri("/protocol/openid-connect/token")
|
|
.body(command)
|
|
.retrieve()
|
|
.body(TokenResult.class);
|
|
}
|
|
}
|
|
```
|
|
|
|
**좋은 이유:**
|
|
|
|
- provider-specific HTTP 오류를 내부 의미로 번역한다
|
|
- application이 raw HTTP client 예외를 직접 보지 않는다
|
|
|
|
### 예시 3. retry 후 성공은 WARN으로 남긴다
|
|
|
|
```java
|
|
log.warn("External auth request succeeded after retry. provider={} operation={} attempts={}",
|
|
"keycloak", "issue-token", attemptCount);
|
|
```
|
|
|
|
**좋은 이유:**
|
|
|
|
- 중간 장애 징후를 추적 가능하게 남긴다
|
|
- 최종 성공을 장애처럼 ERROR로 과장하지 않는다
|
|
|
|
### 예시 4. provider rate limit 신호를 존중한다
|
|
|
|
```java
|
|
if (response.getStatusCode().value() == 429) {
|
|
Duration retryAfter = parseRetryAfter(response.getHeaders());
|
|
throw new RetryableRateLimitedException(retryAfter);
|
|
}
|
|
```
|
|
|
|
**좋은 이유:**
|
|
|
|
- provider가 주는 throttling 신호를 반영할 수 있다
|
|
- 무작정 같은 간격으로 재시도하지 않는다
|
|
|
|
## 나쁜 예시
|
|
|
|
### 예시 1. 모든 예외를 그대로 retry한다
|
|
|
|
```java
|
|
@Retryable
|
|
public void callExternalApi() {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- Spring 기본값은 모든 예외를 재시도할 수 있다
|
|
- deterministic failure와 business rejection까지 재시도될 수 있다.
|
|
|
|
### 예시 2. backoff 없이 즉시 재시도한다
|
|
|
|
```java
|
|
for (int i = 0; i < 3; i++) {
|
|
try {
|
|
return call();
|
|
} catch (Exception ignored) {
|
|
}
|
|
}
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- retry without backoff anti-pattern이다
|
|
- 순간 장애 시 부하를 더 키운다.
|
|
|
|
### 예시 3. side effect API를 idempotency 검토 없이 다시 호출한다
|
|
|
|
```java
|
|
try {
|
|
paymentClient.capture(request);
|
|
} catch (TimeoutException ex) {
|
|
paymentClient.capture(request);
|
|
}
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- timeout은 side effect 미발생을 보장하지 않는다
|
|
- non-idempotent retry anti-pattern에 가깝다.
|
|
|
|
### 예시 4. SDK retry와 adapter retry를 동시에 켠다
|
|
|
|
```java
|
|
public void send() {
|
|
sdkClient.send(); // SDK 내부 retry 있음
|
|
}
|
|
```
|
|
|
|
그리고 바깥에서 다시 @Retryable 적용
|
|
|
|
**나쁜 이유:**
|
|
|
|
- retry layering anti-pattern이다
|
|
- 실제 요청 수와 부하가 폭증할 수 있다.
|