Feign 호출에서 시간 초과가 나면 가장 먼저 timeout 값을 늘리고 싶어집니다. 그런데 연결조차 맺지 못한 요청과, 연결 뒤 응답을 기다리던 요청은 확인할 곳이 다릅니다. 재시도까지 켜져 있다면 한 번 호출한 것처럼 보이는 코드가 서버에는 여러 번 도착할 수 있습니다.
이 글에서는 느린 로컬 서버에 실제로 요청을 보내고, 예외와 서버 호출 횟수를 함께 확인합니다. 응답을 늦추는 실험이므로 연결 실패나 TLS 협상 중 생기는 시간 초과는 따로 살펴봐야 합니다.

연결 실패와 응답 대기 중 시간 초과를 구분합니다
| 관찰한 상황 | 주로 확인할 것 | 먼저 볼 증거 |
|---|---|---|
| 연결이 성립하지 않음 | connectTimeout, 주소·포트·네트워크 | 예외 원인과 대상 주소 |
| 연결 뒤 응답 대기가 길어짐 | readTimeout, 서버 처리·응답 흐름 | 서버의 요청 수신·응답 기록 |
| 404·500 같은 상태를 받음 | HTTP 오류 응답 처리 | 상태 코드와 응답 내용 |
connectTimeout은 연결을 맺는 단계, readTimeout은 연결 뒤 응답을 읽으며 기다리는 단계의 제한을 살필 때 쓰는 설정입니다. 다만 DNS 조회, 커넥션 풀 대기, TLS 협상까지 하나의 숫자로 정확하게 설명할 수는 없습니다. 실제 사용하는 HTTP 클라이언트와 버전의 설정도 함께 봐야 합니다.
HTTP 404는 서버가 오류 상태를 응답한 경우입니다. 응답이 늦어 제한 시간을 넘긴 경우와는 다릅니다. 최상위 예외 이름만 기록하지 말고 원인 예외, HTTP 상태 유무, 총 경과 시간과 서버 수신 기록을 같이 남기면 다음 확인이 빨라집니다.
100ms 기다리는 클라이언트와 450ms 늦게 답하는 서버
예제는 Spring Boot 4.0.8, Spring Cloud 2025.1.3에 포함된 OpenFeign 5.0.3으로 실행했습니다. 서버는 로컬의 임의 포트에서 열고, 요청을 받으면 횟수를 센 뒤 450ms 기다렸다가 답합니다. 타임아웃은 일부러 짧게 잡은 실험값이며 운영 권장값이 아닙니다.
spring:
cloud:
openfeign:
client:
config:
default:
connectTimeout: 1000
readTimeout: 100
두 값의 단위는 밀리초입니다. default는 클라이언트 공통 설정입니다. 한 클라이언트만 조정하려면 해당 클라이언트 이름의 설정으로 범위를 좁혀 확인하세요. 본문 아래 전체 테스트는 같은 설정을 SpringApplicationBuilder.properties()로 전달합니다.
@FeignClient(name = "slowApi", url = "${target.url}")
public interface DefaultApi {
@GetMapping("/slow") String slow();
@GetMapping("/fast") String fast();
@GetMapping("/missing") String missing();
}
이 예제는 응답 본문을 String으로 읽으며, Boot의 HTTP 메시지 변환기 구성을 위해 spring-boot-starter-restclient도 의존성에 넣었습니다. 전체 pom.xml을 같이 사용하면 이 조건을 빠뜨리지 않을 수 있습니다.
기본 설정에서는 요청이 한 번 도착했습니다
Spring Cloud OpenFeign은 기본으로 Retryer.NEVER_RETRY를 사용합니다. Feign 자체의 기본 동작을 설명한 글과 섞어 읽으면 재시도 여부를 잘못 판단할 수 있습니다. 이번 Spring Cloud 예제에서는 느린 응답이 실패한 뒤 같은 요청을 자동으로 다시 보내지 않았습니다.
| 요청 | 서버 도착 횟수 | 결과 |
|---|---|---|
| 즉시 정상 응답 | 횟수 검사 제외 | HTTP 200, ok |
| 450ms 늦은 응답·기본 재시도 | 1회 | RetryableException, 원인 SocketTimeoutException |
| HTTP 404 응답 | 1회 | FeignException.NotFound |
| 450ms 늦은 응답·최대 2회 시도 | 2회 | RetryableException |
정상 요청에서는 응답 본문을 확인하고, 실패한 요청과 재시도한 요청은 서버에 몇 번 도착했는지 셌습니다. 기본 지연 요청은 약 102ms 뒤 실패했고, 최대 두 번 시도한 요청은 약 293ms 뒤 실패했습니다. 실행할 때마다 걸리는 시간에는 차이가 있습니다.
RESULT fast: HTTP 200, body=ok
RESULT default-read-timeout: calls=1, elapsedMs=102, cause=SocketTimeoutException
RESULT http404: calls=1, exception=FeignException.NotFound
RESULT explicit-retry: calls=2, elapsedMs=293
재시도를 켜면 한 요청의 대기 시간도 달라집니다
@FeignClient(name = "retryApi", url = "${target.url}",
configuration = RetryConfig.class)
public interface RetryApi {
@GetMapping("/retry") String slow();
}
public static class RetryConfig {
@Bean
Retryer retryer() {
return new Retryer.Default(10, 10, 2);
}
}
마지막 인자 2는 최초 호출을 포함한 최대 시도 횟수입니다. 이 구성에서는 서버에 요청이 두 번 도착했습니다. RetryConfig를 해당 클라이언트에만 연결했으며, 다른 클라이언트까지 재시도하도록 전역 스캔에 넣지 않았습니다.
따라서 readTimeout=100이라고 해서 호출 전체가 반드시 100ms 안에 끝나는 것은 아닙니다. 시도마다 기다리는 시간과 재시도 간격, 그 밖의 처리 시간이 합쳐집니다. 상위 서비스에 허용된 전체 응답 시간 안에서 하위 호출과 재시도가 끝나는지 별도로 확인해야 합니다.
클라이언트의 시간 초과만으로 서버 작업이 취소됐다고 판단할 수는 없습니다. 이번 서버도 요청을 받은 뒤 기다리는 작업을 계속합니다. 결제·주문 생성처럼 결과가 남는 요청은 응답을 못 받은 동안에도 처리됐을 수 있으므로, 중복 처리 방지와 처리 결과 조회 방법을 정한 뒤 재시도를 검토해야 합니다. 예제는 로컬 GET 요청만 사용합니다.
설정을 바꾸기 전에 네 가지를 확인합니다
- 예외 전체 원인에서 연결 실패, 응답 대기 초과, HTTP 오류 응답을 나눕니다.
- 실제로 적용된 클라이언트 이름과 설정을 확인합니다. Java 구성과 프로퍼티를 함께 쓰면 기본적으로 프로퍼티가 우선하므로 덮어쓴 값도 살핍니다.
- 클라이언트의 시도 횟수와 서버 수신 횟수를 함께 봅니다. 회로 차단기·프록시·상위 계층의 재시도가 있다면 각각의 범위를 확인합니다.
- 전체 허용 시간, 요청의 중복 처리 가능성, 서버 지연 원인을 정리한 뒤 필요한 설정만 조정합니다.
연결·읽기 제한을 늘렸는데도 해결되지 않으면 현재 HTTP 클라이언트의 풀 대기나 다른 계층의 제한에 걸린 것일 수 있습니다. 이 테스트는 로컬 응답 지연을 분리해 본 실험입니다. 운영 환경에서는 네트워크·TLS·서버 부하도 함께 살펴야 합니다.
직접 실행할 전체 예제
새 프로젝트에 아래 표시된 경로대로 두 파일을 저장하고 mvn test를 실행하면 됩니다. 실제 확인 환경은 JDK 25.0.3과 Maven 3.9.9입니다. 테스트가 로컬 HTTP 서버를 띄우고 요청을 보냅니다.
시간 초과·재시도 4개 테스트: pom.xml과 전체 테스트 펼치기
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.0.8</version><relativePath/></parent>
<groupId>blog.example</groupId><artifactId>feign-timeout</artifactId><version>1.0.0</version>
<properties><java.version>17</java.version></properties>
<dependencyManagement><dependencies><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-dependencies</artifactId><version>2025.1.3</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-restclient</artifactId></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><scope>test</scope></dependency>
</dependencies>
</project>
src/test/java/blog/feign/FeignTimeoutTest.java
package blog.feign;
import com.sun.net.httpserver.HttpServer;
import feign.FeignException;
import feign.RetryableException;
import feign.Retryer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.*;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.GetMapping;
import static org.junit.jupiter.api.Assertions.*;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FeignTimeoutTest {
HttpServer server;
ExecutorService executor;
ConfigurableApplicationContext context;
AtomicInteger defaultCalls = new AtomicInteger();
AtomicInteger retryCalls = new AtomicInteger();
AtomicInteger missingCalls = new AtomicInteger();
@BeforeAll void start() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
executor = Executors.newFixedThreadPool(4);
server.setExecutor(executor);
slow("/slow", defaultCalls);
slow("/retry", retryCalls);
server.createContext("/missing", exchange -> {
missingCalls.incrementAndGet();
exchange.sendResponseHeaders(404, -1);
exchange.close();
});
server.createContext("/fast", exchange -> {
byte[] body = "ok".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
context = new SpringApplicationBuilder(App.class)
.web(WebApplicationType.NONE)
.properties("target.url=http://127.0.0.1:" + server.getAddress().getPort(),
"spring.main.banner-mode=off",
"spring.cloud.openfeign.client.config.default.connectTimeout=1000",
"spring.cloud.openfeign.client.config.default.readTimeout=100")
.run();
}
void slow(String path, AtomicInteger count) {
server.createContext(path, exchange -> {
count.incrementAndGet();
try {
Thread.sleep(450);
byte[] body = "late".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
} catch (Exception expectedAfterClientTimeout) {
// 클라이언트가 먼저 연결을 닫아 응답 쓰기가 실패할 수 있다.
} finally { exchange.close(); }
});
}
@AfterAll void stop() throws Exception {
if (context != null) context.close();
if (server != null) server.stop(0);
if (executor != null) {
executor.shutdownNow();
executor.awaitTermination(2, TimeUnit.SECONDS);
}
}
@Test void fastResponseSucceeds() {
assertEquals("ok", context.getBean(DefaultApi.class).fast());
System.out.println("RESULT fast: HTTP 200, body=ok");
}
@Test void springCloudDefaultDoesNotRetryReadTimeout() {
long start = System.nanoTime();
var error = assertThrows(RetryableException.class,
() -> context.getBean(DefaultApi.class).slow());
assertEquals(1, defaultCalls.get());
assertInstanceOf(java.net.SocketTimeoutException.class, error.getCause());
System.out.printf("RESULT default-read-timeout: calls=%d, elapsedMs=%d, cause=%s%n",
defaultCalls.get(), TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start),
error.getCause().getClass().getSimpleName());
}
@Test void retryerWithMaxAttemptsTwoSendsTwoCalls() {
long start = System.nanoTime();
assertThrows(RetryableException.class, () -> context.getBean(RetryApi.class).slow());
assertEquals(2, retryCalls.get());
System.out.printf("RESULT explicit-retry: calls=%d, elapsedMs=%d%n", retryCalls.get(),
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start));
}
@Test void http404IsNotReadTimeout() {
assertThrows(FeignException.NotFound.class, () -> context.getBean(DefaultApi.class).missing());
assertEquals(1, missingCalls.get());
System.out.println("RESULT http404: calls=1, exception=FeignException.NotFound");
}
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@EnableFeignClients(clients = {DefaultApi.class, RetryApi.class})
public static class App { }
@FeignClient(name = "slowApi", url = "${target.url}")
public interface DefaultApi {
@GetMapping("/slow") String slow();
@GetMapping("/fast") String fast();
@GetMapping("/missing") String missing();
}
@FeignClient(name = "retryApi", url = "${target.url}", configuration = RetryConfig.class)
public interface RetryApi { @GetMapping("/retry") String slow(); }
public static class RetryConfig {
@Bean Retryer retryer() { return new Retryer.Default(10, 10, 2); }
}
}
참고한 공식 자료
공식 자료와 예제 확인일: 2026년 10월 8일. 예외 종류와 세부 제한은 사용하는 HTTP 클라이언트·버전·사용자 정의 구성에 따라 달라질 수 있습니다.
'Spring Cloud' 카테고리의 다른 글
| Feign (0) | 2022.12.01 |
|---|
댓글