PHP는 여전히 웹의 서버 간 트래픽에서 막대한 비중을 차지하며, 그 대부분은 두 가지 경로 중 하나를 거칩니다. 순수 cURL, 또는 cURL 위에 얹힌 Guzzle입니다. residential proxy를 둘 중 어느 쪽에 연결하는 것도 몇 가지 옵션을 설정하는 정도이며, 코드를 다시 짜야 할 일은 아닙니다. 문제는 각각이 세부적으로 다르게 처리하는 부분에서 생깁니다. 프록시 인증을 어떻게 전달하는지, HTTPS가 프록시를 통해 작동하는지 여부를 결정짓는 한 가지 설정, 그리고 매 요청마다 완전한 핸드셰이크를 치르는 스크레이퍼와 빠른 스크레이퍼를 가르는 연결 재사용 동작입니다.
이 글은 Python에서의 residential proxy, Playwright에서의 residential proxy, Go에서의 residential proxy와 같은 시리즈의 PHP편입니다. cURL과 Guzzle 양쪽에서 작동하는 코드와 PHP 특유의 함정을 함께 다룹니다.
아래 내용은 모두 Shifter의 residential gateway를 사용합니다. 엔드포인트는 p.shifter.io:443 하나이며, 모든 타겟팅 정보는 사용자명에 인코딩됩니다. 호스트와 인증 정보를 다른 제공업체 것으로 바꾸더라도 구조는 동일합니다.
한 문단으로 보는 게이트웨이 모델
프록시 사용자명은 인증 정보와 타겟팅 정보를 함께 담습니다. 국가나 세션을 바꾸기 위해 엔드포인트를 바꾸는 것이 아니라, 사용자명 문자열을 바꿉니다.
customer-USERNAME-country-us-sid-abc123-ttl-600country-us는 미국을 타겟팅하고, sid는 고정 세션을 지정하며, ttl은 그 IP를 N초 동안 유지합니다. sid/ttl을 생략하면 새 연결마다 로테이션됩니다. 비밀번호는 그대로 유지됩니다. 이 전체 문자열이 cURL이나 Guzzle에 넘기는 사용자명입니다.
순수 cURL
cURL은 프록시 호스트를 CURLOPT_PROXY에, 인증 정보를 CURLOPT_PROXYUSERPWD에 받습니다. 프록시 문자열에 user:pass@host를 인라인으로 넣을 수도 있지만, 인증 정보를 별도 옵션으로 유지하는 것이 더 깔끔하고 긴 사용자명에서 발생하는 URL 인코딩 문제를 피할 수 있습니다.
<?php$user = getenv('SHIFTER_USER') . '-country-us';$pass = getenv('SHIFTER_PASS');
$ch = curl_init('https://api.ipify.org');curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_PROXY => 'p.shifter.io:443', CURLOPT_PROXYUSERPWD => "$user:$pass", CURLOPT_HTTPPROXYTUNNEL => true, // CONNECT tunnel for HTTPS through the proxy CURLOPT_CONNECTTIMEOUT => 10, // connect phase only CURLOPT_TIMEOUT => 30, // whole transfer]);
$body = curl_exec($ch);if ($body === false) { fwrite(STDERR, 'curl error: ' . curl_error($ch) . "\n");} else { echo $body, "\n"; // a US residential IP}curl_close($ch);여기서 두 가지를 기억해야 합니다. $user 문자열에는 타겟팅 플래그(-country-us)가 포함되어 있는데, 지리적 정보가 바로 여기에 담기기 때문입니다. 그리고 CURLOPT_HTTPPROXYTUNNEL이 프록시를 통한 HTTPS를 작동시키는 핵심입니다. 이 옵션은 cURL에게 CONNECT 터널을 열도록 지시해서, TLS가 프록시가 아니라 타겟과 종단 간에 협상되도록 합니다. 이를 빼먹으면 HTTP 프록시를 통한 HTTPS 요청이 실패하거나 이상하게 동작합니다. 이것이 PHP 프록시 관련해서 가장 흔한 실수입니다.
Guzzle
Guzzle은 프록시를 요청(또는 클라이언트) 옵션인 proxy를 통해 받으며, 인증 정보는 URL에 인라인으로 넣습니다. Guzzle이 cURL의 래퍼이기 때문에 내부적으로는 동일한 터널링 동작이 적용되지만, https:// 타겟에 대한 CONNECT는 Guzzle이 자동으로 처리합니다.
<?phprequire 'vendor/autoload.php';
use GuzzleHttp\Client;
$user = getenv('SHIFTER_USER') . '-country-us';$pass = getenv('SHIFTER_PASS');$proxy = "http://$user:$pass@p.shifter.io:443";
// Build the client ONCE and reuse it (see the connection-reuse note below).$client = new Client([ 'proxy' => $proxy, 'connect_timeout' => 10, // connect phase 'timeout' => 30, // whole request]);
$res = $client->get('https://api.ipify.org');echo $res->getBody(), "\n"; // a US residential IPconnect_timeout과 timeout이 별도로 있다는 점에 주목하세요. 이 접속(connect) 대 전체(total)의 구분이 바로 문제가 멈췄을 때 타임아웃을 진단 가능하게 만드는 요소입니다. 접속이 느리면 여러분 쪽 문제나 일치하는 IP가 없다는 신호이고, 전체 시간이 느리면 타겟 쪽 문제라는 신호입니다.
Guzzle은 또한 요청별로 proxy 옵션을 받을 수 있고, 스킴을 키로 하는 배열로도 받을 수 있어서, https만 프록시로 라우팅하거나 no 우회 목록을 설정할 수 있습니다.
$res = $client->get('https://example.com', [ 'proxy' => [ 'http' => $proxy, 'https' => $proxy, 'no' => ['localhost', '127.0.0.1'], ],]);함정 1: Guzzle 클라이언트를 재사용하라(cURL 핸들도 마찬가지)
요청마다 새로운 GuzzleHttp\Client를 만들거나, 요청마다 새로운 curl_init()를 호출하면 매번 프록시를 통한 완전한 TCP + TLS 핸드셰이크를 치르게 되는데, 이는 지연 시간 가이드가 없애고자 하는 바로 그 오버헤드입니다. Guzzle은 내부적으로 cURL 핸들 풀을 유지하며, 클라이언트를 재사용하면 연결도 재사용합니다. 시작 시점에 클라이언트를 하나 만들어서 주입하고 그대로 유지하세요.
순수 cURL의 경우, 요청 사이에 핸들을 재사용하고 호출 사이에는 URL만 바꿔서 연결을 계속 열어두세요.
$ch = curl_init();curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_PROXY => 'p.shifter.io:443', CURLOPT_PROXYUSERPWD => "$user:$pass", CURLOPT_HTTPPROXYTUNNEL => true,]);foreach ($urls as $url) { curl_setopt($ch, CURLOPT_URL, $url); // reuse the handle, keep the connection $body = curl_exec($ch); // ... handle $body}curl_close($ch);함정 2: PHP는 기본적으로 한 번에 하나의 URL만 가져온다
PHP 요청 핸들러는 기본적으로 동기적입니다. curl_exec를 반복 호출하는 스크레이퍼는 엄밀히 한 번에 URL 하나씩만 가져오는데, 작은 작업에는 괜찮지만 규모가 커지면 고통스럽게 느려집니다. 이를 해결하는 방법은 두 가지입니다.
Guzzle 비동기와 제한된 풀을 사용하면 N개의 요청이 동시에 진행되지만 그 이상은 절대 넘어가지 않습니다.
use GuzzleHttp\Pool;use GuzzleHttp\Psr7\Request;
$requests = function ($urls) { foreach ($urls as $u) { yield new Request('GET', $u); }};
$pool = new Pool($client, $requests($urls), [ 'concurrency' => 8, // cap in-flight requests 'fulfilled' => function ($response, $i) { /* handle */ }, 'rejected' => function ($reason, $i) { /* log + retry */ },]);$pool->promise()->wait();또는 순수 cURL이라면 curl_multi_*를 사용합니다. 어느 방식이든, 동시성은 전체 기준이 아니라 타겟 호스트별로 제한해서, 취약한 사이트 하나가 두들겨 맞는 동안 관용적인 사이트가 굶주리지 않게 하세요. 타겟의 허용 범위를 넘어서는 병렬성은 처리량이 아니라 차단을 불러옵니다(차단을 피하는 방법).
함정 3: 응답을 소비하고, 상태 코드만이 아니라 전송 오류도 확인하라
cURL은 전송 실패(프록시 거부, 터널 실패, 타임아웃) 시 false를 반환하고, HTTP 응답에서는 407이나 502라 해도 본문 문자열을 반환합니다. 상태 코드를 신뢰하기 전에 curl_exec의 반환값을 false와 비교하고 curl_error/curl_errno를 확인하세요. Guzzle에서는 연결 실패 시 ConnectException이 발생하고, http_errors가 켜져 있을 때만(기본값으로 켜져 있습니다) 4xx/5xx에서 RequestException이 발생합니다. 둘 다 처리하고, 항상 본문을 읽어서 핸들을 재사용 가능한 상태로 두세요.
use GuzzleHttp\Exception\ConnectException;use GuzzleHttp\Exception\RequestException;
try { $res = $client->get($url); $body = (string) $res->getBody(); // drain the body} catch (ConnectException $e) { // transport: proxy/tunnel/timeout — retry with a fresh identity} catch (RequestException $e) { // HTTP status — inspect $e->getResponse()->getStatusCode()}지리와 세션 로테이션
타겟팅 정보가 사용자명에 담겨 있기 때문에, 신원(identity)을 바꾸는 것은 곧 인증 문자열을 바꾸는 것입니다.
순수 cURL: 호출 전에 CURLOPT_PROXYUSERPWD를 새 사용자명으로 설정하세요. 동일한 핸들이 반복 호출 사이에 여러 신원을 담당할 수 있습니다.
Guzzle: 요청별로 proxy 옵션을 넘겨서 클라이언트 기본값을 재정의하면, 클라이언트를 다시 만들지 않고도 하나의 클라이언트로 여러 신원을 처리할 수 있습니다.
function userFor(string $country, ?string $sid = null): string { $u = getenv('SHIFTER_USER') . '-country-' . $country; if ($sid !== null) { $u .= '-sid-' . $sid . '-ttl-600'; } return $u;}
$pass = getenv('SHIFTER_PASS');$proxy = 'http://' . userFor('de', 'job-42') . ":$pass@p.shifter.io:443";$res = $client->get('https://example.com', ['proxy' => $proxy]);각 논리적 작업 단위마다 고유한 sid를 부여하고, 스트림 중간이 아니라 단위 사이에서 로테이션하세요(sticky 대 rotating에서 이 구분을 다룹니다), 그리고 load-balancing 게시물에서 설명하는 방식으로 작업을 신원에 매핑하세요.
실제로 프록시를 사용하고 있는지 확인하라
다른 무언가를 벤치마크하거나 디버깅하기 전에, 먼저 출구 IP를 확인하세요.
$res = $client->get('http://ip-api.com/json');echo $res->getBody(), "\n"; // expect a residential IP in the targeted country자신의 IP가 나온다면 프록시 옵션이 적용되지 않고 있다는 뜻입니다. 멈춰버린다면 로컬 아웃바운드가 차단되었다는 뜻입니다. 둘 다 타임아웃 진단 가이드에서 다룹니다.
FAQ
순수 cURL에서 HTTPS 요청이 프록시를 통해 실패하는 이유는 무엇인가요?
거의 확실히 CURLOPT_HTTPPROXYTUNNEL을 빼먹은 것입니다. HTTP 프록시를 통한 HTTPS는 CONNECT 터널이 필요한데, 이는 TLS가 프록시가 아니라 타겟과 협상되도록 하기 위해서입니다. CURLOPT_HTTPPROXYTUNNEL => true를 설정하세요. Guzzle은 https:// 타겟에 대해 이를 자동으로 처리하기 때문에, 이 함정은 순수 cURL에만 해당됩니다.
프록시 인증 정보를 URL에 넣어야 하나요, 별도 옵션에 넣어야 하나요?
둘 다 작동합니다. 순수 cURL에서는 CURLOPT_PROXYUSERPWD를 쓰면 긴 게이트웨이 사용자명을 URL 밖에 둘 수 있고 URL 인코딩 문제를 피할 수 있습니다. Guzzle에서는 proxy 문자열에 user:pass@host를 인라인으로 넣는 것이 일반적인 방식입니다. 하나를 선택해서 일관되게 사용하세요.
프록시는 빠른데 PHP 스크레이퍼는 왜 느린가요?
대부분의 경우 요청마다 새 Guzzle 클라이언트(또는 curl_init)를 만들어서 매번 새 핸드셰이크를 치르고 있거나, 엄밀히 한 번에 URL 하나씩만 가져오고 있는 것입니다. 클라이언트/핸들 하나를 재사용하고, 제한된 Guzzle Pool이나 curl_multi를 사용해서 여러 요청을 동시에 실행하세요.
PHP에서 요청마다 IP를 로테이션하려면 어떻게 하나요?
프록시 사용자명을 바꾸세요. 순수 cURL에서는 각 호출 전에 CURLOPT_PROXYUSERPWD를 새 사용자명으로 설정하세요. Guzzle에서는 요청별로 proxy 옵션을 넘기세요. 둘 다 공유된 클라이언트나 핸들 하나로 여러 신원을 처리할 수 있게 해줍니다.
스크레이핑에는 cURL이 좋을까요, Guzzle이 좋을까요? Guzzle은 작은 의존성 하나로 비동기 풀, 미들웨어, 재시도, PSR-7을 제공합니다. 순수 cURL은 의존성이 없고 호출당 약간 더 빠릅니다. 동시성과 구조가 필요하면 Guzzle을, 최소한의 간결한 스크립트에는 순수 cURL을 사용하세요. Guzzle이 cURL 위에서 동작하기 때문에, 위에서 다룬 프록시 설정과 함정은 둘 다에 적용됩니다.
결론
PHP와 residential proxy는 각 클라이언트의 규칙을 존중하면 안정적으로 작동합니다. 순수 cURL에서는 프록시 호스트와 CURLOPT_PROXYUSERPWD를 설정하고, HTTPS를 위한 CURLOPT_HTTPPROXYTUNNEL을 절대 빼먹지 마세요. Guzzle에서는 proxy 옵션을 넘겨서 터널링을 맡기세요. 연결을 계속 열어두려면 클라이언트나 핸들 하나를 재사용하고, 한 번에 하나씩이 아니라 제한된 풀로 요청을 동시에 실행하고, 동시성은 타겟 호스트별로 제한하고, 상태 코드만이 아니라 전송 오류도 확인하세요. 지리나 세션을 바꾸려면 프록시 사용자명을 바꾸세요.
이것만 제대로 해두면 두 경로 모두 규모에 맞게 PHP가 작동해야 하는 방식대로 성능을 냅니다. residential gateway를 대상으로 삼으시고, 풀 품질이 재시도 빈도 자체를 결정한다는 점을 기억하세요(IP 신뢰도). 가격 페이지에는 여러분의 타겟에 맞춰 테스트할 수 있는 GB당 요금제가 있습니다.