본문으로 건너뛰기

net/http 서버

이 챕터에서 다루는 것

Go의 HTTP 서버는 인터페이스 하나 위에 서 있다.

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

프레임워크가 없어도 라우팅·미들웨어·검증이 다 되는 이유가 이 한 줄이다. 파트 10에서 Gin과 Fiber를 볼 텐데, 그것들이 무엇을 대신해 주는지 알려면 먼저 직접 해 봐야 한다.

:::note 이 챕터도 인터넷에 나가지 않는다 데모 프로그램은 httptest.NewServer로 같은 프로세스 안에 서버를 띄우고 자기가 요청을 보낸다. 포트는 OS가 고르고 출력에는 찍지 않는다. :::

Handler와 HandlerFunc

ServeHTTP 메서드를 가진 것은 전부 핸들러다. 함수 하나를 핸들러로 만들려면 http.HandlerFunc로 변환한다.

func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "안녕")
}

var h http.Handler = http.HandlerFunc(hello)

http.HandlerFunc함수 타입에 메서드를 붙인 것이다.

type HandlerFunc func(ResponseWriter, *Request)

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

4-1에서 "메서드는 구조체 전용이 아니다"라고 한 것이 여기서 쓰인다. 이 변환 하나로 함수와 인터페이스가 자유롭게 오간다.

http.ResponseWriter는 인터페이스이고 *http.Request는 포인터라는 비대칭도 알아 둘 만하다. 응답은 "쓰는 통로"라 구현이 바뀔 수 있고 (미들웨어가 감싼다), 요청은 값이 커서 복사하지 않는다.

ServeMux — 라우팅

Go 1.22에서 ServeMux가 크게 바뀌었다. 메서드와 와일드카드가 패턴에 들어간다. 그전까지는 서드파티 라우터를 쓰는 가장 큰 이유가 이것이었다.

패턴 형식은 [METHOD ][HOST]/[PATH]다.

examples/09-standard-library/07-http-server/web/web.go
// 메서드 + 와일드카드. {id}는 세그먼트 하나에 매칭된다.
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
WriteError(w, r, http.StatusBadRequest, "id는 정수여야 한다")
return
}
it, ok := s.Get(id)
if !ok {
WriteError(w, r, http.StatusNotFound, fmt.Sprintf("항목 %d 없음", id))
return
}
WriteJSON(w, http.StatusOK, it)
})

r.PathValue("id")가 와일드카드 값을 준다. 항상 문자열이므로 변환과 검증은 직접 한다 — 그것이 400과 404를 구분하는 지점이다.

패턴 종류를 한 파일에 모아 두면 규칙이 눈에 들어온다.

examples/09-standard-library/07-http-server/web/web.go
// 후행 슬래시 패턴은 그 아래 전부를 잡는다.
mux.HandleFunc("GET /admin/", func(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{"path": r.URL.Path})
})

// {path...}는 남은 세그먼트 전부를 잡는다. 슬래시를 포함한다.
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{"path": r.PathValue("path")})
})

// 더 구체적인 패턴이 이긴다. 등록 순서와 무관하다.
mux.HandleFunc("GET /files/robots.txt", func(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{"special": "robots"})
})

// {$}는 "정확히 이 경로"를 뜻한다. 이게 없으면 "/"가 모든 경로를 잡는다.
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{"service": "items"})
})

실행하면 이렇게 갈린다.

GET / → 200 {"service":"items"}
GET /items/1 → 200 {"id":1,"name":"구두","price":89000}
GET /items/99 → 404 {"error":"항목 99 없음","request_id":"3"}
GET /items/abc → 400 {"error":"id는 정수여야 한다","request_id":"4"}
POST /items/1 → 501 {"error":"아직 안 만들었다","request_id":"5"}
DELETE /items/1 → 404 {"error":"그런 경로 없음","request_id":"6"}
GET /admin → 307 Location=/admin/ <a href="/admin/">Temporary Redirect</a>.
GET /admin/settings → 200 {"path":"/admin/settings"}
GET /files/a/b/c.txt → 200 {"path":"a/b/c.txt"}
GET /files/robots.txt → 200 {"special":"robots"}
GET /search?q=구두&tag=a&tag=b → 200 {"limit":10,"q":"구두","tags":["a","b"]}
GET /panic → 500 {"error":"내부 오류","request_id":"12"}
GET /nope → 404 {"error":"그런 경로 없음","request_id":"13"}

규칙을 정리하면 이렇다.

패턴매칭
/index.html그 경로, 모든 메서드
GET /static//static/ 아래 전부 (GET과 HEAD)
/items/{id}세그먼트 하나
/files/{path...}남은 전부, 슬래시 포함
/{$}정확히 /
/모든 경로
  • 메서드를 안 쓰면 모든 메서드에 매칭된다.
  • GET 패턴은 HEAD도 받는다. 나머지 메서드는 정확히 일치해야 한다.
  • 더 구체적인 패턴이 이기고, 등록 순서는 무관하다. 우열을 가릴 수 없으면 등록 시점에 패닉한다. 라우팅 충돌을 런타임 미스터리가 아니라 시작 실패로 만들어 준다.

후행 슬래시 리다이렉트는 307이다

/admin/을 등록하면 /admin 요청은 자동으로 리다이렉트된다. Go 1.26에서 이 코드가 301에서 307로 바뀌었다.

examples/09-standard-library/07-http-server/web/web_test.go
// 후행 슬래시 리다이렉트는 307이다. 예전 301이 아니다.
func TestTrailingSlashRedirectIs307(t *testing.T) {
h, _ := newHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/admin", nil))

if rec.Code != http.StatusTemporaryRedirect {
t.Errorf("상태 = %d, want 307", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/admin/" {
t.Errorf("Location = %q, want %q", got, "/admin/")
}
}

차이가 실무에 영향을 준다. 301은 영구 리다이렉트라 브라우저가 캐시하고, 메서드를 GET으로 바꿔 버린다. POST /adminGET /admin/이 되어 본문이 사라지는 사고가 있었다. 307은 메서드와 본문을 보존한다.

리다이렉트 자체를 보고 싶으면 클라이언트가 따라가지 않게 해야 한다.

examples/09-standard-library/07-http-server/main.go
// 리다이렉트를 따라가지 않는 클라이언트. 307 자체를 보고 싶다.
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}

/ 캐치올의 대가

에러 응답 형식을 JSON으로 통일하려면 등록되지 않은 경로도 우리가 처리해야 한다. 그런데 여기에 값이 있다.

examples/09-standard-library/07-http-server/web/web.go
// 등록되지 않은 경로 전체를 받아 JSON 404를 준다.
// 이게 없으면 net/http 기본 404가 평문으로 나간다.
//
// 대가가 있다. 메서드를 안 붙인 "/"는 모든 메서드에 매칭되므로,
// DELETE /items/1처럼 "경로는 있는데 메서드가 없는" 요청까지 여기로 온다.
// 즉 mux가 공짜로 주던 405 + Allow 헤더를 잃는다.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
WriteError(w, r, http.StatusNotFound, "그런 경로 없음")
})

캐치올이 없으면 mux가 405와 Allow 헤더를 알아서 만들어 준다.

examples/09-standard-library/07-http-server/web/web_test.go
// 캐치올이 없으면 mux가 405와 Allow 헤더를 알아서 준다.
func TestMethodNotAllowedWithoutCatchAll(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {})
mux.HandleFunc("POST /items/{id}", func(w http.ResponseWriter, r *http.Request) {})

rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("DELETE", "/items/1", nil))

if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("상태 = %d, want 405", rec.Code)
}
if got := rec.Header().Get("Allow"); got != "GET, HEAD, POST" {
t.Errorf("Allow = %q, want %q", got, "GET, HEAD, POST")
}
}

AllowGET, HEAD, POST인 것을 보라 — GET 등록이 HEAD까지 만들어 준 결과다. 캐치올이 있으면 같은 요청이 404가 된다.

examples/09-standard-library/07-http-server/web/web_test.go
// 캐치올이 있으면 같은 요청이 404가 된다. 이것이 대가다.
func TestCatchAllSwallowsMethodNotAllowed(t *testing.T) {
h, _ := newHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("DELETE", "/items/1", nil))

if rec.Code != http.StatusNotFound {
t.Errorf("상태 = %d, want 404", rec.Code)
}
if got := rec.Header().Get("Allow"); got != "" {
t.Errorf("Allow = %q, want 빈 값", got)
}
}

둘 다 가지려면 캐치올을 포기하거나, 라우팅 표를 직접 들고 405를 만들어야 한다. 9-8은 캐치올 없이 가고, 대신 405가 평문으로 나가는 것을 받아들인다. 정답은 없고 아는 상태로 고르면 된다.

요청 읽기

쿼리 파라미터

패턴에는 안 들어간다. 핸들러 안에서 읽는다.

examples/09-standard-library/07-http-server/web/web.go
// 쿼리 파라미터는 패턴이 아니라 핸들러 안에서 읽는다.
mux.HandleFunc("GET /search", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit := 10
if raw := q.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
WriteError(w, r, http.StatusBadRequest, "limit은 양의 정수여야 한다")
return
}
limit = n
}
WriteJSON(w, http.StatusOK, map[string]any{
"q": strings.TrimSpace(q.Get("q")),
"limit": limit,
"tags": q["tag"], // 같은 키가 여러 번 오면 슬라이스로 읽는다
})
})
GET /search?q=구두&tag=a&tag=b → 200 {"limit":10,"q":"구두","tags":["a","b"]}
  • q.Get(k)는 첫 번째 값만 준다. 같은 키가 여러 번 오면 q[k]로 전부 받는다.
  • Get은 "없음"과 "빈 문자열"을 구분하지 못한다. 구분이 필요하면 q.Has(k)를 쓴다.
  • r.URL.Query()는 부를 때마다 다시 파싱한다. 핸들러에서 여러 번 쓰면 변수에 담아 둔다.

limit을 검증하는 부분이 요점이다. 파싱 실패를 무시하고 기본값으로 넘어가면 클라이언트는 자기 요청이 무시된 줄 모른다.

examples/09-standard-library/07-http-server/web/web_test.go
func TestQueryParsing(t *testing.T) {
h, _ := newHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/search?q=+구두+&tag=a&tag=b&limit=5", nil))

var got struct {
Q string `json:"q"`
Limit int `json:"limit"`
Tags []string `json:"tags"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal 에러 = %v (본문 %s)", err, rec.Body)
}
if got.Q != "구두" || got.Limit != 5 || len(got.Tags) != 2 {
t.Errorf("결과 = %+v", got)
}
}

쿼리 문자열의 +가 공백으로 디코딩되는 것도 여기서 확인된다.

본문

r.Bodyio.ReadCloser다. 9-5의 디코딩 규칙이 그대로 적용되고, 9-8에서 http.MaxBytesReader로 상한을 건다.

응답 쓰기 — 순서가 있다

http.ResponseWriter에는 순서 규칙이 있고, 어기면 조용히 무시된다.

  1. w.Header().Set(...)
  2. w.WriteHeader(status)
  3. w.Write(body)

WriteHeader 뒤에 헤더를 바꿔도 아무 효과가 없다. 헤더는 이미 나갔다. 그리고 WriteHeader 없이 Write하면 자동으로 200이 나간다 — 그 뒤에 WriteHeader(500)을 부르면 superfluous WriteHeader call 경고만 로그에 남는다.

examples/09-standard-library/07-http-server/web/web.go
// WriteJSON은 상태 코드와 JSON 본문을 쓴다.
//
// 순서가 중요하다: Header를 먼저 세우고, WriteHeader를 부르고, 그다음에 쓴다.
// WriteHeader 뒤에 Header를 고쳐 봐야 아무 효과가 없다.
func WriteJSON(w http.ResponseWriter, status int, v any) {
// 인코딩이 중간에 실패하면 이미 나간 바이트를 되돌릴 수 없다.
// 먼저 버퍼에 만들고, 성공했을 때만 내보낸다.
body, err := json.Marshal(v)
if err != nil {
http.Error(w, `{"error":"응답 인코딩 실패"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
// 클라이언트가 끊은 경우다. 여기서 할 수 있는 일은 로깅뿐이다.
return
}
}

json.NewEncoder(w).Encode(v)를 쓰지 않은 이유가 여기 있다. Encode는 바로 w에 쓰기 때문에, 인코딩이 중간에 실패하면 (9-4에서 본 NaN, 순환 참조, 커스텀 마셜러 에러) 이미 200과 반쪽짜리 JSON이 나간 뒤다. 되돌릴 방법이 없다.

먼저 Marshal로 버퍼를 만들고 성공했을 때만 내보낸다. 응답이 거대해서 메모리에 못 올리는 경우가 아니면 이쪽이 옳다.

에러 응답 형식도 한 곳에서 고정한다.

examples/09-standard-library/07-http-server/web/web.go
// ErrorBody는 이 서비스의 에러 응답 형식이다. 형식을 하나로 고정한다.
type ErrorBody struct {
Error string `json:"error"`
RequestID string `json:"request_id,omitzero"`
}

9-4의 omitzero가 쓰였다. 요청 ID가 없는 경로(캐치올 밖)에서는 필드가 아예 나가지 않는다.

미들웨어 — 직접 만든다

미들웨어는 핸들러를 받아 핸들러를 돌려주는 함수다. 그게 전부다. 프레임워크가 없어도 되는 이유다.

examples/09-standard-library/07-http-server/web/middleware.go
// Middleware는 핸들러를 감싸 핸들러를 돌려주는 함수다.
// 시그니처가 하나뿐이라 프레임워크 없이도 조합이 된다.
type Middleware func(http.Handler) http.Handler

// Chain은 미들웨어를 순서대로 적용한다.
// Chain(h, a, b, c)는 요청이 a → b → c → h 순으로 지나가게 만든다.
// 뒤에서부터 감싸야 그 순서가 나온다.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}

역순으로 감싸야 읽는 순서와 실행 순서가 일치한다. 정순으로 감싸면 Chain(h, a, b, c)c → b → a → h가 되어, 코드를 읽는 사람이 매번 머릿속에서 뒤집어야 한다.

요청 ID

컨텍스트에 값을 넣는 흔한 미들웨어다.

examples/09-standard-library/07-http-server/web/middleware.go
// ctxKey는 컨텍스트 키 타입이다. 문자열을 그대로 키로 쓰면 충돌한다.
type ctxKey int

const requestIDKey ctxKey = iota

// RequestID는 요청마다 증가하는 ID를 컨텍스트에 넣는다.
// 실무에서는 UUID나 추적 헤더를 쓰지만, 여기서는 출력이 결정적이도록 순번을 쓴다.
func RequestID(counter *atomic.Int64) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := strconv.FormatInt(counter.Add(1), 10)
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

7-7에서 정한 규칙 그대로다. 컨텍스트 키는 비공개 타입이라야 다른 패키지의 키와 충돌하지 않는다. 그리고 r.WithContext(ctx)새 요청을 만들어 준다 — 원본을 바꾸지 않으니 반드시 next.ServeHTTP에 새 것을 넘겨야 한다.

상태 코드 기록하기

http.ResponseWriter에는 "무엇을 썼는지" 물어보는 메서드가 없다. 접근 로그를 남기려면 감싸야 한다.

examples/09-standard-library/07-http-server/web/middleware.go
// statusRecorder는 실제로 나간 상태 코드와 바이트 수를 기록한다.
// http.ResponseWriter에는 "무엇을 썼는지" 물어보는 방법이 없어서 감싸야 한다.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}

func (w *statusRecorder) WriteHeader(code int) {
if w.status == 0 {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
}

func (w *statusRecorder) Write(b []byte) (int, error) {
if w.status == 0 {
// WriteHeader 없이 Write하면 암묵적으로 200이다.
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}

// Unwrap을 두면 http.ResponseController가 원래 Writer의 기능
// (Flush, SetWriteDeadline 등)에 도달할 수 있다.
func (w *statusRecorder) Unwrap() http.ResponseWriter { return w.ResponseWriter }

Unwrap 메서드가 중요하다. ResponseWriter를 감싸면 원래 구현이 가진 부가 인터페이스(http.Flusher, http.Hijacker 등)가 가려진다. 예전에는 그것들을 전부 수동으로 위임해야 했는데, http.ResponseControllerUnwrap을 따라 내려가 준다. 감싸는 타입에는 Unwrap을 붙인다.

if w.status == 0 가드는 이중 WriteHeader를 막는다. 핸들러가 실수로 두 번 불러도 첫 번째만 나간다.

패닉 복구

이게 없으면 핸들러 패닉이 프로세스를 죽이지는 않지만, net/http가 커넥션을 끊어 버려서 클라이언트는 이유 없는 연결 끊김을 본다.

examples/09-standard-library/07-http-server/web/middleware.go
// Recover는 핸들러 패닉을 500으로 바꾼다.
// 이것이 없으면 net/http가 커넥션을 끊어 버려 클라이언트는 원인을 모른다.
func Recover(out io.Writer) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
v := recover()
if v == nil {
return
}
// http.ErrAbortHandler는 의도된 중단이므로 그대로 흘려보낸다.
if v == http.ErrAbortHandler {
panic(v)
}
fmt.Fprintf(out, "panic %s %s: %v\n", r.Method, r.URL.Path, v)
_ = debug.Stack() // 실제 서비스에서는 이 스택을 로그로 남긴다
WriteError(w, r, http.StatusInternalServerError, "내부 오류")
}()
next.ServeHTTP(w, r)
})
}
}

http.ErrAbortHandler는 다시 던진다. 이건 "응답을 포기하고 커넥션을 끊어라"는 약속된 신호라서, 500으로 바꾸면 안 된다.

2-7에서 배운 recover 규칙이 그대로다. defer된 함수 안에서 직접 불러야 하고, recover()의 결과를 변수에 받아 nil 검사부터 한다.

조립과 순서

examples/09-standard-library/07-http-server/web/web.go
return Chain(mux,
RequestID(counter),
Logging(logOut),
Recover(logOut),
)

순서에 이유가 있다.

  • RequestID가 가장 바깥 — 그래야 로그와 에러 응답 양쪽에서 ID를 쓸 수 있다.
  • Logging이 그다음 — Recover보다 바깥이라 패닉이 500으로 바뀐 뒤에 기록되므로 접근 로그에 500이 남는다. 순서를 뒤집으면 패닉난 요청이 접근 로그에서 사라진다.
  • Recover가 핸들러 바로 위.

실행 로그가 그 순서를 보여 준다.

--- 접근 로그 ---
GET / 200 19B id=1
GET /items/1 200 38B id=2
GET /items/99 404 45B id=3
GET /items/abc 400 54B id=4
POST /items/1 501 52B id=5
DELETE /items/1 404 49B id=6
GET /admin 307 43B id=7
GET /admin/settings 200 26B id=8
GET /files/a/b/c.txt 200 20B id=9
GET /files/robots.txt 200 20B id=10
GET /search 200 42B id=11
panic GET /panic: 의도된 패닉
GET /panic 500 43B id=12
GET /nope 404 50B id=13

panic ... 줄이 GET /panic 500 줄보다 먼저 나온다. 안쪽 미들웨어가 먼저 끝나기 때문이다. 그리고 패닉 이후에도 서버는 계속 산다.

examples/09-standard-library/07-http-server/web/web_test.go
// 패닉은 500이 되고 서버는 계속 산다.
func TestRecoverTurnsPanicInto500(t *testing.T) {
h, logs := newHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/panic", nil))

if rec.Code != http.StatusInternalServerError {
t.Errorf("상태 = %d, want 500", rec.Code)
}
if !strings.Contains(logs.String(), "panic GET /panic: 의도된 패닉") {
t.Errorf("패닉 로그가 없다: %q", logs.String())
}

// 그다음 요청은 정상이어야 한다.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/items/1", nil))
if rec.Code != http.StatusOK {
t.Errorf("패닉 이후 상태 = %d, want 200", rec.Code)
}
}

핸들러 타임아웃

http.TimeoutHandler는 표준 라이브러리가 주는 미들웨어다.

examples/09-standard-library/07-http-server/web/middleware.go
// Timeout은 핸들러 하나의 상한이다. http.TimeoutHandler는 상한을 넘으면
// 503과 지정한 메시지를 대신 쓴다.
func Timeout(d time.Duration) Middleware {
return func(next http.Handler) http.Handler {
return http.TimeoutHandler(next, d, `{"error":"시간 초과"}`)
}
}

이것은 핸들러 고루틴을 죽이지 않는다. 응답만 대신 쓴다. 핸들러가 계속 돌면 자원을 계속 쓰므로, 핸들러 쪽에서도 r.Context()를 봐야 진짜로 멈춘다.

http.Server 설정

http.ListenAndServe(":8080", h)는 데모용이다. 프로덕션에서는 http.Server를 직접 만든다.

examples/09-standard-library/08-practice/main.go
srv := &http.Server{
Handler: handler,

// 이 네 개가 없으면 느린 클라이언트 하나가 서버를 묶어 놓을 수 있다.
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,

// 핸들러가 r.Context()로 서버 종료를 감지할 수 있게 한다.
BaseContext: func(net.Listener) context.Context { return context.Background() },
}
필드무엇을 막는가
ReadHeaderTimeout헤더를 아주 천천히 보내는 Slowloris 공격
ReadTimeout본문까지 포함한 요청 읽기 전체
WriteTimeout응답 쓰기가 끝나지 않는 상황
IdleTimeoutkeep-alive 커넥션이 무한정 남는 것

기본값은 전부 0, 즉 무제한이다. ReadHeaderTimeout 하나만 있어도 Slowloris는 막힌다.

CSRF — CrossOriginProtection

Go 1.25에서 표준 라이브러리에 CSRF 방어가 들어왔다. 토큰을 심고 검증하는 전통적인 방식이 아니라, 브라우저가 자동으로 붙이는 Sec-Fetch-Site 헤더를 보는 방식이다.

examples/09-standard-library/07-http-server/web/web.go
// Protected는 CSRF 보호를 씌운 핸들러를 만든다.
//
// CrossOriginProtection은 Sec-Fetch-Site와 Origin 헤더를 보고
// 브라우저에서 온 교차 출처 상태 변경 요청을 거절한다.
// GET·HEAD·OPTIONS는 항상 통과시킨다.
func Protected(h http.Handler, trusted ...string) (http.Handler, error) {
cop := http.NewCrossOriginProtection()
for _, origin := range trusted {
if err := cop.AddTrustedOrigin(origin); err != nil {
return nil, fmt.Errorf("web: 신뢰 출처 %q: %w", origin, err)
}
}
return cop.Handler(h), nil
}

동작을 표로 확인한다.

examples/09-standard-library/07-http-server/web/web_test.go
tests := []struct {
name string
method string
headers map[string]string
wantStatus int
}{
{"헤더 없음(비브라우저)", "POST", nil, 200},
{"동일 출처", "POST", map[string]string{"Sec-Fetch-Site": "same-origin"}, 200},
{"교차 출처 POST", "POST", map[string]string{"Sec-Fetch-Site": "cross-site"}, 403},
{"교차 출처 GET", "GET", map[string]string{"Sec-Fetch-Site": "cross-site"}, 200},
{"신뢰 출처", "POST", map[string]string{
"Sec-Fetch-Site": "cross-site",
"Origin": "https://trusted.example",
}, 200},
}

읽어야 할 지점이 둘이다.

  1. 헤더가 없으면 통과한다. curl이나 서버 간 호출은 브라우저가 아니므로 막지 않는다. CSRF는 브라우저 공격이다.
  2. GET은 교차 출처여도 통과한다. 그래서 GET 핸들러에서 상태를 바꾸면 안 된다. 이 규칙은 CSRF 방어의 전제 조건이다. GET /items/1/delete 같은 설계는 여기서 보호를 못 받는다.

AddInsecureBypassPattern으로 특정 경로를 빼거나 SetDenyHandler로 거절 응답을 바꿀 수 있다. 자세한 CORS 설정은 파트 10의 프레임워크 미들웨어에서 다시 본다.

두 가지 테스트 방식

8-3에서 본 둘을 여기서 나눠 쓴다.

httptest.NewRecorder — 소켓 없이 ServeHTTP를 직접 부른다. 라우팅·검증·응답 형식 검증에 쓴다. 빠르고 결정적이다.

httptest.NewServer — 진짜 루프백 소켓을 연다. 커넥션 재사용, 타임아웃, 리다이렉트 추적처럼 전송 계층이 관여하는 것에 쓴다.

examples/09-standard-library/07-http-server/web/web_test.go
// httptest.NewServer는 진짜 소켓을 쓴다. 라우팅이 아니라
// 커넥션 수준 동작을 확인할 때 필요하다.
func TestAgainstRealServer(t *testing.T) {
h, _ := newHandler(t)
srv := httptest.NewServer(h)
defer srv.Close()

resp, err := srv.Client().Get(srv.URL + "/items/2")
if err != nil {
t.Fatalf("Get() 에러 = %v", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
t.Errorf("Close() 에러 = %v", err)
}
}()

body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("ReadAll() 에러 = %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("상태 = %d (%s)", resp.StatusCode, body)
}
var it web.Item
if err := json.Unmarshal(body, &it); err != nil {
t.Fatalf("Unmarshal() 에러 = %v", err)
}
if it.Name != "가방" {
t.Errorf("Name = %q, want %q", it.Name, "가방")
}
}

srv.Client()가 그 서버에 맞게 설정된 클라이언트를 준다. TLS 서버(httptest.NewTLSServer)라면 인증서까지 신뢰하도록 맞춰져 있다.

라우팅 전체는 테이블로 한 번에 덮는다.

examples/09-standard-library/07-http-server/web/web_test.go
tests := []struct {
name string
method string
target string
wantStatus int
wantBody string
}{
{"항목 조회", "GET", "/items/1", 200, `{"id":1,"name":"구두","price":89000}`},
{"없는 항목", "GET", "/items/99", 404, ""},
{"잘못된 id", "GET", "/items/abc", 400, ""},
// "/" 캐치올 때문에 405가 아니라 404가 된다. 아래 테스트 참고.
{"메서드 불일치", "DELETE", "/items/1", 404, ""},
{"등록된 다른 메서드", "POST", "/items/1", 501, ""},
{"루트", "GET", "/", 200, `{"service":"items"}`},
{"와일드카드 나머지", "GET", "/files/a/b/c.txt", 200, `{"path":"a/b/c.txt"}`},
{"구체적 패턴 우선", "GET", "/files/robots.txt", 200, `{"special":"robots"}`},
{"접두사 패턴", "GET", "/admin/users", 200, `{"path":"/admin/users"}`},
{"없는 경로", "GET", "/nope", 404, ""},
}

8-2의 테이블 주도 테스트가 라우팅 검증에 특히 잘 맞는다. 새 경로를 추가할 때 줄 하나만 늘어난다.

흔한 실수

1. WriteHeader 뒤에 헤더를 설정한다

조용히 무시된다. Content-Type이 안 붙는 흔한 원인이다.

2. WriteHeader를 두 번 부른다

superfluous WriteHeader call 경고가 로그에 남고 두 번째는 무시된다. 에러 응답을 쓴 뒤 return을 빠뜨리면 이렇게 된다.

3. json.NewEncoder(w).Encode(v)로 바로 쓴다

인코딩이 실패하면 반쪽짜리 200이 나간 뒤다. 버퍼에 먼저 만든다.

4. 에러 응답 뒤에 return을 안 한다

if err != nil {
WriteError(w, r, 400, "잘못됨")
// return이 없다 — 아래 코드가 계속 실행된다
}
WriteJSON(w, 200, result)

5. r.WithContext(ctx)의 반환값을 안 쓴다

원본 요청은 바뀌지 않는다. 새 요청을 next.ServeHTTP에 넘겨야 한다.

6. 컨텍스트 키로 문자열을 쓴다

다른 패키지와 충돌한다. 비공개 타입을 쓴다.

7. ResponseWriter를 감싸면서 Unwrap을 안 붙인다

FlushHijack이 필요한 코드(SSE, WebSocket 업그레이드)가 깨진다.

8. http.ListenAndServe를 프로덕션에 쓴다

타임아웃이 전부 무제한이다.

9. GET 핸들러에서 상태를 바꾼다

CSRF 보호가 안전 메서드로 간주해 통과시킨다.

10. / 캐치올을 등록하고 405가 사라진 걸 모른다

위에서 본 그대로다.

정리

  • http.Handler는 메서드 하나짜리 인터페이스이고, http.HandlerFunc가 함수를 그 인터페이스로 바꿔 준다. 프레임워크 없이 조립되는 이유다.
  • **ServeMux 패턴은 [METHOD ][HOST]/[PATH]**이고 {id}, {path...}, {$}를 지원한다. r.PathValue로 읽는다. 더 구체적인 패턴이 이기고, 충돌하면 등록 시점에 패닉한다.
  • GET 패턴은 HEAD도 받고, 메서드를 안 쓴 패턴은 모든 메서드를 받는다.
  • 후행 슬래시 리다이렉트는 307이다 (Go 1.26에서 301에서 바뀌었다). 메서드와 본문이 보존된다.
  • / 캐치올을 등록하면 mux의 405와 Allow 헤더를 잃는다. 아는 상태로 고른다.
  • 응답은 헤더 → WriteHeaderWrite 순서다. 인코딩은 버퍼에 먼저 만들고 성공했을 때만 내보낸다.
  • 미들웨어는 func(http.Handler) http.Handler다. 역순으로 감싸야 읽는 순서와 실행 순서가 맞는다. LoggingRecover보다 바깥에 둬야 500이 접근 로그에 남는다.
  • ResponseWriter를 감쌀 때는 Unwrap을 붙인다.
  • http.Server의 타임아웃 기본값은 전부 0(무제한)이다.
  • http.CrossOriginProtection은 Fetch 메타데이터 기반 CSRF 방어다. 비브라우저 요청과 안전 메서드는 통과시키므로, GET에서 상태를 바꾸면 안 된다.

연습문제

  1. Chain의 루프를 정순(for i := 0; i < len(mws); i++)으로 바꾸고 데모를 실행해 보자. 접근 로그의 어떤 줄이 사라지거나 순서가 바뀌는가? panic 줄과 GET /panic 500 줄의 관계는 어떻게 되는가?

  2. statusRecorder에서 Unwrap 메서드를 지운 뒤, http.NewResponseController(w).Flush()를 부르는 SSE 핸들러를 추가해 보자. 어떤 에러가 나는가? Unwrap을 되살리면 왜 동작하는가?

  3. / 캐치올을 유지하면서도 405와 Allow 헤더를 돌려주려면 어떻게 해야 하는가? mux.Handler(r)가 매칭된 패턴 문자열을 함께 돌려준다는 점을 이용해 보자. 캐치올 핸들러 안에서 "경로는 있는데 메서드가 없다"를 어떻게 판별할 수 있는가?