1. handler의 복붙은 왜 버그의 “조용한” 원천일까요
HTTP 서버를 막 만들기 시작했을 때는 handler가 별일 없어 보입니다. 몇 줄, 몇 개의 검사, w.WriteHeader(...), 끝. 하지만 두 번째 handler가 생기고, 그다음 세 번째가 생기고, 그다음 “검증을 조금 더”가 추가되고, 또 “모든 곳에서 JSON을 쓰자”가 붙는 순간, 프로젝트의 절반이 반복 블록이라는 사실을 깨닫게 됩니다. Content-Type 설정, 상태 코드 선택, JSON 오류 생성, return을 잊지 않기, err.Error()를 500으로 밖에 노출하지 않기 등입니다.
문제는 미학만이 아닙니다(물론 미학도 손상되고 IDE도 울기 시작하죠). 문제는 복붙이 거의 항상 어긋난다는 점입니다. 어떤 handler에서는 charset을 빼먹고, 다른 곳에서는 400 대신 500을 반환하고, 또 다른 곳에서는 오류 형식을 “조금 다르게” 만들고, 결국 API 클라이언트는 예상치 못한 동작의 세계에서 살게 됩니다.
Go는 전통적으로 이런 문제를 단순하고 정직한 방식으로 해결하는 것을 좋아합니다. 오류는 값이다. 즉, 오류는 문자열로 찍기만 하는 것이 아니라 프로그래밍의 대상으로 다룰 수 있습니다. 이 아이디어는 고전적인 글 “Errors are values”에서 잘 정리되어 있습니다. 그리고 오류가 값이라면, 반복되는 오류 처리를 한 곳으로 모으고, 여기저기서 되풀이하지 않을 수 있습니다.
핵심 아이디어: handler는 결과와 오류를 반환한다
중복을 없애려면 책임을 분리해야 합니다. handler는 의미를 담당해야 합니다. “입력을 읽고 → 검증하고 → 계산하고 → 결과를 반환한다”. 반면 HTTP 래퍼는 의식을 담당합니다. “오류면 JSON으로 바꾸고, 상태 코드를 넣고, 헤더를 설정한다. 성공이면 결과 JSON을 내려준다”.
이건 새로운 마법도 아니고, 유행하는 프레임워크의 패턴도 아닙니다. Go에서는 자신의 함수 타입을 만들고, 그 타입에 ServeHTTP 메서드를 붙여서 net/http에 끼워 넣으면 됩니다. 오래되었지만 매우 시사적인 Go 오류 처리 글에서는 appHandler 같은 타입이 개별 handler의 반복적인 오류 처리를 하나의 공통 ServeHTTP로 옮기는 방식을 보여줍니다.
우리는 비슷한 구조를 만들되, “그냥 텍스트로 http.Error를 호출”하는 대신 공통 JSON error envelope를 반환하고, ValidationError.Fields 계약을 따르겠습니다.
2. 계약의 구성 요소: ValidationError, HTTPError, error envelope
어댑터를 만들기 전에 “데이터의 형태”에 대해 합의해야 합니다. 그렇지 않으면 공통 계층이 공통적일 수 없습니다. 표준화할 대상이 없기 때문입니다.
먼저 검증 오류입니다. 이는 특정 필드별 오류를 저장하고, Error()는 단순한 “validation error” 마커만 반환합니다. 이 방식은 편리합니다. 사람에게는 API 수준의 정상적인 메시지를 보여주고, 기계(UI 클라이언트)에게는 fields를 넘겨줄 수 있기 때문입니다.
package main
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string {
return "validation error"
}
다음은 error envelope, 즉 오류 응답의 통일된 형태입니다. code(기계용), message(짧고 안전한 메시지), 그리고 선택적인 fields를 보관합니다.
package main
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type ErrorEnvelope struct {
Error APIError `json:"error"`
}
마지막으로 “HTTP 계층 오류”입니다. 이는 공개용과 내부용을 분리합니다. 내부에는 실제 원인(Err)을 저장해 로그/진단에 쓰고, 외부에는 통제된 Message만 제공합니다.
package main
import "net/http"
type HTTPError struct {
Status int
Code string
Message string
Err error
}
func internal(err error) *HTTPError {
return &HTTPError{
Status: http.StatusInternalServerError,
Code: "internal",
Message: "internal error",
Err: err,
}
}
여기서 중요한 것은 철학입니다. 사용자에게 보여주는 메시지와 내부 원인은 다른 것입니다. 모든 것을 err.Error()로 뭉치면, 불필요한 정보를 노출하거나 유용한 진단 정보를 숨길 수밖에 없습니다.
4. 인코딩의 중심점: writeJSON
어떤 진지한 API든 언젠가는 하나의 단순한 규칙에 부딪힙니다. “응답은 항상 같은 모습이어야 한다.” 그리고 이건 오류뿐 아니라 성공 응답에도 해당합니다.
각 handler가 직접 json.NewEncoder(w).Encode(...)를 호출하면, 차이가 생기기 마련입니다. 어디선가 Content-Type을 빼먹고, 어디선가 본문보다 먼저 상태 코드를 보내는 것을 잊고, 또 다른 곳에서는 응답을 쓰기 시작했다가 나중에 “아, 오류네”라고 깨닫게 됩니다.
우리는 성공 응답을 위한 JSON 직렬화를 담당하는 유일한 장소가 될 작은 writeJSON을 만들겠습니다.
package main
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
네, 지금은 Encode 오류를 무시합니다. “오류가 없어서”가 아니라, 인코더가 엉뚱한 곳에서 실패했다면 이미 클라이언트는 “아름다운 JSON”으로는 도움을 받을 수 없기 때문입니다. 헤더는 이미 전송되었을 수 있고, 본문의 일부는 이미 나갔을 수 있습니다. 실제 서비스에서는 이를 로깅합니다. 지금은 “모든 것을 한 번에” 하려 하지 않도록 로깅을 의도적으로 깊게 다루지 않습니다.
5. 오류의 중심점: writeError와 errors.As
이제 두 번째 중심점을 만듭니다. 바로 오류용입니다. 여기서 우리의 ValidationError.Fields가 error envelope 안의 fields로 변환되어야 합니다.
Err 안에 있는 타입화된 오류를 안전하게 꺼내려면 errors.As를 사용합니다. 이는 Go에서 “오류 체인에서 원하는 타입의 값을 찾아 변수에 넣기” 위한 표준적인 방법이며, 일반적인 error unwrapping 접근과 함께 등장했습니다.
package main
import (
"encoding/json"
"errors"
"net/http"
)
func writeError(w http.ResponseWriter, e *HTTPError) {
var ve *ValidationError
fields := map[string]string(nil)
if e.Err != nil && errors.As(e.Err, &ve) {
fields = ve.Fields
}
env := ErrorEnvelope{
Error: APIError{
Code: e.Code,
Message: e.Message,
Fields: fields,
},
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(e.Status)
_ = json.NewEncoder(w).Encode(env)
}
여기서의 세심함에 주목하세요. fields는 검증 오류일 때만 등장합니다. not found/internal 같은 오류에서는 fields가 omitempty 덕분에 사라지고, 이것이 안정적인 계약의 일부입니다.
6. appHandler 어댑터: 모든 handler의 단일 진입점
이제 오늘의 메인 요리를 조립해 봅시다. 우리에게 편한 함수를 http.Handler로 바꿔 주는 어댑터입니다.
handler가 성공 시 Response를, 실패 시 *HTTPError를 반환한다고 약속하겠습니다. 그러면 단일 경로가 생깁니다. ServeHTTP는 writeError를 호출하거나, 아니면 writeJSON을 호출합니다.
package main
import "net/http"
type Response struct {
Status int
Body any
}
type appHandler func(r *http.Request) (Response, *HTTPError)
func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resp, err := h(r)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, resp.Status, resp.Body)
}
여기서 작은 “Go 마법”이 일어납니다(사실은 정직한 메커니즘입니다). 함수 타입에 메서드가 있으면, 그 타입은 인터페이스 http.Handler를 구현합니다. 따라서 mux에 등록할 수 있습니다. 이 “함수 타입 + ServeHTTP” 스타일은 handler들에서 반복되는 오류 처리를 한 곳으로 모으는 데 자주 쓰입니다.
“정말 이렇게도 되나?”라고 생각했다면, 네, 됩니다. Go는 이런 부분에서 꽤 친절합니다. 클래스도, 상속도 요구하지 않고, 그냥 타입을 만들고 메서드를 붙이면 됩니다.
간단한 흐름도는 다음과 같습니다:
flowchart LR
A[ServeMux가 라우트를 찾음] --> B[appHandler.ServeHTTP]
B --> C[우리 handler가 Response 또는 *HTTPError를 반환함]
C -->|성공| D[writeJSON]
C -->|오류| E[writeError]
7. 미니 애플리케이션: 깔끔한 handler를 가진 작업 API
이제 스타일을 보여 줄 작은 서버를 조립해 봅시다. 저장소를 만들거나 진짜 CRUD를 구현하지는 않습니다(그건 별도의 주제입니다). 대신 복붙이 없는 코드가 어떻게 보이는지 보기 위한 최소한의 핸들러만 만듭니다.
먼저 모델과 “더미 데이터”를 정의합니다:
package main
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
var tasks = []Task{
{ID: 1, Title: "Learn Go", Done: false},
{ID: 2, Title: "Drink tea", Done: true},
}
이제 검색을 만들어 봅시다(단순한 선형 탐색입니다. 지금은 알고리즘 이야기가 아닙니다):
package main
func findTaskByID(id int) (Task, bool) {
for _, t := range tasks {
if t.ID == id {
return t, true
}
}
return Task{}, false
}
parseID: 검사를 여기저기 흩뿌리지 않는 단일 계약
우리는 이미 정해 두었던 parseID 계약을 사용합니다. 문자열 → int, id > 0, 오류는 ValidationError.Fields를 통해 전달됩니다.
package main
import "strconv"
func parseID(s string) (int, error) {
if s == "" {
return 0, &ValidationError{Fields: map[string]string{"id": "is required"}}
}
id, err := strconv.Atoi(s)
if err != nil {
return 0, &ValidationError{Fields: map[string]string{"id": "must be integer"}}
}
if id <= 0 {
return 0, &ValidationError{Fields: map[string]string{"id": "must be positive"}}
}
return id, nil
}
handler GET /tasks/{id}: 수동으로 “오류 응답”을 쓰지 않는 선형 코드
이제 실제 handler입니다. 이 handler는 오류 JSON 응답이 어떻게 생겼는지 알지 못합니다. 필요한 필드를 가진 *HTTPError만 반환합니다.
package main
import (
"fmt"
"net/http"
)
func handleGetTask(r *http.Request) (Response, *HTTPError) {
id, err := parseID(r.PathValue("id"))
if err != nil {
return Response{}, &HTTPError{Status: 400, Code: "validation", Message: "invalid request", Err: err}
}
t, ok := findTaskByID(id)
if !ok {
return Response{}, &HTTPError{Status: 404, Code: "not_found", Message: "task not found", Err: fmt.Errorf("task id=%d", id)}
}
return Response{Status: http.StatusOK, Body: t}, nil
}
여기서 좋은 점이 보입니다. handler가 일반 함수처럼 읽힌다는 것입니다. 오류가 로직을 “흩뜨리지” 않습니다. 마치 “먼저 검사하고, 그다음 수행한다”라고 적는 것 같습니다.
이것이 바로 Go답습니다. 오류를 숨기려는 것이 아니라, 동일한 반응을 중복하지 않으려는 것입니다. 이 생각은 “오류는 값이고, 그것으로 프로그래밍할 수 있다”는 아이디어를 그대로 이어받습니다.
handler GET /tasks: 목록만 간단히 반환
package main
import "net/http"
func handleListTasks(r *http.Request) (Response, *HTTPError) {
return Response{Status: http.StatusOK, Body: tasks}, nil
}
끝입니다. Content-Type도 없고, json.NewEncoder도 없습니다. 이 모든 것은 공통 계층이 처리합니다.
main: method-aware patterns로 경로 등록하기
마지막으로 http.ServeMux에 연결합니다. 여기서 중요한 점은 mux.HandleFunc는 “반환값이 없는 함수”를 받고, mux.Handle은 http.Handler를 받는다는 것입니다. 따라서 mux.Handle(..., appHandler(...))로 등록합니다.
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.Handle("GET /tasks", appHandler(handleListTasks))
mux.Handle("GET /tasks/{id}", appHandler(handleGetTask))
log.Fatal(http.ListenAndServe(":8080", mux))
}
이제 클라이언트가 /tasks/abc를 요청하면, 400과 다음과 같은 JSON을 받게 됩니다:
{
"error": {
"code": "validation",
"message": "invalid request",
"fields": { "id": "must be integer" }
}
}
그리고 /tasks/2를 요청하면 200과 작업 JSON을 받습니다.
decode는 어디에 있고, 왜 레벨을 섞지 않는 것이 중요할까요
HTTP 맥락에서 “decode”라는 단어는 초보자를 종종 불안하게 만듭니다. “이제 JSON을 디코딩하고, DTO를 검증하고, 도메인에 매핑하고, 미들웨어까지 작성해야 하나?”처럼 느껴지기 때문입니다. 사실 decode는 단순히 “요청에서 입력을 꺼내어 필요한 타입으로 바꾸는 것”입니다.
오늘의 최소 예제에서 decode는 두 가지입니다. r.PathValue("id")(경로에서 문자열 가져오기)와 parseID(문자열을 int로 바꾸고 검증하기)입니다. 중요한 점은 parseID가 HTTP 응답을 쓰지 않는다는 것입니다. 그는 error를 반환하고, 이후 handler가 그 오류가 어떤 종류인지 결정합니다(우리 예제에서는 validation). 이렇게 경계를 유지할 수 있습니다. parseID는 유틸리티, handler는 HTTP 로직, 어댑터는 응답 포맷팅입니다.
또 하나의 미묘하지만 유용한 원칙이 있습니다. handler가 성공 응답 쓰기를 시작했다면 (예: w.WriteHeader(200)), 그 뒤에 “아, 오류였네”를 깨달아도 이미 늦습니다. 그래서 “handler가 Response/HTTPError를 반환하고, 쓰기는 항상 어댑터가 한다”는 스타일이 코드를 매우 규율 있게 만듭니다. 성공이든 오류든 결정은 중앙에서 이루어집니다.
8. 공통 handler 계층을 만들 때 흔히 하는 실수
실수 1: 일부 handler는 직접 응답을 쓰고, 일부는 어댑터를 통한다.
이건 무해해 보입니다(“여기서는 내가 그냥 빨리 Encode 해버리면 되겠지”). 하지만 금세 응답 형식이 뒤섞입니다. 어느 순간 API 클라이언트는 서로 다른 두 가지 오류 형식을 보게 되고, 여러분은 반나절 동안 “도대체 어느 handler가 writeError를 사용하지 않는 거지?”를 찾게 됩니다. 해결은 간단합니다. 한 가지 경로를 고르고, 적어도 하나의 HTTP 계층 안에서는 그 경로를 지키면 됩니다.
실수 2: 500 오류에서 err.Error()를 외부로 그대로 내보내기.
유혹은 이해됩니다. “클라이언트가 무엇이 깨졌는지 직접 보게 하자.” 하지만 이건 거의 항상 나쁜 생각입니다. 내부 세부사항 노출, 불안정한 메시지, 그리고 최악의 경우 공격자에게 쓸모 있는 정보가 전달됩니다. 올바른 방법은 5xx에 대해 고정된 안전한 메시지를 사용하고, 내부 오류는 내부에 남겨 두는 것입니다 (HTTPError.Err). Go에서는 오류든 HTTP 응답이든, 무엇을 외부로 “내보낼지”를 의식적으로 결정하는 것이 매우 중요합니다.
실수 3: 모든 검증을 문자열로 Error()에 억지로 넣으려 하기.
ValidationError.Error()를 "id must be integer" 같은 문자열로 만들면, UI 클라이언트가 문자열을 파싱해야 합니다(문자열 파싱은 언제나 작은 부조리극입니다). 훨씬 더 신뢰할 수 있는 방법은 Fields map[string]string처럼 구조적으로 세부 정보를 보관하고, Error() 문자열은 마커로 남겨 두는 것입니다. 그러면 어댑터는 errors.As를 통해 쉽게 데이터를 꺼낼 수 있고, 이것이 바로 표준적인 타입화 오류 메커니즘의 의도입니다.
실수 4: 헤더와 상태 코드를 여러 곳에서 쓰기.
코드의 일부가 Content-Type을 설정하고, 다른 부분이 WriteHeader를 호출하면, “header already written” 상황이나 일관성 없는 응답이 생기기 쉽습니다. 코드에는 직렬화를 위한 분명한 “유일한 진입점”이 있어야 합니다. 이 예제에서는 그것이 writeJSON과 writeError이며, 이 둘은 오직 ServeHTTP에서만 호출됩니다.
실수 5: 어댑터가 너무 똑똑해지려는 것.
위험한 순간이 있습니다. 성공 응답을 보게 되면 “자동 로깅”, “자동 메트릭”, “자동 recover”, “자동 body 읽기”를 추가하고 싶어집니다. 그러면 어느새 당신만 이해하는 미니 프레임워크가 되어버립니다(그것도 공휴일에나). 어댑터는 단순해야 합니다. 목적은 응답을 표준화하고 복붙을 없애는 것입니다. 그 외의 것들은 명확한 이유와 책임 경계가 있을 때만 추가해야 합니다.
GO TO FULL VERSION