본문으로 건너뛰기

HTTP 클라이언트 — net/http 패키지

Go의 net/http 패키지는 표준 라이브러리만으로도 프로덕션급 HTTP 클라이언트를 구현할 수 있습니다. 기본 GET 요청부터 커스텀 Transport, 재시도 패턴까지 단계적으로 살펴봅니다.

기본 HTTP 요청

http.Get / http.Post

package main

import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

func main() {
// GET 요청 — 가장 간단한 방법
resp, err := http.Get("https://httpbin.org/get")
if err != nil {
fmt.Println("GET 실패:", err)
return
}
defer resp.Body.Close() // 반드시 Body를 닫아야 커넥션 풀 반환됨!

fmt.Println("상태 코드:", resp.StatusCode)
fmt.Println("Content-Type:", resp.Header.Get("Content-Type"))

body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("Body 읽기 실패:", err)
return
}
fmt.Printf("응답 본문 (%d 바이트):\n%s\n", len(body), body[:min(len(body), 200)])

// POST JSON 요청
jsonBody := `{"name":"Alice","age":30}`
resp2, err := http.Post(
"https://httpbin.org/post",
"application/json",
strings.NewReader(jsonBody),
)
if err != nil {
fmt.Println("POST 실패:", err)
return
}
defer resp2.Body.Close()
fmt.Println("POST 상태:", resp2.StatusCode)

// PostForm: HTML 폼 데이터 전송
formData := url.Values{
"username": {"alice"},
"password": {"secret"},
}
resp3, err := http.PostForm("https://httpbin.org/post", formData)
if err != nil {
fmt.Println("PostForm 실패:", err)
return
}
defer resp3.Body.Close()
fmt.Println("PostForm 상태:", resp3.StatusCode)
}

func min(a, b int) int {
if a < b {
return a
}
return b
}

http.NewRequest + Client.Do — 세밀한 제어

package main

import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)

type APIResponse struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
JSON map[string]any `json:"json"`
}

func main() {
// NewRequest: 요청 헤더와 메서드를 자유롭게 설정
body := strings.NewReader(`{"query":"Go programming"}`)
req, err := http.NewRequest("POST", "https://httpbin.org/post", body)
if err != nil {
fmt.Println("요청 생성 실패:", err)
return
}

// 헤더 설정
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "MyGoApp/1.0")
req.Header.Set("X-Custom-Header", "custom-value")

// 쿠키 설정
req.AddCookie(&http.Cookie{
Name: "session",
Value: "abc123",
})

// Basic Auth
req.SetBasicAuth("user", "password")

// Bearer Token 설정
// req.Header.Set("Authorization", "Bearer "+token)

// DefaultClient 사용 (타임아웃 없음 — 실제 사용 시 비권장)
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("요청 실패:", err)
return
}
defer resp.Body.Close()

// 상태 코드 확인
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("오류 응답 [%d]: %s\n", resp.StatusCode, body)
return
}

// JSON 디코딩
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Println("JSON 디코딩 실패:", err)
return
}
fmt.Println("요청 URL:", result.URL)
}

커스텀 http.Client — 프로덕션 설정

기본 http.DefaultClient는 타임아웃이 없어 프로덕션 환경에서 위험합니다. 항상 커스텀 http.Client를 사용하세요.

package main

import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"time"
)

// newHTTPClient: 프로덕션 환경에 적합한 HTTP 클라이언트 생성
func newHTTPClient() *http.Client {
transport := &http.Transport{
// 연결 풀 설정
MaxIdleConns: 100, // 유휴 커넥션 최대 수
MaxIdleConnsPerHost: 10, // 호스트당 유휴 커넥션 최대 수
MaxConnsPerHost: 50, // 호스트당 최대 동시 연결
IdleConnTimeout: 90 * time.Second, // 유휴 커넥션 유지 시간

// 타임아웃 설정
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second, // 헤더 수신 타임아웃
ExpectContinueTimeout: 1 * time.Second,

// TCP 연결 설정
DialContext: (&net.Dialer{
Timeout: 30 * time.Second, // TCP 연결 타임아웃
KeepAlive: 30 * time.Second, // TCP Keep-Alive
}).DialContext,

// TLS 설정
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},

// HTTP/2 비활성화 (필요 시)
// ForceAttemptHTTP2: false,
DisableCompression: false, // gzip 자동 처리 유지
}

return &http.Client{
Timeout: 60 * time.Second, // 전체 요청 타임아웃 (연결+전송+응답)
Transport: transport,
// 리다이렉트 정책 설정 (기본: 최대 10회)
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("리다이렉트 최대 횟수 초과: %d", len(via))
}
return nil
},
}
}

func main() {
client := newHTTPClient()

req, _ := http.NewRequest("GET", "https://httpbin.org/get", nil)
req.Header.Set("User-Agent", "MyApp/1.0")

resp, err := client.Do(req)
if err != nil {
fmt.Println("요청 실패:", err)
return
}
defer resp.Body.Close()

var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("응답: %+v\n", result["headers"])
}

응답 처리 패턴

JSON 디코딩과 스트리밍 응답

package main

import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

type GitHubRepo struct {
Name string `json:"name"`
Description string `json:"description"`
Stars int `json:"stargazers_count"`
Language string `json:"language"`
UpdatedAt time.Time `json:"updated_at"`
}

// fetchJSON: JSON API 응답을 구조체로 디코딩
func fetchJSON(client *http.Client, url string, target any) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("요청 생성 실패: %w", err)
}
req.Header.Set("Accept", "application/json")

resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("HTTP 요청 실패: %w", err)
}
defer resp.Body.Close()

// 오류 상태 코드 처리
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("HTTP 오류 [%d]: %s", resp.StatusCode, body)
}

// JSON 스트리밍 디코딩 (메모리 효율적)
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("JSON 디코딩 실패: %w", err)
}
return nil
}

func main() {
client := &http.Client{Timeout: 10 * time.Second}

var repo GitHubRepo
url := "https://api.github.com/repos/golang/go"
if err := fetchJSON(client, url, &repo); err != nil {
fmt.Println("API 호출 실패:", err)
return
}

fmt.Printf("저장소: %s\n", repo.Name)
fmt.Printf("설명: %s\n", repo.Description)
fmt.Printf("별: %d\n", repo.Stars)
fmt.Printf("언어: %s\n", repo.Language)
fmt.Printf("수정일: %s\n", repo.UpdatedAt.Format("2006-01-02"))
}

재시도 패턴 — 지수 백오프

네트워크 오류나 일시적인 서버 오류(5xx)는 재시도로 해결할 수 있습니다. 지수 백오프(exponential backoff) 는 서버 부하를 피하면서 재시도하는 표준 패턴입니다.

package main

import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"time"
)

// RetryConfig: 재시도 설정
type RetryConfig struct {
MaxAttempts int
InitialWait time.Duration
MaxWait time.Duration
Multiplier float64
}

// DefaultRetryConfig: 기본 재시도 설정
var DefaultRetryConfig = RetryConfig{
MaxAttempts: 3,
InitialWait: 500 * time.Millisecond,
MaxWait: 30 * time.Second,
Multiplier: 2.0,
}

// doWithRetry: 지수 백오프 재시도 HTTP 요청
func doWithRetry(ctx context.Context, client *http.Client, req *http.Request, cfg RetryConfig) (*http.Response, error) {
var lastErr error
wait := cfg.InitialWait

for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
// 컨텍스트 취소 확인
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}

// 요청 실행
resp, err := client.Do(req.Clone(ctx))
if err == nil {
// 재시도 가능한 상태 코드 확인
if resp.StatusCode < 500 {
return resp, nil // 성공 또는 클라이언트 오류 (4xx)
}
// 5xx: 서버 오류 — 재시도
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
resp.Body.Close()
lastErr = fmt.Errorf("서버 오류 [%d]: %s", resp.StatusCode, body)
} else {
lastErr = err
}

if attempt == cfg.MaxAttempts {
break
}

// 지터(jitter) 추가: wait의 ±20% 범위로 무작위 분산
jitter := time.Duration(float64(wait) * (0.8 + 0.4*rand.Float64()))
fmt.Printf("시도 %d 실패, %v 후 재시도: %v\n", attempt, jitter, lastErr)

select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(jitter):
}

// 대기 시간 증가 (최대값 제한)
wait = time.Duration(float64(wait) * cfg.Multiplier)
if wait > cfg.MaxWait {
wait = cfg.MaxWait
}
}

return nil, fmt.Errorf("최대 재시도 횟수(%d) 초과: %w", cfg.MaxAttempts, lastErr)
}

func main() {
client := &http.Client{Timeout: 10 * time.Second}
ctx := context.Background()

req, _ := http.NewRequest("GET", "https://httpbin.org/status/200", nil)

resp, err := doWithRetry(ctx, client, req, DefaultRetryConfig)
if err != nil {
fmt.Println("최종 실패:", err)
return
}
defer resp.Body.Close()
fmt.Println("성공! 상태:", resp.StatusCode)
}

실전 예제 1 — REST API 클라이언트 래퍼

package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)

// APIClient: REST API 클라이언트 래퍼
type APIClient struct {
baseURL string
httpClient *http.Client
headers map[string]string
}

func NewAPIClient(baseURL string, token string) *APIClient {
return &APIClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
headers: map[string]string{
"Authorization": "Bearer " + token,
"Content-Type": "application/json",
"Accept": "application/json",
},
}
}

func (c *APIClient) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("JSON 마샬 실패: %w", err)
}
bodyReader = bytes.NewReader(data)
}

fullURL := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, err
}

for k, v := range c.headers {
req.Header.Set(k, v)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}

if resp.StatusCode >= 400 {
defer resp.Body.Close()
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("API 오류 [%d]: %s", resp.StatusCode, errBody)
}
return resp, nil
}

func (c *APIClient) Get(ctx context.Context, path string, params url.Values, result any) error {
if params != nil {
path = path + "?" + params.Encode()
}
resp, err := c.do(ctx, "GET", path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(result)
}

func (c *APIClient) Post(ctx context.Context, path string, payload, result any) error {
resp, err := c.do(ctx, "POST", path, payload)
if err != nil {
return err
}
defer resp.Body.Close()
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}

// GitHub API 예시
type GitHubUser struct {
Login string `json:"login"`
Name string `json:"name"`
PublicRepos int `json:"public_repos"`
Followers int `json:"followers"`
}

func main() {
// GitHub API는 토큰 없이도 일부 엔드포인트 사용 가능
client := NewAPIClient("https://api.github.com", "")
client.headers["Authorization"] = "" // 토큰 없이 공개 API 사용

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

var user GitHubUser
if err := client.Get(ctx, "/users/golang", nil, &user); err != nil {
fmt.Println("API 호출 실패:", err)
return
}

fmt.Printf("사용자: %s (%s)\n", user.Login, user.Name)
fmt.Printf("공개 저장소: %d개\n", user.PublicRepos)
fmt.Printf("팔로워: %d명\n", user.Followers)
}

실전 예제 2 — 파일 다운로드 (진행률 표시)

package main

import (
"fmt"
"io"
"net/http"
"os"
"time"
)

// ProgressWriter: 다운로드 진행률을 추적하는 Writer
type ProgressWriter struct {
Total int64
Current int64
LastShow time.Time
}

func (pw *ProgressWriter) Write(p []byte) (n int, err error) {
n = len(p)
pw.Current += int64(n)

// 500ms마다 진행률 출력
if time.Since(pw.LastShow) > 500*time.Millisecond {
pw.showProgress()
pw.LastShow = time.Now()
}
return n, nil
}

func (pw *ProgressWriter) showProgress() {
if pw.Total > 0 {
pct := float64(pw.Current) / float64(pw.Total) * 100
bar := int(pct / 5) // 최대 20칸 바
filled := ""
for i := 0; i < bar; i++ {
filled += "="
}
for i := bar; i < 20; i++ {
filled += " "
}
fmt.Printf("\r[%s] %.1f%% (%d / %d KB)",
filled, pct, pw.Current/1024, pw.Total/1024)
} else {
fmt.Printf("\r다운로드 중: %d KB", pw.Current/1024)
}
}

// downloadFile: URL에서 파일을 다운로드하고 진행률을 표시
func downloadFile(url, destPath string) error {
client := &http.Client{Timeout: 5 * time.Minute}

resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("요청 실패: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("서버 오류: %s", resp.Status)
}

// 임시 파일에 먼저 저장 (atomic write 패턴)
tmpPath := destPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("파일 생성 실패: %w", err)
}
defer func() {
f.Close()
os.Remove(tmpPath) // 실패 시 임시 파일 정리
}()

progress := &ProgressWriter{
Total: resp.ContentLength,
LastShow: time.Now(),
}

// TeeReader로 진행률 추적하면서 파일 저장
tee := io.TeeReader(resp.Body, progress)
if _, err := io.Copy(f, tee); err != nil {
return fmt.Errorf("다운로드 실패: %w", err)
}

progress.showProgress()
fmt.Println() // 줄바꿈

f.Close()

// 완료 후 최종 경로로 이동 (atomic rename)
if err := os.Rename(tmpPath, destPath); err != nil {
return fmt.Errorf("파일 이동 실패: %w", err)
}
return nil
}

func main() {
url := "https://golang.org/dl/go1.23.0.src.tar.gz"
dest := "go_source.tar.gz"

fmt.Printf("다운로드 시작: %s\n", url)
if err := downloadFile(url, dest); err != nil {
fmt.Println("다운로드 실패:", err)
// 네트워크 없는 환경에서 테스트용 메시지
fmt.Println("(네트워크 연결 확인 필요)")
return
}
fmt.Printf("다운로드 완료: %s\n", dest)
os.Remove(dest) // 테스트 정리
}

고수 팁

반드시 resp.Body.Close()를 호출하세요: Body를 닫지 않으면 TCP 연결이 커넥션 풀로 반환되지 않아 연결이 고갈됩니다. 오류 시에도 Body가 nil이 아니면 반드시 닫아야 합니다.

컨텍스트로 취소 제어: http.NewRequestWithContext(ctx, ...)를 사용하면 타임아웃과 취소를 컨텍스트로 제어할 수 있습니다. HTTP 클라이언트의 Timeout 필드는 전체 요청에 적용되는 최종 안전망입니다.

Transport 재사용: http.Client를 매 요청마다 생성하면 커넥션 풀을 활용할 수 없습니다. 애플리케이션 전역에서 http.Client 인스턴스를 공유하세요.

5xx는 재시도, 4xx는 재시도 금지: 서버 오류(5xx)는 일시적일 수 있으므로 재시도하고, 클라이언트 오류(4xx)는 요청 자체가 잘못된 것이므로 재시도해도 의미가 없습니다.