init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
# External API Client Structure 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. imperative 서비스에서 RestClient adapter를 infrastructure에 둔다
|
||||
|
||||
```java
|
||||
public interface ExternalTokenPort {
|
||||
ExternalTokenResult issueToken(ExternalTokenCommand command);
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
class KeycloakTokenClient implements ExternalTokenPort {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final KeycloakTokenMapper keycloakTokenMapper;
|
||||
|
||||
KeycloakTokenClient(RestClient.Builder restClientBuilder,
|
||||
KeycloakProperties properties,
|
||||
KeycloakAuthHeaderCustomizer authHeaderCustomizer) {
|
||||
this.restClient = restClientBuilder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.defaultHeader("User-Agent", "project-auth-server")
|
||||
.requestInterceptor(authHeaderCustomizer)
|
||||
.build();
|
||||
this.keycloakTokenMapper = new KeycloakTokenMapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExternalTokenResult issueToken(ExternalTokenCommand command) {
|
||||
KeycloakTokenRequest request = keycloakTokenMapper.toRequest(command);
|
||||
|
||||
KeycloakTokenResponse response = restClient.post()
|
||||
.uri("/protocol/openid-connect/token")
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(KeycloakTokenResponse.class);
|
||||
|
||||
return keycloakTokenMapper.toResult(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 외부 호출이 infrastructure adapter에 있다
|
||||
- RestClient.Builder를 주입받아 공통 구성과 관측을 따른다
|
||||
- 외부 DTO와 내부 결과가 분리된다.
|
||||
|
||||
### 예시 2. reactive 경계에서는 WebClient를 사용한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class ExternalAuditClient {
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
ExternalAuditClient(WebClient.Builder webClientBuilder, AuditProperties properties) {
|
||||
this.webClient = webClientBuilder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.build();
|
||||
}
|
||||
|
||||
Mono<Void> send(AuditEventRequest request) {
|
||||
return webClient.post()
|
||||
.uri("/events")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- non-blocking 흐름에 맞는 client를 쓴다
|
||||
- Boot가 권장하는 WebClient.Builder 주입 방식을 따른다.
|
||||
|
||||
### 예시 3. HTTP Service Client를 group 기반으로 묶는다
|
||||
|
||||
```java
|
||||
@HttpExchange
|
||||
public interface KeycloakUserHttpClient {
|
||||
|
||||
@GetExchange("/admin/realms/{realm}/users/{id}")
|
||||
KeycloakUserResponse getUser(@PathVariable String realm, @PathVariable String id);
|
||||
}
|
||||
|
||||
@ImportHttpServices(group = "keycloak", types = KeycloakUserHttpClient.class)
|
||||
@Configuration
|
||||
class KeycloakHttpClientsConfiguration {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 선언형 인터페이스로 계약이 분명하다
|
||||
- group을 통해 URL, timeout, SSL, auth customization을 함께 묶을 수 있다.
|
||||
|
||||
### 예시 4. 외부 DTO와 내부 결과를 명시적으로 분리한다
|
||||
|
||||
```java
|
||||
public record KeycloakUserResponse(
|
||||
@JsonProperty("id") String id,
|
||||
@JsonProperty("email") String email,
|
||||
@JsonProperty("enabled") boolean enabled
|
||||
) {
|
||||
}
|
||||
|
||||
public record ExternalUserResult(
|
||||
String externalUserId,
|
||||
String email,
|
||||
boolean active
|
||||
) {
|
||||
}
|
||||
|
||||
public class KeycloakUserMapper {
|
||||
|
||||
ExternalUserResult toResult(KeycloakUserResponse response) {
|
||||
return new ExternalUserResult(
|
||||
response.id(),
|
||||
response.email(),
|
||||
response.enabled()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider JSON 계약이 내부 모델로 그대로 번지지 않는다
|
||||
- 필드명 mismatch와 provider 의미를 adapter 경계에 가둔다
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. controller가 외부 API를 직접 호출한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
class BadTokenController {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
@PostMapping("/api/v1/tokens")
|
||||
ApiResult<?> create(@RequestBody CreateTokenRequest request) {
|
||||
KeycloakTokenResponse response = restClientBuilder.build()
|
||||
.post()
|
||||
.uri("https://keycloak.example.com/token")
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(KeycloakTokenResponse.class);
|
||||
|
||||
return ApiResult.success(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- controller가 외부 연동과 transport 변환을 직접 수행한다
|
||||
- base URL이 하드코딩돼 있다
|
||||
- 외부 DTO가 내부 API 응답으로 그대로 노출된다
|
||||
|
||||
### 예시 2. 외부 DTO를 application 시그니처에 그대로 넘긴다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class BadIssueTokenService {
|
||||
|
||||
public void issue(KeycloakTokenRequest request) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 provider 계약에 결합된다
|
||||
- 외부 필드명/형식 변화가 내부 계층으로 번진다
|
||||
|
||||
### 예시 3. RestClient.create()를 직접 써서 공통 구성을 우회한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class BadExternalClient {
|
||||
|
||||
private final RestClient client = RestClient.create("https://example.org");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Boot auto-configuration, customizer, instrumentation 적용을 우회한다.
|
||||
|
||||
### 예시 4. provider-specific 예외를 그대로 내부로 던진다
|
||||
|
||||
```java
|
||||
public ExternalUserResult getUser(String id) {
|
||||
try {
|
||||
return webClient.get()
|
||||
.uri("/users/{id}", id)
|
||||
.retrieve()
|
||||
.bodyToMono(ExternalUserResult.class)
|
||||
.block();
|
||||
} catch (WebClientResponseException ex) {
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- application이 HTTP status와 client exception 타입에 직접 묶인다
|
||||
- 예외 번역 책임이 adapter 밖으로 새어 나간다
|
||||
@@ -0,0 +1,191 @@
|
||||
# Fallback 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 외부 추천 실패 시 빈 추천 목록으로 degrade한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecommendationIntegrationService {
|
||||
|
||||
private final RecommendationClient recommendationClient;
|
||||
|
||||
public RecommendationResult getRecommendations(String userId) {
|
||||
try {
|
||||
return recommendationClient.getRecommendations(userId);
|
||||
} catch (ExternalRecommendationTemporaryFailure ex) {
|
||||
return RecommendationResult.degradedEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 추천은 soft dependency로 다룰 수 있다
|
||||
- 핵심 기능을 깨지 않고 degraded mode를 제공한다
|
||||
- fallback 위치가 integration 경계에 있다
|
||||
|
||||
### 예시 2. 캐시된 공개키로 fallback한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class JwkIntegrationService {
|
||||
|
||||
private final JwkClient jwkClient;
|
||||
private final JwkCache jwkCache;
|
||||
|
||||
public JwkSetResult getJwkSet() {
|
||||
try {
|
||||
JwkSetResult result = jwkClient.fetch();
|
||||
jwkCache.put(result);
|
||||
return result;
|
||||
} catch (ExternalJwkTemporaryFailure ex) {
|
||||
return jwkCache.get()
|
||||
.orElseThrow(() -> ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 조회성 데이터에 짧은 TTL 캐시 fallback을 적용할 수 있다
|
||||
- fallback 가능성과 불가능성이 함께 표현된다
|
||||
- 외부 실패를 무조건 숨기지 않는다
|
||||
|
||||
### 예시 3. CircuitBreaker fallback을 명시적으로 둔다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalProfileService {
|
||||
|
||||
private final CircuitBreakerFactory<?, ?> circuitBreakerFactory;
|
||||
private final ExternalProfileClient externalProfileClient;
|
||||
|
||||
public ProfileSupplementResult getSupplement(String userId) {
|
||||
return circuitBreakerFactory.create("external-profile")
|
||||
.run(
|
||||
() -> externalProfileClient.getProfile(userId),
|
||||
throwable -> ProfileSupplementResult.degradedUnavailable()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring Cloud CircuitBreaker의 공식 fallback 모델을 따른다
|
||||
- fallback 결과가 별도 degraded result로 표현된다.
|
||||
|
||||
### 예시 4. 이메일 발송은 비동기 접수로 degrade할 수 있다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MailIntegrationService {
|
||||
|
||||
private final MailClient mailClient;
|
||||
private final MailOutboxRepository mailOutboxRepository;
|
||||
|
||||
public MailDispatchResult sendVerificationMail(MailCommand command) {
|
||||
try {
|
||||
mailClient.send(command);
|
||||
return MailDispatchResult.sent();
|
||||
} catch (ExternalMailTemporaryFailure ex) {
|
||||
mailOutboxRepository.enqueue(command);
|
||||
return MailDispatchResult.acceptedForRetry();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 즉시 발송 실패를 비동기 재처리로 전환한다
|
||||
- API 의미를 “즉시 완료”가 아니라 “접수됨”으로 명확히 바꿀 수 있다
|
||||
- hard dependency를 soft dependency로 바꾸는 사례다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 결제 확정 실패를 성공처럼 fallback한다
|
||||
|
||||
```java
|
||||
public PaymentCaptureResult capture(CaptureCommand command) {
|
||||
try {
|
||||
return paymentClient.capture(command);
|
||||
} catch (Exception ex) {
|
||||
return PaymentCaptureResult.success();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 결제 확정 실패를 성공처럼 숨긴다
|
||||
- 정합성과 감사 가능성을 깨뜨린다
|
||||
- fallback을 쓰면 안 되는 대표 사례다
|
||||
|
||||
### 예시 2. 오래된 캐시를 무기한 사용한다
|
||||
|
||||
```java
|
||||
public ExchangeRateResult getRate(String currency) {
|
||||
try {
|
||||
return exchangeRateClient.getRate(currency);
|
||||
} catch (Exception ex) {
|
||||
return foreverCache.get(currency);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- stale budget이 없다
|
||||
- 오래된 데이터를 최신 사실처럼 쓰게 된다
|
||||
- 운영에서 품질 저하를 통제할 수 없다
|
||||
|
||||
### 예시 3. controller에서 fallback을 직접 구현한다
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final ExternalProfileClient externalProfileClient;
|
||||
|
||||
@GetMapping("/api/v1/users/{userId}")
|
||||
public ApiResult<UserResponse> get(@PathVariable String userId) {
|
||||
try {
|
||||
ExternalProfileResponse response = externalProfileClient.getProfile(userId);
|
||||
return ApiResult.success(UserResponse.from(response));
|
||||
} catch (Exception ex) {
|
||||
return ApiResult.success(UserResponse.withoutProfile());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- fallback이 controller로 새어 나갔다
|
||||
- provider-aware 로직이 presentation 경계에 있다
|
||||
- 공통 observability와 정책 일관성이 깨진다
|
||||
|
||||
### 예시 4. fallback 발생을 전혀 기록하지 않는다
|
||||
|
||||
```java
|
||||
try {
|
||||
return recommendationClient.getRecommendations(userId);
|
||||
} catch (Exception ex) {
|
||||
return RecommendationResult.degradedEmpty();
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- degraded mode가 운영에서 보이지 않는다
|
||||
- fallback rate를 추적할 수 없다
|
||||
- upstream 장애가 숨어 버린다
|
||||
@@ -0,0 +1,133 @@
|
||||
# Integration Idempotency 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. provider 공식 header를 adapter에서 설정한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
class StripePaymentClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
ChargeResult createCharge(CreateChargeCommand command) {
|
||||
return restClient.post()
|
||||
.uri("/v1/payment_intents")
|
||||
.header("Idempotency-Key", command.providerIdempotencyKey())
|
||||
.body(StripeCreateChargeRequest.from(command))
|
||||
.retrieve()
|
||||
.body(ChargeResult.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider 공식 idempotency header를 adapter 경계에서 설정한다
|
||||
- application/domain이 HTTP 헤더 이름을 몰라도 된다
|
||||
- 같은 command 재전송 시 같은 key를 쓸 수 있다.
|
||||
|
||||
### 예시 2. 내부 command id와 provider key를 분리해 관리한다
|
||||
|
||||
```java
|
||||
public record OutboundCallKey(
|
||||
String outboundCommandId,
|
||||
String provider,
|
||||
String operation,
|
||||
String providerIdempotencyKey
|
||||
) {
|
||||
}
|
||||
|
||||
public record OutboundFingerprint(
|
||||
String requestDigest
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 내부 추적 키와 provider 전송 키가 분리된다
|
||||
- provider별 operation scope 차이를 표현하기 쉽다
|
||||
- fingerprint 충돌 검사를 붙이기 좋다
|
||||
|
||||
### 예시 3. timeout 후 같은 key로 재전송한다
|
||||
|
||||
```java
|
||||
try {
|
||||
return paypalCaptureClient.capture(command);
|
||||
} catch (ExternalTimeoutException ex) {
|
||||
return paypalCaptureClient.capture(command.withSameProviderIdempotencyKey());
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- side effect 재시도 시 새 key를 만들지 않는다
|
||||
- 같은 요청 의도에 같은 provider key를 재사용한다
|
||||
- PayPal/Stripe 문서 취지와 맞는다.
|
||||
|
||||
### 예시 4. 같은 key 동시 송신을 막는다
|
||||
|
||||
```java
|
||||
if (!outboundIdempotencyCoordinator.tryAcquire(command.provider(), command.operation(), command.providerIdempotencyKey())) {
|
||||
throw new DuplicateOutboundCallInProgressException();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 같은 key 두 번 동시 전송을 줄인다
|
||||
- PayPal이 설명한 concurrent duplicate 문제를 완화할 수 있다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. timeout 후 새 key로 다시 보낸다
|
||||
|
||||
```java
|
||||
try {
|
||||
return stripeClient.createCharge(command.withNewProviderIdempotencyKey());
|
||||
} catch (ExternalTimeoutException ex) {
|
||||
return stripeClient.createCharge(command.withNewProviderIdempotencyKey());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 외부 side effect 요청이 새 요청으로 처리될 수 있다
|
||||
- 중복 생성/중복 결제 위험이 커진다
|
||||
|
||||
### 예시 2. 같은 key를 다른 operation에 재사용한다
|
||||
|
||||
```java
|
||||
String key = "7f6d...";
|
||||
authorizePayment(key);
|
||||
capturePayment(key);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider마다 operation scope가 다를 수 있다
|
||||
- PayPal은 API call type 단위 고유성을 요구한다.
|
||||
|
||||
### 예시 3. provider key에 이메일을 넣는다
|
||||
|
||||
```java
|
||||
String providerIdempotencyKey = request.email() + ":" + request.orderId();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- PII가 key에 섞인다
|
||||
- Stripe도 민감정보를 key로 쓰지 말라고 권고한다.
|
||||
|
||||
### 예시 4. replay semantics를 무시하고 항상 “새 성공”으로 해석한다
|
||||
|
||||
```java
|
||||
return new PaymentCapturedResult(true, true);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider가 이전 결과 재생인지 최신 상태 조회인지 구분하지 못한다
|
||||
- 내부 감사/운영 추적이 부정확해진다
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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이다
|
||||
- 실제 요청 수와 부하가 폭증할 수 있다.
|
||||
@@ -0,0 +1,208 @@
|
||||
# Integration Serialization / Deserialization 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 외부 response DTO만 관대하게 읽는다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record KeycloakUserResponse(
|
||||
@JsonProperty("id") String id,
|
||||
@JsonProperty("email") String email,
|
||||
@JsonProperty("enabled") boolean enabled
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider가 필드를 추가해도 파싱이 덜 깨진다
|
||||
- 외부 필드명 mismatch를 DTO 경계에서 해결한다
|
||||
- 내부 모델로 바로 새지 않는다.
|
||||
|
||||
### 예시 2. 성공 응답과 오류 응답 DTO를 분리한다
|
||||
|
||||
```java
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record StripeChargeResponse(
|
||||
@JsonProperty("id") String id,
|
||||
@JsonProperty("status") String status
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record StripeErrorResponse(
|
||||
@JsonProperty("error") StripeErrorBody error
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record StripeErrorBody(
|
||||
@JsonProperty("type") String type,
|
||||
@JsonProperty("code") String code,
|
||||
@JsonProperty("message") String message
|
||||
) {
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- success/error shape를 억지로 하나의 DTO에 우겨 넣지 않는다
|
||||
- adapter가 provider failure semantics를 더 명확하게 번역할 수 있다
|
||||
|
||||
### 예시 3. form-urlencoded 계약은 JSON으로 억지 변환하지 않는다
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
class KeycloakTokenClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
TokenResult issue(KeycloakTokenCommand command) {
|
||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||
form.add("grant_type", "password");
|
||||
form.add("client_id", command.clientId());
|
||||
form.add("username", command.username());
|
||||
form.add("password", command.password());
|
||||
|
||||
KeycloakTokenResponse response = restClient.post()
|
||||
.uri("/protocol/openid-connect/token")
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(form)
|
||||
.retrieve()
|
||||
.body(KeycloakTokenResponse.class);
|
||||
|
||||
return new TokenResult(response.accessToken(), response.expiresIn());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider media type을 정확히 따른다
|
||||
- form 계약을 JSON DTO로 왜곡하지 않는다
|
||||
- Spring converter 지원과도 맞는다.
|
||||
|
||||
### 예시 4. provider-specific weird format은 adapter mapper에서 흡수한다
|
||||
|
||||
```java
|
||||
public record ExternalPaymentResult(
|
||||
String paymentId,
|
||||
PaymentState state
|
||||
) {
|
||||
}
|
||||
|
||||
public class StripePaymentMapper {
|
||||
|
||||
ExternalPaymentResult toResult(StripeChargeResponse response) {
|
||||
return new ExternalPaymentResult(
|
||||
response.id(),
|
||||
switch (response.status()) {
|
||||
case "succeeded" -> PaymentState.SUCCEEDED;
|
||||
case "processing" -> PaymentState.PROCESSING;
|
||||
default -> PaymentState.UNKNOWN;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- provider string enum이 domain enum으로 직접 새지 않는다
|
||||
- 새 값이 추가돼도 UNKNOWN으로 흡수할 수 있다
|
||||
|
||||
### 예시 5. 공통 builder를 주입받아 client를 만든다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class ExternalUserClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
ExternalUserClient(RestClient.Builder builder, ExternalUserProperties properties) {
|
||||
this.restClient = builder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 공통 HttpMessageConverters와 request factory를 따른다
|
||||
- 로컬 ObjectMapper/client 생성을 줄인다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 내부 entity를 외부 request body로 직접 보낸다
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
private String email;
|
||||
private String password;
|
||||
private String role;
|
||||
}
|
||||
|
||||
restClient.post()
|
||||
.uri("/users")
|
||||
.body(user)
|
||||
.retrieve();
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 내부 모델이 외부 계약으로 새어 나간다
|
||||
- provider에 보내면 안 되는 필드까지 함께 나갈 수 있다
|
||||
- serialization concern이 domain/entity를 오염시킨다
|
||||
|
||||
### 예시 2. adapter 메서드 안에서 new ObjectMapper()를 만든다
|
||||
|
||||
```java
|
||||
public ExternalUserResult getUser(String id) throws Exception {
|
||||
String body = httpClient.get(...);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
return objectMapper.readValue(body, ExternalUserResult.class);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공통 mapper/configuration을 우회한다
|
||||
- client별 일관성이 깨진다
|
||||
- message converter 경계를 스스로 무너뜨린다.
|
||||
|
||||
### 예시 3. external response를 raw Map으로 받아 business 로직에 넘긴다
|
||||
|
||||
```java
|
||||
Map<String, Object> response = restClient.get()
|
||||
.uri("/users/{id}", id)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
|
||||
return userService.handle(response);
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- payload reading 경계가 application으로 번진다
|
||||
- contract drift가 여러 계층에 퍼진다
|
||||
- tolerant reader가 아니라 “아무도 책임지지 않는 reader”가 된다
|
||||
|
||||
### 예시 4. 외부 오류 본문을 그대로 예외 메시지로 올린다
|
||||
|
||||
```java
|
||||
catch (HttpClientErrorException ex) {
|
||||
throw new RuntimeException(ex.getResponseBodyAsString());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider raw payload가 내부 예외/로그로 새어 나간다
|
||||
- 민감정보나 과도한 본문이 포함될 수 있다
|
||||
- success/error parsing 규칙이 사라진다
|
||||
@@ -0,0 +1,163 @@
|
||||
# Timeout 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 전역 기본값은 공통 설정으로 둔다
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
http:
|
||||
clients:
|
||||
connect-timeout: 500ms
|
||||
read-timeout: 2s
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 서비스 전체 기본값이 한 곳에 있다
|
||||
- 모든 client에 최소 timeout 정책이 적용된다.
|
||||
|
||||
### 예시 2. provider별 차이는 HTTP service group에서 override한다
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
http:
|
||||
clients:
|
||||
connect-timeout: 500ms
|
||||
serviceclient:
|
||||
keycloak:
|
||||
base-url: https://keycloak.example.com
|
||||
read-timeout: 3s
|
||||
payment:
|
||||
base-url: https://payment.example.com
|
||||
read-timeout: 5s
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 공통 기본값과 provider별 차이가 함께 보인다
|
||||
- Spring Boot가 제공하는 group-level connect/read timeout 구조와 맞는다.
|
||||
|
||||
### 예시 3. RestClient는 주입된 builder를 사용한다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class KeycloakTokenClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
KeycloakTokenClient(RestClient.Builder builder, KeycloakProperties properties) {
|
||||
this.restClient = builder
|
||||
.baseUrl(properties.baseUrl())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Boot auto-configuration과 공통 timeout/customizer를 따른다
|
||||
- RestClient.create()로 공통 구성을 우회하지 않는다.
|
||||
|
||||
### 예시 4. WebClient는 Reactor Netty timeout을 명시적으로 구성할 수 있다
|
||||
|
||||
```java
|
||||
@Bean
|
||||
WebClient paymentWebClient(WebClient.Builder builder) {
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.responseTimeout(Duration.ofSeconds(3))
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000);
|
||||
|
||||
return builder
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.baseUrl("https://payment.example.com")
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- connect timeout과 response timeout을 분리한다
|
||||
- Reactor Netty의 구체 timeout 지점을 활용한다.
|
||||
|
||||
### 예시 5. timeout 값은 operation별로 명시적 override만 허용한다
|
||||
|
||||
```java
|
||||
Mono<ResponseDto> callLongRunningOperation(RequestDto request) {
|
||||
return webClient.post()
|
||||
.uri("/reports")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToMono(ResponseDto.class)
|
||||
.timeout(Duration.ofSeconds(8));
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “이 operation만 더 길다”는 의도가 코드에 드러난다
|
||||
- 기본값과 다른 이유를 문서화하기 쉽다
|
||||
|
||||
**주의:**
|
||||
|
||||
- reactive 전체 timeout()은 최후 수단에 가깝고, 가능하면 client-specific timeout이 더 우선이다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 외부 호출에 timeout이 없다
|
||||
|
||||
```java
|
||||
@Service
|
||||
class BadExternalClient {
|
||||
|
||||
private final RestClient restClient = RestClient.create("https://example.com");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 공통 timeout/customizer/관측 구성을 우회한다
|
||||
- 무제한 또는 의도 불명확한 대기에 빠질 수 있다.
|
||||
|
||||
### 예시 2. 너무 낮은 timeout을 근거 없이 하드코딩한다
|
||||
|
||||
```java
|
||||
webClient.get()
|
||||
.uri("/token")
|
||||
.retrieve()
|
||||
.bodyToMono(TokenResponse.class)
|
||||
.timeout(Duration.ofMillis(20));
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- TLS handshake, 새 연결, DNS 비용을 고려하지 않은 값일 수 있다
|
||||
- 배포 직후/콜드 커넥션에서 false timeout을 유발하기 쉽다.
|
||||
|
||||
### 예시 3. timeout 값을 서비스 전체에 하나의 숫자로 강제한다
|
||||
|
||||
```yaml
|
||||
external:
|
||||
timeout-ms: 1000
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- provider별 latency와 business 중요도가 다를 수 있다
|
||||
- connect/read/response 구분도 사라진다
|
||||
- operation별 차이를 담기 어렵다
|
||||
|
||||
### 예시 4. timeout 이후 side effect API를 무심코 재시도한다
|
||||
|
||||
```java
|
||||
try {
|
||||
paymentClient.capture(request);
|
||||
} catch (TimeoutException ex) {
|
||||
paymentClient.capture(request);
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- timeout이 side effect 미발생을 보장하지 않는다
|
||||
- idempotency 검토 없이 중복 실행 위험이 생긴다.
|
||||
Reference in New Issue
Block a user