From b499dab34e9f656076c1a8fe49f3605d3154b6fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:01:32 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20fetch=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=9E=AC=EC=8B=9C=EB=8F=84=EB=A5=BC=20=EB=81=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HttpClient5 는 I/O 오류를 기본으로 한 번 더 실행한다(maxRetries=1, 대기 0ms). 우리가 켠 적 없고 아무도 보고 있지 않던 재시도다 - 방침은 이미 코드에 적혀 있었다. application.yml 의 gemini.retry 주석이 "URL 파싱 재시도는 호출자의 recover 한 곳으로 모여 있다. 여기서 내부 재시도를 켜면 재시도가 이중으로 겹친다" 고 못박고 Gemini 쪽은 max-attempts=1 로 껐는데, fetch 쪽은 라이브러리 기본값이 살아 있었다 - 바로 윗줄 disableRedirectHandling() 과 같은 성격이다. 우리가 상위에서 관리하는 동작을 라이브러리가 몰래 또 하는 것인데, 리다이렉트는 껐고 재시도는 놓쳤다 - 성격이 다르다는 게 핵심이다. core 큐 재시도는 attempt 가 DB 에 남고 상한(MAX_ATTEMPTS=2)·메트릭·트레이스가 붙는데, 이쪽은 기록도 간격도 없이 방금 우리를 끊어낸 호스트를 즉시 다시 두드린다 prod 사고(2026-08-23)에서 드러났다. 에이블리 등록 1건이 실패했는데, 정적 fetch 한 번이 57초를 먹어 core 의 55초 예산을 통째로 소진했고 7초 만에 성공한 브라우저 렌더가 도착할 자리가 없었다. 그 57초의 절반이 이 재시도였다(12:27:04 SocketException -> 0ms 뒤 재실행 -> 24초 더 쓰고 403). 검증: 실제 소켓으로 못 박는 테스트를 넣었다. MockRestServiceServer 는 RestClient 위에 붙어 HttpClient 를 아예 안 타므로 이 설정을 못 잡는다. 연결을 받자마자 RST 로 끊는 로컬 소켓에 붙여 도착한 연결 수가 1인지 센다. 고친 줄을 빼면 이 테스트가 실패하는 것도 확인했다(negative control). --- .../http/PageFetchHttpClientConfig.java | 6 ++ .../http/PageFetchHttpClientRetryTest.java | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java index 88ef527..b4e20bd 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientConfig.java @@ -67,6 +67,12 @@ public String resolveCanonicalHostname(String host) throws UnknownHostException // 따라가므로(JDK 의 instanceFollowRedirects=false 등가물), 라이브러리 자동 추적을 끈다. 끄지 않으면 // HttpPageFetcher.nextRedirect 의 cross-domain·다운그레이드 차단이 우회된다. .disableRedirectHandling() + // HttpClient5 는 I/O 오류를 기본으로 한 번 더 실행한다(maxRetries=1, 대기 0ms). 그것도 끈다 — + // 파싱 재시도는 호출자(core)의 작업 큐 한 곳에만 두는 것이 이 서비스의 방침이고(application.yml + // 의 gemini.retry 주석), 여기 재시도는 그 방침 밖에서 조용히 겹친다. 겹치는 값이 아니라 성격이 + // 다르다: 큐 재시도는 attempt 가 DB 에 남고 상한·관측이 붙는데, 이쪽은 기록도 간격도 없이 + // 방금 우리를 끊어낸 호스트를 즉시 다시 두드려 fetch 최악 시간만 두 배로 만든다. + .disableAutomaticRetries() .setDefaultRequestConfig( RequestConfig.custom() .setConnectionRequestTimeout(Timeout.ofMilliseconds(properties.connectionRequestTimeout().toMillis())) diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java new file mode 100644 index 0000000..2173bd8 --- /dev/null +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/PageFetchHttpClientRetryTest.java @@ -0,0 +1,68 @@ +package com.depromeet.piki.extractor.extraction.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micrometer.observation.ObservationRegistry; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +/** + * 페이지 fetch 클라이언트가 I/O 오류를 스스로 재시도하지 않는지 **실제 소켓으로** 검증한다. + * + *

HttpClient5 는 이 재시도를 기본으로 켜 둔다(maxRetries=1, 대기 0ms). 파싱 재시도는 호출자(core)의 작업 큐 + * 한 곳에만 두는 것이 이 서비스의 방침이라 그 기본값을 끄는데, 끄는 코드가 사라져도 컴파일·기동·대부분의 테스트는 + * 멀쩡하다. 실제로 이 기본값이 살아 있는 걸 아무도 못 본 채 fetch 최악 시간이 두 배로 돌던 기간이 있었다. + * + *

MockRestServiceServer 로는 못 잡는다 — 그건 RestClient 위에 붙어 HttpClient 를 아예 타지 않는다. + * 그래서 연결을 받자마자 끊는 로컬 소켓을 두고 **도착한 연결 수**를 센다. + */ +class PageFetchHttpClientRetryTest { + + @Test + @Timeout(value = 20, unit = TimeUnit.SECONDS) + @DisplayName("연결이 끊겨도 클라이언트가 스스로 다시 붙지 않는다 — 재시도는 호출자의 큐 한 곳에만 있다") + void doesNotRetryOnIoError() throws Exception { + AtomicInteger connections = new AtomicInteger(); + + try (ServerSocket server = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) { + Thread accepter = new Thread(() -> { + while (!server.isClosed()) { + try (Socket socket = server.accept()) { + connections.incrementAndGet(); + socket.setSoLinger(true, 0); // RST 로 끊어 클라이언트에 I/O 오류를 준다 + } catch (IOException e) { + return; // 서버 종료 + } + } + }); + accepter.setDaemon(true); + accepter.start(); + + RestClient client = new PageFetchHttpClientConfig() + .pageFetchRestClient(ObservationRegistry.NOOP, loopbackResolver(), FetchProperties.defaults()); + + assertThrows( + ResourceAccessException.class, + () -> client.get() + .uri("http://127.0.0.1:" + server.getLocalPort() + "/p") + .retrieve() + .body(String.class)); + + assertEquals(1, connections.get(), "재시도가 켜져 있으면 같은 곳에 2번 붙는다"); + } + } + + private static RequestScopedDnsResolver loopbackResolver() { + return new RequestScopedDnsResolver(host -> new InetAddress[] {InetAddress.getLoopbackAddress()}); + } +}