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
@@ -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 밖으로 새어 나간다
+191
View File
@@ -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 장애가 숨어 버린다
+133
View File
@@ -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가 이전 결과 재생인지 최신 상태 조회인지 구분하지 못한다
- 내부 감사/운영 추적이 부정확해진다
+159
View File
@@ -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 규칙이 사라진다
+163
View File
@@ -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 검토 없이 중복 실행 위험이 생긴다.