Go 1.22의 새로운 라우팅으로 REST API 작성하기

1. 개요

Go 1.22 이전에는 http.ServeMux가 경로만 매칭했습니다. GET과 POST를 구분하지 않았고 /orders/{id}처럼 path 파라미터를 읽을 수 없었습니다. 따라서 많은 프로젝트에서 gorilla/mux나 chi 같은 추가 라우터를 사용해야 했습니다.

Go 1.22부터 ServeMux는 method 매칭, wildcard, 명확한 우선순위 규칙을 지원합니다. 이 글에서는 표준 라이브러리만으로 Order를 관리하는 REST API를 구축하는 방법을 안내합니다. httptest로 테스트를 작성하는 방법도 배우고, 새로운 문법에서 흔한 실수도 알아봅니다.

2. 프로젝트 준비

Go 1.22 이상이 필요합니다. go.mod의 go 줄도 1.22 이상이어야 합니다:

mkdir order-api && cd order-api
go mod init example.com/order-api

생성된 go.mod 파일:

module example.com/order-api

go 1.22

주의: 새로운 라우팅 동작은 설치된 toolchain 버전이 아니라 go.mod의 go 줄에 따라 결정됩니다. go.mod에 go 1.21이라고 되어 있으면 ServeMux는 이전 방식으로 작동합니다. 그러면 "GET /orders/{id}" 패턴이 공백이 포함된 경로로 해석되고, 요청이 404를 받지만 오류 메시지는 출력되지 않습니다.

이 글에서는 외부 의존성이 필요하지 않으므로 go get 명령이 없습니다.

3. 새로운 pattern 문법

3.1. Method와 path 파라미터 매칭

main.go에서 가장 간단한 예제:

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
		name := r.PathValue("name")
		w.Write([]byte("안녕하세요, " + name + "\n"))
	})

	log.Fatal(http.ListenAndServe(":8080", mux))
}

위 코드는 다음을 수행합니다:

  • "GET /hello/{name}": 공백 앞은 method, 뒤는 경로입니다. {name}은 wildcard이며 정확히 하나의 segment와 매칭합니다.
  • r.PathValue("name")은 매칭된 값을 반환하며, 항상 string 타입입니다. 만약 wildcard 이름이 존재하지 않으면 빈 문자열을 반환합니다.

테스트해보기:

go run .
curl -i localhost:8080/hello/Lan
curl -i -X POST localhost:8080/hello/Lan
HTTP/1.1 200 OK
...
안녕하세요, Lan

HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...
Method Not Allowed

경로는 매칭되지만 method가 매칭되지 않으면 ServeMux가 자동으로 405를 반환하고 Allow 헤더를 포함합니다. GET 패턴은 자동으로 HEAD도 매칭합니다.

3.2. 다중 segment wildcard와 정확한 매칭

두 가지 특수 문법이 더 있습니다:

mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("파일: " + r.PathValue("path") + "\n"))
})

mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("홈페이지\n"))
})
  • {path...}는 pattern의 끝에만 올 수 있습니다. 남은 경로 전체를 매칭하며, /도 포함됩니다. /files/docs/2024/report.pdf의 경우 값은 docs/2024/report.pdf입니다.
  • {$}는 정확히 경로 끝과 매칭됩니다. 이것이 없으면 "/" 패턴이 모든 경로와 매칭됩니다. 이것이 이상한 route가 404를 받지 않는 흔한 원인입니다.

3.3. 우선순위 규칙

여러 패턴이 같은 요청과 매칭될 때, 더 구체적인 패턴이 우선입니다:

mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /orders/latest", getLatestOrder) // /orders/latest와 매칭될 때 우선

/orders/latest가 더 구체적입니다. 두 번째 패턴과 매칭되는 모든 요청은 첫 번째 패턴과도 매칭되지만, 반대는 그렇지 않기 때문입니다. 등록 순서는 영향을 주지 않습니다.

두 패턴이 겹치는데 어느 것도 더 구체적이지 않으면 Go는 등록 시점에 panic을 발생시킵니다:

mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /{resource}/latest", getLatest) // panic: conflicts with ...

오류는 요청을 처리하는 중이 아니라 프로그램 시작 시에 나타납니다. 따라서 라우팅 오류를 조기에 발견할 수 있습니다.

4. Order API 구축

4.1. Model과 메모리 저장소

store.go 파일을 생성합니다:

package main

import (
	"sort"
	"sync"
)

type Order struct {
	ID       int     `json:"id"`
	Customer string  `json:"customer"`
	Total    float64 `json:"total"`
}

type OrderStore struct {
	mu     sync.RWMutex
	orders map[int]Order
	nextID int
}

func NewOrderStore() *OrderStore {
	return &OrderStore{orders: make(map[int]Order), nextID: 1}
}

func (s *OrderStore) Create(o Order) Order {
	s.mu.Lock()
	defer s.mu.Unlock()
	o.ID = s.nextID
	s.nextID++
	s.orders[o.ID] = o
	return o
}

func (s *OrderStore) Get(id int) (Order, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	o, ok := s.orders[id]
	return o, ok
}

func (s *OrderStore) List() []Order {
	s.mu.RLock()
	defer s.mu.RUnlock()
	result := make([]Order, 0, len(s.orders))
	for _, o := range s.orders {
		result = append(result, o)
	}
	sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
	return result
}

func (s *OrderStore) Delete(id int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.orders[id]; !ok {
		return false
	}
	delete(s.orders, id)
	return true
}

각 요청은 고유한 goroutine에서 처리됩니다. 따라서 map은 sync.RWMutex로 보호되어야 합니다. 만약 mutex가 없으면 go test -race를 실행할 때 data race가 보고됩니다. List는 ID로 정렬하는데, Go에서 map 순회는 무작위이기 때문입니다.

4.2. JSON 헬퍼

json.go 파일을 생성합니다:

package main

import (
	"encoding/json"
	"net/http"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

호출 순서가 매우 중요합니다. WriteHeader 또는 Write를 호출한 후에는 header 변경이 무시됩니다. Header().Set을 WriteHeader 전에 호출해야 합니다.

4.3. Handler

handler.go 파일을 생성합니다:

package main

import (
	"encoding/json"
	"net/http"
	"strconv"
)

type OrderHandler struct {
	store *OrderStore
}

func (h *OrderHandler) Routes(mux *http.ServeMux) {
	mux.HandleFunc("GET /orders", h.list)
	mux.HandleFunc("POST /orders", h.create)
	mux.HandleFunc("GET /orders/{id}", h.get)
	mux.HandleFunc("DELETE /orders/{id}", h.delete)
}

func (h *OrderHandler) list(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, h.store.List())
}

func (h *OrderHandler) create(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 최대 1 MB
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	var in Order
	if err := dec.Decode(&in); err != nil {
		writeError(w, http.StatusBadRequest, "본문이 유효하지 않습니다: "+err.Error())
		return
	}
	if in.Customer == "" || in.Total <= 0 {
		writeError(w, http.StatusUnprocessableEntity, "customer와 total은 필수입니다")
		return
	}

	created := h.store.Create(in)
	w.Header().Set("Location", "/orders/"+strconv.Itoa(created.ID))
	writeJSON(w, http.StatusCreated, created)
}

func (h *OrderHandler) get(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}
	order, found := h.store.Get(id)
	if !found {
		writeError(w, http.StatusNotFound, "order를 찾을 수 없습니다")
		return
	}
	writeJSON(w, http.StatusOK, order)
}

func (h *OrderHandler) delete(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}
	if !h.store.Delete(id) {
		writeError(w, http.StatusNotFound, "order를 찾을 수 없습니다")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil || id <= 0 {
		writeError(w, http.StatusBadRequest, "id는 양의 정수여야 합니다")
		return 0, false
	}
	return id, true
}

주목할 점들:

  • Routes는 모든 route 선언을 한 곳에 모읍니다. 한 번만 읽어도 API가 어떤 endpoint를 가졌는지 알 수 있습니다.
  • GET /orders와 GET /orders/{id}는 segment 수가 다르므로 충돌하지 않습니다.
  • ServeMux는 데이터 타입을 검사하지 않습니다. {id}는 abc와도 매칭되므로 strconv.Atoi로 직접 검증해야 합니다.
  • MaxBytesReader는 너무 큰 body를 차단합니다. DisallowUnknownFields는 오타로 인한 "totl" 같은 알 수 없는 필드를 거부합니다.

4.4. Middleware와 main 함수

net/http의 middleware는 http.Handler를 받아 http.Handler를 반환하는 함수일 뿐입니다. main.go를 업데이트합니다:

package main

import (
	"log"
	"net/http"
	"time"
)

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))
	})
}

func newServer() http.Handler {
	mux := http.NewServeMux()
	h := &OrderHandler{store: NewOrderStore()}
	h.Routes(mux)
	return logging(mux)
}

func main() {
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           newServer(),
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

newServer 함수는 handler 생성 로직을 main에서 분리합니다. 이를 통해 테스트가 정확히 같은 설정을 재사용할 수 있습니다. http.Server는 header를 너무 천천히 보내는 client를 방지하기 위해 ReadHeaderTimeout을 설정합니다. http.ListenAndServe는 기본적으로 이 timeout을 설정하지 않습니다.

Go 1.23부터 http.Request에는 Pattern 필드가 추가되었습니다. 실제 경로 대신 r.Pattern(예: GET /orders/{id})을 로그할 수 있습니다. 이 방법은 route별로 metric을 수집하는 데 도움이 됩니다.

테스트해보기:

go run .
curl -s -X POST localhost:8080/orders -d '{"customer":"Minh","total":250000}'
curl -s localhost:8080/orders/1
curl -s localhost:8080/orders/abc
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8080/orders/1
{"id":1,"customer":"Minh","total":250000}
{"id":1,"customer":"Minh","total":250000}
{"error":"id는 양의 정수여야 합니다"}
204

5. httptest로 테스트 작성

Go에는 net/http/httptest가 내장되어 있으므로 실제 포트를 열 필요가 없습니다. handler_test.go 파일을 생성합니다:

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestCreateAndGetOrder(t *testing.T) {
	srv := newServer()

	body := strings.NewReader(`{"customer":"Lan","total":120000}`)
	req := httptest.NewRequest(http.MethodPost, "/orders", body)
	rec := httptest.NewRecorder()
	srv.ServeHTTP(rec, req)

	if rec.Code != http.StatusCreated {
		t.Fatalf("POST status = %d, want 201", rec.Code)
	}
	if loc := rec.Header().Get("Location"); loc != "/orders/1" {
		t.Errorf("Location = %q, want /orders/1", loc)
	}

	req = httptest.NewRequest(http.MethodGet, "/orders/1", nil)
	rec = httptest.NewRecorder()
	srv.ServeHTTP(rec, req)

	var got Order
	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
		t.Fatal(err)
	}
	if got.Customer != "Lan" || got.Total != 120000 {
		t.Errorf("got %+v", got)
	}
}

func TestRouting(t *testing.T) {
	srv := newServer()
	tests := []struct {
		name, method, path string
		want               int
	}{
		{"id가 숫자가 아님", http.MethodGet, "/orders/abc", http.StatusBadRequest},
		{"존재하지 않음", http.MethodGet, "/orders/99", http.StatusNotFound},
		{"잘못된 method", http.MethodPut, "/orders/1", http.StatusMethodNotAllowed},
		{"없는 route", http.MethodGet, "/customers", http.StatusNotFound},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			rec := httptest.NewRecorder()
			srv.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
			if rec.Code != tc.want {
				t.Errorf("status = %d, want %d", rec.Code, tc.want)
			}
		})
	}
}

첫 번째 테스트는 order 생성 후 읽기 흐름을 검사합니다. 두 번째 테스트는 table-driven 테스트이며, ServeMux가 자동으로 생성하는 405를 포함하여 라우팅 동작을 검사합니다. race detector와 함께 실행해보세요:

go test -race -v ./...
=== RUN   TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.00s)
=== RUN   TestRouting
=== RUN   TestRouting/id가_숫자가_아님
...
--- PASS: TestRouting (0.00s)
PASS

6. 흔한 실수와 사용하면 안 되는 경우

흔한 실수들:

  • go.mod에 이전 버전이 작성되어 있음. Route가 모두 404를 반환합니다. 먼저 go.mod의 go 줄을 확인하세요.
  • Pattern 문법을 잘못 작성함. method와 경로 사이에 정확히 하나의 공백만 있어야 합니다. Method는 대문자여야 합니다. 예를 들어 GET이지 get이 아닙니다.
  • 의도하지 않게 "/" fallback 사용. "/" 패턴은 다른 route와 매칭되지 않은 모든 요청을 잡습니다. 홈페이지만 원한다면 "/{$}"를 사용하세요.
  • Wildcard가 segment 전체를 차지하지 않음. /orders/{id}.json 패턴은 유효하지 않습니다. Wildcard는 같은 segment에서 다른 글자와 함께 올 수 없습니다.
  • 405 응답이 text 형식. ServeMux는 404와 405 응답 본문을 순수 text로 반환하며 JSON이 아닙니다. client가 JSON을 통일하게 원하면 별도 처리 계층을 추가해야 합니다.

주의: go.mod를 1.22로 업그레이드한 후 오래된 프로젝트가 손상되었다면 GODEBUG=httpmuxgo121=1을 설정하여 임시로 이전 동작으로 되돌릴 수 있습니다. 이것은 일시적인 해결책일 뿐입니다. pattern을 제대로 수정해야 합니다.

표준 net/http는 다음이 필요한 경우 부족할 수 있습니다:

  • Route 내에서 타입을 바인딩하기. 예를 들어 숫자만 매칭하거나 regex 매칭.
  • 그룹별로 다른 middleware를 적용하기. 여러 ServeMux를 중첩하여 할 수 있지만 코드가 길어집니다.
  • 기본 제공 middleware 에코시스템. 예를 들어 CORS, rate limit 또는 recovery.

이 경우들에는 chi가 가볍고 http.Handler와 완전히 호환되는 선택입니다. 그 외의 경우, 표준 라이브러리는 대부분의 REST API에 충분합니다.

7. 결론

Go 1.22는 http.ServeMux를 실제 REST API에 충분할 정도로 강력하게 만들었습니다. method로 매칭하고, r.PathValue로 path 파라미터를 읽으며, Go가 405와 우선순위 규칙을 자동으로 처리하도록 할 수 있습니다. 또한 middleware, 입력 검증, httptest로 작성된 테스트가 있는 완전한 Order API를 외부 의존성 없이 구축했습니다.

기억해야 할 가장 중요한 점: go.mod에 go 1.22 이상을 설정하고, 정확한 매칭을 위해 {$}를 사용하며, wildcard 값을 사용하기 전에 직접 검증하세요.

golangnet/httprest-apigo-1.22httptest