공공부하자개발 · 영어 학습 노트
자바
실무 확장Excel · 파일 업로드 · DB 연동0/22 완료
  • 01Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기
  • 02Apache POI로 Excel 업로드/다운로드
  • 03파일 업로드/다운로드 서버 (HttpServer)
  • 04JDBC 기초와 트랜잭션 (H2)
  • 05MyBatis 어노테이션 매퍼로 쿼리 연동
  • 06MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot
  • 07REST API 서버와 JSON
  • 08Vue 3 SPA 와 Java 서버 연동
  • 09@Scheduled 운영
  • 10로깅 실무: 레벨·계층, MDC 추적, 예외·성능, 마스킹, 롤링, JSON 로그
  • 11외부 API 연동
  • 12테스트 실무
  • 13암호화·개인정보 보호
  • 14인코딩·한글 실무
  • 15@Transactional 심화
  • 16긴 작업 비동기 처리와 진행률
  • 17SFTP·FTP 파일 연계
  • 18로컬 캐시와 @Cacheable
  • 19메일·알림 발송
  • 20웹 보안 체크리스트
  • 21빌드 도구와 폐쇄망 의존성 반입
  • 22성능 측정: p50·p95·p99, 측정 계층, JFR, JMH 함정, 자체 부하 테스트, 병목 순위
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 확장 › 11 / 22

외부 API 연동

타임아웃·재시도·멱등성·서킷 브레이커·동시 한도·폴백
섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

3. 코드 예제

실행 방법입니다. 외부 jar 는 필요 없고 FakePartner 가 같은 JVM 안에서 협력사를 흉내 냅니다.

powershell
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초

재시도 루프 (ApiClient.call)

java
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() 를 건너뛰어 서킷 상태에 영향을 주지 않습니다.

타임아웃과 예외 변환 (ApiClient.once)

java
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 를 몰라도 됩니다.

서킷 브레이커 (CircuitBreaker 전체)

java
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 시험 호출을 보내는 문제가 생깁니다.

시나리오 [4] 멱등 키로 중복 결제 막기

java
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건, 재시도 안전

시나리오 [5] 서킷 브레이커로 죽은 상대 차단

java
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회는 서버를 부르지 않고 즉시 실패

시나리오 [6] 세마포어로 동시 호출 5개 제한

java
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·포트 값은 실행마다 다름)

text
[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) 는 금지: 중간자 공격에 그대로 노출

예제 직접 실행

아래 폴더를 JDK 21 로 컴파일하고 실행합니다. 외부 jar 를 쓰는 레슨은 java-src/lib 를 클래스패스에 넣습니다.

cd java-src\extension\11_external_api
javac -encoding UTF-8 *.java && java Main

:: 외부 jar 가 필요한 레슨
javac -encoding UTF-8 -cp "..\..\lib\*;." *.java && java -cp "..\..\lib\*;." Main
코드 예제
  • 재시도 루프 (ApiClient.call)
  • 타임아웃과 예외 변환 (ApiClient.once)
  • 서킷 브레이커 (CircuitBreaker 전체)
  • 시나리오 [4] 멱등 키로 중복 결제 막기
  • 시나리오 [5] 서킷 브레이커로 죽은 상대 차단
  • 시나리오 [6] 세마포어로 동시 호출 5개 제한
  • 실행 결과 (java Main, 8~9초, ms·포트 값은 실행마다 다름)
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제