Devin.KR

net/http 서버 설계 - 라우팅·미들웨어·종료

개발자KR 조회 0

이 장에서 배우는 것

앞 장까지는 주문을 처리하는 코드를 빠르고 가볍게 만드는 데 집중했다. 이 장에서는 그 코드를 네트워크에 노출하는 입구, 곧 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 /ordersPOST /ordersGET /orders메서드와 경로가 모두 같아야 한다
GET /orders/{id}GET /orders/7, HEAD /orders/7GET /orders/7/items변수는 경로 조각 하나에 일치하고, GET 패턴은 HEAD 도 받는다
GET /files/{path...}GET /files/a/b/cGET /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.MaxBytesReader413과도하게 큰 본문
구문과 구조Decoder.Decode, DisallowUnknownFields400깨진 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, 곧 제한 없음이다. 제한이 없는 서버는 느리게 보내거나 보내다 멈춘 연결 하나하나에 고루틴을 붙들어 둔다. 필드마다 재는 구간이 다르다.

http.Server 의 시간 제한 필드
필드재는 구간초과 시예제 값
ReadHeaderTimeout연결 후 요청 헤더를 다 읽기까지연결을 닫는다2초
ReadTimeout헤더와 본문을 포함한 요청 전체읽기가 실패한다5초
WriteTimeout요청 헤더를 읽은 뒤 응답을 다 쓰기까지쓰기가 실패한다10초
IdleTimeoutkeep-alive 연결이 다음 요청을 기다리는 시간연결을 닫는다60초

주의할 점이 두 가지 있다. 첫째, WriteTimeout 이 지나도 핸들러 고루틴은 자동으로 멈추지 않는다. 쓰기만 실패할 뿐이다. 핸들러 실행 시간 자체를 제한하려면 http.TimeoutHandler 로 감싸거나 요청 컨텍스트의 취소를 핸들러가 확인해야 하며, 취소 전파는 앞서 context 를 다룬 장의 주제다. 둘째, 서버 전체 값은 가장 느린 경로에 맞춰야 한다. 큰 파일을 올리는 경로만 더 길게 하려면 http.NewResponseController(w).SetReadDeadline 으로 요청별 데드라인을 따로 건다.

Shutdown 으로 정상 종료하기

Server.Shutdown(ctx) 는 서버를 다음 순서로 내린다. 먼저 리스너를 닫아 새 연결을 받지 않는다. 그다음 진행 중인 요청이 끝나기를 기다리고, 유휴 상태가 된 연결을 닫는다. 모두 끝나면 nil 을 돌려준다. 전달한 컨텍스트가 먼저 만료되면 그 오류를 돌려주고 기다림을 멈춘다. 이때 남은 연결을 끊고 싶으면 Close 를 이어서 부른다.

Shutdown 은 리스너를 닫고 진행 중인 요청을 기다린 뒤 유휴 연결을 닫고 반환한다.

흔한 실수가 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, CloseShutdown 의 반환까지 기다린다진행 중이던 요청이 끊긴다
상황별 상태 코드 선택
상황코드근거예제 입력
등록 성공201새 리소스가 생겼고 Location 을 준다정상 주문
깨진 본문, 알 수 없는 필드400요청을 해석할 수 없다addr 필드
의미 검증 실패422해석은 되나 값이 규칙에 어긋난다빈 store
본문 과대413서버가 정한 한도를 넘었다1KiB 초과

연습 문제

  1. POST 요청의 Content-Type 이 application/json 이 아니면 415 를 돌려주는 미들웨어 requireJSON 을 middleware 타입으로 작성하라. GET 요청은 통과시킨다.
  2. 패턴 GET /orders/{id} 하나만 등록되어 있을 때 HEAD /orders/3 과 PUT /orders/3 의 결과를 설명하라.
  3. 어떤 클라이언트가 헤더를 1초에 한 바이트씩 보내 연결을 오래 붙들고 있다. 어떤 제한이 가장 직접적인 방어이며, IdleTimeout 으로는 왜 막히지 않는가.
  4. 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() 를 불러 남은 연결을 닫는다. 그 요청들은 응답을 받지 못하므로, 종료 제한 시간은 가장 긴 정상 요청보다 길게 잡는다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.