지식

Go에서 net/http와 Colly로 레지덴셜 프록시 사용하는 방법

Go에서의 프록시: 트랜스포트 설정, 연결 재사용을 망치는 body-drain 및 MaxIdleConnsPerHost 함정, 요청별 지역 로테이션, 그리고 Colly의 SetProxyFunc.

Chris Collins

Chris Collins

2026년 7월 24일 · 7 분 소요

Go는 스크래핑에 자연스럽게 어울리는 언어다. 저렴한 동시성, 탄탄한 표준 라이브러리, 배포하기 쉬운 단일 정적 바이너리까지 갖췄다. 레지덴셜 프록시net/http에 연결하는 것은 정말로 세 줄이면 끝난다. 사람들이 하루를 날리는 부분은 그 세 줄 이후에 벌어지는 모든 것인데, Go의 HTTP 클라이언트에는 연결 재사용을 조용히 망가뜨리는 몇 가지 날카로운 함정이 있고, 프록시를 거치면 이것이 지연 시간과 타임아웃으로 이어져 설명하기 곤란해진다.

이 글은 Python으로 레지덴셜 프록시 사용하기, Playwright로 사용하기와 같은 시리즈의 Go 편이다. 실제로 동작하는 코드와 함께 Go에서 특히 중요한 함정들을 다룬다.

아래 내용은 모두 Shifter의 레지덴셜 게이트웨이를 사용한다. 엔드포인트 하나, p.shifter.io:443으로 모든 타겟팅이 사용자 이름에 인코딩되어 있다. 다른 제공업체를 쓴다면 호스트와 인증 정보만 바꾸면 되고, 구조는 동일하다.

게이트웨이 모델을 한 문단으로

프록시 사용자 이름에는 인증 정보 타겟팅 정보가 함께 담긴다. 국가나 세션을 바꾸려고 엔드포인트를 바꾸는 게 아니라, 사용자 이름 문자열을 바꾼다:

customer-USERNAME-country-us-sid-abc123-ttl-600

country-us는 미국을 타겟팅하고, sid는 고정 세션을 지정하며, ttl은 해당 IP를 N초 동안 유지한다. sid/ttl을 생략하면 새 연결마다 로테이션된다. 비밀번호는 고정이다.

인라인 프록시 인증 정보를 무시하는 Chromium과 달리, Go의 net/http는 프록시 URL의 인증 정보를 올바르게 처리하며, Proxy-Authorization을 알아서 전송한다. 따라서 http://user:pass@host:port 형식이 그대로 동작한다.

최소 버전

package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
func main() {
user, pass := os.Getenv("SHIFTER_USER"), os.Getenv("SHIFTER_PASS")
proxyURL, err := url.Parse(fmt.Sprintf("http://%s-country-us:%s@p.shifter.io:443", user, pass))
if err != nil {
panic(err)
}
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 30 * time.Second,
}
resp, err := client.Get("https://api.ipify.org")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body)) // 미국 레지덴셜 IP
}

통합 작업은 이게 전부다. 이제 실제 성능을 좌우하는 부분들을 살펴보자.

함정 1: 클라이언트를 재사용하라, 요청마다 새로 만들지 마라

이것은 Go에서 가장 흔하고 가장 비용이 큰 실수이며, 프록시를 거치면 더 심각해진다.

http.Clienthttp.Transport오래 유지되고 공유되도록 설계되었다. 여러 고루틴에서 동시에 사용해도 안전하며, 연결 풀은 Transport 내부에 존재한다. 요청마다 새 클라이언트를 만들면 모든 요청이 새로운 TCP 핸드셰이크 프록시를 거친 TLS 핸드셰이크의 비용을 치르게 되는데, 이는 지연 시간 가이드가 없애라고 말하는 바로 그 오버헤드다. 더 나쁜 것은 유휴 연결이 누수된다는 점이다.

// 나쁜 예: 요청마다 새 클라이언트. 연결 재사용이 전혀 없고 연결이 누수된다.
func fetch(u string) (*http.Response, error) {
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
return client.Get(u)
}
// 좋은 예: 클라이언트 하나를 패키지 레벨 또는 주입받아, 고루틴 간에 공유한다.
var client = &http.Client{ /* 아래에서 한 번만 설정 */ }

시작 시점에 한 번 만들고 그것을 전달해서 쓴다.

함정 2: 바디를 끝까지 읽고 닫아라, 그렇지 않으면 재사용이 안 된다

Go는 응답 바디를 끝까지 읽고 닫은 경우에만 연결을 풀에 반환한다. 읽지 않고 닫으면 연결은 버려지며, 클라이언트를 공유하고 있어도 조용히 풀링을 잃게 된다.

resp, err := client.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
// 바디가 필요 없어도, 연결이 재사용될 수 있도록 끝까지 읽어서 버려야 한다.
// defer는 후입선출로 실행되므로, 이 드레인은 위의 Close보다
// *먼저* 실행되며, 이것이 원하는 순서다.
defer io.Copy(io.Discard, resp.Body)

바디를 정상적으로 파싱하고 있다면(io.ReadAll, EOF까지 읽는 JSON 디코더 등) 문제없다. 함정은 조기 반환이다. 200이 아닌 상태 코드에서 드레인 없이 빠져나가면 연결이 쓸모없어지는데, 차단을 많이 겪는 스크래퍼라면 이것이 트래픽 대부분에 해당할 수 있다.

함정 3: MaxIdleConnsPerHost의 기본값은 2다

이것은 거의 모든 Go 스크래퍼를 물어뜯는다. http.Transport의 기본 MaxIdleConnsPerHost2다. 하나의 호스트에 대해 고루틴 50개를 실행하면 그중 48개는 계속 새 연결을 열었다 버리게 되고, 각각 프록시를 거친 전체 핸드셰이크 비용을 치른다.

호스트당 동시성 수준에 맞춰 최소한 그만큼으로 설정하라:

transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50, // 호스트당 동시성 이상으로 설정
IdleConnTimeout: 90 * time.Second,
// 세분화된 타임아웃이 뭉뚱그린 Client.Timeout 하나보다 낫다.
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{Transport: transport}

여기에 일부러 Client.Timeout을 두지 않았다. Client.Timeout은 dial, TLS, 헤더, 바디를 모두 포괄하는 하나의 전체 예산이다. 세분화된 transport 타임아웃을 사용하면 느린 연결과 느린 응답을 구분할 수 있는데, 이는 타임아웃을 진단 가능하게 만드는 connect와 read의 구분과 정확히 같은 개념이다. 요청별 전체 마감 시한에는 context.WithTimeout을 사용하라.

지역과 세션 로테이션

타겟팅이 사용자 이름에 들어있으므로, 다른 아이덴티티는 곧 다른 프록시 URL을 의미한다. 두 가지 접근법이 있다:

요청별로, Proxy 함수를 통해. Transport.Proxy는 요청마다 호출되므로 여기서 아이덴티티를 바꿀 수 있다. Go는 프록시 URL별로 연결을 풀링하므로 이 방식은 효율을 유지한다:

func proxyFor(country, sid string) (*url.URL, error) {
u := fmt.Sprintf("%s-country-%s", os.Getenv("SHIFTER_USER"), country)
if sid != "" {
u += fmt.Sprintf("-sid-%s-ttl-600", sid)
}
return url.Parse(fmt.Sprintf("http://%s:%s@p.shifter.io:443", u, os.Getenv("SHIFTER_PASS")))
}
// 요청 컨텍스트에서 타겟팅 정보를 읽어와 작업마다 자신의 아이덴티티를 고르게 한다.
type ctxKey string
const identityKey ctxKey = "identity"
type identity struct{ Country, SID string }
transport := &http.Transport{
Proxy: func(r *http.Request) (*url.URL, error) {
if id, ok := r.Context().Value(identityKey).(identity); ok {
return proxyFor(id.Country, id.SID)
}
return proxyFor("us", "")
},
MaxIdleConnsPerHost: 50,
}

그다음 작업마다 아이덴티티를 부여한다:

ctx := context.WithValue(req.Context(), identityKey, identity{Country: "de", SID: "job-42"})
resp, err := client.Do(req.WithContext(ctx))

또는 아이덴티티마다 transport를 맵에 캐싱해두는 방법도 있는데, 국가별로 하나씩처럼 고정된 소규모 집합이 있을 때 적합하다. 둘 다 유효하며, 컨텍스트 방식은 아이덴티티가 작업 단위별로 결정될 때 더 잘 확장된다.

논리적 작업 단위마다 고유한 sid를 부여하고 흐름 도중이 아니라 단위 사이에서 로테이션하라(고정 프록시 vs 로테이팅 프록시에서 각각 어떤 경우에 적용되는지 다루며, 로드 밸런싱 글에서 작업을 아이덴티티에 매핑하는 방법을 다룬다).

호스트별 동시성

Go에서는 10,000개의 고루틴을 실행하는 것도 쉽지만, 그만큼 쉽게 차단당할 수도 있다. 전역이 아니라 타겟 호스트별로 동시성을 제한해서, 취약한 타겟은 두들겨 맞지 않고 관대한 타겟은 불필요하게 제한받지 않도록 하라:

import "golang.org/x/sync/semaphore"
var limits = map[string]*semaphore.Weighted{
"tough-site.example": semaphore.NewWeighted(4),
"open-site.example": semaphore.NewWeighted(32),
}
func fetch(ctx context.Context, host, u string) error {
sem := limits[host]
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
// ... 요청 수행
return nil
}

타겟의 허용 한도를 넘어서면, 병렬성을 늘려봐야 처리량이 아니라 차단만 더 사게 된다(차단을 피하는 방법).

Colly

Colly는 표준적인 Go 스크래핑 프레임워크이며, 프록시 설정은 한 줄이면 된다:

c := colly.NewCollector(colly.AllowedDomains("example.com"))
proxyURL := fmt.Sprintf("http://%s-country-us:%s@p.shifter.io:443", user, pass)
if err := c.SetProxy(proxyURL); err != nil {
log.Fatal(err)
}

요청별 지역 설정이나 고정 세션이 필요하면 SetProxyFunc를 사용하라. 위의 Transport.Proxy 함수와 같은 개념이다:

c.SetProxyFunc(func(r *http.Request) (*url.URL, error) {
// URL이나 작업 맵 등에서 국가/세션을 요청별로 선택한다.
return proxyFor("de", "job-42")
})

Colly에는 자체 도메인별 제한기가 있으니, 직접 만들지 말고 그것을 사용하라:

c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 8,
Delay: 200 * time.Millisecond,
RandomDelay: 200 * time.Millisecond, // 지터. 요청이 메트로놈처럼 규칙적이지 않도록
})

그리고 앞서 다룬 풀링 및 타임아웃 설정을 쓰고 싶다면 커스텀 transport를 설정하라:

c.WithTransport(transport)

Colly의 OnError가 재시도 로직이 들어갈 자리다. 재시도하기 전에 분류하고, 차단이 발생하면 같은 출구로 재시도하지 말고 아이덴티티를 바꿔라. 로드 밸런싱 글의 분류 표가 그대로 적용된다.

실제로 프록시를 타고 있는지 확인하라

벤치마킹이나 다른 디버깅에 들어가기 전에, 먼저 출구 IP를 확인하라:

resp, _ := client.Get("http://ip-api.com/json")
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b)) // 타겟팅한 국가의 레지덴셜 IP가 나와야 한다

자신의 IP가 나온다면 클라이언트가 프록시를 사용하지 않고 있다는 뜻이다. 멈춘다면 로컬 이그레스가 차단된 것이다. 둘 다 타임아웃 진단 가이드에서 다룬다.

FAQ

Go는 URL에 프록시 인증 정보를 넣는 것을 지원하는가? 그렇다. Chromium 기반 브라우저와 달리, Go의 net/httphttp://user:pass@host:port를 제대로 처리하며 Proxy-Authorization을 알아서 보낸다. 게이트웨이가 사용자 이름에 타겟팅을 인코딩하므로 이것만으로 충분하다.

Go 스크래퍼가 클라이언트를 하나만 쓰는데도 프록시를 거치면 느린 이유는 무엇인가? 십중팔구 응답 바디를 드레인하지 않고 있어서(연결이 풀에 반환되지 않음) 그렇거나, 여러 고루틴을 실행하는데도 MaxIdleConnsPerHost가 여전히 기본값 2로 남아있어서 그렇다. 둘 다 고치면 요청별 핸드셰이크 오버헤드가 대부분 사라진다.

Go에서 요청마다 IP를 로테이션하려면 어떻게 하는가? 프록시 사용자 이름을 바꾸면 된다. Transport.Proxy에서 요청 컨텍스트로부터 아이덴티티를 읽어 다른 URL을 반환하거나, 아이덴티티마다 transport를 유지하면 된다. Go는 프록시 URL별로 연결을 풀링하므로 요청별 변경도 여전히 효율적이다.

Client.Timeout을 써야 하는가, transport 타임아웃을 써야 하는가? 용도에 따라 둘 다 필요하다. 세분화된 transport 타임아웃(TLSHandshakeTimeout, ResponseHeaderTimeout)은 느린 연결과 느린 응답을 구분해준다. 전체 마감 시한에는 요청별로 context.WithTimeout을 사용하라. 뭉뚱그린 Client.Timeout 하나만 쓰면 어느 단계에서 실패했는지 알 수 없다.

http.ProxyFromEnvironment를 대신 써도 되는가? 그렇다. HTTP_PROXY/HTTPS_PROXY/NO_PROXY를 읽으며, 컨테이너 환경에서 편리하다. 모든 요청이 동일한 아이덴티티를 쓴다면 괜찮은 기본값이지만, 그 방식으로는 요청별로 지역이나 세션을 바꿀 수 없으므로, 타겟팅이 필요하다면 명시적인 Proxy 함수를 써야 한다.

결론

Go와 레지덴셜 프록시는 표준 라이브러리의 규칙만 지키면 강력한 조합이 된다. 오래 유지되는 클라이언트 하나를 공유하고, 항상 바디를 끝까지 읽고 닫고, 동시성에 맞춰 MaxIdleConnsPerHost를 올리고, 뭉뚱그린 전체 타임아웃 하나보다 세분화된 transport 타임아웃을 선호하라. 지역이나 세션을 바꾸려면 프록시 사용자 이름을 변경하고, 호스트별로 동시성을 제한하고, Colly의 제한기와 SetProxyFunc가 프레임워크 레벨에서 같은 역할을 하도록 두라.

이것들만 제대로 해두면 세 줄짜리 통합이 Go다운 성능을 낼 수 있다. 레지덴셜 게이트웨이를 겨냥해보고, 풀의 품질이 애초에 재시도를 얼마나 자주 하게 되는지를 결정한다는 점을 기억하라(IP 평판). 가격 페이지에는 자신의 타겟을 대상으로 테스트해볼 수 있는 GB당 요금제가 나와 있다.

시작할 준비가 되셨나요?

205M개 이상의 IP, 195개 이상의 국가를 지원하는 Shifter의 레지덴셜 프록시를 $0.75/GB부터 이용해보세요.

시작하기