본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
7장 : 마이크로서비스 경계와 통신

Gateway·timeout·복구 예산

이 절의 gateway는 외부 요청을 받는 API Gateway 제품이 아니라 ReportPort를 원격 HTTP 호출로 구현하는 outbound adapter입니다. 같은 이름을 구분해야 라우팅 책임과 원격 의존성 호출 책임이 한 클래스에 섞이지 않습니다.

동기 원격 호출은 실패할 때까지 기다리는 시간도 API 지연 시간에 포함됩니다. connect와 응답 읽기에 상한을 두고, 남은 요청 시간 안에서만 재시도할 수 있도록 복구 예산을 계산합니다.


복원력 정책은 실패 시간을 제한한다

timeout은 장애를 고치는 기능이 아니라 대기를 끝내는 경계입니다. 호출자가 3초 안에 응답해야 하는데 원격 1회에 connect 1초와 read 2초를 모두 쓰면 재시도할 예산은 이미 남지 않습니다. 계층마다 재시도하면 호출 수가 곱으로 늘어납니다.


Gateway 앞뒤의 책임과 timeout을 배치한다

앞 절의 declarative ReportClient와 이번 수동 RestClient adapter는 같은 원격 API를 호출하는 두 접근입니다. 학습을 위해 둘 다 보지만 실제 구현에서는 하나를 주 adapter로 선택합니다. 여기서는 상태 코드·빈 body·connection 예외 변환을 직접 보여 주기 위해 RestClient를 사용합니다.

src/main/java/com/andongmin/studylog/report/client/ReportUnavailableException.java
package com.andongmin.studylog.report.client;

import org.springframework.http.HttpStatusCode;

public class ReportUnavailableException extends RuntimeException {
    public ReportUnavailableException() {
        super("report response body is empty");
    }

    public ReportUnavailableException(HttpStatusCode status) {
        super(status == null ? "report response body is empty"
                : "report service returned " + status.value());
    }

    public ReportUnavailableException(String detail) {
        super(detail);
    }

    public ReportUnavailableException(Throwable cause) {
        super("report service call failed", cause);
    }
}
src/main/java/com/andongmin/studylog/report/client/RemoteReportConfiguration.java
package com.andongmin.studylog.report.client;

import java.net.http.HttpClient;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = "study-log.report.mode", havingValue = "remote")
public class RemoteReportConfiguration {
    @Bean
    RestClient reportRestClient(RestClient.Builder builder,
            @Value("${spring.http.serviceclient.reports.base-url}") String baseUrl) {
        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(1))
                .build();
        var requestFactory = new JdkClientHttpRequestFactory(httpClient);
        requestFactory.setReadTimeout(Duration.ofSeconds(2));
        return builder
                .baseUrl(baseUrl)
                .requestFactory(requestFactory)
                .build();
    }
}
src/main/java/com/andongmin/studylog/report/client/RemoteReportGateway.java
package com.andongmin.studylog.report.client;

import org.springframework.http.HttpStatusCode;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import com.andongmin.studylog.report.ReportPort;

@Component
@ConditionalOnProperty(name = "study-log.report.mode", havingValue = "remote")
public class RemoteReportGateway implements ReportPort {
    private final RestClient client;

    public RemoteReportGateway(RestClient reportRestClient) {
        this.client = reportRestClient;
    }

    @Override
    public SubjectReport findBySubject(String subject) {
        try {
            ReportClient.ReportResponse response = client.get()
                    .uri("/api/reports/{subject}", subject)
                    .retrieve()
                    .onStatus(HttpStatusCode::isError, (request, remote) -> {
                        throw new ReportUnavailableException(remote.getStatusCode());
                    })
                    .body(ReportClient.ReportResponse.class);
            if (response == null) {
                throw new ReportUnavailableException();
            }
            if (!subject.equals(response.subject())
                    || response.sessions() < 0 || response.minutes() < 0) {
                throw new ReportUnavailableException("report response is inconsistent");
            }
            return new SubjectReport(
                    response.subject(), response.sessions(), response.minutes());
        } catch (ReportUnavailableException failure) {
            throw failure;
        } catch (RestClientException failure) {
            throw new ReportUnavailableException(failure);
        }
    }
}

local mode에서는 ch7-1의 LocalReportAdapter만, remote mode에서는 이 configuration과 gateway만 등록됩니다. 이 조건이 없으면 ReportPort 구현이 둘이 되어 StudyReportService 생성자 주입이 모호해집니다.

reports.example.test는 실제 서비스가 없는 예약 주소입니다. remote mode로 실행하기 전에 spring.http.serviceclient.reports.base-url을 준비된 stub server나 실제 보고서 서비스 주소로 바꿔야 합니다.

HTTP 4xx·5xx와 빈 body는 명시적인 원격 응답 실패이고, 연결 거부·읽기 timeout은 RestClientException 계열의 전송 실패입니다. adapter는 둘을 내부 예외로 바꾸되 원인을 보존해 운영 로그에서 원격 상태와 네트워크 장애를 구분할 수 있게 합니다.


준비된 stub 없이 원격 성공을 주장하지 않는다

이 문서에는 RemoteReportGatewayTest나 실행 중인 보고서 서버가 없습니다. 아래 명령은 bean 조건과 예외 변환 코드의 컴파일만 확인합니다. 지연·5xx·빈 body 검증은 응답을 제어할 수 있는 local stub server를 테스트에서 준비한 뒤 추가합니다.

./mvnw -q -DskipTests compile
Budget 확인 결과
컴파일 성공은 timeout 동작을 증명하지 않습니다. stub 테스트에서는 연결 실패, 2초를 넘긴 응답, 404, 503, 빈 body, subject 불일치와 음수 집계를 각각 `ReportUnavailableException`으로 관찰해야 합니다.

재시도는 멱등한 호출과 남은 시간 안에서만 사용한다

쓰기 요청을 무조건 재시도하면 중복 변경을 만들고 계층마다 재시도하면 부하가 증폭됩니다.

이 조회는 멱등하지만 현재 3초 예산을 한 번의 시도가 모두 사용할 수 있어 코드에 재시도를 넣지 않았습니다. 재시도가 꼭 필요하다면 더 짧은 시도별 timeout, 전체 deadline, 작은 backoff, 최대 횟수를 함께 정하고 호출자와 gateway 가운데 한 계층만 소유하게 합니다.

서비스 경계를 나누면 실패 모양도 늘어납니다. 다음 장의 테스트는 실제 외부 시스템에 기대지 않고 transaction, HTTP stub, 보안 경계를 각각 알맞은 범위에서 검증해야 합니다.