실행 방법입니다. 외부 jar 는 필요 없고 FakePartner 가 같은 JVM 안에서 협력사를 흉내 냅니다.
cd java-src\extension\11_external_api
C:\project\jdk-21.0.8\bin\javac -encoding UTF-8 *.java
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main # 8개 시나리오, 약 8초String call(String method, String url, String json, String idempotencyKey) {
boolean retryAllowed = method.equals("GET") || idempotencyKey != null;
int attempts = retryAllowed ? maxAttempts : 1;
ApiException last = null;
for (int attempt = 1; attempt <= attempts; attempt++) {
if (!breaker.allow()) throw new ApiException(0, "서킷 OPEN: 호출 생략 (" + url + ")");
try {
String body = once(method, url, json, idempotencyKey);
breaker.onSuccess();
return body;
} catch (ApiException e) {
last = e;
if (e.status == 0 || e.status >= 500) breaker.onFailure(); // 4xx 는 서킷과 무관
if (!e.retryable() || attempt == attempts) break;
long wait = backoffMillis(attempt);
System.out.printf(" 재시도 %d/%d: %s → %dms 대기%n", attempt, attempts, e.getMessage(), wait);
FakePartner.sleep(wait);
}
}
throw last;
}POST 는 idempotencyKey 가 없으면 attempts 가 1로 고정됩니다. 즉 멱등 키 없이는 재시도 자체가 일어나지 않습니다. 4xx 는 breaker.onFailure() 를 건너뛰어 서킷 상태에 영향을 주지 않습니다.
private String once(String method, String url, String json, String idempotencyKey) {
var b = HttpRequest.newBuilder(URI.create(url)).timeout(requestTimeout)
.header("Authorization", "Bearer " + apiKey)
.header("Accept", "application/json");
if (idempotencyKey != null) b.header("Idempotency-Key", idempotencyKey);
if (json == null) b.method(method, HttpRequest.BodyPublishers.noBody());
else b.header("Content-Type", "application/json")
.method(method, HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8));
try {
HttpResponse<String> r = http.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (r.statusCode() / 100 == 2) return r.body();
throw new ApiException(r.statusCode(), "HTTP " + r.statusCode() + " " + r.body());
} catch (HttpTimeoutException e) {
throw new ApiException(0, "타임아웃 " + requestTimeout.toMillis() + "ms");
} catch (IOException e) {
throw new ApiException(0, "네트워크 오류: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ApiException(0, "인터럽트");
}
}응답 타임아웃은 요청의 .timeout(requestTimeout) 이, 연결 타임아웃은 HttpClient.newBuilder().connectTimeout(...) 이 맡습니다. HttpTimeoutException·IOException 을 모두 ApiException(status=0) 하나로 바꿔, 호출자는 HttpResponse 를 몰라도 됩니다.
public class CircuitBreaker {
public enum State { CLOSED, OPEN, HALF_OPEN }
private final int failureThreshold;
private final Duration openDuration;
private int consecutiveFailures;
private State state = State.CLOSED;
private Instant openedAt;
public CircuitBreaker(int failureThreshold, Duration openDuration) {
this.failureThreshold = failureThreshold; this.openDuration = openDuration;
}
public synchronized boolean allow() {
if (state == State.OPEN) {
if (Duration.between(openedAt, Instant.now()).compareTo(openDuration) < 0) return false;
state = State.HALF_OPEN;
}
return true;
}
public synchronized void onSuccess() { consecutiveFailures = 0; state = State.CLOSED; }
public synchronized void onFailure() {
consecutiveFailures++;
if (state == State.HALF_OPEN || consecutiveFailures >= failureThreshold) {
state = State.OPEN; openedAt = Instant.now();
}
}
}allow() 가 OPEN 이면서 대기 시간이 지났을 때 HALF_OPEN 으로 바꾸고 호출 1건을 통과시키는 부분이 핵심입니다. onFailure() 는 HALF_OPEN 에서 실패하면 임계치와 무관하게 곧바로 다시 OPEN 으로 되돌립니다.
모든 메서드가 synchronized 인 이유는 동시 호출 스레드가 많기 때문입니다. 상태 판단과 상태 변경 사이에 다른 스레드가 끼어들면, 이미 OPEN 인데도 여러 스레드가 동시에 HALF_OPEN 시험 호출을 보내는 문제가 생깁니다.
ApiClient c = client(3, new CircuitBreaker(99, Duration.ofSeconds(1)));
String a1 = c.post(p.url("/pay"), "{\"amount\":10000}", null);
String a2 = c.post(p.url("/pay"), "{\"amount\":10000}", null);
// 키 없음: 매번 새 결제번호 → 결제 2건
String key = UUID.randomUUID().toString();
String b1 = c.post(p.url("/pay"), "{\"amount\":10000}", key);
String b2 = c.post(p.url("/pay"), "{\"amount\":10000}", key);
// 키 있음: 같은 결제번호 → 같은 결제 1건, 재시도 안전CircuitBreaker cb = new CircuitBreaker(3, Duration.ofSeconds(1));
ApiClient c = client(1, cb);
for (int i = 1; i <= 6; i++) {
try { c.get(p.url("/down")); }
catch (ApiClient.ApiException e) { System.out.println(i + ": " + e.getMessage() + " state=" + cb.state()); }
}
// 3회 실패 후 OPEN, 이후 3회는 서버를 부르지 않고 즉시 실패Semaphore permits = new Semaphore(5);
AtomicInteger inFlight = new AtomicInteger(), peak = new AtomicInteger();
for (int i = 0; i < 20; i++) pool.submit(() -> {
permits.acquire();
try {
peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
FakePartner.sleep(50);
return c.get(p.url("/ok"));
} finally { inFlight.decrementAndGet(); permits.release(); }
});
// 20건 완료, 동시 최대 5개 ← 한도 5 유지java Main, 8~9초, ms·포트 값은 실행마다 다름)[1] /slow (3초 응답) 를 타임아웃 1.5초로 호출
타임아웃 1500ms (status=0) 경과 1510ms
[2] /flaky (2번 500 후 성공) 를 최대 3회 재시도로 호출
재시도 1/3: HTTP 500 {"error":"internal"} → 247ms 대기
재시도 2/3: HTTP 500 {"error":"internal"} → 505ms 대기
성공: {"status":"ok after 3"}
[3] /bad (400) 는 재시도 없이 즉시 실패
HTTP 400 {"error":"invalid account"} retryable=false
[4] POST /pay 를 두 번: 멱등 키 없음 vs 있음
키 없음: {"paymentId":"PAY-1"} / {"paymentId":"PAY-2"} ← 결제 2건
키 있음: {"paymentId":"PAY-3"} / {"paymentId":"PAY-3"} ← 같은 결제 1건, 재시도 안전
[5] /down (항상 503): 실패 3회면 OPEN 1초, 그동안 호출 생략, 이후 HALF_OPEN 시험
1: HTTP 503 {"error":"maintenance"} state=CLOSED
2: HTTP 503 {"error":"maintenance"} state=CLOSED
3: HTTP 503 {"error":"maintenance"} state=OPEN
4: 서킷 OPEN: 호출 생략 (http://127.0.0.1:63830/down) state=OPEN
5: 서킷 OPEN: 호출 생략 (http://127.0.0.1:63830/down) state=OPEN
6: 서킷 OPEN: 호출 생략 (http://127.0.0.1:63830/down) state=OPEN
실제 서버 호출 수: 3회 (6회 중)
1초 후: HTTP 503 {"error":"maintenance"} state=OPEN ← 시험 호출 1번 후 다시 OPEN
[6] 요청 20개를 동시 5개 한도로 (세마포어)
20건 완료, 동시 최대 5개 ← 한도 5 유지
[7] 환율 조회: 성공값 캐시 → 장애 시 캐시 폴백
1차 성공 → 캐시 저장: {"status":"ok"}
2차 실패(HTTP 503 {"error":"maintenance"}) → 캐시 폴백: {"status":"ok"} (stale 표시 권장)
[8] 폐쇄망 설정 (코드만): 프록시·사내 CA 신뢰
HttpClient.newBuilder()
.proxy(ProxySelector.of(new InetSocketAddress("proxy.corp.local", 8080)))
.sslContext(sslContextWith("corp-ca.jks", "changeit")) // 사내 루트 CA 를 담은 truststore
.connectTimeout(Duration.ofSeconds(3)).build();
// JVM 전체 적용: -Dhttps.proxyHost=... -Djavax.net.ssl.trustStore=corp-ca.jks
// 인증서 무시(TrustAll) 는 금지: 중간자 공격에 그대로 노출