본문으로 건너뛰기

net/http 기초

Go 표준 라이브러리의 net/http 패키지는 외부 프레임워크 없이도 프로덕션 수준의 HTTP 서버를 구축할 수 있는 강력한 도구입니다. Gin, Echo 같은 프레임워크들도 내부적으로 net/http를 기반으로 합니다. 표준 라이브러리만으로도 충분한 경우가 많으며, 의존성을 최소화하고 싶을 때 특히 유용합니다.

ServeMux와 핸들러

package main

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

func main() {
// DefaultServeMux 사용 (패키지 레벨 전역 mux)
http.HandleFunc("/hello", helloHandler)

// 커스텀 ServeMux 사용 (권장 - 테스트 용이, 전역 상태 없음)
mux := http.NewServeMux()

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

// http.Handler 인터페이스 구현체 등록
mux.Handle("/metrics", metricsHandler{})

log.Println("서버 시작: http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}

func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
}

func listUsers(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "사용자 목록")
}

func createUser(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "사용자 생성")
}

func getUser(w http.ResponseWriter, r *http.Request) {
// Go 1.22+ 경로 파라미터 추출
id := r.PathValue("id")
fmt.Fprintf(w, "사용자 ID: %s\n", id)
}

func deleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "사용자 %s 삭제\n", id)
}

// http.Handler 인터페이스 구현
type metricsHandler struct{}

func (h metricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "# metrics")
}

http.Server 구조체 설정

package main

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

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})

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

// 클라이언트가 요청 헤더를 보내는 최대 시간
ReadHeaderTimeout: 5 * time.Second,
// 요청 전체(바디 포함) 읽기 최대 시간
ReadTimeout: 10 * time.Second,
// 응답 쓰기 최대 시간
WriteTimeout: 30 * time.Second,
// Keep-Alive 유휴 연결 유지 시간
IdleTimeout: 120 * time.Second,
// 요청 헤더 최대 크기 (기본 1MB)
MaxHeaderBytes: 1 << 20,
}

log.Fatal(srv.ListenAndServe())
}

요청 처리

package main

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

func requestInfoHandler(w http.ResponseWriter, r *http.Request) {
// HTTP 메서드
fmt.Println("Method:", r.Method)

// URL 정보
fmt.Println("Path:", r.URL.Path)
fmt.Println("RawQuery:", r.URL.RawQuery)

// 쿼리 파라미터
name := r.URL.Query().Get("name") // 단일 값
tags := r.URL.Query()["tag"] // 다중 값 (tag=go&tag=api)
fmt.Println("name:", name, "tags:", tags)

// 헤더
contentType := r.Header.Get("Content-Type")
userAgent := r.Header.Get("User-Agent")
fmt.Println("Content-Type:", contentType)
fmt.Println("User-Agent:", userAgent)

// 바디 읽기 (크기 제한 필수)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB 제한
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "요청 바디 읽기 실패", http.StatusBadRequest)
return
}
defer r.Body.Close()
fmt.Println("Body:", string(body))

// 클라이언트 IP
fmt.Println("RemoteAddr:", r.RemoteAddr)
}

// JSON 요청 파싱
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields() // 알 수 없는 필드 허용 안 함

if err := decoder.Decode(&req); err != nil {
http.Error(w, `{"error":"잘못된 JSON 형식"}`, http.StatusBadRequest)
return
}

if req.Name == "" || req.Email == "" {
http.Error(w, `{"error":"name과 email은 필수입니다"}`, http.StatusUnprocessableEntity)
return
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{
"id": 1,
"name": req.Name,
"email": req.Email,
})
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/request-info", requestInfoHandler)
mux.HandleFunc("POST /users", createUserHandler)
log.Fatal(http.ListenAndServe(":8080", mux))
}

응답 작성

package main

import (
"encoding/json"
"net/http"
)

// JSON 응답 헬퍼
func jsonResponse(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

// 에러 응답 헬퍼
func errorResponse(w http.ResponseWriter, status int, code, message string) {
jsonResponse(w, status, map[string]any{
"error": map[string]string{
"code": code,
"message": message,
},
})
}

func exampleHandler(w http.ResponseWriter, r *http.Request) {
// 헤더 설정은 WriteHeader/Write 전에 해야 함
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Add("X-Custom-Header", "value")

// 상태 코드 설정 (한 번만 호출 가능)
w.WriteHeader(http.StatusOK)

// 바디 쓰기
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

// 파일 응답
func downloadHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="data.json"`)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data": "example"}`))
}

// 리다이렉트
func redirectHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/new-path", http.StatusMovedPermanently)
}

정적 파일 서빙

package main

import (
"log"
"net/http"
)

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

// /static/ 경로를 ./public 디렉토리에 매핑
// StripPrefix로 /static 프리픽스 제거 후 FileServer에 전달
fileServer := http.FileServer(http.Dir("./public"))
mux.Handle("/static/", http.StripPrefix("/static/", fileServer))

// embed.FS를 사용한 정적 파일 (바이너리 내장)
// mux.Handle("/assets/", http.FileServer(http.FS(staticFiles)))

// SPA (Single Page Application) 지원
mux.HandleFunc("/app/", spaHandler)

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./public/index.html")
})

log.Fatal(http.ListenAndServe(":8080", mux))
}

// SPA 핸들러: 모든 경로를 index.html로 서빙
func spaHandler(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./public/index.html")
}

미들웨어 체인 (함수 래핑 패턴)

package main

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

// Middleware 타입 정의
type Middleware func(http.Handler) http.Handler

// Chain 미들웨어를 순서대로 적용
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}

// responseWriter 응답 상태코드 캡처
type responseWriter struct {
http.ResponseWriter
status int
wroteHeader bool
}

func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{ResponseWriter: w, status: http.StatusOK}
}

func (rw *responseWriter) WriteHeader(status int) {
if !rw.wroteHeader {
rw.status = status
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(status)
}
}

// LoggingMiddleware 요청 로깅
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := newResponseWriter(w)
next.ServeHTTP(rw, r)
log.Printf("[%d] %s %s (%v)", rw.status, r.Method, r.URL.Path, time.Since(start))
})
}

// RecoveryMiddleware panic 복구
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v", err)
http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"서버 내부 오류"}}`,
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

// CORSMiddleware 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,PATCH,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)
})
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"users":[]}`))
})

// 미들웨어 체인 적용: Recovery → CORS → Logging → Handler
handler := Chain(mux, RecoveryMiddleware, CORSMiddleware, LoggingMiddleware)
log.Fatal(http.ListenAndServe(":8080", handler))
}

실전 예제: Todo REST API

package main

import (
"encoding/json"
"log"
"net/http"
"strconv"
"sync"
"time"
)

type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}

type Store struct {
mu sync.RWMutex
todos map[int]*Todo
nextID int
}

func NewStore() *Store {
return &Store{todos: make(map[int]*Todo), nextID: 1}
}

func (s *Store) List() []*Todo {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]*Todo, 0, len(s.todos))
for _, t := range s.todos {
list = append(list, t)
}
return list
}

func (s *Store) Create(title string) *Todo {
s.mu.Lock()
defer s.mu.Unlock()
t := &Todo{ID: s.nextID, Title: title, CreatedAt: time.Now()}
s.todos[s.nextID] = t
s.nextID++
return t
}

func (s *Store) Get(id int) (*Todo, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.todos[id]
return t, ok
}

func (s *Store) Update(id int, done bool) (*Todo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.todos[id]
if !ok {
return nil, false
}
t.Done = done
return t, true
}

func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.todos[id]; !ok {
return false
}
delete(s.todos, id)
return true
}

type Handler struct{ store *Store }

func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
todos := h.store.List()
respond(w, http.StatusOK, map[string]any{"data": todos})
}

func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Title == "" {
respond(w, http.StatusBadRequest, map[string]any{
"error": map[string]string{"code": "BAD_REQUEST", "message": "title이 필요합니다"},
})
return
}
respond(w, http.StatusCreated, map[string]any{"data": h.store.Create(body.Title)})
}

func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
respond(w, http.StatusBadRequest, errResp("INVALID_ID", "잘못된 ID"))
return
}
todo, ok := h.store.Get(id)
if !ok {
respond(w, http.StatusNotFound, errResp("NOT_FOUND", "할 일을 찾을 수 없습니다"))
return
}
respond(w, http.StatusOK, map[string]any{"data": todo})
}

func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
respond(w, http.StatusBadRequest, errResp("INVALID_ID", "잘못된 ID"))
return
}
var body struct {
Done bool `json:"done"`
}
json.NewDecoder(r.Body).Decode(&body)
todo, ok := h.store.Update(id, body.Done)
if !ok {
respond(w, http.StatusNotFound, errResp("NOT_FOUND", "할 일을 찾을 수 없습니다"))
return
}
respond(w, http.StatusOK, map[string]any{"data": todo})
}

func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
respond(w, http.StatusBadRequest, errResp("INVALID_ID", "잘못된 ID"))
return
}
if !h.store.Delete(id) {
respond(w, http.StatusNotFound, errResp("NOT_FOUND", "할 일을 찾을 수 없습니다"))
return
}
w.WriteHeader(http.StatusNoContent)
}

func respond(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

func errResp(code, message string) map[string]any {
return map[string]any{"error": map[string]string{"code": code, "message": message}}
}

func main() {
h := &Handler{store: NewStore()}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/todos", h.list)
mux.HandleFunc("POST /api/todos", h.create)
mux.HandleFunc("GET /api/todos/{id}", h.get)
mux.HandleFunc("PATCH /api/todos/{id}", h.update)
mux.HandleFunc("DELETE /api/todos/{id}", h.delete)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, map[string]string{"status": "ok"})
})

handler := Chain(mux, RecoveryMiddleware, CORSMiddleware, LoggingMiddleware)
log.Println("Todo API 서버: http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}

고수 팁

1. Go 1.22+ 패턴 라우팅 활용: "GET /users/{id}" 형태로 메서드와 경로를 동시에 지정하면 별도 라우터가 필요 없습니다

2. 커스텀 ServeMux 사용: 전역 http.DefaultServeMux는 테스트가 어렵고 서드파티 패키지가 오염시킬 수 있습니다

3. 헤더는 WriteHeader 전에: w.Header().Set()은 반드시 w.WriteHeader() 또는 첫 번째 w.Write() 전에 호출해야 합니다

4. 바디는 항상 닫기: defer r.Body.Close()를 잊지 마세요. 그리고 MaxBytesReader로 크기를 제한하세요