Quick Flow
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
handler := logging(mux)
srv := &http.Server{Addr: ":8080", Handler: handler}
log.Fatal(srv.ListenAndServe())Handler가 요청 하나를 처리하고, ServeMux가 연결하며, middleware가 바깥에서 공통 처리를 감쌉니다. handler는 header와 status를 먼저 정한 뒤 body를 쓰고, 취소 가능한 하위 작업에는 r.Context()를 넘깁니다.
Handler와 응답
handler는 요청 하나를 처리하는 단위다
func health(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}http.HandlerFunc는 이 함수 형태를 http.Handler로 바꾸는 어댑터입니다. 직접 구현할 때의 인터페이스는 아래와 같습니다.
type Handler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}핵심 시그니처는 항상 같습니다.
func(w http.ResponseWriter, r *http.Request)http.ResponseWriter: status, header, body를 작성합니다.*http.Request: method, URL, header, body, path parameter, context를 읽습니다.
header와 status는 body보다 먼저 확정한다
func health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}Header().Set과 WriteHeader는 첫 Write보다 앞에 둡니다. Write를 먼저 호출하면 서버는 기본 status 200 OK와 그 시점의 header를 보낼 수 있어, 뒤늦은 http.Error나 WriteHeader로 응답을 되돌릴 수 없습니다. 오류를 썼다면 즉시 return해 한 요청에서 성공 응답을 다시 쓰지 않습니다.
ResponseWriter는 ServeHTTP가 끝난 뒤 사용할 수 없습니다. handler에서 시작한 goroutine이 나중에 w.Write를 호출하는 구조는 만들지 말고, 필요한 작업을 handler가 기다리거나 별도 작업 큐로 넘깁니다.
request body와 context의 책임을 구분한다
서버가 받은 r.Body는 net/http 서버가 닫으므로 일반 handler가 defer r.Body.Close()를 둘 필요가 없습니다. 반면 handler가 스스로 연 파일, outbound HTTP 응답 body, database row처럼 얻은 자원은 그 코드가 닫을 책임이 있습니다.
r.Context()는 클라이언트 연결 종료나 서버 취소를 하위 작업에 전달하는 신호입니다. DB와 outbound HTTP 요청에 넘기되, context 취소만으로 이미 보낸 HTTP 응답을 되돌릴 수 있다고 기대해서는 안 됩니다.
라우팅과 middleware
ServeMux는 요청을 handler로 연결한다
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)ServeMux는 요청 path와 method를 handler로 연결합니다. 작은 서비스나 내부 도구는 표준 ServeMux만으로도 충분한 경우가 많습니다.
"GET /users/{id}"처럼 method와 path를 함께 쓰는 패턴은 Go 1.22 이후의 ServeMux 표면입니다. 더 오래된 Go 버전을 기준으로 유지하는 프로젝트라면 path만 등록하고 method 검사를 handler 안에서 처리해야 합니다.
path parameter는 같은 요청에서 r.PathValue("id")로 읽습니다. 값이 없으면 빈 문자열이므로, 라우팅 패턴 밖에서 이 값을 사용하거나 빈 값이 유효하지 않은 API라면 별도 검증이 필요합니다.
middleware는 handler를 받아 handler를 돌려준다
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}인증, request ID, 로깅, panic 복구처럼 모든 route에 적용할 동작은 middleware에 둡니다. logging(mux)는 mux 호출 전후를 감싸므로, 바깥에 둘수록 더 넓은 범위를 봅니다.
handler := logging(authenticate(mux))위 조합에서 logging이 가장 바깥이고, 그 안에서 authenticate, route handler 순으로 실행됩니다. panic 복구 middleware도 둘 때는 logging보다 바깥에 두어 복구한 실패까지 남길지, 안쪽에 두어 복구 동작을 분리할지 정책으로 정합니다. status까지 로깅하려면 ResponseWriter를 감싸 첫 status를 기록해야 합니다. 단, wrapper도 optional interface와 ResponseWriter의 수명 규칙을 망가뜨리지 않게 설계해야 합니다.
서버 경계는 http.Server에 둔다
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}실서비스는 http.ListenAndServe 한 줄보다 http.Server를 직접 만들고 timeout과 shutdown 경계를 정하는 편이 안전합니다. timeout 값과 body 제한은 HTTP server timeout과 body limit에서 endpoint 성격에 맞춰 결정합니다.
주의할 점
Go HTTP 서버에서 흔한 실수는 한 요청에 응답을 두 번 쓰는 것, server request body를 불필요하게 닫는 것, handler 반환 뒤 ResponseWriter를 쓰는 것입니다. 공통 middleware는 순서 자체가 정책이므로 인증 실패, panic 복구, 로그에 무엇이 남아야 하는지부터 정한 뒤 감싸야 합니다.
참고 링크
2 sources