내부 메서드 호출은 거의 항상 성공합니다. 외부 API 호출은 네트워크 문제, 상대 서버 장애, 느린 응답, 잘못된 응답이 항상 일어날 수 있는 경계입니다. 이 경계를 내부 호출처럼 다루면 사고가 납니다.
가장 흔한 사고는 톰캣 스레드가 상대의 응답을 기다리며 전부 묶이는 것입니다. 스레드 풀이 가득 차면 그 API 와 무관한 다른 요청도 처리할 스레드가 없어 함께 멈춥니다. 아래 표는 이 경계에서 필요한 방어 6종과 각각이 막는 사고입니다.
| 방어 | 막는 사고 |
|---|---|
| 타임아웃 | 상대 지연으로 인한 스레드 고갈 |
| 재시도 | 일시적 오류로 인한 요청 실패 |
| 멱등 키 | 재시도로 인한 중복 결제·발송 |
| 서킷 브레이커 | 죽은 상대 대기로 인한 자원 낭비 |
| 동시 한도 | 상대 한도 초과로 인한 차단 |
| 폴백 | 상대 장애의 화면 오류 전파 |
이 6종은 서로 독립이 아니라 겹으로 작동합니다. 타임아웃이 첫 방어선이고, 재시도·멱등 키가 두 번째, 서킷 브레이커가 세 번째, 동시 한도와 폴백이 나머지를 받칩니다.
타임아웃은 연결 타임아웃과 응답 타임아웃 두 가지로 나뉩니다. 연결 타임아웃은 TCP 연결이 맺어지기까지의 한도이고, 응답 타임아웃은 요청을 보낸 뒤 응답이 오기까지의 한도입니다.
| 종류 | 측정 시점 | 기본값 |
|---|---|---|
| 연결 타임아웃 | 요청 시작 ~ TCP 연결 성립 | 라이브러리별로 다름, 무한도 있음 |
| 응답 타임아웃 | 연결 성립 ~ 응답 완료 | 라이브러리별로 다름, 무한도 있음 |
많은 클라이언트가 기본값을 무한으로 둡니다. 예전 RestTemplate 의 기본 SimpleClientHttpRequestFactory 도 별도 설정이 없으면 무한 대기입니다. 값을 반드시 명시해야 합니다.
값을 정할 때는 상대의 SLA 나 실측 p99 에 여유를 더합니다. 화면에서 기다리는 사용자용 호출은 짧게, 배치처럼 사람이 기다리지 않는 호출은 길게 잡습니다. 데모 [1]은 3초 걸리는 /slow 를 1.5초 타임아웃으로 끊는 장면입니다.
HttpClient 는 두 타임아웃을 분리해 지정합니다. connectTimeout 은 빌더에, 응답 타임아웃은 각 요청의 timeout 에 둡니다. RestClient·WebClient 도 같은 구조로 ClientHttpRequestFactory 나 커넥터 설정에 둘을 따로 지정합니다.
재시도는 5xx, 타임아웃, 연결 오류, 429 에만 합니다. 4xx 는 우리 요청 자체가 틀렸다는 뜻이라 다시 보내도 같은 응답이 옵니다. 데모 [2]는 5xx 재시도가 성공하는 장면이고, 데모 [3]은 4xx 를 재시도 없이 즉시 실패시키는 장면입니다.
재시도 간격은 매번 같은 간격이 아니라 지수 백오프로 늘립니다. 여기에 무작위 지터를 더해야, 같은 순간 실패한 여러 요청이 같은 순간 다시 몰리는 "thundering herd" 를 막을 수 있습니다.
최대 재시도 횟수는 작게, 보통 3회 이하로 둡니다. 재시도를 포함한 전체 소요 시간에도 상한을 둬야, 사용자가 무한히 기다리는 상황을 막을 수 있습니다.
상대가 Retry-After 헤더를 주면 그 값을 존중해야 합니다. 우리가 정한 백오프보다 상대가 알려준 대기 시간이 더 정확한 정보입니다. Resilience4j 를 쓰면 Retry 설정의 maxAttempts·waitDuration·retryExceptions 로 이 규칙을 선언합니다.
이 네 조건은 데모 [2] ApiClient.backoffMillis 에 그대로 들어 있습니다. 재시도 여부는 ApiException.retryable() 이 판단하고, 대기 시간은 backoffMillis 가 계산해 둘을 분리해 둡니다.
타임아웃은 상대가 처리하지 않았다는 증거가 아니라 응답을 못 받았다는 뜻일 뿐입니다. 상대는 이미 결제를 처리했는데 우리만 응답을 못 받아 재시도하면 중복 결제가 됩니다. 이것이 GET 재시도와 POST 재시도의 근본적인 차이입니다.
GET·PUT·DELETE 는 원래 멱등한 연산이라 여러 번 보내도 결과가 같습니다. POST 는 대부분 "생성" 이라 여러 번 보내면 여러 번 생성됩니다. 그래서 POST 재시도에는 별도 장치가 필요합니다.
가장 좋은 해결은 Idempotency-Key 헤더입니다. 상대가 이 헤더를 지원하면, 같은 키로 온 요청은 실제로는 한 번만 처리하고 같은 결과를 돌려줍니다. 데모 [4]가 이 동작을 보여줍니다.
상대가 멱등 키를 지원하지 않을 때는 두 가지 대안이 있습니다. 하나는 재시도 전에 조회 API 로 상태를 먼저 확인하는 것이고, 다른 하나는 재시도 자체를 금지하고 결과를 "미확인" 으로 저장한 뒤 나중에 대사하는 것입니다. 4.2 절에서 이 대안을 코드로 다룹니다.
상대가 완전히 죽어 있으면 매번 타임아웃 시간만큼 기다리다 실패하는 것은 낭비입니다. 서킷 브레이커는 실패가 임계치를 넘으면 호출 자체를 끊어 이 낭비를 없애고 상대에게 회복할 시간을 줍니다.
| 상태 | 호출 허용 | 전이 조건 |
|---|---|---|
| CLOSED | 정상 허용 | 연속 실패가 임계치 도달 시 OPEN |
| OPEN | 차단, 즉시 실패 | 대기 시간 경과 시 HALF_OPEN |
| HALF_OPEN | 시험 호출 1건만 허용 | 성공 시 CLOSED, 실패 시 다시 OPEN |
데모 [5]는 3회 연속 실패면 1초간 OPEN 되고, 그 1초 동안은 서버를 호출하지도 않은 채 즉시 실패하는 장면입니다. 1초 뒤 시험 호출 1번이 실패하면 다시 OPEN 으로 돌아갑니다.
4xx 는 우리 요청이 틀린 것이라 서킷 실패로 세지 않습니다. 우리 잘못을 상대 장애로 오판해 서킷을 열면, 정상인 상대를 불필요하게 차단하게 됩니다.
실무는 Resilience4j 의 @CircuitBreaker(name="pg", fallbackMethod="payFallback") 를 씁니다. 설정 키는 slidingWindowSize·failureRateThreshold·waitDurationInOpenState 로, 이 레슨의 임계치·OPEN 시간과 대응합니다.
상대는 보통 동시 호출 수나 분당 호출 수에 한도를 정해 둡니다. 이 한도를 넘기면 상대가 429 로 차단하거나, 계약 위반으로 이어질 수 있습니다. 그래서 한도는 상대가 아니라 우리 쪽에서 먼저 지켜야 합니다.
동시 호출 수 제한에는 세마포어가, 초당·분당 호출 수 제한에는 토큰 버킷이 맞습니다. 데모 [6]은 세마포어로 20개 요청을 동시 5개로 제한하는 장면입니다. 4.3 절에서 토큰 버킷을 따로 다룹니다.
외부 호출을 톰캣 스레드 풀과 같은 풀에서 처리하면, 그 API 가 느려질 때 다른 기능의 스레드까지 함께 줄어듭니다. 외부 호출마다 전용 스레드 풀을 두는 격리를 Bulkhead 라고 부릅니다. 격리하면 한 API 의 장애가 다른 기능으로 번지지 않습니다.
Resilience4j 는 이 격리를 @Bulkhead(name="pg") 로 선언형으로 제공합니다. 내부는 세마포어 방식과 전용 스레드 풀 방식 중 하나를 고를 수 있고, 이 레슨의 세마포어 데모는 그중 세마포어 방식과 같은 원리입니다.
모든 실패를 사용자에게 오류 화면으로 보여주는 것이 항상 최선은 아닙니다. 데이터 성격에 따라 폴백 전략이 달라집니다.
데모 [7]은 환율 조회가 실패했을 때 직전 성공 값을 캐시에서 꺼내 쓰는 장면입니다. 폴백을 두지 않기로 한 경우도 있을 수 있는데, 그 결정 역시 코드나 문서에 명시적으로 남겨야 합니다.
폐쇄망에서는 외부 API 호출이 사내 프록시를 거치는 경우가 많습니다. ProxySelector 로 프록시를 지정하거나, JVM 전체에 -Dhttps.proxyHost·-Dhttps.proxyPort 를 줍니다. 사내 주소는 -Dhttp.nonProxyHosts 로 프록시에서 제외합니다.
상대 서버 인증서가 사내 CA 로 서명돼 있으면, 그 CA 인증서를 keytool -importcert 로 truststore 에 등록해야 합니다. 이 truststore 를 -Djavax.net.ssl.trustStore 로 JVM 에 알려줍니다.
인증서 검증을 통째로 건너뛰는 TrustAll 방식은 금지합니다. 검증을 끄면 중간자 공격에 그대로 노출되므로, 사내 CA 를 정식으로 등록하는 것이 유일한 정답입니다.
API 키·토큰은 소스 코드가 아니라 환경 변수나 설정 서버에 둡니다. 요청·응답을 로그로 남길 때는 10 레슨의 마스킹 규칙을 그대로 적용해, Authorization 헤더 값을 로그에 그대로 찍지 않습니다. 데모 [8]은 이 설정을 코드로만 보여줍니다.
2.1~2.8 을 코드로 옮기면 타임아웃·재시도·멱등 키·서킷·동시 한도·폴백 여섯 가지가 ApiClient 하나에, 폐쇄망 설정은 HttpClient 빌더 하나에 모입니다. 아래 3절부터는 이 여섯 가지가 실제 코드에서 어떻게 맞물리는지 순서대로 봅니다.