init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
# External API Client Structure 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API 호출용 client 구조와 책임 분리를 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 외부 연동 코드를 application/domain에서 분리한다
|
||||
- HTTP client 선택 기준을 일관되게 만든다
|
||||
- request/response DTO, mapper, exception translation 위치를 명확히 한다
|
||||
- 관측 가능성, 설정, 인증 헤더 주입, 공통 customization을 한곳에 모은다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 지원 방식 위에 일반적인 실무 연동 구조를 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 외부 API client는 infrastructure adapter다
|
||||
|
||||
Spring은 RestClient, WebClient, HTTP Service Client를 모두 “원격 HTTP 서비스 호출” 도구로 제공한다. 즉, 이들은 비즈니스 로직이 아니라 외부 시스템 경계 접근 수단이다. 이 프로젝트에서는 외부 API client를 기본적으로 infrastructure 레이어의 adapter로 본다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API client는 infrastructure/integration adapter에 둔다
|
||||
- application/domain이 RestClient, WebClient, ResponseEntity, HTTP status, provider-specific DTO를 직접 다루지 않는다
|
||||
- 외부 호출은 port/adapter 경계를 통해 사용한다
|
||||
|
||||
### 3.2 외부 API 연동 구조의 기본 단위는 “adapter + DTO + mapper + translation”이다
|
||||
|
||||
Spring이 HTTP client 자체는 제공하지만, 어떤 DTO를 어떻게 매핑하고 어떤 예외로 번역할지는 애플리케이션이 책임져야 한다. 따라서 이 프로젝트는 외부 API client를 단순한 HTTP 호출 클래스가 아니라 연동 adapter 패키지로 다룬다.
|
||||
|
||||
프로젝트 기본 구조:
|
||||
|
||||
- client adapter
|
||||
- external request DTO
|
||||
- external response DTO
|
||||
- mapper / translator
|
||||
- provider-specific exception translation
|
||||
- 설정(properties / builder customization)
|
||||
|
||||
### 3.3 외부 연동 계약과 내부 모델은 분리한다
|
||||
|
||||
Spring의 message conversion과 HTTP service interface는 DTO를 손쉽게 직렬화/역직렬화해 주지만, 그것이 곧 외부 DTO를 내부 모델처럼 써도 된다는 뜻은 아니다. 이 프로젝트에서는 외부 API request/response DTO를 내부 application/domain 모델과 분리한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API request/response DTO는 provider contract 전용 타입이다
|
||||
- application/domain은 외부 DTO를 직접 알지 않는다
|
||||
- adapter 경계에서 내부 command/result 또는 domain 값으로 변환한다
|
||||
|
||||
## 4. HTTP client 선택 기준
|
||||
|
||||
### 4.1 imperative 애플리케이션 기본값은 RestClient
|
||||
|
||||
Spring Boot는 비리액티브 애플리케이션이면 RestClient 또는 RestTemplate를 사용할 수 있다고 설명하고, Spring Framework는 RestClient를 동기식 fluent API 로 설명하며, RestTemplate는 현재 RestClient 쪽이 더 권장되는 방향이라고 명시한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일반 Spring MVC / imperative 애플리케이션의 외부 HTTP 호출 기본값은 RestClient
|
||||
- 새 코드에서 RestTemplate를 기본 선택지로 두지 않는다
|
||||
- 동기 블로킹 호출이 자연스러운 use case에는 RestClient를 우선한다
|
||||
|
||||
### 4.2 reactive 애플리케이션 또는 진짜 non-blocking 경계에는 WebClient
|
||||
|
||||
Spring Boot는 WebFlux 기반 non-blocking reactive 애플리케이션이면 WebClient 사용을 권장한다. WebClient는 fully reactive client이며, Boot는 WebClient.Builder를 미리 구성해서 제공한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 애플리케이션 자체가 reactive이거나, non-blocking end-to-end 흐름이 필요한 경우 WebClient
|
||||
- 단순히 최신 API라는 이유만으로 imperative 서비스에 WebClient를 기본 도입하지 않는다
|
||||
- reactive client를 도입할 때는 호출부부터 반환 타입, backpressure, timeout 모델까지 함께 고려한다
|
||||
|
||||
### 4.3 HTTP Service Client는 선언적 계약이 분명할 때 허용한다
|
||||
|
||||
Spring은 @HttpExchange, @GetExchange, @PostExchange 등으로 정의한 인터페이스에 프록시를 붙이는 HTTP Service Client를 공식 지원하고, Boot는 이를 import하고 group으로 묶는 기능도 제공한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API 계약이 안정적이고 메서드 시그니처가 선언적으로 잘 드러나는 경우 HTTP Service Client 허용
|
||||
- 다만 복잡한 동적 요청 조립, 세밀한 에러 처리, 낮은 수준의 HTTP 제어가 많으면 RestClient/WebClient를 우선 검토한다
|
||||
- 선언형 인터페이스를 쓰더라도 adapter 경계와 DTO 분리 규칙은 그대로 유지한다
|
||||
|
||||
## 5. Builder / 공통 구성 규칙
|
||||
|
||||
### 5.1 Boot가 자동 구성한 builder를 주입해서 사용한다
|
||||
|
||||
Spring Boot는 WebClient.Builder와 RestClient.Builder를 prototype bean으로 자동 구성하고, 이를 주입해 사용하는 것을 강하게 권장한다. Boot가 제공하는 builder를 사용해야 HTTP resource 공유, codec 반영, 적절한 request factory, 그리고 관측/계측이 함께 적용된다. RestClient.create()를 직접 쓰면 auto-configuration과 customizer 적용이 따라오지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- RestClient.Builder / WebClient.Builder는 주입받아 사용한다
|
||||
- RestClient.create() / WebClient.builder()를 코드 곳곳에서 직접 호출하는 것을 기본 금지한다
|
||||
- 공통 관측, SSL, codec, 인증 헤더, timeout 설정을 우회하지 않는다
|
||||
|
||||
### 5.2 공통 customization은 builder/customizer/group에 둔다
|
||||
|
||||
Spring Boot는 RestClientCustomizer, WebClient.Builder, SSL bundle 적용, HTTP Service client group 등을 통해 공통 구성을 모을 수 있다고 설명한다. HTTP Service group은 URL뿐 아니라 timeout, SSL, auth customization 같은 공통 특성을 공유할 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- base URL, timeout, SSL, 공통 header, user-agent, auth header 삽입은 공통 구성으로 관리
|
||||
- client마다 같은 interceptor/filter/header 삽입 로직을 복붙하지 않는다
|
||||
- provider 단위의 공통 설정은 group 또는 전용 configuration으로 묶는다
|
||||
|
||||
### 5.3 builder는 “전역 기본값 + 클라이언트별 좁은 추가 설정” 구조로 쓴다
|
||||
|
||||
Spring Boot 문서는 RestClient.Builder customization은 범위를 좁게 적용할수록 좋고, builder가 stateful이므로 필요하면 clone을 고려하라고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 전역 공통값은 customizer/configuration
|
||||
- 특정 provider에만 필요한 설정은 그 adapter 구성 지점에서 추가
|
||||
- 하나의 builder를 여러 외부 시스템에 무비판적으로 뒤섞어 쓰지 않는다
|
||||
|
||||
## 6. 패키지 / 타입 구조 규칙
|
||||
|
||||
### 6.1 provider별 또는 capability별로 구조를 분리한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 시스템이 다르면 패키지를 분리한다
|
||||
- 하나의 외부 시스템 안에서도 계약이 크면 capability 단위로 나눌 수 있다
|
||||
|
||||
권장 예:
|
||||
|
||||
```text
|
||||
integration/keycloak/...
|
||||
integration/payment/...
|
||||
integration/email/...
|
||||
```
|
||||
|
||||
또는
|
||||
|
||||
```text
|
||||
integration/keycloak/token/...
|
||||
integration/keycloak/user/...
|
||||
```
|
||||
|
||||
### 6.2 한 adapter는 한 외부 계약 또는 한 capability를 담당한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 하나의 client class가 외부 시스템 전체를 거대한 god client처럼 다루지 않는다
|
||||
- 토큰 발급, 사용자 조회, 세션 폐기처럼 책임이 다르면 분리한다
|
||||
- 다만 지나치게 잘게 쪼개서 공통 설정이 흩어지지 않게 provider 구성과 capability 구성을 함께 본다
|
||||
|
||||
### 6.3 외부 DTO, 내부 결과, 매퍼를 분리한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- *Request, *Response는 외부 계약용 DTO
|
||||
- *Result, *Command, *FailureReason 등은 내부용 모델
|
||||
- DTO → 내부 결과 변환은 mapper/translator가 담당
|
||||
- application/domain은 외부 JSON 필드명과 provider-specific enum을 모른다
|
||||
|
||||
## 7. 인증 / 헤더 / URL 규칙
|
||||
|
||||
### 7.1 base URL은 코드 하드코딩이 아니라 설정 기반으로 둔다
|
||||
|
||||
Spring Boot는 HTTP Service groups에서 logical name과 property 기반 URL lookup을 사용하는 방향을 설명하며, absolute URL 하드코딩은 production에 이상적이지 않다고 말한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- base URL은 properties/configuration으로 관리
|
||||
- 코드 안 https://... 하드코딩을 기본 금지
|
||||
- 환경별 URL 차이는 설정으로 해결한다
|
||||
|
||||
### 7.2 인증 헤더 삽입은 adapter 공통 레이어에서 처리한다
|
||||
|
||||
Spring 문서는 RestClient에 default header, interceptor, request initializer를 둘 수 있고, HTTP Service group에도 authorization header 삽입 같은 customization을 연결할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- Authorization, API key, user-agent, correlation header는 공통 client 구성에서 삽입
|
||||
- business 로직에서 매번 header를 조립하지 않는다
|
||||
- 토큰 갱신/획득 로직도 provider adapter 경계에 둔다
|
||||
|
||||
### 7.3 URI template와 path variable을 우선 사용한다
|
||||
|
||||
Spring Framework는 RestClient, WebClient, RestTemplate가 URI template와 URI builder를 지원한다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- string concatenation으로 URL을 만들지 않는다
|
||||
- path/query 조립은 template / builder 방식으로 처리한다
|
||||
- query parameter 의미가 드러나게 작성한다
|
||||
|
||||
## 8. 반환 / 예외 / 번역 규칙
|
||||
|
||||
### 8.1 adapter는 ResponseEntity, raw status, client exception을 그대로 위로 올리지 않는다
|
||||
|
||||
Spring의 client는 HTTP status, body, exception을 직접 다룰 수 있지만, application/domain이 그 디테일을 그대로 보게 두면 외부 계약이 내부 계층으로 번진다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- adapter는 내부 결과 타입 또는 port 계약 타입을 반환한다
|
||||
- application은 WebClientResponseException, HttpStatusCodeException, ClientResponse 같은 타입을 직접 다루지 않는다
|
||||
- HTTP status 해석은 adapter 안에서 끝낸다
|
||||
|
||||
### 8.2 provider-specific 실패는 integration exception으로 번역한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 401/403/404/409/5xx를 그대로 application에 노출하지 않는다
|
||||
- provider-specific error body는 integration exception 또는 내부 failure reason으로 번역한다
|
||||
- 예외 번역 상세 규칙은 별도 exception-translation.md에서 source of truth로 둔다
|
||||
|
||||
### 8.3 2xx만 성공으로 보는 단순 규칙을 넘어서 provider 계약을 해석한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- HTTP 200이어도 business failure payload이면 실패로 번역할 수 있다
|
||||
- 반대로 일부 4xx가 provider 계약상 “정상적인 부재/중복 상태”라면 내부 의미로 적절히 번역한다
|
||||
- 성공/실패 판정 기준은 provider contract 단위로 명시한다
|
||||
|
||||
## 9. DTO / 직렬화 규칙
|
||||
|
||||
### 9.1 외부 요청/응답 DTO는 provider contract에 맞춘다
|
||||
|
||||
Spring은 RestClient, WebClient, HTTP Service Client 모두 message conversion으로 DTO를 JSON과 매핑한다. 이 DTO는 provider JSON 계약에 맞춰야 하며, 내부 표준 DTO와 동일할 필요가 없다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 JSON 필드명은 외부 DTO에서만 해결한다
|
||||
- provider-specific field naming, enum, optionality는 외부 DTO에 국소화한다
|
||||
- 내부 모델 필드명을 외부 계약 때문에 바꾸지 않는다
|
||||
|
||||
### 9.2 외부 응답 파싱 정책은 first-party API보다 더 lenient할 수 있다
|
||||
|
||||
Spring/Jackson 조합은 DTO 역직렬화를 유연하게 지원한다. 외부 시스템은 필드 추가/응답 shape 변화가 일어날 수 있으므로, third-party response DTO는 first-party API request DTO보다 lenient 정책을 택할 수 있다. 이 세부 기준은 별도 serialization 문서에서 다루되, external client 구조에서도 이 방향을 따른다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 response DTO는 unknown field 허용 가능
|
||||
- 외부 request DTO는 provider 요구에 맞춰 엄격하게 작성
|
||||
- 내부 domain/application DTO와 정책을 섞지 않는다
|
||||
|
||||
## 10. 관측 가능성 규칙
|
||||
|
||||
### 10.1 외부 API client는 관측 가능해야 한다
|
||||
|
||||
Spring Boot Actuator는 RestTemplate, WebClient, RestClient의 HTTP client instrumentation을 지원하고, 이를 위해 auto-configured builder를 사용하라고 설명한다. Spring Framework observability 문서는 기본 저카디널리티 키로 method, uri template, client.name, status, outcome, error를 정의한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API client는 auto-configured builder를 통해 관측 가능성을 확보한다
|
||||
- metrics/traces/logs에서 최소한 client.name, method, uri template, status, error를 추적 가능하게 한다
|
||||
- raw full URL과 payload 전문을 로그 기본값으로 남기지 않는다
|
||||
|
||||
### 10.2 URI template를 유지한다
|
||||
|
||||
Spring observability 문서는 low cardinality key로 uri template를 쓰고, host/port를 제외한 template 개념을 사용한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 호출 관측에서는 가능한 한 URI template를 유지한다
|
||||
- /users/123 같은 실제 path 대신 /users/{id} 같은 템플릿이 추적 가능하게 한다
|
||||
- 메트릭 태그에 고카디널리티 path를 그대로 쓰지 않는다
|
||||
|
||||
## 11. 문서 간 경계
|
||||
|
||||
이 문서는 구조와 책임 분리를 다룬다. 아래 주제의 세부 규칙은 별도 문서를 source of truth로 둔다.
|
||||
|
||||
- timeout
|
||||
- retry
|
||||
- idempotency
|
||||
- serialization/deserialization
|
||||
- fallback
|
||||
- exception translation
|
||||
|
||||
이 문서는 위 주제들을 다시 처음부터 반복하지 않고, 외부 API client 구조 안에서 어디에 둘지만 정의한다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- controller/application/domain에서 RestClient/WebClient 직접 호출
|
||||
- RestClient.create() / WebClient.builder()를 여기저기서 직접 생성
|
||||
- base URL 하드코딩
|
||||
- 외부 DTO를 내부 application/domain 메서드 시그니처에 그대로 전달
|
||||
- ResponseEntity, raw status code, client exception을 그대로 내부에 전파
|
||||
- giant external client 하나에 모든 provider capability 몰아넣기
|
||||
- 인증 헤더/공통 header를 business code에서 매번 조립
|
||||
- provider payload 전문을 기본 로그로 남기기
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 외부 연동 코드는 infrastructure adapter에 위치하는가?
|
||||
- 애플리케이션 성격에 맞게 RestClient/WebClient/HTTP Service Client를 선택했는가?
|
||||
- auto-configured builder를 주입해 사용하고 있는가?
|
||||
- base URL, auth, timeout, SSL, 공통 customization이 공통 구성에 모여 있는가?
|
||||
- 외부 request/response DTO와 내부 모델이 분리되어 있는가?
|
||||
- adapter가 provider-specific HTTP 디테일을 내부로 누수시키지 않는가?
|
||||
- 관측 가능성(metrics/traces/logs)이 확보되어 있는가?
|
||||
@@ -0,0 +1,318 @@
|
||||
# Fallback 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API / integration 호출 실패 시 fallback을 어떻게 적용할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- fallback을 retry와 구분한다
|
||||
- 어떤 외부 의존성을 soft dependency로 바꿀 수 있는지 판단 기준을 만든다
|
||||
- fallback 결과가 비즈니스 의미를 왜곡하지 않게 한다
|
||||
- 캐시, 정적 기본값, 제한된 기능, 비동기 전환 같은 대체 전략을 일관되게 사용한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Cloud CircuitBreaker, Resilience4j, AWS Well-Architected 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 graceful degradation 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 fallback은 retry의 연장이 아니라 별도 결과 전략이다
|
||||
|
||||
Spring Cloud CircuitBreaker는 원래 실행 코드와 별도로 fallback function을 받으며, 실패 시 그 fallback이 실행된다고 설명합니다. Resilience4j도 fallback method를 try/catch와 유사한 대체 경로로 설명합니다. 즉 fallback은 “한 번 더 시도”가 아니라 “다른 결과 경로”입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- retry는 원래 호출을 다시 시도하는 것
|
||||
- fallback은 원래 호출을 포기하고 다른 결과를 내는 것
|
||||
- 두 개념을 한 문서/한 코드 블록에서 섞어 흐리게 만들지 않는다
|
||||
|
||||
### 3.2 fallback은 hard dependency를 soft dependency로 바꿀 수 있을 때만 사용한다
|
||||
|
||||
AWS Well-Architected는 graceful degradation을 통해 적용 가능한 hard dependency를 soft dependency로 바꾸라고 권장합니다. 의존성이 unhealthy해도 컴포넌트가 degraded mode로 계속 동작할 수 있어야 한다는 뜻입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback은 “이 의존성이 없어도 핵심 기능이 여전히 의미 있게 동작하는가?”를 먼저 묻는다
|
||||
- 핵심 정합성/보안/결제 확정처럼 정답이 아니면 안 되는 기능에는 fallback을 기본 금지한다
|
||||
- 보조 기능, 부가 정보, 랭킹, 추천, 프로필 부가 데이터, 캐시 가능한 조회는 fallback 후보가 될 수 있다
|
||||
|
||||
### 3.3 fallback은 실패를 숨기지 말고 degraded mode를 명시해야 한다
|
||||
|
||||
Spring Cloud CircuitBreaker의 fallback은 예외를 받아 대체 결과를 만들 수 있습니다. AWS도 degraded response를 반환하되, 그것이 대체 응답이라는 사실을 이해하고 설계해야 한다고 보는 맥락입니다. 즉 fallback은 “조용히 다른 값을 넣기”가 아니라 품질 저하 상태를 의도적으로 선택하는 것입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback 결과는 내부적으로 추적 가능해야 한다
|
||||
- 운영 로그/메트릭에서 fallback 발생 여부를 구분할 수 있어야 한다
|
||||
- 호출자가 알아야 하는 degraded semantics를 숨기지 않는다
|
||||
|
||||
## 4. 언제 fallback을 허용하는가
|
||||
|
||||
### 4.1 허용 가능한 대표 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 fallback 후보가 될 수 있다.
|
||||
|
||||
- 외부 추천 시스템 실패 시 빈 추천 목록 반환
|
||||
- 외부 프로필 부가 정보 실패 시 핵심 프로필만 반환
|
||||
- 외부 공개키/JWK 조회 실패 시 짧은 TTL 캐시값 사용
|
||||
- 외부 feature flag 조회 실패 시 안전한 기본값 사용
|
||||
- 외부 알림 발송 실패 시 outbox 적재 후 비동기 재시도 전환
|
||||
- 외부 검색/랭킹 실패 시 기본 정렬 결과 반환
|
||||
|
||||
이 방향은 AWS가 말하는 graceful degradation, 즉 정적 응답·사전 결정된 대체 응답으로 hard dependency를 soft dependency로 바꾸는 사고와 맞습니다.
|
||||
|
||||
### 4.2 기본적으로 허용하지 않는 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 기본적으로 fallback을 금지한다.
|
||||
|
||||
- 결제 승인/캡처/환불 확정
|
||||
- 인증/인가의 핵심 판정
|
||||
- 비밀번호 변경/토큰 발급/보안 민감 작업
|
||||
- 재고 차감/정산 반영/회원 상태 확정
|
||||
- 법적/감사적 정합성이 필요한 기록 확정
|
||||
- “성공처럼 보이면 안 되는” 핵심 command
|
||||
|
||||
이 경우는 graceful degradation보다 명시적 실패가 더 안전하다. AWS의 graceful degradation도 모든 dependency를 soft dependency로 바꾸라는 뜻은 아니며, “applicable hard dependencies”에 한정합니다.
|
||||
|
||||
## 5. fallback 종류
|
||||
|
||||
### 5.1 정적 기본값 fallback
|
||||
|
||||
AWS는 predetermined static response를 fallback 예시로 듭니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 추천 없음 → 빈 리스트
|
||||
- 부가 배지 없음 → 빈 값
|
||||
- 외부 설명문 없음 → 기본 문구
|
||||
|
||||
처럼 명백히 안전한 기본값만 허용
|
||||
|
||||
핵심 비즈니스 의미를 바꾸는 가짜 성공값은 금지
|
||||
|
||||
### 5.2 캐시 기반 fallback
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 최근 성공 응답을 짧은 TTL로 캐시해 두고 외부 장애 시 사용 가능
|
||||
- 단, stale 허용 범위가 문서화돼야 한다
|
||||
- 캐시 fallback은 조회성 데이터에 우선 적용한다
|
||||
- 오래된 데이터를 최신 사실처럼 보이게 하면 안 된다
|
||||
|
||||
### 5.3 기능 축소(degraded mode) fallback
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 부가 서비스가 죽으면 핵심 기능만 제공
|
||||
- 예:
|
||||
- “프로필 상세 + 외부 배지” → “프로필 상세만”
|
||||
- “개인화 추천 + 일반 목록” → “일반 목록만”
|
||||
- 기능 축소 후에도 결과 의미가 일관돼야 한다
|
||||
|
||||
### 5.4 비동기 전환 fallback
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 동기 호출 실패 시 즉시 실패 대신 outbox/queue 적재 후 비동기 처리로 전환할 수 있다
|
||||
- 예:
|
||||
- 이메일 전송 요청 → “접수됨” 응답 후 비동기 발송
|
||||
- 단, 이 경우 API 의미가 “즉시 완료”가 아니라 “접수”로 바뀌므로 계약이 명확해야 한다
|
||||
|
||||
### 5.5 fallback 없이 명시적 실패
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback이 어색하거나 의미를 왜곡하면 실패가 정답이다
|
||||
- “fallback이 없으면 덜 우아해 보인다”는 이유로 억지 fallback을 두지 않는다
|
||||
- 실패가 더 정직한 경우에는 실패를 택한다
|
||||
|
||||
## 6. 설계 규칙
|
||||
|
||||
### 6.1 fallback 결과는 원래 결과와 같은 의미를 가장하지 않는다
|
||||
|
||||
Spring Cloud CircuitBreaker fallback은 예외를 받아 대체 결과를 리턴할 수 있지만, 그 결과가 원래 외부 호출 성공과 동일한 의미를 가진다고 보장하지는 않습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback 응답을 “정상 외부 응답”처럼 위장하지 않는다
|
||||
- 내부 result 모델에서 degraded 여부를 표현할 수 있으면 표현한다
|
||||
- API 바깥으로 드러나야 하는 경우에는 metadata/flag로 구분 가능하게 한다
|
||||
|
||||
### 6.2 fallback은 provider-specific 예외보다 내부 의미로 판단한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
fallback 조건은 SocketTimeoutException, WebClientResponseException 같은 저수준 타입 그 자체보다
|
||||
|
||||
- ExternalProfileTemporaryFailure
|
||||
- RecommendationProviderUnavailable
|
||||
|
||||
같은 내부 번역 예외 기준으로 두는 편을 선호한다
|
||||
|
||||
adapter가 provider 예외를 먼저 번역하고, 상위 integration service가 fallback 여부를 판단할 수 있다
|
||||
|
||||
### 6.3 fallback은 조용한 데이터 오염을 만들면 안 된다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 캐시 fallback은 stale 가능성을 고려한다
|
||||
- 기본값 fallback은 진짜 부재와 fallback 결과를 혼동시키지 않는다
|
||||
- 외부 검증 실패를 내부 성공으로 바꾸는 fallback을 금지한다
|
||||
|
||||
## 7. 위치 규칙
|
||||
|
||||
### 7.1 fallback은 integration adapter 바로 위 또는 integration service 경계에 둔다
|
||||
|
||||
Spring Cloud CircuitBreaker fallback은 wrapped supplier를 대체하는 함수로 붙습니다. 실무적으로도 fallback은 외부 호출 의미를 가장 잘 아는 integration 경계에 두는 것이 맞습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback은 external client adapter 바로 위의 integration service에서 우선 검토
|
||||
- controller/application/domain에 provider-aware fallback 로직을 두지 않는다
|
||||
- domain이 fallback 존재를 알아야 하는 구조를 기본 금지한다
|
||||
|
||||
### 7.2 controller에서 fallback 결과를 직접 조립하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- controller는 fallback 여부를 판단하는 위치가 아니다
|
||||
- 외부 의존성 실패와 대체 전략은 integration 경계에서 끝낸다
|
||||
- controller는 최종 내부 result만 받아 응답으로 번역한다
|
||||
|
||||
## 8. Circuit Breaker와의 관계
|
||||
|
||||
### 8.1 fallback은 circuit breaker와 함께 쓰일 수 있다
|
||||
|
||||
Spring Cloud CircuitBreaker는 fallback function을 공식 지원하고, OpenFeign + CircuitBreaker 문서도 fallback class를 둘 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 회로 차단기와 fallback을 함께 사용하는 것은 허용
|
||||
- 단, circuit open 상태라고 항상 fallback이 정답인 것은 아니다
|
||||
- “실패를 빠르게 차단”과 “대체 결과 제공”은 별도 결정으로 본다
|
||||
|
||||
### 8.2 circuit open fallback과 단일 호출 실패 fallback을 구분한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 일시적 단일 실패에서의 fallback
|
||||
- circuit open 상태에서의 fallback
|
||||
- 은 운영 의미가 다를 수 있다
|
||||
- observability에서는 이 둘을 구분할 수 있어야 한다
|
||||
|
||||
## 9. cache/staleness 규칙
|
||||
|
||||
### 9.1 캐시 fallback은 staleness budget이 있어야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 캐시 fallback은 “얼마나 오래된 값을 허용할지”가 먼저 정해져야 한다
|
||||
- 무기한 stale fallback 금지
|
||||
- provider 데이터 성격에 따라 허용 TTL을 문서화한다
|
||||
|
||||
### 9.2 stale 데이터는 최신 사실처럼 취급하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 프로필, 환율, 추천, 재고성 정보는 stale일 수 있다
|
||||
- stale 허용이 어려운 정보에는 캐시 fallback을 두지 않는다
|
||||
- stale 사용 사실이 내부적으로 추적 가능해야 한다
|
||||
|
||||
## 10. observability 규칙
|
||||
|
||||
### 10.1 fallback 발생은 반드시 관측 가능해야 한다
|
||||
|
||||
AWS의 graceful degradation은 장애 시 soft dependency로 동작을 바꾸는 것이므로, 운영자는 fallback이 언제 얼마나 발생하는지 알아야 합니다. Spring Cloud CircuitBreaker fallback도 Throwable을 인자로 받아 원인과 함께 처리할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
fallback 발생 로그/메트릭을 남긴다
|
||||
|
||||
최소한 다음을 구분 가능해야 한다
|
||||
|
||||
- provider
|
||||
- operation
|
||||
- fallback type(정적/캐시/비동기 전환 등)
|
||||
- 원인 예외
|
||||
- 최종 결과(success degraded / fail)
|
||||
|
||||
### 10.2 fallback 후 성공은 ERROR로 기록하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback이 적용돼 요청이 의미 있게 처리됐다면 기본 WARN 또는 INFO
|
||||
- 최종 실패만 대표 ERROR
|
||||
- fallback 성공을 장애처럼 과장하지 않는다
|
||||
- 다만 fallback 비율이 높아지면 경고 신호로 본다
|
||||
|
||||
### 10.3 fallback 비율은 SLO/품질 지표로 본다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 성공률만 보지 않고 fallback rate도 본다
|
||||
- “서비스는 성공했지만 품질은 저하된 상태”를 따로 추적한다
|
||||
- fallback이 많으면 upstream 문제 또는 timeout/retry 설정 문제를 의심한다
|
||||
|
||||
## 11. 예외와 응답 규칙
|
||||
|
||||
### 11.1 fallback 결과가 있으면 예외를 그대로 밖으로 던지지 않는다
|
||||
|
||||
Resilience4j fallback도 실패를 대체 결과로 바꾸는 구조입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback이 최종 결과를 제공하면 외부 예외를 그대로 상위 계층에 올리지 않는다
|
||||
- 대신 degraded result를 반환한다
|
||||
- 원인 예외는 observability에 남긴다
|
||||
|
||||
### 11.2 fallback이 불가능하면 실패를 번역해 올린다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- fallback이 적용되지 않거나 의미가 없으면 integration exception으로 번역해 올린다
|
||||
- “fallback도 실패했는데 기본값으로 성공처럼 처리”를 금지한다
|
||||
|
||||
## 12. 다른 문서와의 경계
|
||||
|
||||
이 문서는 fallback만 다룬다.
|
||||
아래 주제의 source of truth는 별도 문서다.
|
||||
|
||||
- timeout
|
||||
- retry
|
||||
- outbound idempotency
|
||||
- serialization/deserialization
|
||||
- exception translation
|
||||
|
||||
이 문서는 위 내용을 반복하지 않고, 외부 의존성 실패 시 어떤 대체 결과를 허용할지만 정의한다.
|
||||
|
||||
## 13. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 핵심 정합성/보안/결제 확정에 억지 fallback 적용
|
||||
- fallback 결과를 원래 정상 결과처럼 위장
|
||||
- stale 데이터 무기한 사용
|
||||
- controller에서 provider-aware fallback 구현
|
||||
- fallback 발생을 관측하지 않음
|
||||
- fallback으로 실패를 전부 숨김
|
||||
- provider 저수준 예외 타입에 강하게 결합된 fallback 분기
|
||||
- 캐시/정적 기본값이 비즈니스 의미를 왜곡하는데도 사용
|
||||
|
||||
## 14. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 외부 의존성은 soft dependency로 바꿔도 되는가?
|
||||
- fallback 결과가 비즈니스 의미를 왜곡하지 않는가?
|
||||
- fallback 종류(정적/캐시/기능 축소/비동기 전환)가 명확한가?
|
||||
- stale 허용 범위가 문서화돼 있는가?
|
||||
- fallback 발생이 로그/메트릭에서 관측 가능한가?
|
||||
- fallback 성공을 실패처럼 ERROR로 과장하지 않는가?
|
||||
- controller/application/domain이 아니라 integration 경계에 fallback이 있는가?
|
||||
@@ -0,0 +1,316 @@
|
||||
# Integration Idempotency 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API / integration 호출에서 outbound idempotency 를 어떻게 다룰지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 외부 provider가 제공하는 idempotency 기능을 안전하게 사용한다
|
||||
- timeout, partial failure, 응답 유실 상황에서 중복 side effect 를 막는다
|
||||
- 우리 내부 idempotency key와 provider idempotency key의 관계를 명확히 한다
|
||||
- 외부 API 재시도 시 어떤 조건에서 같은 key를 재사용해야 하는지 정한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: IETF HTTPAPI draft, Stripe, PayPal 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 규약 위에 일반적인 연동 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 이 문서는 inbound가 아니라 outbound idempotency를 다룬다
|
||||
|
||||
IETF 초안은 Idempotency-Key를 클라이언트가 서버에 보내는 중복 방지 키로 설명한다. 우리 서비스가 provider를 호출할 때는, 우리가 그 provider 입장에서 “클라이언트”가 된다. 따라서 이 문서는 “사용자가 우리 API를 다시 호출하는 상황”이 아니라, “우리가 외부 provider에 같은 요청을 다시 보내는 상황”을 다룬다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- inbound idempotency와 outbound idempotency를 같은 문서로 뒤섞지 않는다
|
||||
- 이 문서는 provider 호출용 키 생성/재사용/저장/오류 처리 규칙만 정의한다
|
||||
|
||||
### 3.2 outbound idempotency의 핵심 목적은 “같은 외부 side effect를 한 번만 일으키는 것”이다
|
||||
|
||||
Stripe는 생성/수정 요청에 idempotency key를 사용하면 연결 오류나 응답 유실이 있어도 같은 요청을 안전하게 반복할 수 있다고 설명한다. PayPal도 POST 호출에서 PayPal-Request-Id를 사용하면 서버가 중복 생성/처리를 피할 수 있다고 설명한다. 즉 outbound idempotency는 “같은 외부 요청 의도”를 다시 보내더라도 provider 쪽에서 한 번만 처리되게 만드는 장치다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 생성/확정/발급/결제/전송 같은 side effect 호출에는 outbound idempotency를 기본 검토한다
|
||||
- “응답을 못 받았으니 다시 보내자” 상황에서 중복 side effect가 나지 않아야 한다
|
||||
|
||||
### 3.3 retry와 outbound idempotency는 함께 설계한다
|
||||
|
||||
Stripe는 네트워크 오류가 나더라도 같은 idempotency key를 써서 다시 보내면 중복 생성 위험을 줄일 수 있다고 설명한다. 반대로 key 없이 같은 POST를 다시 보내면 중복 호출이 될 수 있다. PayPal도 PayPal-Request-Id를 생략하면 요청이 중복될 수 있다고 설명한다. 따라서 retry는 outbound idempotency와 분리해서 설계할 수 없다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- side effect가 있는 외부 API retry는 outbound idempotency 검토 없이 자동화하지 않는다
|
||||
- timeout 이후 retry 전략은 반드시 provider idempotency 지원 여부와 함께 본다
|
||||
|
||||
## 4. 언제 필요한가
|
||||
|
||||
### 4.1 기본 검토 대상
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 outbound idempotency 기본 검토 대상이다.
|
||||
|
||||
- 결제 승인/확정/캡처
|
||||
- 토큰/세션/쿠폰/번호 발급
|
||||
- 이메일/SMS/웹훅 발송 요청
|
||||
- 외부 시스템에 리소스를 생성하는 POST
|
||||
- 외부 상태를 irreversible 하게 바꾸는 요청
|
||||
- timeout 이후 retry 가능성이 높은 provider 호출
|
||||
|
||||
Stripe와 PayPal의 공식 idempotency 문서도 이런 POST 중심 side effect 요청을 주된 대상으로 설명한다.
|
||||
|
||||
### 4.2 기본 검토 대상이 아닌 경우
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 outbound idempotency header를 기본값으로 요구하지 않는다.
|
||||
|
||||
- 단순 GET 조회
|
||||
- provider가 이미 HTTP 의미상 idempotent한 PUT/DELETE만 제공하는 경우
|
||||
- 읽기 전용 상태 확인 API
|
||||
- side effect가 없는 health/ping/check API
|
||||
|
||||
Stripe도 GET/DELETE에는 idempotency key를 보내도 의미가 없다고 안내한다.
|
||||
|
||||
## 5. provider key와 내부 key의 관계
|
||||
|
||||
### 5.1 provider가 공식 idempotency key를 지원하면 그 계약을 우선 따른다
|
||||
|
||||
Stripe는 Idempotency-Key 헤더를, PayPal은 PayPal-Request-Id 헤더를 공식 지원한다. PayPal은 API call type마다 고유해야 한다고도 설명한다. 따라서 provider가 지원하는 공식 키 규약이 있으면 그 규약을 먼저 따른다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider 공식 idempotency header가 있으면 그 이름과 제약을 그대로 따른다
|
||||
- 우리 내부 표준 헤더 이름을 provider에 억지로 강요하지 않는다
|
||||
- adapter가 provider별 차이를 캡슐화한다
|
||||
|
||||
### 5.2 내부 idempotency key와 provider idempotency key는 같을 수도, 다를 수도 있다
|
||||
|
||||
IETF 초안과 Stripe 문서는 key를 클라이언트가 생성하는 고유 값으로 설명하지만, 실제 운영에서는 우리 내부 command id 와 provider 전송용 key 를 같은 값으로 쓸지 별도 매핑할지 설계 선택이 있다. PayPal은 API call type 단위 고유성을 요구하므로, 단순히 “사용자 요청 ID 하나”를 모든 provider operation에 그대로 쓰는 방식은 맞지 않을 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 내부 command id와 provider key를 1:1로 매핑할 수는 있다
|
||||
- 하지만 provider가 operation scope를 다르게 요구하면 별도 provider key를 만든다
|
||||
- 내부 키와 provider 키를 무조건 동일시하지 않는다
|
||||
|
||||
권장 예:
|
||||
|
||||
- 내부 키: outboundCommandId
|
||||
- provider 키: (provider, operation, outboundCommandId) 기반 생성
|
||||
|
||||
### 5.3 provider key scope는 provider 계약을 따른다
|
||||
|
||||
PayPal은 PayPal-Request-Id가 “요청마다 그리고 API call type마다” 고유해야 한다고 설명한다. Stripe도 endpoint와 파라미터가 다르면 idempotency error가 난다고 설명한다. 즉 key scope는 provider마다 다를 수 있다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 key를 다른 provider operation에 재사용하지 않는다
|
||||
- 같은 provider라도 다른 endpoint/call type에 key 재사용 여부를 provider 계약 기준으로 판단한다
|
||||
- scope는 최소한 provider + operation + key 수준으로 본다
|
||||
|
||||
## 6. 키 생성 규칙
|
||||
|
||||
### 6.1 키는 우리가 생성한다
|
||||
|
||||
Stripe는 V4 UUID 또는 충분한 entropy를 가진 랜덤 문자열을 권장하고, 민감정보를 key로 쓰지 말라고 말한다. PayPal도 UUID 사용을 권장한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider key는 우리 서비스가 생성한다
|
||||
- 권장 형식은 UUID v4 또는 이에 준하는 opaque random string
|
||||
- 이메일, 전화번호, 사용자명, 주문번호 같은 의미 있는 PII를 key에 넣지 않는다
|
||||
|
||||
### 6.2 키는 “같은 외부 요청 의도”에만 재사용한다
|
||||
|
||||
Stripe는 동일 key에 대해 원래 요청과 들어온 파라미터를 비교하고, 다르면 에러를 반환한다고 설명한다. 따라서 key는 장기 식별자가 아니라 같은 요청의 재전송용 식별자 여야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 provider 호출을 다시 보낼 때만 같은 key를 재사용한다
|
||||
- 요청 의미가 달라지면 새 key를 생성한다
|
||||
- key를 “사용자별 고정 키”처럼 쓰지 않는다
|
||||
|
||||
## 7. 저장 규칙
|
||||
|
||||
### 7.1 outbound provider 호출에도 내부적으로 key 매핑 기록을 남긴다
|
||||
|
||||
Stripe와 PayPal은 provider 측 idempotency를 제공하지만, 우리 서비스가 timeout/partial failure를 겪었을 때 “이 key로 이미 보냈는가, 응답을 받았는가, 재전송해야 하는가”를 판단하려면 내부 기록이 필요하다. 공식 문서들도 provider가 이전 요청의 결과나 최신 상태를 반환한다고 설명하므로, 우리 쪽에서도 그 연관관계를 추적해야 운영이 가능하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
내부적으로 다음을 기록할 수 있어야 한다
|
||||
|
||||
- provider
|
||||
- operation
|
||||
- provider idempotency key
|
||||
- 내부 command id
|
||||
- request fingerprint
|
||||
- provider request status(시도 중/완료/최종 실패)
|
||||
- provider response reference
|
||||
- provider가 idempotency를 제공해도 우리 내부 기록을 완전히 생략하지 않는다
|
||||
|
||||
### 7.2 request fingerprint를 함께 저장한다
|
||||
|
||||
Stripe는 같은 key 재사용 시 들어온 파라미터를 원래 요청과 비교해 다르면 에러를 낸다고 설명한다. 우리도 내부적으로 같은 key가 다른 요청 의미로 재사용되지 않았는지 확인할 수 있어야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 내부 저장소에는 key뿐 아니라 request fingerprint도 함께 둔다
|
||||
- fingerprint는 provider operation 의미를 기준으로 계산한다
|
||||
- 같은 key + 다른 fingerprint는 버그 또는 오용으로 본다
|
||||
|
||||
## 8. 재전송 규칙
|
||||
|
||||
### 8.1 timeout/응답 유실 시에는 같은 key로 재전송한다
|
||||
|
||||
Stripe는 네트워크 연결 오류로 응답을 못 받아도 같은 key로 재시도하면 안전하다고 설명한다. PayPal도 동일한 PayPal-Request-Id를 다시 보내면 이전 요청의 최신 상태를 반환한다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider에 요청을 보냈지만 응답을 못 받았으면 같은 key 재전송을 기본 검토한다
|
||||
- 새 key로 다시 보내는 것을 기본값으로 두지 않는다
|
||||
- 이 판단은 retry/timeout 정책과 함께 묶어서 설계한다
|
||||
|
||||
### 8.2 provider가 “실행이 시작되지 않았다”고 말한 경우는 새 시도로 볼 수 있다
|
||||
|
||||
Stripe는 validation 실패나 concurrent conflict처럼 endpoint 실행이 시작되지 않은 경우에는 결과를 저장하지 않으며, 이런 경우는 다시 시도할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 execution not started에 해당하는 오류를 명시하면 같은 key 재시도 가능성을 검토한다
|
||||
- validation 자체가 잘못된 요청이라면 재시도보다 요청 수정이 우선이다
|
||||
- “실행이 시작되지 않았음”과 “응답만 못 받음”을 구분한다
|
||||
|
||||
### 8.3 동시 중복 송신을 피한다
|
||||
|
||||
PayPal은 같은 PayPal-Request-Id로 동시에 두 요청을 보내면 첫 번째를 처리하고 두 번째는 실패할 수 있다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 provider key를 가진 outbound 호출은 동시에 두 개 이상 송신하지 않는다
|
||||
- 내부적으로 키 단위 동시성 제어를 검토한다
|
||||
- 같은 command를 여러 worker가 동시에 처리하는 구조라면 key-level dedup/lock을 둔다
|
||||
|
||||
## 9. provider 응답 해석 규칙
|
||||
|
||||
### 9.1 replay 응답은 새 성공과 같은 의미로 취급하되, 출처는 구분 가능해야 한다
|
||||
|
||||
Stripe는 같은 key에 대해 첫 결과의 status와 body를 재사용한다고 설명하고, PayPal은 이전 요청의 최신 상태를 반환한다고 설명한다. 즉, provider가 반환한 응답이 “새로 실행된 결과”인지 “기존 실행의 재생/현재 상태”인지 내부적으로는 구분할 수 있는 편이 좋다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider replay 응답도 비즈니스적으로는 성공/실패 결과로 받아들인다
|
||||
- 다만 내부 observability에는
|
||||
- new execution
|
||||
- replayed result
|
||||
- latest known status
|
||||
- 를 구분할 수 있게 한다
|
||||
- 외부 API 응답 body를 우리 내부 의미로 무조건 “새로 생성됨”으로 번역하지 않는다
|
||||
|
||||
### 9.2 provider의 “latest status”와 “original result” 차이를 이해한다
|
||||
|
||||
PayPal은 이전 요청의 “원래 응답”이 아니라 “현재 시점의 최신 상태”를 반환한다고 설명한다. Stripe는 첫 실행 결과를 재사용하는 쪽에 더 가깝다. provider마다 의미가 다르므로, outbound adapter는 이 차이를 내부로 올바르게 번역해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider replay semantics를 문서화한다
|
||||
- “같은 key면 항상 동일 body 재생”이라고 일반화하지 않는다
|
||||
- provider별로
|
||||
- original response replay
|
||||
- latest status lookup
|
||||
- concurrent duplicate failure
|
||||
- 를 구분한다
|
||||
|
||||
## 10. TTL 규칙
|
||||
|
||||
### 10.1 provider TTL을 존중한다
|
||||
|
||||
Stripe는 키를 최소 24시간 이후 정리할 수 있다고 설명한다. PayPal은 일부 API에서 PayPal-Request-Id 보관 기간이 정해져 있고, 그동안 재시도 가능하다고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider key TTL은 provider 공식 문서 기준을 따른다
|
||||
- TTL 내 재전송은 같은 key 사용
|
||||
- TTL 이후는 새 요청으로 처리될 수 있음을 전제로 한다
|
||||
|
||||
### 10.2 내부 기록 TTL은 provider TTL보다 짧게 두지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 내부 key 매핑 기록 TTL은 provider TTL 이상을 기본 검토한다
|
||||
- provider는 아직 기억하는데 우리는 잊어버리는 상태를 만들지 않는다
|
||||
- 최소한 “왜 같은 key가 다시 쓰였는지” 추적 가능한 기간을 확보한다
|
||||
|
||||
## 11. observability 규칙
|
||||
|
||||
### 11.1 outbound idempotency key는 로그에 원문 전체를 남기지 않는다
|
||||
|
||||
Stripe는 key에 민감정보를 넣지 말라고 하지만, 그렇다고 로그에 원문 전체를 항상 남겨도 된다는 뜻은 아니다. 외부 키도 운영 식별자일 뿐 민감도 없는 공개값으로 취급하지 않는다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider key 원문 전체 로그를 기본 금지
|
||||
- 필요하면 prefix 또는 내부 correlation id만 남긴다
|
||||
- 로그에는
|
||||
- provider
|
||||
- operation
|
||||
- outboundCommandId
|
||||
- providerRequestId
|
||||
- 정도의 내부 식별자를 우선 사용한다
|
||||
|
||||
### 11.2 replay / duplicate / key mismatch는 관측 가능해야 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
outbound idempotency 관련 운영 이벤트는 최소한 다음을 구분 가능해야 한다
|
||||
|
||||
- 새 호출
|
||||
- 같은 key 재전송
|
||||
- provider replay 응답
|
||||
- 같은 key 다른 fingerprint 충돌
|
||||
- 동시 중복 송신 차단
|
||||
- retry와 idempotency를 함께 분석할 수 있어야 한다
|
||||
|
||||
## 12. 다른 문서와의 경계
|
||||
|
||||
이 문서는 outbound idempotency만 다룬다.
|
||||
아래 주제의 source of truth는 별도 문서다.
|
||||
|
||||
- retry
|
||||
- timeout
|
||||
- fallback
|
||||
- serialization/deserialization
|
||||
- exception translation
|
||||
|
||||
이 문서는 위 주제들을 다시 반복하지 않고, 외부 provider idempotency를 어떻게 써야 하는지만 정의한다.
|
||||
|
||||
## 13. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- provider 공식 idempotency key 지원이 있는데 무시하고 새 요청처럼 재전송
|
||||
- 같은 key를 다른 provider operation에 재사용
|
||||
- 같은 key를 다른 fingerprint 요청에 재사용
|
||||
- timeout 후 새 key로 같은 side effect 요청 재전송
|
||||
- provider key와 내부 command 추적 관계를 저장하지 않음
|
||||
- 같은 key의 동시 중복 송신 허용
|
||||
- key에 이메일/전화번호/주문자명 같은 의미 있는 PII 사용
|
||||
- replay semantics가 다른 provider를 같은 규칙으로 단순화
|
||||
|
||||
## 14. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 outbound 호출은 side effect가 있어 idempotency가 필요한가?
|
||||
- provider가 공식 idempotency key/header를 지원하는가?
|
||||
- 내부 key와 provider key의 scope가 명확한가?
|
||||
- timeout/응답 유실 시 같은 key 재전송 전략이 정의되어 있는가?
|
||||
- 같은 key의 fingerprint 충돌을 감지할 수 있는가?
|
||||
- provider TTL과 내부 저장 TTL이 정렬되어 있는가?
|
||||
- replay/new/latest-status semantics를 provider별로 구분하고 있는가?
|
||||
- retry와 idempotency가 함께 관측 가능한가?
|
||||
@@ -0,0 +1,292 @@
|
||||
# Retry 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API / integration 호출에서 retry를 어떻게 적용할지 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- retry 대상을 일시적 실패로 제한한다
|
||||
- retry가 장애를 증폭시키지 않게 한다
|
||||
- retry, timeout, idempotency, fallback의 책임을 구분한다
|
||||
- 외부 HTTP 호출 retry를 adapter 경계 안에서 일관되게 처리한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Retry / AWS / Google Cloud 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 일반적인 운영 관행을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 retry는 기본값이 아니라 제한적 도구다
|
||||
|
||||
Spring은 retry 기능을 제공하지만, Spring Framework resilience 문서 기준 기본 retry는 모든 예외를 대상으로 최대 3회 재시도하며 1초 간격을 둡니다. 즉, 프레임워크 기본값만 믿으면 너무 넓게 재시도할 수 있습니다. AWS도 retry는 유용하지만, 부하가 높은 상황에서는 서버에 더 많은 요청을 보내 상황을 악화시킬 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API retry는 명시적으로 허용한 경우에만 사용한다
|
||||
- 기본 retry 정책에 의존하지 않는다
|
||||
- retry를 “일단 켜 두는 안정화 옵션”으로 쓰지 않는다
|
||||
|
||||
### 3.2 retry는 timeout, idempotency, fallback과 분리해서 설계한다
|
||||
|
||||
AWS는 timeout, retry, backoff를 별도 도구로 설명하고, side effect가 있는 API는 retry 전에 idempotency 가 중요하다고 말합니다. Google Cloud도 non-idempotent operation retry를 anti-pattern으로 경고합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout은 “얼마나 기다릴지”
|
||||
- retry는 “다시 시도할지”
|
||||
- idempotency는 “다시 시도해도 안전한지”
|
||||
- fallback은 “재시도 후에도 실패하면 대체 경로가 있는지”
|
||||
- 를 각각 따로 판단한다
|
||||
|
||||
### 3.3 retry는 외부 연동 경계(adapter)에 둔다
|
||||
|
||||
Spring이 RestClient, WebClient, HTTP Service Client 같은 외부 호출 도구를 제공하는 만큼, retry도 외부 연동 경계에서 결정하는 것이 자연스럽습니다. application/domain이 provider-specific HTTP 오류나 retry 대상 예외를 직접 다루는 구조는 피하는 것이 좋습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 HTTP retry는 integration adapter/client 경계에서 처리한다
|
||||
- application/domain이 SocketTimeoutException, WebClientResponseException, ConnectException 분류를 직접 하지 않는다
|
||||
- retry 이후 최종 실패만 내부 예외로 번역한다
|
||||
|
||||
## 4. 언제 retry하는가
|
||||
|
||||
### 4.1 retry 대상은 “일시적 실패”다
|
||||
|
||||
AWS는 retry가 부분 실패, 일시적 네트워크 문제, 순간적인 과부하 같은 상황에 유용하다고 설명합니다. Google Cloud도 retry는 response criteria와 idempotency criteria를 동시에 만족 하는 요청에만 적용하라고 안내합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 retry 후보가 될 수 있다.
|
||||
|
||||
- connection timeout / connection reset
|
||||
- read timeout / response timeout
|
||||
- 일시적 5xx
|
||||
- 일시적 429
|
||||
- 네트워크 단절/짧은 DNS/TLS 실패
|
||||
- provider가 transient failure로 명시한 에러
|
||||
|
||||
### 4.2 retry하지 않는 대상
|
||||
|
||||
Google Cloud는 retrying unretryable errors, non-idempotent operation retry를 anti-pattern으로 명시합니다. Spring 기본 retry는 모든 예외를 재시도할 수 있으므로, 이 프로젝트에서는 반드시 대상을 좁혀야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
다음은 기본적으로 retry하지 않는다.
|
||||
|
||||
- 4xx business 오류
|
||||
- 인증 실패(401/403)
|
||||
- 잘못된 요청 형식(400)
|
||||
- provider 계약 위반
|
||||
- validation/parsing 오류
|
||||
- deterministic failure
|
||||
- side effect가 있는데 idempotency가 보장되지 않는 호출
|
||||
|
||||
### 4.3 404/409 같은 상태는 provider 계약에 따라 해석한다
|
||||
|
||||
Spring의 HTTP client는 status를 읽을 수 있지만, 어떤 status를 transient로 볼지는 provider 계약이 결정합니다. 일부 409/404는 정상적인 부재/중복 의미일 수 있고, 일부 429/503은 transient일 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- retry 가능 여부는 HTTP status 숫자만 으로 결정하지 않는다
|
||||
- provider 계약 문서와 실제 운영 의미를 함께 본다
|
||||
- 같은 provider 안에서는 status 해석 기준을 문서화한다
|
||||
|
||||
## 5. retry 전제조건
|
||||
|
||||
### 5.1 retry 전에는 idempotency 가능성을 확인한다
|
||||
|
||||
AWS는 side effect가 있는 API는 timeout/partial failure 이후 재시도 시 중복 side effect가 생길 수 있으므로 idempotent API 설계 가 중요하다고 설명합니다. Google Cloud도 non-idempotent operation retry를 anti-pattern으로 지적합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- side effect 없는 read 호출은 retry 허용을 더 쉽게 검토한다
|
||||
- side effect 있는 write 호출은
|
||||
- provider idempotency support
|
||||
- 우리 쪽 idempotency key
|
||||
- 중복 실행 허용 여부
|
||||
- 를 먼저 확인한다
|
||||
- 이 검토 없이 자동 retry를 켜지 않는다
|
||||
|
||||
### 5.2 retry는 timeout이 먼저 있어야 의미가 있다
|
||||
|
||||
timeout이 없으면 호출이 오래 붙잡힌 채 retry까지 가지 못하고, AWS도 timeout을 원격 호출의 기본 안전장치로 설명합니다. retry는 timeout과 함께 설계되어야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- retry가 있는 외부 API 호출에는 timeout이 먼저 정의되어 있어야 한다
|
||||
- timeout 없이 retry만 정의하는 것을 금지한다
|
||||
- retry 문서는 timeout 문서와 함께 읽는 것을 전제로 한다
|
||||
|
||||
## 6. backoff 규칙
|
||||
|
||||
### 6.1 즉시 반복 retry를 금지한다
|
||||
|
||||
Google Cloud는 retry without backoff 를 대표 anti-pattern으로 지적합니다. AWS도 retry는 backoff와 함께 써야 하고, 그렇지 않으면 부하를 폭증시킬 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 자동 retry는 backoff 없이 즉시 연속 재시도하지 않는다
|
||||
- 최소한 exponential backoff를 기본으로 한다
|
||||
- “짧게 여러 번 때리면 되겠지”를 금지한다
|
||||
|
||||
### 6.2 jitter를 기본으로 한다
|
||||
|
||||
AWS는 retries and backoff with jitter를 공식적으로 설명하고, 동시 재시도로 인한 thundering herd를 줄이기 위해 jitter가 중요하다고 강조합니다. Google Cloud도 exponential backoff with jitter를 일반적으로 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- backoff에는 jitter를 기본 포함한다
|
||||
- 같은 장애 시점에 모든 인스턴스가 같은 간격으로 동시에 재시도하지 않게 한다
|
||||
- jitter 없는 fixed backoff를 기본값으로 두지 않는다
|
||||
|
||||
### 6.3 backoff는 무한히 커지지 않게 상한을 둔다
|
||||
|
||||
AWS는 exponential backoff를 설명하면서도 상한을 두고, 전체 retry budget 안에서 움직이게 설계해야 한다고 말합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- initial interval
|
||||
- multiplier
|
||||
- max interval
|
||||
- max attempts
|
||||
- 를 모두 명시한다
|
||||
- 무한 증가형 backoff를 금지한다
|
||||
|
||||
## 7. retry 횟수 규칙
|
||||
|
||||
### 7.1 짧고 보수적인 max attempts를 기본으로 한다
|
||||
|
||||
Spring Framework 기본 retry는 최대 3회 재시도입니다. Google Cloud는 unnecessarily layering retries를 anti-pattern으로 지적합니다. 이 프로젝트도 외부 HTTP 호출은 짧고 보수적인 횟수를 기본으로 둡니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 API 자동 retry 기본값은 짧게
|
||||
- 일반 권장 시작점:
|
||||
- 총 시도 2~3회 수준
|
||||
- “10번까지 해보자” 같은 공격적 retry를 기본 금지한다
|
||||
|
||||
### 7.2 상위/하위 레이어 retry 중복을 금지한다
|
||||
|
||||
Google Cloud는 unnecessarily layering retries 를 anti-pattern으로 지적합니다. client library, gateway, adapter, application service가 모두 retry하면 실제 요청 수가 폭증할 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 한 호출 경로에서 대표 retry layer를 정한다
|
||||
- provider SDK가 이미 retry를 하면 우리 adapter retry를 다시 올리지 않는다
|
||||
- gateway / SDK / client / application retry가 겹치지 않게 한다
|
||||
|
||||
## 8. 기술 선택 규칙
|
||||
|
||||
### 8.1 선언적 retry와 프로그래밍식 retry를 구분한다
|
||||
|
||||
Spring은 @Retryable 애노테이션과 RetryPolicy/RetryTemplate 계열을 제공하고, Spring Framework 7 resilience 기능도 method-level retry를 지원합니다. @Retryable은 간단하지만 기본적으로 프록시 기반이고, 대상/횟수/backoff를 명시적으로 설정해야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 단순한 adapter method 재시도에는 선언적 retry 허용
|
||||
- 복잡한 분기, 일부 코드 블록만 재시도, 동적 정책은 프로그래밍식 retry 우선
|
||||
- 어떤 방식을 쓰든 retry 대상 예외와 backoff를 명시한다
|
||||
|
||||
### 8.2 새 코드의 기본 선택은 provider/client 구조에 맞춰 명시적으로 한다
|
||||
|
||||
Spring 자체는 retry 기능을 제공하지만, Boot의 외부 client 구조와 결합할 때는 “어디에 적용할지”가 더 중요합니다. integration adapter 단위의 retry가 가장 기본이며, Spring Cloud CircuitBreaker/Resilience4j 같은 도구가 있다면 공통 정책화도 가능합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- retry는 external client adapter 또는 그 바로 위 integration service에 둔다
|
||||
- controller/application/domain에 retry 애노테이션을 붙이지 않는다
|
||||
- 공통 라이브러리를 쓰더라도 source of truth는 프로젝트 문서다
|
||||
|
||||
## 9. observability 규칙
|
||||
|
||||
### 9.1 retry는 관측 가능해야 한다
|
||||
|
||||
AWS는 retry가 장애를 완화할 수도 있지만 반대로 악화시킬 수도 있으므로, retry 동작을 이해할 수 있어야 한다고 설명합니다. Spring/Boot의 HTTP client instrumentation과 함께 retry attempt, provider, operation, 최종 결과를 추적 가능하게 해야 합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
retry 로그/메트릭에는 최소한 다음을 남긴다
|
||||
|
||||
- provider/client name
|
||||
- operation
|
||||
- attempt number
|
||||
- final outcome
|
||||
- error type
|
||||
- retry가 있었는지, 몇 번 있었는지, 결국 성공/실패했는지 구분 가능해야 한다
|
||||
|
||||
### 9.2 최종 실패만 대표 ERROR로 남긴다
|
||||
|
||||
AWS/Google Cloud가 경고하는 retry storm와 중복 부하 문제를 고려하면, 중간 실패를 모두 ERROR로 남기면 운영 신호가 오염됩니다. 최종 실패만 대표 ERROR, 중간 실패는 WARN 또는 DEBUG가 기본입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 중간 retry 실패: WARN 또는 DEBUG
|
||||
- retry 후 성공: WARN 또는 INFO
|
||||
- retry 후 최종 실패: 대표 ERROR
|
||||
|
||||
## 10. provider 계약과의 관계
|
||||
|
||||
### 10.1 Retry-After 같은 provider 신호를 존중한다
|
||||
|
||||
HTTP/공급자 문서가 retry 간격이나 throttling 신호를 주는 경우, 그 신호를 우선 고려하는 것이 일반적 운영 원칙입니다. Google Cloud도 response criteria를 보고 retry해야 한다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 Retry-After 또는 throttling 가이드를 주면 우선 따른다
|
||||
- 로컬 backoff 정책이 provider 신호와 충돌하지 않게 한다
|
||||
- provider rate limit 계약을 무시한 retry를 금지한다
|
||||
|
||||
### 10.2 provider별 retryable 오류 목록을 문서화한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
각 주요 외부 시스템별로
|
||||
|
||||
- retryable status
|
||||
- retryable exception
|
||||
- non-retryable business error
|
||||
|
||||
를 문서화한다
|
||||
|
||||
코드 안 산발적인 if status == 503 식 분기를 줄인다
|
||||
|
||||
## 11. 이 문서와 다른 문서의 경계
|
||||
|
||||
이 문서는 외부 API retry만 다룬다. 아래 주제의 source of truth는 별도 문서다.
|
||||
|
||||
- timeout
|
||||
- idempotency
|
||||
- fallback
|
||||
- exception translation
|
||||
- serialization/deserialization
|
||||
|
||||
이 문서는 위 내용을 반복하지 않고, 외부 연동 retry에서 어디까지 함께 고려해야 하는지만 정의한다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 모든 예외 retry
|
||||
- backoff 없는 즉시 재시도
|
||||
- jitter 없는 고정 간격 재시도 기본값
|
||||
- side effect API를 idempotency 검토 없이 자동 retry
|
||||
- SDK + gateway + adapter + application 중복 retry
|
||||
- retry 대상 아닌 4xx/business error retry
|
||||
- retry가 있는데 timeout이 없음
|
||||
- retry 동작이 관측되지 않음
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 실패는 정말 transient인가?
|
||||
- 이 호출은 retry해도 안전한가, 특히 idempotent한가?
|
||||
- timeout이 먼저 정의되어 있는가?
|
||||
- retry 대상 예외/status가 명시되어 있는가?
|
||||
- backoff와 jitter가 있는가?
|
||||
- max attempts가 짧고 보수적인가?
|
||||
- 상위/하위 레이어 retry 중복이 없는가?
|
||||
- retry attempt와 최종 결과가 관측 가능한가?
|
||||
@@ -0,0 +1,279 @@
|
||||
# Integration Serialization / Deserialization 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API / integration 호출에서 request serialization과 response deserialization 기준을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- provider 계약과 내부 모델을 분리한다
|
||||
- 외부 payload 변화에 대한 내성을 높인다
|
||||
- media type, 필드명, null/absent, 에러 바디, 날짜/시간 포맷을 일관되게 처리한다
|
||||
- serialization concern이 application/domain으로 번지지 않게 한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Framework / Spring Boot / Jackson 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 Tolerant Reader 같은 실무 패턴을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 외부 serialization은 provider contract가 결정한다
|
||||
|
||||
Spring의 REST client는 HTTP 본문을 상위 Java 객체로 읽고 쓰며, JSON 외에도 application/x-www-form-urlencoded, multipart, byte array, XML 같은 형식을 converter로 다룹니다. 따라서 외부 연동 직렬화 기준은 우리 내부 JSON 취향이 아니라 provider가 요구하는 content type, field shape, wire format 이 먼저다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 JSON을 요구하면 JSON으로
|
||||
- provider가 form-urlencoded를 요구하면 form으로
|
||||
- provider가 XML/byte/binary를 요구하면 그 형식으로 보낸다
|
||||
- “우리 서비스는 JSON 표준이 있으니 외부도 JSON으로 통일”을 금지한다
|
||||
|
||||
### 3.2 외부 DTO와 내부 모델은 반드시 분리한다
|
||||
|
||||
Spring client가 DTO 변환을 쉽게 해 준다고 해서, provider DTO를 내부 모델처럼 써도 된다는 뜻은 아니다. Tolerant Reader 관점에서도 payload를 읽는 코드는 한 경계에 모아야 나머지 시스템이 변경에 덜 민감해진다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 request DTO / response DTO는 provider 계약 전용 타입
|
||||
- application/domain은 외부 DTO를 직접 모른다
|
||||
- adapter 경계에서 내부 command/result/failure reason으로 변환한다
|
||||
|
||||
### 3.3 읽기는 느슨하게, 쓰기는 명시적으로 한다
|
||||
|
||||
Jackson의 ignoreUnknown=true는 외부 응답이 필드를 추가해도 파싱을 덜 깨뜨리게 해 준다. 반면 outbound request는 provider가 받지 않는 필드를 보내거나, null과 absent를 헷갈리게 보내면 계약 오류가 생긴다. 따라서 외부 연동에서는 response는 tolerant reader, request는 explicit writer 전략을 기본으로 둔다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 response DTO는 additive change에 관대할 수 있다
|
||||
- 외부 request DTO는 보내는 필드를 명시적으로 통제한다
|
||||
- 내부 객체를 그대로 직렬화해서 provider에 보내지 않는다
|
||||
|
||||
## 4. Request serialization 표준
|
||||
|
||||
### 4.1 outbound request는 provider 계약에 정확히 맞춘다
|
||||
|
||||
Spring REST client는 DTO를 바탕으로 본문을 직렬화하지만, 실제 field name과 media type은 adapter가 정해야 한다. Jackson의 @JsonProperty는 외부 필드명을 DTO 경계에서 맞추는 공식 수단이다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider field name mismatch는 외부 request DTO에서 해결한다
|
||||
- 내부 필드명/도메인 용어를 provider 계약에 맞춰 바꾸지 않는다
|
||||
- provider-specific enum/string/value shape를 request DTO에 국소화한다
|
||||
|
||||
### 4.2 request DTO는 allowlist 방식으로 설계한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider에 보낼 필드만 request DTO에 둔다
|
||||
- 내부 계산값, 디버그 값, 서버 내부 상태를 request DTO에 섞지 않는다
|
||||
- “언젠가 쓸 수 있으니 같이 보내자”를 금지한다
|
||||
|
||||
### 4.3 null과 absent는 provider 계약 기준으로 명시한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 null과 필드 omission을 다르게 해석하면 반드시 구분한다
|
||||
- 기본 정책은 “의미가 다르면 DTO와 mapper에서 명시적으로 처리”
|
||||
- 전역 NON_NULL 같은 설정으로 provider별 의미를 무심코 바꾸지 않는다
|
||||
|
||||
### 4.4 media type은 명시적으로 맞춘다
|
||||
|
||||
Spring converter는 JSON, form, multipart, byte array, XML 등을 지원한다. 외부 연동에서는 특히 OAuth/token 발급, webhook, 파일 업로드, binary download처럼 JSON이 아닌 형식이 흔하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- application/json을 기본 추정값으로 두지 않는다
|
||||
- provider가 요구하는 Content-Type과 Accept를 adapter에서 명시한다
|
||||
- form 요청은 JSON DTO를 억지로 보내지 않는다
|
||||
|
||||
## 5. Response deserialization 표준
|
||||
|
||||
### 5.1 external response DTO는 tolerant reader를 기본 검토한다
|
||||
|
||||
Jackson의 @JsonIgnoreProperties(ignoreUnknown = true)는 인식하지 못한 필드를 deserialization에서 무시한다. Fowler의 Tolerant Reader도 producer가 필드를 추가해도 consumer가 덜 깨지도록 payload reading을 느슨하게 설계하라고 설명한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- third-party response DTO는 ignoreUnknown=true를 기본 검토한다
|
||||
- provider가 필드를 추가해도 우리 파싱이 즉시 깨지지 않게 한다
|
||||
- 단, first-party API request DTO까지 이 정책을 일반화하지 않는다
|
||||
|
||||
### 5.2 success body와 error body를 분리한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 성공 응답 DTO와 오류 응답 DTO를 따로 둔다
|
||||
- provider error JSON을 success DTO에 억지로 파싱하지 않는다
|
||||
- error body는 adapter가 읽고 내부 failure reason/exception으로 번역한다
|
||||
|
||||
### 5.3 raw Map/JsonNode는 마지막 수단이다
|
||||
|
||||
Spring/Jackson은 상위 객체 매핑과 custom deserializer를 지원한다. 따라서 외부 응답 구조가 완전히 동적이지 않다면 typed DTO가 기본이다. raw map/tree는 계약이 너무 불안정하거나 일부 필드만 읽을 때의 마지막 수단으로 본다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본은 typed response DTO
|
||||
- 정말 불안정한 payload만 JsonNode/Map 허용
|
||||
- raw tree를 application/domain까지 들고 가지 않는다
|
||||
- boundary에서 읽고 안정적인 내부 모델로 바꾼다
|
||||
|
||||
### 5.4 외부 enum은 바로 domain enum에 연결하지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider enum/string 값은 외부 DTO 또는 mapper 단계에서 해석한다
|
||||
- domain enum에 provider 값을 직접 박아 넣지 않는다
|
||||
- provider가 새 enum 값을 추가할 수 있으면 UNKNOWN/기본 처리 전략을 둔다
|
||||
|
||||
## 6. 날짜/시간/숫자 규칙
|
||||
|
||||
### 6.1 날짜/시간 형식은 provider 계약을 따른다
|
||||
|
||||
Jackson의 JavaTimeModule은 java.time 타입을 지원하고, timestamps 기능이 꺼져 있으면 보통 ISO-8601 문자열을 사용한다. 하지만 외부 연동에서는 provider가 epoch millis, string, custom format 중 무엇을 쓰는지가 더 중요하다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider가 ISO-8601을 쓰면 Instant/OffsetDateTime 등으로 명시적으로 읽는다
|
||||
- provider가 epoch number를 쓰면 그 계약을 DTO/커스텀 deserializer에서 처리한다
|
||||
- 내부 표준 시간 타입을 provider wire format 때문에 오염시키지 않는다
|
||||
|
||||
### 6.2 숫자/정밀도는 domain 의미를 잃지 않게 한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 금액, 환율, 정산 수치처럼 정밀도가 중요한 값은 double을 기본값으로 두지 않는다
|
||||
- provider가 문자열 금액을 보내면 문자열 → 안전한 내부 수치 타입으로 변환한다
|
||||
- 숫자 파싱 실패는 provider parsing failure로 다루고 domain 예외와 섞지 않는다
|
||||
|
||||
## 7. Jackson / mapper / module 규칙
|
||||
|
||||
### 7.1 메서드 안에서 new ObjectMapper()를 만들지 않는다
|
||||
|
||||
Spring Boot는 auto-configured JSON mapper와 RestClient.Builder/WebClient.Builder를 제공하고, 그 builder에는 converter/codecs와 적절한 공통 구성이 반영된다. 메서드마다 새 mapper를 만들면 그 구성을 우회하게 된다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- adapter 메서드 안 new ObjectMapper() 금지
|
||||
- 공통 builder와 공통 mapper를 우선 사용한다
|
||||
- provider 특수 규칙이 있으면 adapter configuration에서 분리해 구성한다
|
||||
|
||||
### 7.2 전역 @JacksonComponent / @JacksonMixin은 진짜 공통 규칙에만 쓴다
|
||||
|
||||
Spring Boot는 @JacksonComponent를 자동 등록하고, @JacksonMixin도 auto-configured mapper에 등록한다. 즉, 이 둘은 전역 영향 이 있다. 따라서 provider 하나만을 위한 특수 직렬화 규칙을 전역에 뿌리는 것은 신중해야 한다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 여러 연동/여러 DTO에 공통인 serializer/deserializer만 전역 등록
|
||||
- 특정 provider 전용 weird format은 adapter-local configuration 우선
|
||||
- provider 하나 때문에 전체 애플리케이션 JSON 규칙을 바꾸지 않는다
|
||||
|
||||
### 7.3 imperative/reactive client가 쓰는 JSON mapper 경계를 의식한다
|
||||
|
||||
Boot는 imperative HTTP clients와 reactive HTTP clients에 대해 각각 pre-configured builder를 제공하고, preferred JSON mapper 설정도 분리해 둔다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- RestClient/WebClient에서 provider-specific codec/mapper를 바꿀 때 범위를 명시한다
|
||||
- imperative client용 변경이 reactive client 전체에 번지지 않게 한다
|
||||
- “한 군데 바꾸면 다 되겠지” 식 전역 변경을 지양한다
|
||||
|
||||
## 8. 검증 / 번역 규칙
|
||||
|
||||
### 8.1 파싱 성공과 비즈니스 성공을 같은 것으로 보지 않는다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- JSON/XML/form parsing 성공은 “wire format 해석 성공”일 뿐
|
||||
- provider가 business failure body를 200으로 줄 수도 있다
|
||||
- adapter는 파싱 후에 success/error semantics를 다시 해석한다
|
||||
|
||||
### 8.2 deserialization 예외는 provider parsing failure로 번역한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- malformed payload, required field missing, unexpected type mismatch는 integration parsing failure로 번역한다
|
||||
- application/domain이 Jackson 예외 타입을 직접 보지 않게 한다
|
||||
- provider contract drift 여부를 운영에서 추적 가능하게 한다
|
||||
|
||||
## 9. 관측 가능성 규칙
|
||||
|
||||
### 9.1 payload 전문 로그를 기본 금지한다
|
||||
|
||||
외부 payload는 PII, 토큰, 비밀값, 내부 식별자 등을 포함할 수 있다. 이전 observability 기준과 마찬가지로, serialization/deserialization 문제를 추적한다는 이유로 request/response 전문을 기본 로그에 남기지 않는다. 이 점은 OWASP의 민감정보 로그 금지 원칙과도 맞다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 기본 로그는 provider, operation, status, contentType, payloadBytes, parse failure type 정도만
|
||||
- payload 원문은 기본 금지
|
||||
- 꼭 필요하면 테스트/격리 환경에서 제한적으로 남긴다
|
||||
|
||||
### 9.2 parse failure는 contract drift 신호로 남긴다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- deserialization 실패는 단순 예외로 묻지 않는다
|
||||
- provider, operation, content type, failing field/shape 정도를 안전하게 남긴다
|
||||
- “provider contract가 변했을 수 있음”을 운영에서 추적할 수 있어야 한다
|
||||
|
||||
## 10. 테스트 규칙
|
||||
|
||||
### 10.1 외부 DTO는 fixture 기반 계약 테스트를 둔다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 대표 성공 응답
|
||||
- 대표 오류 응답
|
||||
- provider가 필드를 추가한 응답
|
||||
- 일부 필드 누락 응답
|
||||
- 에 대한 parsing 테스트를 둔다
|
||||
- provider 예시 payload나 실제 캡처 샘플을 fixture로 관리할 수 있다
|
||||
|
||||
### 10.2 request serialization도 golden sample로 확인한다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider에 보내는 JSON/form/XML shape를 golden sample로 검증한다
|
||||
- field name, null/absent, date/time format, enum value가 계약대로 직렬화되는지 확인한다
|
||||
- “직렬화는 framework가 알아서 하겠지”에 기대지 않는다
|
||||
|
||||
## 11. 다른 문서와의 경계
|
||||
|
||||
이 문서는 외부 provider payload의 serialization/deserialization 만 다룬다.
|
||||
아래 주제의 source of truth는 별도 문서다.
|
||||
|
||||
- external API client structure
|
||||
- timeout
|
||||
- retry
|
||||
- outbound idempotency
|
||||
- fallback
|
||||
- exception translation
|
||||
|
||||
이 문서는 위 문서를 반복하지 않고, payload contract를 읽고 쓰는 경계 규칙 만 정의한다.
|
||||
|
||||
## 12. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 내부 domain/entity를 외부 request/response DTO로 직접 사용
|
||||
- provider response DTO를 application/domain 시그니처에 그대로 전달
|
||||
- 메서드 안 new ObjectMapper() 생성
|
||||
- 특정 provider 응답 대응을 위해 전역 mapper 규칙을 무심코 변경
|
||||
- first-party API strict 정책과 external response tolerant 정책을 혼동
|
||||
- success/error body를 같은 DTO로 억지 파싱
|
||||
- payload 전문 로그를 기본으로 남김
|
||||
- 외부 enum/string 값을 바로 domain enum에 박아 넣음
|
||||
|
||||
## 13. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- provider request/response DTO와 내부 모델이 분리되어 있는가?
|
||||
- outbound request가 provider contract를 정확히 반영하는가?
|
||||
- external response는 additive change에 대해 필요한 만큼 tolerant한가?
|
||||
- success body와 error body DTO가 분리되어 있는가?
|
||||
- null과 absent 의미를 provider 계약 기준으로 다루는가?
|
||||
- provider-specific weird format이 adapter 경계 안에 갇혀 있는가?
|
||||
- RestClient/WebClient의 공통 builder/mapper 구성을 우회하지 않는가?
|
||||
- serialization/deserialization fixture 테스트가 있는가?
|
||||
@@ -0,0 +1,286 @@
|
||||
# Timeout 기준
|
||||
|
||||
## 1. 목적
|
||||
|
||||
이 문서는 외부 API 호출 timeout의 의미, 위치, 기본 정책을 정의한다.
|
||||
|
||||
이 문서의 목표는 다음과 같다.
|
||||
|
||||
- 외부 연동 호출이 무기한 대기하지 않게 한다
|
||||
- connect/read/response/pool acquire 같은 timeout 종류를 구분한다
|
||||
- timeout을 retry, fallback, idempotency와 혼동하지 않게 한다
|
||||
- provider별/operation별 timeout을 일관되게 설계한다
|
||||
|
||||
## 2. 근거 수준
|
||||
|
||||
- Official: Spring Boot / Spring Framework / Reactor Netty 공식 문서에서 직접 확인되는 내용
|
||||
- Official + Practice: 공식 기능 위에 AWS Builders’ Library 같은 실무 운영 원칙을 결합한 내용
|
||||
- Project Recommendation: 이 프로젝트 구조와 운영 방식에 맞춘 규칙
|
||||
|
||||
## 3. 기본 원칙
|
||||
|
||||
### 3.1 모든 원격 호출에는 timeout이 있어야 한다
|
||||
|
||||
AWS는 원격 호출에는 timeout을 두는 것이 모범 사례이며, 같은 프로세스 내부가 아닌 프로세스 간 호출 전반에 timeout을 두라고 설명합니다. timeout이 없으면 오래 걸리는 요청이 메모리, 스레드, 연결, ephemeral port 같은 자원을 오래 붙잡아 시스템 전체를 악화시킬 수 있습니다. Reactor Netty도 response timeout을 설정하는 것이 좋은 실천이라고 명시합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 HTTP 호출에 무제한 대기 금지
|
||||
- 모든 외부 API client는 최소한 timeout 정책을 가져야 한다
|
||||
- timeout이 설정되지 않은 외부 연동 코드는 승인 후보에서 지양한다
|
||||
|
||||
### 3.2 timeout은 retry가 아니다
|
||||
|
||||
AWS는 timeout, retry, backoff를 별도 도구로 설명하며, timeout은 대기 시간을 제한하고, retry는 부분 실패/일시 실패를 다시 시도하는 메커니즘이라고 설명합니다. timeout이 발생했다고 해서 side effect가 없었다고 보장되지 않으며, side effect가 있는 API는 idempotency가 있어야 안전하게 재시도할 수 있다고도 말합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout은 “언제 포기할지”를 정하는 규칙
|
||||
- retry는 “포기 전에 다시 시도할지”를 정하는 규칙
|
||||
- timeout 문서에서 retry를 중복 정의하지 않는다
|
||||
- side effect가 있는 외부 API는 timeout 이후 재시도 전에 idempotency 가능 여부를 반드시 검토한다
|
||||
|
||||
### 3.3 timeout은 외부 시스템별, operation별로 달라질 수 있다
|
||||
|
||||
AWS는 timeout 값을 고를 때 downstream latency와 허용 가능한 false timeout 비율을 보고 정해야 하며, 인터넷 구간처럼 네트워크 편차가 큰 경우와 p99.9와 p50이 가까운 tight latency 서비스는 같은 방식으로 잡으면 안 된다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 시스템마다 timeout이 다를 수 있다
|
||||
- 같은 외부 시스템 안에서도 operation별 timeout이 다를 수 있다
|
||||
- “전 서비스 공통 3초” 같은 일괄값을 기본 전략으로 두지 않는다
|
||||
- timeout은 실제 latency와 business 중요도에 근거해 결정한다
|
||||
|
||||
## 4. timeout 종류 구분
|
||||
|
||||
### 4.1 최소한 connect timeout과 read/response timeout을 구분한다
|
||||
|
||||
Spring Boot는 전역 HTTP client 설정으로 spring.http.clients.connect-timeout와 spring.http.clients.read-timeout를 제공합니다. HTTP Service group도 connection/read timeout을 그룹별로 설정할 수 있습니다. Reactor Netty는 response timeout과 connection timeout을 별도 개념으로 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 외부 HTTP client는 최소한 다음 둘을 구분한다
|
||||
- connect timeout
|
||||
- read/response timeout
|
||||
- 연결이 안 되는 상황과, 연결은 되었지만 응답이 늦는 상황을 같은 timeout 하나로 퉁치지 않는다
|
||||
|
||||
### 4.2 reactive client에서는 pool acquire / TLS / DNS도 별도 고려 대상이다
|
||||
|
||||
Reactor Netty는 connection pool acquire timeout, SSL/TLS handshake timeout, proxy timeout, DNS query timeout까지 별도 timeout 옵션으로 설명합니다. connection pool의 pendingAcquireTimeout 기본값은 45초이고, SSL handshake timeout 기본값은 10초이며, DNS query timeout 기본값은 5초입니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
WebClient + Reactor Netty를 쓸 때는 단순 response timeout만 볼 것이 아니라 다음도 검토한다
|
||||
|
||||
- connection pool acquire timeout
|
||||
- SSL handshake timeout
|
||||
- DNS resolution timeout
|
||||
- 트래픽이 많거나 TLS/프록시/DNS 영향이 큰 환경에서는 이 고급 timeout을 운영 설계에 포함한다
|
||||
|
||||
### 4.3 “전체 호출 deadline”과 client-level timeout을 구분한다
|
||||
|
||||
Reactor Netty는 specific timeout 옵션을 두는 편이 Reactor timeout 연산자보다 더 목적에 맞는 제어를 준다고 설명합니다. timeout 연산자는 연결부터 응답 수신까지 전체 동작에 걸리는 시간을 통째로 제한하지만, client-specific timeout은 더 세밀합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- client-level timeout은 connect/read/pool/TLS 같은 기술적 단계별 timeout
|
||||
- business/application deadline은 “이 유스케이스가 전체적으로 몇 초 안에 끝나야 하는가”라는 별도 개념
|
||||
- 둘을 혼동하지 않는다
|
||||
- reactive 체인 전체에 무턱대고 timeout()만 거는 것을 기본값으로 두지 않는다
|
||||
|
||||
## 5. client 종류별 표준
|
||||
|
||||
### 5.1 RestClient 기본값은 Boot 전역 설정 + provider별 override다
|
||||
|
||||
Spring Boot는 RestClient.Builder를 자동 구성하고, spring.http.clients.connect-timeout / read-timeout 같은 전역 속성을 제공합니다. 또한 HTTP Service client group별로 connection/read timeout을 다르게 둘 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- imperative 외부 HTTP 호출 기본값은 RestClient
|
||||
- timeout 기본값은 전역 spring.http.clients.*
|
||||
- provider별/그룹별 차이는 spring.http.serviceclient.<group> 또는 전용 configuration에서 override
|
||||
- RestClient.create()를 직접 만들어 timeout 구성을 우회하지 않는다
|
||||
|
||||
### 5.2 WebClient는 Reactor Netty timeout까지 함께 본다
|
||||
|
||||
Spring Boot는 WebClient.Builder를 자동 구성하고, Boot가 제공하는 builder를 주입해서 쓰는 것을 강하게 권장합니다. Spring Framework는 Reactor Netty HttpClient를 미리 구성해 ReactorClientHttpConnector로 WebClient에 붙일 수 있다고 설명합니다. Reactor Netty는 response timeout, connect timeout, pool timeout, TLS timeout, DNS timeout을 별도로 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- reactive/non-blocking 외부 호출은 WebClient
|
||||
- 단순 Boot 전역 read-timeout만 믿지 않고, 필요하면 Reactor Netty HttpClient를 명시적으로 구성한다
|
||||
- 대기 원인이 connection인지 response인지 pool acquire인지 구분 가능한 구조를 선호한다
|
||||
|
||||
### 5.3 HTTP Service Client는 group 속성으로 timeout을 관리한다
|
||||
|
||||
Spring Boot는 @ImportHttpServices와 group 개념을 제공하고, spring.http.serviceclient.<group-name> 아래에서 base URL, default headers, redirect, connection/read timeout, SSL bundle 등을 설정할 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 선언형 HTTP interface client를 쓸 때는 timeout도 group 단위로 관리한다
|
||||
- interface마다 개별 하드코딩하지 않는다
|
||||
- 같은 provider 아래 여러 인터페이스가 공통 timeout을 공유하게 한다
|
||||
|
||||
## 6. timeout 값 선택 기준
|
||||
|
||||
### 6.1 timeout은 downstream latency와 허용 가능한 false timeout 비율로 잡는다
|
||||
|
||||
AWS는 intra-region 서비스 호출의 경우 허용 가능한 false timeout 비율(예: 0.1%)을 먼저 정하고, downstream latency percentile(예: p99.9)을 참고해 timeout을 고르는 방식을 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout 값은 “감”으로 정하지 않는다
|
||||
- 가능하면 provider/operation latency 기준을 본다
|
||||
- 기본 질문은 다음과 같다
|
||||
- 이 호출이 몇 ms/초 이상 걸리면 사실상 실패로 봐야 하는가?
|
||||
- false timeout을 얼마나 허용할 것인가?
|
||||
- timeout 이후 retry/fallback이 가능한가?
|
||||
|
||||
### 6.2 인터넷 구간과 내부 구간은 같은 값으로 잡지 않는다
|
||||
|
||||
AWS는 인터넷처럼 네트워크 편차가 큰 경우에는 downstream percentile만 보고 timeout을 잡으면 안 되고, reasonable worst-case network latency를 추가로 고려해야 한다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
같은 데이터센터/같은 리전에 있는 내부 서비스 호출과
|
||||
인터넷을 거치는 SaaS/third-party 호출은
|
||||
timeout 기준을 다르게 둔다
|
||||
|
||||
외부 공개 인터넷 API는 더 큰 네트워크 변동성을 감안한다
|
||||
|
||||
### 6.3 너무 낮은 timeout은 배포/콜드 커넥션/TLS 구간에서 오탐을 만든다
|
||||
|
||||
AWS는 아주 낮은 timeout(예: 20ms)을 썼을 때 배포 직후 새 secure connection 수립 시간이 timeout에 포함되어 오탐이 생긴 사례를 설명하며, 이후 연결을 미리 준비(prewarm)하는 방식으로 개선했다고 말합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout을 지나치게 공격적으로 줄이지 않는다
|
||||
- 새 연결 수립, TLS handshake, DNS lookup이 포함되는지 확인한다
|
||||
- 낮은 timeout을 쓰려면 connection reuse/prewarm 전략도 함께 검토한다
|
||||
|
||||
### 6.4 “tight latency service”에는 padding을 둔다
|
||||
|
||||
AWS는 p99.9와 p50이 가까운 서비스에서는 작은 latency 증가에도 timeout이 급증할 수 있으므로 padding을 두라고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- timeout은 percentile 값에 기계적으로 딱 맞추지 않는다
|
||||
- 급격한 오탐 증가를 막기 위한 안전 여유를 둔다
|
||||
- 극단적으로 빡빡한 timeout은 특별한 근거가 있을 때만 허용한다
|
||||
|
||||
## 7. operation별 기준
|
||||
|
||||
### 7.1 사용자 요청 경로의 외부 호출은 더 엄격한 timeout을 가진다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- synchronous request path 안의 외부 호출은 사용자 응답 SLA를 고려해 더 엄격한 timeout을 둔다
|
||||
- 장시간 대기가 UX와 thread/resource 점유를 악화시키는 경우가 많다
|
||||
- “느리지만 언젠가 오면 된다”는 기준을 기본값으로 두지 않는다
|
||||
|
||||
### 7.2 백그라운드/배치 호출은 더 긴 timeout을 가질 수 있다
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 배치/백그라운드 호출은 사용자 직접 응답보다 긴 timeout을 가질 수 있다
|
||||
- 다만 무기한 대기를 허용하지는 않는다
|
||||
- 작업 단위 SLA와 재시도/보상 전략을 함께 본다
|
||||
|
||||
### 7.3 읽기와 쓰기 호출을 구분한다
|
||||
|
||||
AWS는 side effect가 있는 API는 timeout 이후 retry가 중복 side effect를 만들 수 있으므로 idempotency가 중요하다고 설명합니다. timeout 자체도 읽기 호출과 쓰기 호출의 의미가 다를 수 있습니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- read-only 조회는 상대적으로 더 짧은 timeout을 선호할 수 있다
|
||||
- side effect가 있는 write 호출은 timeout 후 retry 가능성까지 함께 본다
|
||||
- “timeout 값만” 정하지 말고, 그 timeout 이후 어떤 동작이 이어질지도 함께 문서화한다
|
||||
|
||||
## 8. 설정 위치 규칙
|
||||
|
||||
### 8.1 전역 기본값은 공통 설정으로 둔다
|
||||
|
||||
Spring Boot는 모든 HTTP client에 적용되는 전역 spring.http.clients.* 속성을 제공합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- connect/read timeout의 공통 기본값은 전역 설정으로 둔다
|
||||
- 서비스 전체 기본값을 문서화한다
|
||||
- 각 adapter가 제각각 timeout을 하드코딩하지 않는다
|
||||
|
||||
### 8.2 provider별 차이는 group 또는 전용 configuration으로 override한다
|
||||
|
||||
Spring Boot는 HTTP Service group에 대해 base URL, headers, redirect, connect/read timeout, SSL bundle 등을 그룹별로 둘 수 있다고 설명합니다. 또한 RestClient/WebClient는 injected builder에 좁은 범위 customization을 추가하는 방식을 권장합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- provider별 timeout 차이는 group 설정 또는 전용 configuration으로 둔다
|
||||
- adapter 생성자 안 상수 하드코딩을 기본 금지한다
|
||||
- 왜 override가 필요한지 근거를 남긴다
|
||||
|
||||
### 8.3 operation별 차이는 client 내부에서 명시적으로 표현한다
|
||||
|
||||
Reactor Netty는 기본 response timeout 외에 request별 response timeout override도 지원합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
- 같은 provider 안에서도 operation별로 timeout이 다르면 코드에 의도를 드러낸다
|
||||
- “특정 operation만 더 길다/짧다”를 숨긴 magic number를 금지한다
|
||||
- operation별 override는 드물고 명시적이어야 한다
|
||||
|
||||
## 9. observability 규칙
|
||||
|
||||
### 9.1 timeout은 관측 가능해야 한다
|
||||
|
||||
Spring Boot는 auto-configured builders를 통해 HTTP client instrumentation을 함께 적용할 수 있다고 설명합니다. timeout이 일어나도 어떤 provider, 어떤 operation, 어느 단계에서 발생했는지 관측 가능해야 운영이 됩니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
timeout 발생 시 최소한 다음 맥락을 로그/메트릭에서 추적 가능하게 한다
|
||||
|
||||
- provider 또는 client name
|
||||
- operation
|
||||
- method
|
||||
- uri template
|
||||
- timeout type(connect/read/response/pool 등)
|
||||
- “그냥 timed out” 한 줄 로그로 끝내지 않는다
|
||||
|
||||
### 9.2 timeout은 retry/fallback과 함께 해석 가능해야 한다
|
||||
|
||||
AWS는 timeout, retry, backoff를 함께 설계해야 하고, retry는 부하를 악화시킬 수 있다고 설명합니다.
|
||||
|
||||
프로젝트 규칙:
|
||||
|
||||
timeout 로그에는 retry/fallback 결과 맥락이 이어져야 한다
|
||||
|
||||
- timeout이 났지만 retry 후 성공했는지
|
||||
- timeout이 최종 실패인지
|
||||
- fallback으로 복구됐는지
|
||||
|
||||
를 운영자가 구분할 수 있어야 한다
|
||||
|
||||
## 10. 금지 규칙
|
||||
|
||||
다음은 기본 금지다.
|
||||
|
||||
- 외부 HTTP 호출에 무제한 timeout
|
||||
- connect/read/response timeout을 구분하지 않고 하나의 감각적 숫자로 통일
|
||||
- RestClient.create() / WebClient.builder() 직접 생성으로 공통 timeout 설정 우회
|
||||
- base URL, timeout을 adapter 코드 안에 상수로 하드코딩
|
||||
- reactive 체인 전체에 무턱대고 timeout()만 걸어 세부 원인을 잃어버림
|
||||
- 매우 낮은 timeout을 두고 TLS/DNS/새 연결 비용을 고려하지 않음
|
||||
- timeout 이후 retry/idempotency 전략 없이 side effect 호출을 재시도
|
||||
- timeout 발생 로그에 provider/operation 맥락이 없음
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
다음 질문에 “예”로 답할 수 있어야 한다.
|
||||
|
||||
- 이 외부 호출에는 timeout이 명시되어 있는가?
|
||||
- 최소 connect timeout과 read/response timeout을 구분하고 있는가?
|
||||
- timeout 값이 downstream latency와 business SLA에 근거하는가?
|
||||
- 인터넷 구간, TLS handshake, DNS 비용을 고려했는가?
|
||||
- timeout 기본값과 provider별 override 위치가 일관적인가?
|
||||
- reactive client라면 pool acquire / TLS / DNS timeout도 필요한지 검토했는가?
|
||||
- timeout 이후 retry/fallback/idempotency 동작이 함께 설계돼 있는가?
|
||||
- timeout 발생 시 provider/operation/timeout type이 관측 가능한가?
|
||||
Reference in New Issue
Block a user