종합 실습 — 표준 라이브러리만으로 만드는 REST API
이 챕터에서 다루는 것
파트 9에서 배운 것을 하나로 합친다. go.mod에 require 줄이 하나도 없는
할 일(task) REST API다.
| 엔드포인트 | 하는 일 |
|---|---|
GET /healthz | 상태 확인 |
GET /tasks | 목록. status, limit 쿼리 필터 |
POST /tasks | 생성. Location 헤더와 201 |
GET /tasks/{id} | 단건 조회 |
PATCH /tasks/{id} | 부분 수정 |
DELETE /tasks/{id} | 삭제. 204 |
파트 10에서 같은 API를 Gin과 Fiber로 다시 만든다. 그때 무엇이 줄어들고 무엇을 잃는지 비교하려면 이 버전이 기준선이 되어야 한다.
계층 나누기
파일이 셋이다.
08-practice/
├── main.go 서버 조립, 리스닝, 종료
└── api/
├── store.go 도메인 타입과 저장소
├── api.go 라우팅, 핸들러, 미들웨어, 검증
└── context.go 컨텍스트 키 도우미
저장소를 따로 뗀 이유가 있다. 파트 11에서 이걸 진짜 DB로 바꿀 때
api.go가 안 바뀌어야 한다. 9-6의 Fetcher와 같은 발상이다.
저장소 — 시간과 동시성
// Store는 동시 접근에 안전한 메모리 저장소다.
// 파트 11에서 이 인터페이스를 그대로 두고 구현만 DB로 바꾼다.
type Store struct {
mu sync.RWMutex
tasks map[int]Task
nextID int
now func() time.Time
}
// NewStore는 저장소를 만든다. now를 주입받는 이유는 8-3과 같다 —
// 테스트가 시간을 고정할 수 있어야 한다.
func NewStore(now func() time.Time) *Store {
if now == nil {
now = time.Now
}
return &Store{
tasks: make(map[int]Task),
nextID: 1,
now: now,
}
}
두 가지가 들어 있다.
sync.RWMutex. HTTP 핸들러는 요청마다 다른 고루틴에서 돈다. 7-5에서 본 대로, 맵에 동시 접근하면 런타임이concurrent map writes로 프로세스를 죽인다.now주입. 8-3의 시계 주입이다.CreatedAt이 매번 달라지면 응답을 단언할 수 없다.
List는 순서를 보장한다.
// List는 ID 오름차순으로 반환한다. 맵 순회 순서에 기대지 않는다.
func (s *Store) List(opt ListOptions) []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range slices.SortedFunc(maps.Values(s.tasks), func(a, b Task) int {
return a.ID - b.ID
}) {
if opt.Status != "" && t.Status != opt.Status {
continue
}
out = append(out, t)
if opt.Limit > 0 && len(out) == opt.Limit {
break
}
}
return out
}
5-3의 maps.Values와
slices.SortedFunc가 쓰였다. maps.Values는 iter.Seq를 주고
slices.SortedFunc가 그것을 정렬된 슬라이스로 만든다.
make([]Task, 0, ...)로 시작하는 것이 중요하다. nil 슬라이스는 9-4에서
본 대로 null로 인코딩된다. 목록 API가 null을 돌려주면 클라이언트가 깨진다.
Update는 뮤테이터 함수를 받는다.
// Update는 기존 항목에 mutate를 적용한다.
func (s *Store) Update(id int, mutate func(*Task)) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, ErrNotFound
}
mutate(&t)
t.ID = id // mutate가 ID를 건드려도 무시한다
t.UpdatedAt = s.now()
s.tasks[id] = t
return t, nil
}
"읽고 → 고치고 → 쓰기"가 락 하나 안에서 일어난다. 핸들러가
Get 후 Set을 따로 부르는 구조였다면 그 사이에 다른 요청이 끼어들 수 있다.
도메인 타입과 요청 타입을 나눈다
응답으로 나가는 Task와 요청으로 들어오는 타입이 다르다.
// Task는 저장되는 도메인 객체다.
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Status Status `json:"status"`
Due time.Time `json:"due,omitzero"` // 없으면 아예 필드가 안 나간다
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// createRequest는 POST 본문이다. 도메인 타입과 분리한다.
// 클라이언트가 ID나 CreatedAt을 정하지 못하게 하려면 이 분리가 필수다.
type createRequest struct {
Title string `json:"title"`
Status Status `json:"status"`
Due time.Time `json:"due,omitzero"`
}
도메인 타입을 그대로 요청 본문으로 쓰면 클라이언트가 "id": 999나
"created_at": "1999-01-01T00:00:00Z"를 보내서 내부 상태를 오염시킬 수 있다.
이걸 mass assignment 취약점이라고 부른다. 타입을 나누면 원천적으로 막힌다.
Due에 붙은 omitzero는 9-4에서 본 그것이다. omitempty였다면
마감이 없는 할 일마다 "due":"0001-01-01T00:00:00Z"가 나갔을 것이다.
PATCH 본문은 9-5의 포인터 패턴이다.
// patchRequest는 부분 수정이다. 포인터라야 "미지정"과 "제로값"이 구분된다.
type patchRequest struct {
Title *string `json:"title"`
Status *Status `json:"status"`
Due *time.Time `json:"due"`
}
본문 읽기 — 한 곳에서 전부 막는다
요청 본문 디코딩은 실패 경로가 많다. 한 함수에 모은다.
// decodeBody는 본문을 읽어 v에 담는다. 실패하면 응답을 쓰고 false를 반환한다.
func (a *API) decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
if ct := r.Header.Get("Content-Type"); ct != "" {
// "application/json; charset=utf-8"도 허용한다.
if base, _, _ := strings.Cut(ct, ";"); strings.TrimSpace(base) != "application/json" {
a.writeError(w, r, http.StatusUnsupportedMediaType, "bad_content_type",
"Content-Type은 application/json이어야 한다", nil)
return false
}
}
// MaxBytesReader는 상한을 넘으면 읽기를 끊고, 서버가 커넥션도 정리한다.
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
a.writeError(w, r, http.StatusBadRequest, "bad_json", decodeMessage(err), nil)
return false
}
if dec.More() {
a.writeError(w, r, http.StatusBadRequest, "bad_json", "본문에 JSON 값이 두 개 이상 있다", nil)
return false
}
return true
}
네 가지를 한다.
Content-Type검사.strings.Cut으로; charset=utf-8을 떼어낸다. 헤더가 아예 없으면 통과시킨다 —curl -d처럼 안 붙이는 경우가 많다.http.MaxBytesReader. 9-1에서 예고한 자리다.io.LimitReader와 달리 상한을 넘으면*http.MaxBytesError를 내고 서버가 커넥션 정리까지 한다.DisallowUnknownFields. 9-5에서 본 오타 방어.dec.More(). 값이 두 개 이상인 본문 거부.
반환값이 bool인 것은 의도적이다. 응답을 이미 썼는지를 호출자가 알아야
한다. 에러를 돌려주면 호출자가 또 응답을 쓸 위험이 있다.
에러 메시지 번역
9-5에서 본 에러 타입들을 사람이 읽을 문장으로 바꾼다.
// decodeMessage는 디코딩 에러를 클라이언트가 읽을 만한 문장으로 바꾼다.
// 내부 타입 이름이 그대로 새어 나가지 않게 하는 것이 목적이다.
func decodeMessage(err error) string {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return fmt.Sprintf("본문이 너무 크다 (상한 %d바이트)", maxErr.Limit)
}
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return fmt.Sprintf("JSON 문법 오류 (offset %d)", syntaxErr.Offset)
}
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
return fmt.Sprintf("필드 %q의 타입이 잘못됐다 (%s를 받았다)", typeErr.Field, typeErr.Value)
}
if errors.Is(err, io.EOF) {
return "본문이 비어 있다"
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return "본문이 중간에 끊겼다"
}
// DisallowUnknownFields의 에러는 전용 타입이 없어 문자열로 판별해야 한다.
if field, ok := strings.CutPrefix(err.Error(), "json: unknown field "); ok {
return "모르는 필드 " + field
}
return "본문을 읽을 수 없다"
}
마지막에서 두 번째 분기가 표준 라이브러리의 약점이다.
DisallowUnknownFields의 에러에는 전용 타입이 없어서 문자열 접두사로
판별할 수밖에 없다. 이런 코드는 표준 라이브러리 버전이 올라가면 깨질 수
있으므로, 테스트로 못을 박아 둔다.
{"모르는 필드", "POST", "/tasks", `{"title":"a","titel":"오타"}`, 400, "bad_json", "모르는 필드"},
default 분기가 있어서 실패해도 500이 되지는 않는다. 메시지가 덜 친절해질 뿐이다.
통일된 에러 응답
형식이 하나여야 클라이언트가 분기할 수 있다.
// errorResponse는 이 API의 유일한 에러 형식이다.
// 클라이언트가 분기할 수 있도록 code를 고정 문자열로 준다.
type errorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
RequestID string `json:"request_id"`
}
code는 기계용이다. 사람이 읽는message는 바뀔 수 있어도code는 안 바뀐다. 클라이언트가message문자열로 분기하면 다국어 지원 시점에 전부 깨진다.fields는 검증 실패 전용이다. 어느 필드가 왜 틀렸는지 담는다.omitempty라 검증 에러가 아니면 안 나간다.request_id가 항상 붙는다. 사용자가 "에러 났어요"라고 하면 이 값으로 로그를 찾는다.
상태 코드도 규칙을 정한다.
| 코드 | 언제 |
|---|---|
| 400 | 본문이나 경로·쿼리를 읽을 수 없다 (bad_json, bad_path, bad_query) |
| 404 | 문법은 맞는데 그런 자원이 없다 (not_found) |
| 415 | Content-Type이 JSON이 아니다 (bad_content_type) |
| 422 | 읽기는 됐는데 값이 규칙에 안 맞는다 (validation) |
| 500 | 우리 잘못 (internal) |
400과 422의 구분이 실무에서 유용하다. 400은 "말을 못 알아듣겠다", 422는 "무슨 말인지는 알겠는데 그렇게는 안 된다"이다.
검증
const maxTitleRunes = 100
func validateTitle(title string) string {
t := strings.TrimSpace(title)
switch {
case t == "":
return "필수 항목이다"
case utf8.RuneCountInString(t) > maxTitleRunes:
return fmt.Sprintf("%d자를 넘을 수 없다", maxTitleRunes)
}
return ""
}
func validateCreate(req createRequest) map[string]string {
fields := map[string]string{}
if msg := validateTitle(req.Title); msg != "" {
fields["title"] = msg
}
if req.Status != "" && !req.Status.Valid() {
fields["status"] = "todo, doing, done 중 하나여야 한다"
}
if len(fields) == 0 {
return nil
}
return fields
}
utf8.RuneCountInString을 쓴 것이 핵심이다. len(t)는 바이트 수라
한글 100자가 300바이트로 세어져 거부된다.
2-3에서 본 그대로다.
모든 필드를 검사하고 한꺼번에 돌려준다. 첫 에러에서 멈추면 클라이언트가 왕복을 여러 번 해야 한다.
POST /tasks → 422 {"code":"validation","message":"입력이 올바르지 않다","fields":{"title":"필수 항목이다"},"request_id":"4"}
핸들러
생성 핸들러가 전형적인 모양이다.
func (a *API) create(w http.ResponseWriter, r *http.Request) {
var req createRequest
if !a.decodeBody(w, r, &req) {
return
}
if fields := validateCreate(req); len(fields) > 0 {
a.writeError(w, r, http.StatusUnprocessableEntity, "validation", "입력이 올바르지 않다", fields)
return
}
t := a.store.Create(Task{Title: strings.TrimSpace(req.Title), Status: req.Status, Due: req.Due})
w.Header().Set("Location", "/tasks/"+strconv.Itoa(t.ID))
a.writeJSON(w, r, http.StatusCreated, t)
}
읽기 → 검증 → 실행 → 응답. 네 단계가 항상 같은 순서고, 각 단계는
실패하면 즉시 return한다.
Location 헤더와 201은 REST 관례다. 만들어진 자원의 위치를 알려 준다.
삭제는 204다.
func (a *API) delete(w http.ResponseWriter, r *http.Request) {
id, ok := a.pathID(w, r)
if !ok {
return
}
if err := a.store.Delete(id); err != nil {
a.notFound(w, r, id, err)
return
}
// 204에는 본문이 없다. 본문을 쓰면 net/http가 무시하고 경고를 남긴다.
w.WriteHeader(http.StatusNoContent)
}
저장소 에러를 HTTP 상태로 옮기는 곳도 한 군데로 모은다.
func (a *API) notFound(w http.ResponseWriter, r *http.Request, id int, err error) {
if !errors.Is(err, ErrNotFound) {
a.logger.Printf("예상 못 한 저장소 에러: %v", err)
a.writeError(w, r, http.StatusInternalServerError, "internal", "내부 오류", nil)
return
}
a.writeError(w, r, http.StatusNotFound, "not_found", fmt.Sprintf("할 일 %d 없음", id), nil)
}
:::warning 에러를 그대로 클라이언트에 보내지 않는다
ErrNotFound가 아닌 에러는 원문을 내보내지 않고 로그에만 남긴다.
DB 에러 메시지에는 테이블 이름, 컬럼 이름, 때로는 쿼리 전문이 들어 있다.
공격자에게 스키마를 알려 줄 이유가 없다.
:::
미들웨어 조립
9-7에서 만든 것과 같은 구조다.
func (a *API) chain(h http.Handler) http.Handler {
// 바깥부터 안쪽 순서: 요청 ID → 접근 로그 → 패닉 복구 → 핸들러.
// Recover가 Logging 안쪽에 있어야 500도 접근 로그에 남는다.
return a.withRequestID(a.withLogging(a.withRecover(h)))
}
라우팅 표는 짧다.
// Handler는 미들웨어까지 씌운 최종 핸들러를 반환한다.
func (a *API) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", a.health)
mux.HandleFunc("GET /tasks", a.list)
mux.HandleFunc("POST /tasks", a.create)
mux.HandleFunc("GET /tasks/{id}", a.get)
mux.HandleFunc("PATCH /tasks/{id}", a.patch)
mux.HandleFunc("DELETE /tasks/{id}", a.delete)
return a.chain(mux)
}
여기에는 / 캐치올이 없다. 9-7에서 본 대가를 이번에는 반대로 골랐다 —
등록되지 않은 메서드에 대해 mux의 405와 Allow 헤더를 받는 대신,
없는 경로에는 net/http 기본 404가 평문으로 나간다. 어느 쪽이든 하나는
포기해야 한다.
서버 조립과 종료
main.go가 하는 일은 조립과 수명 관리뿐이다.
// 리스너를 직접 만들면 "언제부터 요청을 받을 수 있는지"가 확정된다.
// srv.ListenAndServe는 그 시점을 알려 주지 않아 테스트에서 경합이 난다.
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("리스닝: %w", err)
}
baseURL := "http://" + ln.Addr().String()
net.Listen을 직접 부르는 이유가 있다. ListenAndServe는 소켓이 열린
시점을 알려 주지 않아서, "서버 띄우고 바로 요청 보내기"가 경합이 된다.
time.Sleep(100 * time.Millisecond)으로 때우는 코드를 자주 보는데,
리스너를 먼저 만들면 그럴 필요가 없다. 주소를 :0으로 주면 OS가 빈 포트를
고르고 ln.Addr()이 그 결과를 알려 준다.
// SIGINT/SIGTERM이 오면 ctx가 취소된다. 원인에는 시그널이 담긴다.
// 종료 처리의 전체 이야기는 파트 12가 맡는다. 여기서는 최소형이다.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
// Shutdown이 호출되면 Serve는 ErrServerClosed를 반환한다. 정상 종료다.
errCh <- srv.Serve(ln)
}()
Go 1.26에서 signal.NotifyContext가 취소 원인에 시그널을 담아 준다.
context.Cause(ctx)로 "무엇 때문에 내려가는지"를 알 수 있다.
if serve {
logger.Printf("listening on %s (Ctrl+C로 종료)", baseURL)
<-ctx.Done()
if cause := context.Cause(ctx); cause != nil {
logger.Printf("종료 신호: %v", cause)
}
} else {
if err := drive(baseURL); err != nil {
return err
}
}
// graceful shutdown: 새 연결을 받지 않고, 처리 중인 요청이 끝날 때까지 기다린다.
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
return fmt.Errorf("종료: %w", err)
}
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("서빙: %w", err)
}
logger.Println("정상 종료")
return nil
세 가지를 짚는다.
Shutdown의 컨텍스트는 새로 만든다. 취소된ctx를 넘기면 즉시 포기해 버려서 graceful이 아니게 된다.errCh는 버퍼 1이다.Shutdown뒤에 아무도 안 받아도 고루틴이 막히지 않는다.http.ErrServerClosed는 정상이다.Shutdown때문에Serve가 반환한 것이므로 에러로 취급하면 안 된다.
:::note 종료 처리는 파트 12가 제대로 다룬다
여기 있는 것은 최소형이다. 실제로는 종료 순서(리스너 → 진행 중 요청 →
백그라운드 워커 → DB 커넥션), 준비 상태 프로브와의 연동, 종료 유예 시간
튜닝, 두 번째 시그널에 강제 종료 같은 것이 더 필요하다.
log/slog, 설정 관리, 프로파일링과 함께 파트 12에서 다룬다.
:::
실행
go run ./08-practice
서버를 띄우고, 자기가 요청을 보내고, graceful shutdown까지 하고 끝난다.
--- 요청/응답 ---
GET /healthz 200 id=1
GET /healthz → 200 {"status":"ok"}
POST /tasks 201 id=2
POST /tasks → 201 {"id":1,"title":"우유 사기","status":"todo","created_at":"T","updated_at":"T"}
POST /tasks 201 id=3
POST /tasks → 201 {"id":2,"title":"보고서 쓰기","status":"doing","created_at":"T","updated_at":"T"}
POST /tasks 422 id=4
POST /tasks → 422 {"code":"validation","message":"입력이 올바르지 않다","fields":{"title":"필수 항목이다"},"request_id":"4"}
POST /tasks 400 id=5
POST /tasks → 400 {"code":"bad_json","message":"모르는 필드 \"titel\"","request_id":"5"}
GET /tasks 200 id=6
GET /tasks?status=doing → 200 {"count":1,"items":[{"id":2,"title":"보고서 쓰기","status":"doing","created_at":"T","updated_at":"T"}]}
PATCH /tasks/1 200 id=7
PATCH /tasks/1 → 200 {"id":1,"title":"우유 사기","status":"done","created_at":"T","updated_at":"T"}
GET /tasks/1 200 id=8
GET /tasks/1 → 200 {"id":1,"title":"우유 사기","status":"done","created_at":"T","updated_at":"T"}
GET /tasks/999 404 id=9
GET /tasks/999 → 404 {"code":"not_found","message":"할 일 999 없음","request_id":"9"}
DELETE /tasks/2 204 id=10
DELETE /tasks/2 → 204
GET /tasks 200 id=11
GET /tasks → 200 {"count":1,"items":[{"id":1,"title":"우유 사기","status":"done","created_at":"T","updated_at":"T"}]}
--- 접근 로그는 위 stdout에 섞여 나온다 ---
정상 종료
:::note 이 출력이 결정적인 이유 두 가지를 손봤다.
- 포트를 찍지 않는다.
:0으로 OS가 고른 포트라 실행마다 다르다. - 타임스탬프를 가린다.
created_at과updated_at은 실제time.Now()라 매번 다르므로, 데모가 값을"T"로 치환해서 찍는다.
// created_at/updated_at은 실행마다 다르므로 가린다.
fmt.Printf("%-6s %-22s → %d %s\n",
rq.method, rq.path, resp.StatusCode, maskTimestamps(strings.TrimSpace(string(out))))
접근 로그(GET /healthz 200 id=1)와 요청/응답 줄이 섞여 나오는 것은
둘 다 os.Stdout으로 가기 때문이다. 순서는 요청이 순차적이라 결정적이다.
:::
계속 띄워 두고 curl로 만져 보려면 이렇게 한다.
go run ./08-practice -serve -addr 127.0.0.1:8080
curl -s localhost:8080/healthz
curl -s -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title":"우유 사기"}'
curl -s localhost:8080/tasks
# Ctrl+C로 graceful shutdown
테스트
httptest.NewRecorder로 대부분을 덮는다. 소켓이 없으니 빠르다.
// fixedNow는 테스트가 시간을 고정하도록 한다. 8-3에서 정한 패턴 그대로다.
var fixedNow = time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC)
func newAPI(t *testing.T) (http.Handler, *strings.Builder) {
t.Helper()
var logs strings.Builder
store := api.NewStore(func() time.Time { return fixedNow })
a := api.New(store, log.New(&logs, "", 0))
return a.Handler(), &logs
}
// do는 요청을 보내고 상태 코드와 본문을 돌려준다.
func do(t *testing.T, h http.Handler, method, target, body string) (int, []byte, http.Header) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, target, r)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.Bytes(), rec.Header()
}
시계를 고정했으니 CreatedAt을 정확히 단언할 수 있다.
로그를 strings.Builder로 받는 것도 같은 이유다 — 로그 출력까지 검증한다.
CRUD 왕복 하나로 흐름 전체를 덮는다.
var created api.Task
if err := json.Unmarshal(body, &created); err != nil {
t.Fatalf("Unmarshal 에러 = %v", err)
}
if created.ID != 1 || created.Status != api.StatusTodo {
t.Errorf("생성 결과 = %+v", created)
}
if !created.CreatedAt.Equal(fixedNow) {
t.Errorf("CreatedAt = %v, want %v", created.CreatedAt, fixedNow)
}
// due를 안 줬으므로 필드 자체가 없어야 한다 (omitzero).
if strings.Contains(string(body), `"due"`) {
t.Errorf("빈 due가 응답에 들어갔다: %s", body)
}
9-3에서 배운 대로 Equal을 쓴다. ==로 비교하면 Location 포인터 차이로
실패할 수 있다.
에러 응답은 테이블 하나로 전부 덮는다.
tests := []struct {
name string
method string
target string
body string
wantStatus int
wantCode string
wantSub string
}{
{"빈 제목", "POST", "/tasks", `{"title":" "}`, 422, "validation", "필수 항목이다"},
{"긴 제목", "POST", "/tasks", `{"title":"` + strings.Repeat("가", 101) + `"}`, 422, "validation", "100자를 넘을 수 없다"},
{"모르는 상태", "POST", "/tasks", `{"title":"a","status":"물론"}`, 422, "validation", "todo, doing, done"},
{"모르는 필드", "POST", "/tasks", `{"title":"a","titel":"오타"}`, 400, "bad_json", "모르는 필드"},
{"깨진 JSON", "POST", "/tasks", `{"title":`, 400, "bad_json", "본문이 중간에 끊겼다"},
{"타입 불일치", "POST", "/tasks", `{"title":42}`, 400, "bad_json", "타입이 잘못됐다"},
{"빈 본문", "POST", "/tasks", `{}`, 422, "validation", "필수 항목이다"},
{"값 두 개", "POST", "/tasks", `{"title":"a"}{"title":"b"}`, 400, "bad_json", "두 개 이상"},
{"잘못된 id", "GET", "/tasks/abc", "", 400, "bad_path", `id \"abc\"`},
{"0 id", "GET", "/tasks/0", "", 400, "bad_path", `id \"0\"`},
{"없는 id", "GET", "/tasks/999", "", 404, "not_found", "999"},
{"잘못된 status 쿼리", "GET", "/tasks?status=xyz", "", 400, "bad_query", "status"},
{"잘못된 limit", "GET", "/tasks?limit=-1", "", 400, "bad_query", "limit"},
}
긴 제목 케이스가 한글 101자다. len()으로 셌다면 이 케이스는
"100자 이하인데도 거부됨"이 되어 깨진다.
각 케이스에서 형식까지 검증한다.
if got.RequestID == "" {
t.Error("request_id가 비어 있다")
}
if got.RequestID != header.Get("X-Request-ID") {
t.Errorf("본문 request_id=%q, 헤더=%q", got.RequestID, header.Get("X-Request-ID"))
}
본문의 request_id와 헤더의 X-Request-ID가 같아야 한다. 다르면
사용자가 알려 준 ID로 로그를 못 찾는다.
동시성 테스트
ID 부여에 경합이 없는지 확인한다. 여기서만 httptest.NewServer를 쓴다.
// 동시 생성에서 ID가 겹치지 않는지 확인한다. -race로 돌린다.
func TestConcurrentCreates(t *testing.T) {
h, _ := newAPI(t)
srv := httptest.NewServer(h)
defer srv.Close()
const n = 50
var wg sync.WaitGroup
ids := make([]int, n)
errs := make([]error, n)
for i := range n {
wg.Go(func() {
resp, err := srv.Client().Post(srv.URL+"/tasks", "application/json",
strings.NewReader(`{"title":"동시"}`))
if err != nil {
errs[i] = err
return
}
defer func() { _ = resp.Body.Close() }()
var task api.Task
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
errs[i] = err
return
}
ids[i] = task.ID
})
}
wg.Wait()
seen := make(map[int]bool, n)
for i := range n {
if errs[i] != nil {
t.Fatalf("요청 %d 실패: %v", i, errs[i])
}
if seen[ids[i]] {
t.Fatalf("ID %d가 두 번 부여됐다", ids[i])
}
seen[ids[i]] = true
}
if len(seen) != n {
t.Errorf("고유 ID %d개, want %d개", len(seen), n)
}
}
7-1의 wg.Go와
8-7의 인덱스 슬롯 패턴이다.
각 고루틴이 ids[i]와 errs[i]에만 쓰므로 락이 필요 없다.
이 테스트의 값어치는 -race와 함께 나온다. Store의 뮤텍스를 지우고
돌려 보면 즉시 잡힌다.
$ go test -race -count=1 ./...
ok example.com/standard-library/08-practice/api 1.410s
여기서 멈춘 것들
이 API는 프로덕션에 그대로 올릴 수 없다. 일부러 빼 둔 것들이 있다.
| 빠진 것 | 어디서 다루는가 |
|---|---|
구조적 로깅 (log/slog) | 파트 12 |
| 설정 관리 (환경 변수, 파일) | 파트 12 |
| 인증·인가 (JWT) | 파트 10 |
| 프로파일링, 메트릭 | 파트 12 |
| 완전한 graceful shutdown | 파트 12 |
| 진짜 데이터베이스 | 파트 11 |
| 라우터·바인딩·검증 라이브러리 | 파트 10 |
log.Logger를 쓴 것도 그래서다. 표준 라이브러리만 쓴다는 이 챕터의 제약을
지키면서, 로깅 인터페이스를 주입 가능하게 열어 뒀다.
흔한 실수
1. 도메인 타입을 요청 본문으로 그대로 쓴다
클라이언트가 id나 created_at을 덮어쓸 수 있다.
2. 목록 응답에 nil 슬라이스를 내보낸다
null이 나가 클라이언트가 깨진다. make([]T, 0)으로 시작한다.
3. 본문 크기 제한을 안 건다
http.MaxBytesReader를 쓴다. io.LimitReader보다 낫다 — 전용 에러 타입이
있고 커넥션 정리까지 한다.
4. 검증에서 첫 에러에 멈춘다
클라이언트가 왕복을 여러 번 한다. 전부 모아서 준다.
5. len(s)로 글자 수를 센다
한글이 세 배로 세어진다. utf8.RuneCountInString.
6. 내부 에러 메시지를 그대로 내보낸다
스키마와 쿼리가 노출된다. 로그에만 남기고 클라이언트에는 일반 메시지를 준다.
7. 에러 응답에 code가 없다
클라이언트가 message 문자열로 분기하게 되고, 메시지를 고치는 순간 깨진다.
8. Shutdown에 취소된 컨텍스트를 넘긴다
즉시 포기해서 graceful이 아니게 된다.
9. http.ErrServerClosed를 에러로 취급한다
정상 종료인데 프로세스가 실패 코드로 끝난다.
10. 저장소 접근을 락 밖에서 조립한다
Get 후 Set을 따로 부르면 그 사이에 다른 요청이 끼어든다.
정리
- 외부 의존성 없이 REST API가 된다. 라우팅은
ServeMux패턴, 직렬화는encoding/json, 미들웨어는func(Handler) Handler. - 도메인 타입과 요청 타입을 나눈다. 클라이언트가
id나 타임스탬프를 정하지 못하게 하는 유일하게 확실한 방법이다. - 본문 읽기를 한 함수에 모은다:
Content-Type검사,MaxBytesReader,DisallowUnknownFields,More(). 실패 시 응답까지 쓰고bool을 돌려준다. - 에러 응답 형식을 하나로 고정한다. 기계용
code, 사람용message, 검증용fields, 추적용request_id. 400은 "못 읽겠다", 422는 "값이 틀렸다". - 검증은 전부 모아서 한 번에 돌려주고, 글자 수는 룬으로 센다.
- 저장소는 뮤텍스로 감싸고 시계를 주입받는다. 읽고-고치고-쓰기는 락 하나 안에서 한다.
- 리스너를 직접 만들면
:0으로 포트를 받고 준비 시점이 확정된다. 테스트에서Sleep이 필요 없어진다. Shutdown은 새 컨텍스트로 부르고,ErrServerClosed는 정상으로 처리한다.- 테스트는
NewRecorder가 기본이고, 커넥션이 관여할 때만NewServer. 시계를 고정하면 타임스탬프까지 단언할 수 있다.
연습문제
-
PUT /tasks/{id}(전체 교체)를 추가해 보자.PATCH와 무엇이 달라야 하는가? 요청 타입에 포인터를 써야 하는가? 없는 ID에PUT을 하면 404여야 하는가, 아니면 그 ID로 생성해야 하는가 — 둘 중 하나를 고르고 그 결정을 테스트로 못 박아 보자. -
목록에 커서 기반 페이지네이션을 넣어 보자.
GET /tasks?after=3&limit=2가 ID 3보다 큰 것 2개를 주고, 응답에next_cursor를 담는다. 마지막 페이지에서next_cursor는 무엇이어야 하는가? 9-4의omitzero가 여기서 쓸모가 있는가? -
Store를 인터페이스로 추출하고,api.New가 그 인터페이스를 받게 바꿔 보자. 그다음 "세 번째Create부터 실패하는" fake를 만들어 500 응답 경로를 테스트해 보자. 인터페이스에 메서드가 몇 개 필요한가? 6-5의 "인터페이스는 쓰는 쪽에 선언한다"를 따르면 그 인터페이스는 어느 파일에 있어야 하는가? -
이 API에 9-7의
CrossOriginProtection을 씌워 보자.POST/PATCH/DELETE가 교차 출처에서 막히는지 테스트로 확인하고,GET /tasks는 계속 통과하는지도 확인한다. 이 API의 어떤 설계 결정이 그 보호를 유효하게 만드는가?