Java는 대규모 데이터 수집에 강력한 도구입니다. 성숙한 HTTP 클라이언트, 실제 스레드, 그리고 장시간 실행되는 크롤러를 관찰 가능하게 만드는 JVM 도구를 갖추고 있습니다. 대부분의 팀이 사용하는 두 클라이언트인 OkHttp와 Apache HttpClient 중 하나에 residential proxy를 연결하는 작업은 간단합니다. 문제는 각 라이브러리가 서로 다르게 처리하는 세부사항에 있습니다. 프록시 인증이 제공되는 방식, 조용히 처리량을 제한하는 커넥션 풀 기본값, 그리고 풀링이 애초에 작동하는지 여부를 결정하는 응답 소비 규칙이 바로 그것입니다.
이 글은 Python으로 residential proxy 사용하기, Playwright로 사용하기, Go로 사용하기와 같은 시리즈의 Java 편입니다. 두 클라이언트 모두에 대한 실제 코드와 Java 특유의 함정들을 다룹니다.
아래 내용은 모두 Shifter의 residential gateway를 사용합니다. 엔드포인트는 하나, p.shifter.io:443이며, 모든 타겟팅 정보는 사용자 이름에 인코딩되어 있습니다. 호스트와 인증 정보를 다른 제공업체 것으로 바꾸면 됩니다. 구조는 동일합니다.
게이트웨이 모델을 한 문단으로
프록시 사용자 이름은 인증 정보와 타겟팅 정보를 함께 담고 있습니다. 국가나 세션을 변경하기 위해 엔드포인트를 바꾸는 것이 아니라, 사용자 이름 문자열을 바꾸는 것입니다.
customer-USERNAME-country-us-sid-abc123-ttl-600country-us는 미국을 타겟팅하고, sid는 고정 세션을 지정하며, ttl은 해당 IP를 N초 동안 유지합니다. sid/ttl을 생략하면 새 연결마다 로테이션됩니다. 비밀번호는 고정입니다. 미리 짚고 넘어갈 만한 부분이 있습니다. 두 Java 클라이언트 모두에서 프록시 인증 정보는 프록시 URL에 넣지 않고, 전용 인증 메커니즘을 통해 전달됩니다. 이것이 사람들이 가장 흔히 실수하는 부분입니다.
OkHttp
OkHttp는 주소를 위한 Proxy 객체와 인증 정보를 위한 **별도의 proxyAuthenticator**를 받습니다. URL에 user:pass@를 인코딩하려 하지 마세요. OkHttp는 이를 읽지 않습니다.
import okhttp3.*;import java.net.InetSocketAddress;import java.net.Proxy;import java.io.IOException;
public class ProxyExample { public static void main(String[] args) throws IOException { String user = System.getenv("SHIFTER_USER") + "-country-us"; String pass = System.getenv("SHIFTER_PASS");
OkHttpClient client = new OkHttpClient.Builder() .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("p.shifter.io", 443))) .proxyAuthenticator((route, response) -> { // Called when the proxy returns 407. Attach Proxy-Authorization. String credential = Credentials.basic(user, pass); return response.request().newBuilder() .header("Proxy-Authorization", credential) .build(); }) .build();
Request request = new Request.Builder().url("https://api.ipify.org").build(); try (Response response = client.newCall(request).execute()) { // try-with-resources closes the body System.out.println(response.body().string()); // a US residential IP } }}두 가지를 기억해야 합니다. user 문자열에는 타겟팅 플래그(-country-us)가 포함되어 있는데, 지역 정보가 여기 담기기 때문입니다. 그리고 Response를 감싸는 try-with-resources는 선택적인 스타일이 아니라, 응답 본문을 닫음으로써 연결을 풀로 반환하는 역할을 합니다.
클라이언트를 재사용하세요. OkHttpClient는 한 번 생성해서 공유하도록 설계되었습니다. 커넥션 풀과 스레드 디스패처를 보유하며, 스레드 안전합니다. 요청마다 하나씩 생성하면 풀링을 포기하는 것이고 자원이 누수됩니다. 요청별로 신원을 다르게 하려면 newBuilder()로 변형을 파생시키세요. 이는 기존 풀과 디스패처를 공유합니다.
// One base client, shared. Per-identity variants reuse its pool + dispatcher.OkHttpClient forGeo(OkHttpClient base, String country, String sid) { String user = System.getenv("SHIFTER_USER") + "-country-" + country + (sid != null ? "-sid-" + sid + "-ttl-600" : ""); String pass = System.getenv("SHIFTER_PASS"); return base.newBuilder() .proxyAuthenticator((route, resp) -> resp.request().newBuilder() .header("Proxy-Authorization", Credentials.basic(user, pass)) .build()) .build();}각 논리적 작업 단위마다 고유한 sid를 부여하고, 작업 중간이 아니라 작업 단위 사이에서 로테이션하세요(고정 세션 대 로테이팅에서 이 차이를 다룹니다).
Apache HttpClient (5.x)
Apache HttpClient는 요청 설정이나 라우트 플래너를 통해 프록시를 제공하고, 프록시 호스트에 범위가 지정된 CredentialsProvider를 통해 인증 정보를 제공합니다.
import org.apache.hc.client5.http.classic.methods.HttpGet;import org.apache.hc.client5.http.impl.classic.*;import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;import org.apache.hc.client5.http.auth.*;import org.apache.hc.client5.http.config.RequestConfig;import org.apache.hc.core5.http.HttpHost;import org.apache.hc.core5.util.Timeout;
public class ApacheProxyExample { public static void main(String[] args) throws Exception { HttpHost proxy = new HttpHost("http", "p.shifter.io", 443); String user = System.getenv("SHIFTER_USER") + "-country-us"; char[] pass = System.getenv("SHIFTER_PASS").toCharArray();
BasicCredentialsProvider creds = new BasicCredentialsProvider(); creds.setCredentials(new AuthScope(proxy), new UsernamePasswordCredentials(user, pass));
// Pool: raise per-route from the default of 5 (see gotcha below). PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(50);
RequestConfig config = RequestConfig.custom() .setProxy(proxy) .setConnectTimeout(Timeout.ofSeconds(10)) // connect phase .setResponseTimeout(Timeout.ofSeconds(30)) // response phase .build();
try (CloseableHttpClient client = HttpClients.custom() .setConnectionManager(cm) .setDefaultCredentialsProvider(creds) .setDefaultRequestConfig(config) .build()) {
HttpGet get = new HttpGet("https://api.ipify.org"); // try-with-resources on the response consumes + releases the connection. try (var response = client.execute(get)) { System.out.println(new String(response.getEntity().getContent().readAllBytes())); } } }}connect와 response 타임아웃이 분리되어 있음을 주목하세요. 이 connect 대 response 분리가 바로 무언가 멈췄을 때 타임아웃을 진단 가능하게 만드는 요소입니다.
함정 1: Apache의 기본 라우트당 최대 연결 수는 5
이는 Go의 MaxIdleConnsPerHost 함정에 해당하는 Java 버전이며, 심각한 문제를 일으킵니다. PoolingHttpClientConnectionManager는 구버전에서는 라우트당 2개 연결, 전체 20개를 기본값으로 하며, 5.x에서도 라우트당 낮은 상한을 갖습니다. 한 호스트에 대해 50개 스레드를 실행하면 대부분이 연결이 풀릴 때까지 대기하게 되는데, 이는 프록시가 느린 것처럼 보입니다.
풀을 최소한 호스트당 동시성 수준으로 설정하세요.
cm.setMaxTotal(200);cm.setDefaultMaxPerRoute(50); // >= your per-host concurrency스레드를 아무리 추가해도 크롤러의 처리량이 정체된다면, 이 기본값을 가장 먼저 확인해야 합니다.
함정 2: 엔티티를 소비하지 않으면 연결이 반환되지 않는다
두 클라이언트 모두 연결을 풀링하며, 응답이 완전히 소비되고 닫힐 때만 연결을 풀로 반환합니다. Apache HttpClient에서는 소비되지 않은 엔티티가 연결을 체크아웃 상태로 유지시킵니다. 이런 일이 충분히 쌓이면 실제로는 아무것도 기술적으로 누수되지 않았는데도 풀이 고갈됩니다.
응답에 try-with-resources를 사용하거나(위와 같이), 명시적으로 소비하세요.
import org.apache.hc.core5.http.io.entity.EntityUtils;// ...EntityUtils.consume(response.getEntity()); // drains + frees the connection함정은 조기 종료입니다. 엔티티를 소비하지 않은 채 200이 아닌 상태 코드에서 빠져나오면 연결이 발이 묶입니다. 차단을 많이 겪는 스크레이퍼에서는 이것이 트래픽의 대부분을 차지하며, 풀이 조용히 죽어갑니다.
함정 3: 클라이언트는 항상 재사용하라
OkHttpClient와 CloseableHttpClient 모두 무거우며, 스레드 안전하고, 애플리케이션 전체에서 공유되도록 설계되었습니다. 커넥션 풀을 소유하고 있으며, 요청마다 새로 만들면 매번 프록시를 통한 완전한 TCP + TLS 핸드셰이크 오버헤드가 발생합니다. 이는 지연시간 가이드가 없애고자 하는 바로 그 오버헤드입니다. 시작 시 하나를 만들어 주입하고, 신원을 다르게 해야 할 때만 요청 범위의 변형을 파생시키세요.
지역 및 세션 로테이션
타겟팅 정보가 사용자 이름에 담기기 때문에, 서로 다른 신원은 서로 다른 인증 정보 문자열입니다.
OkHttp: 신원마다 newBuilder()로 클라이언트를 파생시키세요(위에서 설명함). 공유 풀을 재사용하므로 효율적입니다.
Apache HttpClient: 해당 신원에 대한 CredentialsProvider를 담은 요청별 HttpClientContext를 붙이면, 클라이언트 하나로 재구성 없이 여러 신원을 처리할 수 있습니다.
HttpClientContext ctx = HttpClientContext.create();BasicCredentialsProvider perCall = new BasicCredentialsProvider();perCall.setCredentials(new AuthScope(proxy), new UsernamePasswordCredentials(userFor("de", "job-42"), pass));ctx.setCredentialsProvider(perCall);client.execute(get, ctx, resp -> { /* handle */ return null; });작업 단위 내부가 아니라 논리적 작업 단위 사이에서 로테이션하고, 로드 밸런싱 글에서 설명하는 방식으로 작업을 신원에 매핑하세요.
호스트별 동시성
전역 상한 하나가 아니라 타겟 호스트별로 동시성을 제한해서, 취약한 타겟이 공격받지 않고 관대한 타겟이 굶주리지 않도록 하세요. 호스트별 Semaphore가 가장 간단한 표현입니다.
Map<String, Semaphore> limits = Map.of( "tough-site.example", new Semaphore(4), "open-site.example", new Semaphore(32));
void fetch(String host, Runnable work) throws InterruptedException { Semaphore sem = limits.get(host); sem.acquire(); try { work.run(); } finally { sem.release(); }}타겟의 허용 한도를 넘어서면 스레드를 더 늘려도 처리량이 아니라 차단만 늘어납니다(차단 회피 방법). 풀의 라우트당 제한과 호스트별 세마포어를 함께 조정하세요.
실제로 프록시를 사용 중인지 확인
다른 무언가를 벤치마킹하거나 디버깅하기 전에, 먼저 나가는 IP를 확인하세요.
Request req = new Request.Builder().url("http://ip-api.com/json").build();try (Response r = client.newCall(req).execute()) { System.out.println(r.body().string()); // expect a residential IP in the targeted country}자신의 IP가 나온다면 클라이언트가 프록시를 사용하고 있지 않은 것입니다. 멈춘다면 로컬 아웃바운드가 차단된 것입니다. 둘 다 타임아웃 진단 가이드에서 다룹니다.
자주 묻는 질문
Java에서 프록시에 http://user:pass@host가 왜 작동하지 않나요?
OkHttp와 Apache HttpClient 모두 URL에서 인라인 프록시 인증 정보를 읽지 않습니다. OkHttp는 Proxy-Authorization을 설정하는 proxyAuthenticator를 사용하고, Apache는 프록시 호스트에 범위가 지정된 CredentialsProvider를 사용합니다. 게이트웨이가 타겟팅 정보를 사용자 이름에 인코딩하므로, 그 사용자 이름은 URL이 아니라 authenticator/credentials로 들어갑니다.
클라이언트 하나만 사용해도 부하 상태에서 Java 스크레이퍼가 멈춥니다. 왜 그런가요?
가장 유력한 원인은 Apache 풀의 라우트당 제한(기본값이 낮음)이 처리량을 제한하고 있거나, 응답 엔티티를 소비하지 않아 연결이 풀로 반환되지 않는 것입니다. setDefaultMaxPerRoute를 동시성 수준으로 올리고, try-with-resources로 모든 응답을 소비하세요.
Java에서 요청마다 IP를 로테이션하려면 어떻게 하나요?
프록시 사용자 이름을 변경하세요. OkHttp에서는 newBuilder()로 신원별 클라이언트를 파생시킵니다(풀을 공유함). Apache HttpClient에서는 해당 신원에 대한 CredentialsProvider를 담은 요청별 HttpClientContext를 전달합니다. 둘 다 클라이언트 하나로 여러 신원을 처리할 수 있게 해줍니다.
connect 타임아웃과 response 타임아웃을 별도로 설정해야 하나요? 그렇습니다. 별도의 connect 타임아웃과 response/socket 타임아웃을 두면 느린 연결(내 쪽 문제거나 일치하는 IP가 없는 경우)과 느린 응답(타겟 쪽 문제)을 구분할 수 있으며, 이는 타임아웃을 진단할 때 핵심적인 구분입니다. 뭉뚱그린 단일 타임아웃은 어느 단계에서 실패했는지 숨깁니다.
스크레이핑에는 OkHttp와 Apache HttpClient 중 무엇이 좋나요? 둘 다 잘 작동합니다. OkHttp는 더 가볍고 깔끔한 API를 갖고 있으며, Apache HttpClient는 더 설정 가능하고 오래전부터 자리잡은 라이브러리입니다. 사용성과 기존 의존성에 따라 선택하세요. 프록시 설정과 위에서 다룬 함정들은 둘 다에 적용됩니다.
결론
Java와 residential proxy의 조합은 각 클라이언트의 규칙을 존중하면 견고하게 작동합니다. 프록시 인증 정보는 적절한 메커니즘(OkHttp의 proxyAuthenticator, Apache의 CredentialsProvider)을 통해 제공하고 절대 URL에 인라인으로 넣지 마세요. 오래 지속되는 클라이언트 하나를 공유하고 거기서 신원 변형을 파생시키세요. Apache의 라우트당 풀 제한을 동시성 수준에 맞게 올리세요. 연결이 풀로 반환되도록 항상 응답을 소비하고 닫으세요. connect 타임아웃과 response 타임아웃을 분리하세요. 지역이나 세션을 바꾸려면 프록시 사용자 이름을 변경하고, 호스트별로 동시성을 제한하세요.
이것들을 제대로 하면 두 클라이언트 모두 JVM이 마땅히 해야 하는 방식으로 성능을 냅니다. residential gateway를 대상으로 설정하고, 풀의 품질이 애초에 얼마나 자주 재시도하게 되는지를 결정한다는 점을 기억하세요(IP reputation). 가격 페이지에서 자신의 타겟에 맞게 테스트해볼 수 있는 GB당 요금제를 확인할 수 있습니다.