Go 언어 net/http 패키지로 HTTP 서버와 클라이언트 구현 가이드

Go 언어 HTTP 서버와 클라이언트: net/http 패키지 완전 정복

Go 언어 문법을 익히고 나면 실제로 동작하는 무언가를 만들고 싶어집니다. 그 첫 번째 관문이자 가장 강력한 무기가 바로 HTTP 서버 구축입니다. Go는 표준 라이브러리 net/http 패키지만으로도 프로덕션 수준의 웹 서버를 만들 수 있습니다.

이 글에서는 net/http 패키지의 내부 구조부터 HTTP 서버 구현, HTTP 클라이언트 요청, 미들웨어 패턴, REST API, 파일 업로드, 그리고 Graceful Shutdown까지 — 실무에서 즉시 활용할 수 있는 패턴을 체계적으로 다룹니다.

🛠️ 필자의 실무 경험

필자는 사내 메신저 서버, Telegram 봇 웹훅, 작업 알림 API 등을 Go의 net/http로 구축해 왔습니다. 처음에는 fmt.Fprint로 응답을 보내는 수준에서 시작했지만, 점차 JSON 인코딩, 미들웨어 체인, context 연동, Graceful Shutdown까지 확장해 나갔습니다. 이 모든 것을 외부 프레임워크 없이 표준 라이브러리만으로 구현할 수 있다는 점이 Go의 가장 큰 매력 중 하나입니다.


1. net/http 패키지 구조 이해

net/http의 핵심은 단 하나의 인터페이스입니다.

// http.Handler 인터페이스 — Go HTTP의 근간
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

모든 핸들러, 미들웨어, 라우터는 이 인터페이스를 구현합니다. http.HandlerFunc는 함수를 Handler로 변환하는 어댑터 타입입니다.

// HandlerFunc는 func(ResponseWriter, *Request)를 Handler로 변환
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

요청 흐름을 시각화하면 다음과 같습니다.

클라이언트 → ListenAndServe → ServeMux(라우터) → Handler → ResponseWriter

2. 첫 번째 HTTP 서버

package main

import (
    "fmt"
    "log"
    "net/http"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "안녕하세요, Go HTTP 서버입니다!")
}

func main() {
    http.HandleFunc("/", helloHandler)

    fmt.Println("서버 실행 중: http://localhost:8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatalf("서버 시작 실패: %v", err)
    }
}

🛠️ 프로덕션에서는 DefaultServeMux를 피하라

DefaultServeMux는 전역 변수이므로, 외부 패키지에서 경로를 등록할 수 있어 보안 위험이 있습니다. 실무에서는 http.NewServeMux()로 독립적인 ServeMux를 생성하세요.


3. ServeMux와 라우팅

3-1. http.NewServeMux 사용 (권장)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("/", homeHandler)
    mux.HandleFunc("/api/users", usersHandler)
    mux.HandleFunc("/api/users/", userByIDHandler) // 트레일링 슬래시는 prefix 매칭

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    log.Fatal(server.ListenAndServe())
}

3-2. Go 1.22의 새로운 패턴 매칭

Go 1.22부터 ServeMux가 메서드와 경로 파라미터를 지원합니다.

mux := http.NewServeMux()

// 메서드 + 경로 패턴 (Go 1.22+)
mux.HandleFunc("GET /api/users", listUsers)
mux.HandleFunc("POST /api/users", createUser)
mux.HandleFunc("GET /api/users/{id}", getUser)
mux.HandleFunc("PUT /api/users/{id}", updateUser)
mux.HandleFunc("DELETE /api/users/{id}", deleteUser)

// 핸들러에서 경로 파라미터 추출
func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // Go 1.22+
    // id 사용...
}

4. JSON 응답 처리

실무에서 JSON API를 구현할 때 사용하는 패턴입니다.

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type ErrorResponse struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

// JSON 응답 헬퍼 함수
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(v); err != nil {
        log.Printf("JSON 인코딩 실패: %v", err)
    }
}

// JSON 요청 파싱 헬퍼 함수
func readJSON(r *http.Request, v any) error {
    r.Body = http.MaxBytesReader(nil, r.Body, 1<<20) // 1MB 제한
    return json.NewDecoder(r.Body).Decode(v)
}

func getUserHandler(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    user, err := findUserByID(id)
    if err != nil {
        writeJSON(w, http.StatusNotFound, ErrorResponse{
            Code:    404,
            Message: "사용자를 찾을 수 없습니다",
        })
        return
    }

    writeJSON(w, http.StatusOK, user)
}

🛠️ json.NewEncoder vs json.Marshal

응답 크기가 크다면 json.NewEncoder를 사용하세요.


5. HTTP 클라이언트 요청

5-1. 단순 GET/POST 요청

// GET 요청 — 간편 함수
resp, err := http.Get("https://api.example.com/users")
if err != nil {
    return fmt.Errorf("GET 요청 실패: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
    return fmt.Errorf("응답 본문 읽기 실패: %w", err)
}
// POST 요청 — JSON body
payload := map[string]any{"name": "Alice", "age": 30}
data, _ := json.Marshal(payload)

resp, err := http.Post(
    "https://api.example.com/users",
    "application/json",
    bytes.NewBuffer(data),
)

5-2. http.Client — 실무 필수 설정

기본 http.Get()타임아웃이 없어 프로덕션에서 위험합니다. 항상 커스텀 클라이언트를 사용하세요.

// 패키지 수준 싱글턴 클라이언트 (재사용)
var httpClient = &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 10 * time.Second,
    },
}

func fetchUser(ctx context.Context, id string) (*User, error) {
    url := "https://api.example.com/users/" + id

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("요청 생성 실패: %w", err)
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("API_TOKEN"))
    req.Header.Set("Accept", "application/json")

    resp, err := httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("API 호출 실패: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("API 오류: status %d", resp.StatusCode)
    }

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("응답 파싱 실패: %w", err)
    }

    return &user, nil
}

http.NewRequestWithContext(ctx, ...)를 사용하면 context의 취소/타임아웃이 HTTP 요청에도 전파됩니다. 이전 글에서 다룬 context 패키지와의 연동 포인트입니다.


6. 미들웨어 패턴

미들웨어는 Handler를 받아 Handler를 반환하는 함수입니다.

type Middleware func(http.Handler) http.Handler

6-1. 로깅 미들웨어

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // 상태 코드를 캡처하기 위한 ResponseWriter 래퍼
        lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}

        next.ServeHTTP(lrw, r)

        log.Printf("[%s] %s %s %d %s",
            r.Method,
            r.RequestURI,
            r.RemoteAddr,
            lrw.statusCode,
            time.Since(start),
        )
    })
}

type loggingResponseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (lrw *loggingResponseWriter) WriteHeader(code int) {
    lrw.statusCode = code
    lrw.ResponseWriter.WriteHeader(code)
}

6-2. 인증 미들웨어 (JWT 스타일)

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if !strings.HasPrefix(authHeader, "Bearer ") {
            http.Error(w, "인증이 필요합니다", http.StatusUnauthorized)
            return
        }

        token := strings.TrimPrefix(authHeader, "Bearer ")
        userID, err := validateToken(token)
        if err != nil {
            http.Error(w, "유효하지 않은 토큰입니다", http.StatusUnauthorized)
            return
        }

        // context에 사용자 ID 주입
        ctx := context.WithValue(r.Context(), contextKeyUserID, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

type contextKey string
const contextKeyUserID contextKey = "userID"

6-3. CORS 미들웨어

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }

        next.ServeHTTP(w, r)
    })
}

6-4. 미들웨어 체인 구성

func chain(h http.Handler, middlewares ...Middleware) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/users/{id}", getUserHandler)

    // 미들웨어 체인: 요청은 logging → cors → auth → handler 순으로 통과
    handler := chain(mux,
        loggingMiddleware,
        corsMiddleware,
        authMiddleware,
    )

    server := &http.Server{
        Addr:    ":8080",
        Handler: handler,
    }
    log.Fatal(server.ListenAndServe())
}

7. REST API 구현 패턴

의존성을 주입받는 구조체 기반 핸들러 패턴을 사용하면 테스트하기 쉬운 코드를 작성할 수 있습니다.

type UserRepository interface {
    FindAll() ([]User, error)
    FindByID(id int) (*User, error)
    Create(user *User) error
    Update(user *User) error
    Delete(id int) error
}

type UserHandler struct {
    repo UserRepository
}

func NewUserHandler(repo UserRepository) *UserHandler {
    return &UserHandler{repo: repo}
}

func (h *UserHandler) Register(mux *http.ServeMux) {
    mux.HandleFunc("GET /api/users", h.List)
    mux.HandleFunc("POST /api/users", h.Create)
    mux.HandleFunc("GET /api/users/{id}", h.Get)
    mux.HandleFunc("PUT /api/users/{id}", h.Update)
    mux.HandleFunc("DELETE /api/users/{id}", h.Delete)
}

func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
    users, err := h.repo.FindAll()
    if err != nil {
        writeJSON(w, http.StatusInternalServerError, ErrorResponse{
            Code: 500, Message: "내부 서버 오류",
        })
        return
    }
    writeJSON(w, http.StatusOK, users)
}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }

    if err := readJSON(r, &req); err != nil {
        writeJSON(w, http.StatusBadRequest, ErrorResponse{
            Code: 400, Message: "잘못된 요청 형식입니다",
        })
        return
    }

    // 입력 검증
    if req.Name == "" || req.Email == "" {
        writeJSON(w, http.StatusBadRequest, ErrorResponse{
            Code: 400, Message: "name과 email은 필수 항목입니다",
        })
        return
    }

    user := &User{Name: req.Name, Email: req.Email}
    if err := h.repo.Create(user); err != nil {
        writeJSON(w, http.StatusInternalServerError, ErrorResponse{
            Code: 500, Message: "사용자 생성 실패",
        })
        return
    }

    writeJSON(w, http.StatusCreated, user)
}

8. 파일 업로드 처리

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    // 최대 10MB 제한 (메모리 + 디스크)
    if err := r.ParseMultipartForm(10 << 20); err != nil {
        http.Error(w, "파일 크기 초과 (최대 10MB)", http.StatusRequestEntityTooLarge)
        return
    }

    file, header, err := r.FormFile("file")
    if err != nil {
        http.Error(w, "파일 필드를 찾을 수 없습니다", http.StatusBadRequest)
        return
    }
    defer file.Close()

    // 파일 타입 검증 (Content-Type 헤더는 조작 가능하므로 실제 내용 검사)
    buf := make([]byte, 512)
    n, err := file.Read(buf)
    if err != nil && err != io.EOF {
        http.Error(w, "파일 읽기 실패", http.StatusInternalServerError)
        return
    }
    contentType := http.DetectContentType(buf[:n])
    if !strings.HasPrefix(contentType, "image/") {
        http.Error(w, "이미지 파일만 업로드 가능합니다", http.StatusBadRequest)
        return
    }

    // 파일 포인터를 처음으로 되돌리기
    file.Seek(0, io.SeekStart)

    // 안전한 파일명 생성 (사용자 입력 파일명은 신뢰하지 않음)
    ext := filepath.Ext(header.Filename)
    safeFilename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
    savePath := filepath.Join("./uploads", safeFilename)

    if err := os.MkdirAll("./uploads", 0755); err != nil {
        http.Error(w, "디렉터리 생성 실패", http.StatusInternalServerError)
        return
    }

    dst, err := os.Create(savePath)
    if err != nil {
        http.Error(w, "파일 저장 실패", http.StatusInternalServerError)
        return
    }
    defer dst.Close()

    if _, err := io.Copy(dst, file); err != nil {
        http.Error(w, "파일 복사 실패", http.StatusInternalServerError)
        return
    }

    writeJSON(w, http.StatusCreated, map[string]string{
        "filename": safeFilename,
        "url":      "/uploads/" + safeFilename,
    })
}

🛠️ 파일 업로드 보안 체크리스트

  1. 파일 크기 제한: r.ParseMultipartForm(maxSize)http.MaxBytesReader
  2. 파일 타입 검증: Content-Type 헤더 대신 http.DetectContentType()으로 실제 내용 검사
  3. 파일명 검증: 사용자 제공 파일명을 사용하지 말고 새로 생성
  4. 저장 경로 검증: filepath.Clean() 후 허용 경로 내에 있는지 확인 (Path Traversal 방지)

9. Graceful Shutdown

서버를 갑자기 종료하면 진행 중인 요청이 손상될 수 있습니다. http.Server.Shutdown()으로 진행 중인 요청을 완료하고 종료합니다.

func main() {
    mux := http.NewServeMux()
    // 핸들러 등록...

    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // 별도 고루틴에서 서버 시작
    go func() {
        log.Println("서버 시작: :8080")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("서버 오류: %v", err)
        }
    }()

    // OS 시그널 대기 (Ctrl+C, kill 등)
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("서버 종료 중...")

    // 최대 30초 동안 진행 중인 요청 완료 대기
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("강제 종료: %v", err)
    }

    log.Println("서버 정상 종료 완료")
}

ReadTimeout, WriteTimeout, IdleTimeout 설정도 필수입니다. 설정하지 않으면 슬로우 클라이언트 공격(Slowloris)에 취약합니다.


10. HTTP 서버 테스트

net/http/httptest 패키지로 서버를 실제로 실행하지 않고 핸들러를 테스트할 수 있습니다.

func TestGetUserHandler(t *testing.T) {
    // 테스트용 Mock Repository
    mockRepo := &mockUserRepository{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
        },
    }
    handler := NewUserHandler(mockRepo)

    // 테스트 요청/응답 생성
    req := httptest.NewRequest("GET", "/api/users/1", nil)
    req.SetPathValue("id", "1") // Go 1.22+
    w := httptest.NewRecorder()

    handler.Get(w, req)

    resp := w.Result()
    if resp.StatusCode != http.StatusOK {
        t.Errorf("예상 상태코드 200, 실제: %d", resp.StatusCode)
    }

    var user User
    json.NewDecoder(resp.Body).Decode(&user)
    if user.Name != "Alice" {
        t.Errorf("예상 이름 Alice, 실제: %s", user.Name)
    }
}

// 전체 서버를 테스트하는 통합 테스트
func TestServerIntegration(t *testing.T) {
    ts := httptest.NewServer(setupRouter())
    defer ts.Close()

    resp, err := http.Get(ts.URL + "/api/users")
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Errorf("예상 200, 실제: %d", resp.StatusCode)
    }
}

11. 정적 파일 서빙과 Caddy 배포

11-1. 정적 파일 서빙

mux := http.NewServeMux()

// /static/ 경로로 public 디렉터리 파일 서빙
fs := http.FileServer(http.Dir("./public"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))

// API 핸들러
mux.HandleFunc("GET /api/users", listUsers)

11-2. Caddy로 HTTPS + 리버스 프록시

Go 서버를 외부에 배포할 때 Caddy를 리버스 프록시로 사용하면 HTTPS 설정이 자동화됩니다.

# Caddyfile
example.com {
    reverse_proxy localhost:8080
}

Caddy는 Let’s Encrypt를 통한 인증서 발급·갱신을 자동으로 처리합니다. 개인 프로젝트나 소규모 서비스 배포에 매우 효과적입니다.


마치며: net/http 패키지 핵심 정리

Go의 net/http는 외부 프레임워크 없이도 프로덕션 수준의 HTTP 서버와 클라이언트를 구현할 수 있는 강력한 표준 라이브러리입니다.

오늘 배운 핵심을 정리하면:

  1. http.Handler 인터페이스가 모든 핸들러와 미들웨어의 근간이다.
  2. http.NewServeMux()로 독립적인 라우터를 생성하고, Go 1.22+에서는 메서드+경로 파라미터를 지원한다.
  3. http.Client는 항상 커스텀 설정으로 사용하며, http.NewRequestWithContext()로 context를 연동한다.
  4. 미들웨어 체인func(http.Handler) http.Handler 패턴으로 구성한다.
  5. 파일 업로드는 크기 제한, 파일 타입 검증, 안전한 파일명 생성을 반드시 포함해야 한다.
  6. Graceful Shutdown으로 진행 중인 요청을 완료하고 안전하게 서버를 종료한다.
  7. httptest 패키지로 실제 서버 없이 핸들러 단위 테스트가 가능하다.

다음 심화편 글에서는 Go 언어의 HTML 템플릿(html/template) 패키지를 다룹니다. 동적 HTML 페이지 생성, XSS 자동 방어, 레이아웃 상속 패턴, 그리고 이번에 배운 HTTP 서버와 결합하여 완전한 웹 애플리케이션을 구축하는 방법을 살펴보겠습니다.