net/http 서버 설계 - 라우팅·미들웨어·종료
이 장에서 배우는 것
앞 장까지는 주문을 처리하는 코드를 빠르고 가볍게 만드는 데 집중했다. 이 장에서는 그 코드를 네트워크에 노출하는 입구, 곧 HTTP 서버를 설계한다. 표준 라이브러리의 net/http 만으로도 메서드별 라우팅, 미들웨어, 요청 검증, 시간 제한, 정상 종료까지 갖춘 서버를 만들 수 있다. 이 장은 그 조각들을 하나씩 만들어 배달 주문 중계 서비스의 입구에 붙인다.
- Go 1.22 이후의
ServeMux에서 메서드와 경로 변수가 들어간 패턴을 쓰고, 일치 규칙과 405 응답을 설명할 수 있다. - 핸들러 함수와
func(http.Handler) http.Handler형태의 미들웨어를 만들고, 체인의 바깥과 안쪽 순서를 정할 수 있다. - JSON 요청 본문을 크기 제한, 알 수 없는 필드 거부, 값 검증의 세 단계로 걸러 알맞은 상태 코드로 응답할 수 있다.
- 서버 시간 제한 네 가지의 역할을 구분해 설정할 수 있다.
http.Server.Shutdown으로 진행 중인 요청을 끝까지 처리한 뒤 서버를 내릴 수 있다.
문제 상황
주문 중계 서비스의 첫 버전은 http.HandleFunc 로 경로를 등록하고 http.ListenAndServe 한 줄로 띄운 서버였다. 가게 몇 곳이 붙어 있는 동안에는 문제가 드러나지 않았다. 가게가 늘자 다음 일들이 차례로 생겼다.
- 주문 등록은 POST 만 받아야 하는데 핸들러마다
r.Method를 비교하는 코드가 흩어져 있다. 주문 조회 경로는strings.Split으로 쪼개 번호를 꺼내고 있어 경로가 하나 늘 때마다 파싱 코드도 하나 는다. - 로그를 남기는 코드와 패닉을 잡는 코드가 핸들러마다 복사되어 있고, 어떤 핸들러에는 빠져 있다.
- 가게 쪽 단말이 잘못 만든 본문, 필드 이름을 틀린 본문, 수십 MB 짜리 본문이 그대로 디코딩된다.
- 헤더를 보내다 말고 멈춘 연결이 쌓여 고루틴과 파일 디스크립터가 줄지 않는다.
- 배포할 때 프로세스를 그냥 끝내면 접수 중이던 주문이 응답 없이 끊긴다.
이 문제들은 서로 다른 것처럼 보이지만 모두 서버의 가장자리 설계에 속한다. 라우팅은 ServeMux 에, 공통 관심사는 미들웨어에, 입력 방어는 요청 해석 단계에, 연결 관리는 http.Server 설정에, 배포는 Shutdown 에 맡기면 핸들러는 주문 처리라는 본래 일에만 집중한다.
ServeMux 로 라우팅하기
메서드와 경로 변수가 들어간 패턴
Go 1.22 부터 ServeMux 의 패턴 문자열은 "[메서드 ][호스트]/경로" 형식을 쓸 수 있다. 메서드를 앞에 적으면 그 메서드의 요청만 일치한다. 경로 조각을 중괄호로 감싸면 변수가 되고, 핸들러 안에서 r.PathValue("id") 로 꺼낸다. 이전 버전에서는 외부 라우터 패키지를 쓰거나 직접 파싱해야 했던 부분이다.
| 패턴 | 일치하는 요청 | 일치하지 않는 요청 | 설명 |
|---|---|---|---|
POST /orders | POST /orders | GET /orders | 메서드와 경로가 모두 같아야 한다 |
GET /orders/{id} | GET /orders/7, HEAD /orders/7 | GET /orders/7/items | 변수는 경로 조각 하나에 일치하고, GET 패턴은 HEAD 도 받는다 |
GET /files/{path...} | GET /files/a/b/c | GET /file | 끝의 ... 변수는 나머지 경로 전체에 일치한다 |
GET /{$} | GET / | GET /orders | {$} 는 정확히 루트 경로만 가리킨다 |
끝이 슬래시로 끝나는 패턴(/static/)은 그 아래 모든 경로에 일치하는 서브트리 패턴이다. 루트를 정확히 하나만 받으려면 {$} 를 붙인다.
우선순위와 405 응답
여러 패턴이 한 요청에 일치하면 더 구체적인 패턴이 이긴다. 한쪽이 받는 요청 집합이 다른 쪽의 부분 집합이면 그쪽이 더 구체적이다. 둘 중 어느 쪽도 더 구체적이지 않은데 겹치면 등록 시점에 패닉이 나므로, 충돌은 서버가 뜨기 전에 드러난다.
경로는 맞는데 메서드가 맞는 패턴이 없으면 ServeMux 가 직접 405(Method Not Allowed)를 돌려주고 Allow 헤더에 허용 메서드를 채운다. 경로가 아예 없으면 404 다. 핸들러 안에서 메서드를 검사하던 코드는 이제 필요 없다. 또한 http.DefaultServeMux 는 프로세스 전체가 공유하므로, 의존하는 패키지가 몰래 경로를 등록할 수 있다. 서버마다 http.NewServeMux() 로 직접 만든 것을 쓴다.
핸들러와 미들웨어
http.Handler 와 HandlerFunc
핸들러는 ServeHTTP(http.ResponseWriter, *http.Request) 메서드를 가진 값이다. 함수를 핸들러로 쓰고 싶을 때 쓰는 어댑터가 http.HandlerFunc 이며, 기본서에서 본 "함수 타입에 메서드를 붙이는" 방식 그대로다. 핸들러가 해야 할 일은 요청을 읽고, 응답 헤더를 정하고, 상태 코드를 쓰고, 본문을 쓰는 것이다. 순서가 중요하다. 본문을 한 바이트라도 쓰면 상태 코드가 200 으로 확정되므로 WriteHeader 는 그 전에 불러야 한다.
미들웨어 체인
미들웨어는 핸들러를 받아 핸들러를 돌려주는 함수다. next.ServeHTTP 를 부르기 전에는 요청을 가공하고, 부른 뒤에는 응답을 관찰한다. 이 책의 예제에서는 세 가지를 만든다. 요청 번호를 붙이는 것, 처리 결과를 한 줄로 기록하는 것, 패닉을 500 응답으로 바꾸는 것이다.
순서는 의미를 가진다. 바깥에 있는 미들웨어가 안쪽의 결과를 볼 수 있다. 로깅이 복구 미들웨어보다 바깥에 있어야 패닉이 변환된 500 이 로그에 남는다. 반대로 놓으면 로깅은 패닉이 자기 위로 지나가는 것만 보게 된다. 그림 1은 이 예제의 체인이다.
상태 코드를 기록하려면 ResponseWriter 를 감싸야 한다. 표준 인터페이스에는 상태 코드를 읽는 메서드가 없기 때문이다. 예제의 statusRecorder 는 WriteHeader 를 가로채 값을 저장한 뒤 원래 writer 에 넘긴다. Unwrap 메서드를 두면 http.NewResponseController 가 안쪽 writer 의 기능(플러시, 데드라인 설정)에 접근할 수 있다.
패닉 복구에서 한 가지 예외가 있다. 서버는 http.ErrAbortHandler 로 패닉한 핸들러를 일부러 중단한 것으로 보고 로그를 남기지 않는다. 복구 미들웨어가 이 값까지 삼키면 그 의도가 사라지므로 다시 패닉시킨다.
JSON 요청과 응답 검증
외부에서 들어온 본문은 믿지 않는다. 한 번에 검사하려 하지 말고, 실패 원인이 구분되는 단계로 나눠 각 단계마다 알맞은 상태 코드를 돌려준다. 클라이언트는 상태 코드만 보고도 재시도할지, 요청을 고칠지 판단할 수 있다.
| 단계 | 수단 | 실패 상태 | 잡아내는 것 |
|---|---|---|---|
| 크기 제한 | http.MaxBytesReader | 413 | 과도하게 큰 본문 |
| 구문과 구조 | Decoder.Decode, DisallowUnknownFields | 400 | 깨진 JSON, 타입 불일치, 철자가 틀린 필드 |
| 값 하나만 | 두 번째 Decode 가 io.EOF 인지 확인 | 400 | 본문 뒤에 붙은 군더더기 값 |
| 의미 검증 | 요청 타입의 validate 메서드 | 422 | 빈 가게 이름, 빈 품목 목록 |
크기 제한은 디코딩 이전에 reader 를 감싸는 방식이다. 한도를 넘으면 읽기가 *http.MaxBytesError 로 실패하며, errors.As 로 구분해 413 을 돌려준다. 이 오류는 디코더가 그대로 전달하므로 다른 파싱 오류와 섞이지 않는다. 오류 감싸기와 분류는 앞서 오류 설계를 다룬 장에서 본 방식이다.
요청 전용 타입(orderRequest)과 저장 타입(Order)을 나눈 점에도 이유가 있다. 클라이언트가 id 를 보내 와도 서버가 번호를 정하게 하려면, 요청 타입에 그 필드가 없어야 한다. 이때 DisallowUnknownFields 가 켜져 있으면 id 를 보낸 요청은 400 으로 거절된다. 필드 태그와 사용자 정의 변환은 다음 장에서 깊이 다루므로, 여기서는 기본 태그만 쓴다.
응답은 항상 같은 모양을 쓰는 편이 클라이언트에게 쉽다. 예제는 성공 시 객체를, 실패 시 {"error": "..."} 를 돌려주는 writeJSON, writeError 두 함수로 통일했다. 헤더를 먼저 정하고 WriteHeader 를 부른 뒤 본문을 쓰는 순서를 이 함수 안에 가둬 두면 핸들러에서 순서를 틀릴 일이 줄어든다.
시간 제한과 정상 종료
서버 시간 제한 네 가지
http.Server 의 시간 제한은 기본값이 0, 곧 제한 없음이다. 제한이 없는 서버는 느리게 보내거나 보내다 멈춘 연결 하나하나에 고루틴을 붙들어 둔다. 필드마다 재는 구간이 다르다.
| 필드 | 재는 구간 | 초과 시 | 예제 값 |
|---|---|---|---|
ReadHeaderTimeout | 연결 후 요청 헤더를 다 읽기까지 | 연결을 닫는다 | 2초 |
ReadTimeout | 헤더와 본문을 포함한 요청 전체 | 읽기가 실패한다 | 5초 |
WriteTimeout | 요청 헤더를 읽은 뒤 응답을 다 쓰기까지 | 쓰기가 실패한다 | 10초 |
IdleTimeout | keep-alive 연결이 다음 요청을 기다리는 시간 | 연결을 닫는다 | 60초 |
주의할 점이 두 가지 있다. 첫째, WriteTimeout 이 지나도 핸들러 고루틴은 자동으로 멈추지 않는다. 쓰기만 실패할 뿐이다. 핸들러 실행 시간 자체를 제한하려면 http.TimeoutHandler 로 감싸거나 요청 컨텍스트의 취소를 핸들러가 확인해야 하며, 취소 전파는 앞서 context 를 다룬 장의 주제다. 둘째, 서버 전체 값은 가장 느린 경로에 맞춰야 한다. 큰 파일을 올리는 경로만 더 길게 하려면 http.NewResponseController(w).SetReadDeadline 으로 요청별 데드라인을 따로 건다.
Shutdown 으로 정상 종료하기
Server.Shutdown(ctx) 는 서버를 다음 순서로 내린다. 먼저 리스너를 닫아 새 연결을 받지 않는다. 그다음 진행 중인 요청이 끝나기를 기다리고, 유휴 상태가 된 연결을 닫는다. 모두 끝나면 nil 을 돌려준다. 전달한 컨텍스트가 먼저 만료되면 그 오류를 돌려주고 기다림을 멈춘다. 이때 남은 연결을 끊고 싶으면 Close 를 이어서 부른다.
흔한 실수가 Serve 와 ListenAndServe 의 반환 시점을 오해하는 것이다. 이 함수들은 Shutdown 이 호출되는 즉시 http.ErrServerClosed 를 돌려준다. 그 시점은 정리가 끝난 때가 아니라 시작된 때다. 따라서 main 은 Serve 가 아니라 Shutdown 의 반환을 기다려야 한다. 실제 서버의 뼈대는 다음 모양이다.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case err := <-errCh:
// 시작 자체가 실패했다(포트 사용 중 등)
fmt.Println("서버 오류:", err)
return
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
srv.Close()
}
웹소켓처럼 하이재킹된 연결은 Shutdown 이 기다리지 않는다. 그런 연결은 RegisterOnShutdown 에 등록한 함수에서 직접 닫는다. 아래 완성 코드는 이 훅을 다른 용도로 쓴다. 핸들러가 "종료가 시작된 뒤에야" 응답하도록 만들어, 진행 중인 요청이 종료 중에도 끝까지 처리된다는 점을 순서가 고정된 형태로 보여 준다.
완성 코드
먼저 httptest.NewRecorder 로 라우터와 미들웨어, 검증을 확인한다. 그다음 실제 리스너를 임시 포트에 열어 Shutdown 을 시험하고, 끝나면 모두 닫고 종료한다. 포트 번호는 출력하지 않는다.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
const maxBody = 1 << 10
// Order 는 접수된 배달 주문이다.
type Order struct {
ID int `json:"id"`
Store string `json:"store"`
Address string `json:"address"`
Items []string `json:"items"`
}
// orderRequest 는 클라이언트가 보내는 주문 본문이다. id 는 서버가 정한다.
type orderRequest struct {
Store string `json:"store"`
Address string `json:"address"`
Items []string `json:"items"`
}
func (r orderRequest) validate() error {
var problems []string
if strings.TrimSpace(r.Store) == "" {
problems = append(problems, "store 가 비어 있다")
}
if strings.TrimSpace(r.Address) == "" {
problems = append(problems, "address 가 비어 있다")
}
if len(r.Items) == 0 {
problems = append(problems, "items 가 비어 있다")
}
if len(problems) == 0 {
return nil
}
return errors.New(strings.Join(problems, "; "))
}
type orderStore struct {
mu sync.Mutex
nextID int
orders map[int]Order
}
func (s *orderStore) add(r orderRequest) Order {
s.mu.Lock()
defer s.mu.Unlock()
o := Order{ID: s.nextID, Store: r.Store, Address: r.Address, Items: r.Items}
s.orders[o.ID] = o
s.nextID++
return o
}
func (s *orderStore) get(id int) (Order, bool) {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.orders[id]
return o, ok
}
type api struct {
store *orderStore
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
fmt.Println("응답 쓰기 실패:", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func (a *api) createOrder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req orderRequest
if err := dec.Decode(&req); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
writeError(w, http.StatusRequestEntityTooLarge, "본문이 너무 크다")
return
}
writeError(w, http.StatusBadRequest, "요청 본문을 해석할 수 없다")
return
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "본문에는 JSON 값이 하나만 있어야 한다")
return
}
if err := req.validate(); err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
o := a.store.add(req)
w.Header().Set("Location", "/orders/"+strconv.Itoa(o.ID))
writeJSON(w, http.StatusCreated, o)
}
func (a *api) getOrder(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "id 는 정수여야 한다")
return
}
o, ok := a.store.get(id)
if !ok {
writeError(w, http.StatusNotFound, "주문을 찾을 수 없다")
return
}
writeJSON(w, http.StatusOK, o)
}
func routes(a *api) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
mux.HandleFunc("POST /orders", a.createOrder)
mux.HandleFunc("GET /orders/{id}", a.getOrder)
mux.HandleFunc("GET /panic", func(w http.ResponseWriter, r *http.Request) {
panic("의도한 장애")
})
return mux
}
type middleware func(http.Handler) http.Handler
// chain 은 앞에 적은 미들웨어가 가장 바깥에 오도록 감싼다.
func chain(h http.Handler, ms ...middleware) http.Handler {
for i := len(ms) - 1; i >= 0; i-- {
h = ms[i](h)
}
return h
}
func requestID() middleware {
var seq atomic.Int64
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Request-Id", fmt.Sprintf("req-%d", seq.Add(1)))
next.ServeHTTP(w, r)
})
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Unwrap() http.ResponseWriter {
return s.ResponseWriter
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
fmt.Printf(" log: %s %s %d %s\n", r.Method, r.URL.Path, rec.status, w.Header().Get("X-Request-Id"))
})
}
func recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
if v == http.ErrAbortHandler {
panic(v)
}
writeError(w, http.StatusInternalServerError, "내부 오류")
}
}()
next.ServeHTTP(w, r)
})
}
func newServer(h http.Handler) *http.Server {
return &http.Server{
Handler: h,
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
}
}
func call(h http.Handler, label, method, target, body string) {
var rd io.Reader
if body != "" {
rd = strings.NewReader(body)
}
req := httptest.NewRequest(method, target, rd)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Printf("[%s] %d %s\n", label, rec.Code, strings.TrimSpace(rec.Body.String()))
for _, k := range []string{"Location", "Allow"} {
if v := rec.Header().Get(k); v != "" {
fmt.Printf(" %s: %s\n", k, v)
}
}
}
func shutdownDemo() {
started := make(chan struct{})
draining := make(chan struct{})
mux := http.NewServeMux()
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
close(started)
<-draining
fmt.Fprintln(w, "처리 완료")
})
srv := newServer(mux)
srv.RegisterOnShutdown(func() { close(draining) })
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Println("리스너 생성 실패:", err)
return
}
serveDone := make(chan error, 1)
go func() { serveDone <- srv.Serve(ln) }()
url := "http://" + ln.Addr().String() + "/slow"
type result struct {
body string
err error
}
got := make(chan result, 1)
go func() {
resp, err := http.Get(url)
if err != nil {
got <- result{err: err}
return
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
got <- result{body: string(b), err: err}
}()
<-started
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
fmt.Println("Shutdown 반환:", srv.Shutdown(ctx))
res := <-got
fmt.Printf("진행 중이던 요청: %q err=%v\n", res.body, res.err)
fmt.Println("Serve 반환:", <-serveDone)
_, err = http.Get(url)
fmt.Println("종료 후 새 요청 실패:", err != nil)
}
func main() {
a := &api{store: &orderStore{nextID: 1, orders: map[int]Order{}}}
h := chain(routes(a), requestID(), logging, recoverer)
fmt.Println("== 라우팅과 검증 ==")
call(h, "상태 확인", "GET", "/healthz", "")
call(h, "주문 등록", "POST", "/orders",
`{"store":"모퉁이분식","address":"한빛로 12","items":["떡볶이","김밥"]}`)
call(h, "주문 조회", "GET", "/orders/1", "")
call(h, "없는 주문", "GET", "/orders/9", "")
call(h, "잘못된 id", "GET", "/orders/abc", "")
call(h, "검증 실패", "POST", "/orders", `{"store":"","address":"한빛로 12","items":[]}`)
call(h, "알 수 없는 필드", "POST", "/orders", `{"store":"모퉁이분식","addr":"한빛로 12"}`)
big := `{"store":"` + strings.Repeat("가", 600) + `"}`
call(h, "과대 본문", "POST", "/orders", big)
call(h, "허용 안 된 메서드", "DELETE", "/orders/1", "")
call(h, "패닉 복구", "GET", "/panic", "")
fmt.Println("== 서버 설정 ==")
srv := newServer(h)
fmt.Println("ReadHeaderTimeout:", srv.ReadHeaderTimeout)
fmt.Println("ReadTimeout:", srv.ReadTimeout)
fmt.Println("WriteTimeout:", srv.WriteTimeout)
fmt.Println("IdleTimeout:", srv.IdleTimeout)
fmt.Println("MaxHeaderBytes:", srv.MaxHeaderBytes)
fmt.Println("== 정상 종료 ==")
shutdownDemo()
}
줄별 해설
Order와orderRequest: 저장 모양과 입력 모양을 나눴다. 입력 타입에id가 없으므로 클라이언트가 번호를 지정할 수 없다.validate: 문제를 하나 찾고 멈추지 않고 모아서 한 번에 알려 준다. 클라이언트가 한 번에 모두 고칠 수 있다.orderStore: 여러 요청이 동시에 들어오므로 맵 접근을sync.Mutex로 보호한다. 동기화 도구는 앞서 경쟁 상태를 다룬 장에서 본 내용이다.writeJSON: 헤더, 상태, 본문 순서를 한 곳에 고정했다.writeError는 이를 이용해 오류 모양을 통일한다.createOrder앞부분:MaxBytesReader로 1KiB 를 넘는 본문을 막고,DisallowUnknownFields로 철자가 틀린 필드를 400 으로 돌린다. 크기 초과는errors.As로 구분해 413 이다.- 두 번째
Decode: 첫 값 뒤에 아무것도 없으면io.EOF가 나온다. 다른 결과면 본문에 군더더기가 있는 것이다. 각 오류 분기 끝의return을 빠뜨리지 않는 것이 중요하다. routes: 메서드가 들어간 패턴 네 개를 등록한다.r.PathValue("id")로 변수를 꺼낸다.chain: 슬라이스를 뒤에서부터 감싸므로 첫 번째 인자가 가장 바깥이 된다.requestID는 클로저에 카운터를 가둬 서버마다 독립된 번호를 만든다.statusRecorder:WriteHeader를 가로채 상태 코드를 기억한다.WriteHeader를 부르지 않고 쓰면 200 이므로 초깃값을 200 으로 둔다.logging:next가 끝난 뒤에 한 줄을 출력한다. 시간은 출력하지 않아 결과가 결정적이다.recoverer: 패닉을 잡아 500 으로 바꾼다.ErrAbortHandler는 다시 던진다.newServer: 네 가지 시간 제한과 헤더 크기 제한을 한 곳에서 정한다.call:httptest.NewRequest와NewRecorder로 네트워크 없이 핸들러를 직접 호출하고, 상태·본문·일부 헤더를 출력한다.shutdownDemo: 핸들러는started를 닫아 요청이 도착했음을 알리고draining이 닫히기를 기다린다.RegisterOnShutdown에 등록한 함수가Shutdown시작 후에draining을 닫으므로, 응답은 항상 종료가 시작된 뒤에 나간다.Shutdown이nil을 반환한다는 것은 그 요청이 끝까지 처리되었다는 뜻이다.- 마지막
http.Get: 리스너가 닫혔으므로 연결이 거절된다. 오류 문구는 환경마다 다를 수 있어 실패 여부만 출력한다.
실행 결과
$ go run main.go
== 라우팅과 검증 ==
log: GET /healthz 200 req-1
[상태 확인] 200 ok
log: POST /orders 201 req-2
[주문 등록] 201 {"id":1,"store":"모퉁이분식","address":"한빛로 12","items":["떡볶이","김밥"]}
Location: /orders/1
log: GET /orders/1 200 req-3
[주문 조회] 200 {"id":1,"store":"모퉁이분식","address":"한빛로 12","items":["떡볶이","김밥"]}
log: GET /orders/9 404 req-4
[없는 주문] 404 {"error":"주문을 찾을 수 없다"}
log: GET /orders/abc 400 req-5
[잘못된 id] 400 {"error":"id 는 정수여야 한다"}
log: POST /orders 422 req-6
[검증 실패] 422 {"error":"store 가 비어 있다; items 가 비어 있다"}
log: POST /orders 400 req-7
[알 수 없는 필드] 400 {"error":"요청 본문을 해석할 수 없다"}
log: POST /orders 413 req-8
[과대 본문] 413 {"error":"본문이 너무 크다"}
log: DELETE /orders/1 405 req-9
[허용 안 된 메서드] 405 Method Not Allowed
Allow: GET, HEAD
log: GET /panic 500 req-10
[패닉 복구] 500 {"error":"내부 오류"}
== 서버 설정 ==
ReadHeaderTimeout: 2s
ReadTimeout: 5s
WriteTimeout: 10s
IdleTimeout: 1m0s
MaxHeaderBytes: 65536
== 정상 종료 ==
Shutdown 반환: <nil>
진행 중이던 요청: "처리 완료\n" err=<nil>
Serve 반환: http: Server closed
종료 후 새 요청 실패: true
실무에서 자주 틀리는 것
시간 제한 없는 ListenAndServe
편의 함수는 시간 제한이 모두 0 인 서버를 만든다. 헤더를 보내다 멈춘 연결이 고루틴을 영원히 붙들 수 있다.
// 틀린 코드
err := http.ListenAndServe(":8080", mux)
// 고친 코드
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
IdleTimeout: 60 * time.Second,
}
err := srv.ListenAndServe()
오류 응답 뒤에 return 을 빼먹기
writeError 는 응답을 쓸 뿐 함수를 끝내 주지 않는다. 그대로 진행하면 응답이 두 번 쓰이고, 검증을 통과하지 못한 주문이 저장된다.
// 틀린 코드
if err := req.validate(); err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
}
o := a.store.add(req)
// 고친 코드
if err := req.validate(); err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
o := a.store.add(req)
제한 없이 디코딩하기
본문 크기를 제한하지 않고, 알 수 없는 필드도 조용히 무시하면 큰 요청이 메모리를 채우고 오타가 데이터 손실로 이어진다.
// 틀린 코드
var req orderRequest
json.NewDecoder(r.Body).Decode(&req)
// 고친 코드
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req orderRequest
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "요청 본문을 해석할 수 없다")
return
}
ErrServerClosed 를 실패로 보고 바로 끝내기
Shutdown 을 부르면 ListenAndServe 는 곧바로 ErrServerClosed 를 돌려준다. 그 값을 오류로 처리해 프로세스를 끝내면 진행 중인 요청을 기다리지 못한다.
// 틀린 코드
go srv.Shutdown(ctx)
log.Fatal(srv.ListenAndServe()) // 즉시 반환되어 프로세스가 끝난다
// 고친 코드
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
fmt.Println("서버 오류:", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := srv.Shutdown(shutdownCtx) // 이 반환까지 기다린다
한눈에 보기
| 주제 | 도구 | 핵심 규칙 | 실패 시 증상 |
|---|---|---|---|
| 라우팅 | ServeMux, PathValue | 메서드와 경로를 패턴에 적는다 | 405 또는 404 |
| 공통 처리 | 미들웨어 체인 | 바깥이 안쪽의 결과를 본다 | 로그에 500 이 안 남는다 |
| 입력 방어 | MaxBytesReader, DisallowUnknownFields | 크기, 구조, 값을 단계별로 거른다 | 413, 400, 422 |
| 연결 관리 | 네 가지 시간 제한 | 0 으로 두지 않는다 | 고루틴과 연결이 쌓인다 |
| 종료 | Shutdown, Close | Shutdown 의 반환까지 기다린다 | 진행 중이던 요청이 끊긴다 |
| 상황 | 코드 | 근거 | 예제 입력 |
|---|---|---|---|
| 등록 성공 | 201 | 새 리소스가 생겼고 Location 을 준다 | 정상 주문 |
| 깨진 본문, 알 수 없는 필드 | 400 | 요청을 해석할 수 없다 | addr 필드 |
| 의미 검증 실패 | 422 | 해석은 되나 값이 규칙에 어긋난다 | 빈 store |
| 본문 과대 | 413 | 서버가 정한 한도를 넘었다 | 1KiB 초과 |
연습 문제
- POST 요청의
Content-Type이application/json이 아니면 415 를 돌려주는 미들웨어requireJSON을middleware타입으로 작성하라. GET 요청은 통과시킨다. - 패턴
GET /orders/{id}하나만 등록되어 있을 때HEAD /orders/3과PUT /orders/3의 결과를 설명하라. - 어떤 클라이언트가 헤더를 1초에 한 바이트씩 보내 연결을 오래 붙들고 있다. 어떤 제한이 가장 직접적인 방어이며,
IdleTimeout으로는 왜 막히지 않는가. Shutdown에 준 컨텍스트가 만료되면 어떤 값을 돌려주는가. 그때 서버를 어떻게 마무리해야 하는가.
정답과 해설
1. 요청 메서드가 POST 일 때만 헤더를 검사하고, 맞지 않으면 체인을 끊는다.
func requireJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "application/json 만 받는다")
return
}
}
next.ServeHTTP(w, r)
})
}
접두어로 비교하는 이유는 application/json; charset=utf-8 처럼 매개변수가 붙을 수 있어서다. 위치는 로깅 안쪽, 라우터 바깥이 알맞다. 그러면 거절된 요청도 로그에 남는다.
2. GET 패턴은 HEAD 요청도 받으므로 HEAD /orders/3 은 핸들러에 도달한다. 응답 본문은 HEAD 규칙에 따라 전송되지 않는다. PUT /orders/3 은 경로는 맞고 메서드가 맞는 패턴이 없으므로 405 이고, Allow 헤더에 GET, HEAD 가 담긴다.
3. ReadHeaderTimeout 이다. 헤더를 다 읽는 데 걸리는 시간에 상한을 두기 때문에 천천히 보내는 연결을 끊는다. IdleTimeout 은 요청 사이의 대기 시간만 재므로, 헤더를 읽는 도중인 연결에는 적용되지 않는다.
4. context.DeadlineExceeded 를 돌려준다. 이는 아직 끝나지 않은 요청이 남았다는 뜻이다. 더 기다리지 않기로 했다면 srv.Close() 를 불러 남은 연결을 닫는다. 그 요청들은 응답을 받지 못하므로, 종료 제한 시간은 가장 긴 정상 요청보다 길게 잡는다.