Go · 기본
작고 명확한 Go 프로그램
종합 실습 - net/http 로 만드는 작은 JSON API
라우팅(ServeMux 패턴), JSON 인코딩, 핸들러 테스트 httptest, 완성 코드는 httptest 서버로 자기 API 를 호출해 결과를 출력하고 끝난다
개발자KR · 원고 갱신
이 장에서 배우는 것
이 장에서는 지금까지 배운 문법을 모아 동네 자전거 대여소의 작은 JSON API를 만든다. 웹 서버라고 하면 프레임워크를 떠올리기 쉽지만, Go는 표준 라이브러리의 net/http 패키지만으로 요청을 받고 응답을 보낼 수 있다. 구조체와 메서드, 오류 값, 뮤텍스 같은 앞 장의 재료가 여기서 한 프로그램으로 합쳐진다.
API(Application Programming Interface)는 프로그램끼리 주고받는 약속이다. 여기서는 HTTP 요청 한 줄과 JSON 문서 하나가 그 약속이다. 완성 코드는 서버를 띄운 뒤 자기 자신에게 요청을 보내 결과를 출력하고 끝난다. 포트를 직접 열지 않으므로 어느 환경에서나 같은 출력이 나온다.
- 메서드와 경로 와일드카드가 들어간
ServeMux패턴으로 요청을 핸들러에 연결한다. encoding/json으로 구조체를 JSON으로 바꾸고 요청 본문을 구조체로 읽는다.- 상태 코드와 오류 응답을 일관된 모양으로 돌려주는 작은 도우미 함수를 만든다.
httptest로 핸들러를 네트워크 없이 테스트하고, 테스트용 서버로 전체 경로를 호출한다.
문제 상황
대여소 관리자가 이런 요청을 한다. "단말기 앱이 대여소 목록을 보고, 자전거를 빌리고, 반납하면 요금을 받고 싶다. 앱은 다른 팀이 만들 테니 우리는 주소와 응답 모양만 정해 달라."
이 요청에는 결정할 것이 여러 개 있다. 어떤 주소가 어떤 일을 하는가. 빈 대여소에서 빌리려 하면 어떻게 답하는가. 없는 대여소를 물으면 어떻게 답하는가. 이 답들이 사람마다 다르면 앱을 만드는 쪽이 매번 문서를 뒤져야 한다. 그래서 정한 규칙은 세 가지다.
- 주소는
GET /stations,GET /stations/{id},POST /rentals,POST /rentals/{id}/return네 개다. - 성공과 실패 모두 JSON으로 답한다. 실패는
{"error":"…"}모양이다. - 잘못된 입력은 400, 없는 대상은 404, 현재 상태와 맞지 않는 요청은 409로 구분한다.
요금 규칙도 정해 두었다. 30분까지는 1000원이고, 30분을 넘으면 10분(올림)마다 500원을 더한다. 예를 들어 45분을 탔다면 15분이 초과이므로 10분 단위로 올림해 2단위, 곧 1000 + 1000 = 2000원이다.
ServeMux 로 요청을 나누기
웹 서버가 하는 일의 출발점은 라우팅(routing)이다. 요청이 들어오면 메서드(GET, POST 등)와 경로(/stations/s1 같은 주소)를 보고 어떤 함수가 처리할지 고르는 일이다. http.ServeMux가 이 역할을 한다. 핸들러(handler)는 요청을 받아 응답을 쓰는 함수다. 시그니처는 func(w http.ResponseWriter, r *http.Request)이다. r에서 요청 내용을 읽고 w에 응답을 쓴다.
메서드와 와일드카드가 들어간 패턴
Go 1.22부터 패턴 문자열 안에 메서드와 경로 변수를 쓸 수 있다. "GET /stations/{id}"는 GET 요청이면서 경로가 /stations/ 다음에 한 조각이 오는 경우에만 맞는다. 그 조각은 핸들러 안에서 r.PathValue("id")로 꺼낸다. 패턴에 메서드를 쓰지 않으면 모든 메서드가 들어오므로, 핸들러 안에서 메서드를 직접 검사해야 한다. 이 장에서는 항상 메서드를 패턴에 적는다.
| 요청 | 맞는 패턴 | 상태 | 이유 |
|---|---|---|---|
| GET /stations/s1 | GET /stations/{id} | 핸들러가 정함 | 메서드와 경로가 모두 맞는다 |
| DELETE /stations | 없음 | 405 | 경로는 있으나 그 메서드는 등록되지 않았다 |
| GET /stations/s1/x | 없음 | 404 | {id}는 한 조각만 받는다 |
| POST /rentals/1/return | POST /rentals/{id}/return | 핸들러가 정함 | {id}에 1이 들어간다 |
표의 405 행에서 볼 수 있듯이, 경로는 등록돼 있는데 메서드만 다르면 ServeMux가 알아서 405(Method Not Allowed)로 답한다. 이 동작을 위해 코드를 더 쓸 필요는 없다.
JSON 주고받기
구조체 태그로 모양 정하기
구조체를 JSON으로 바꾸려면 json.NewEncoder(w).Encode(값)을 쓴다. 필드 이름은 기본적으로 Go 이름 그대로(Bikes) 나가는데, 앱 쪽에서는 소문자 bikes를 기대한다. 구조체 필드 뒤에 백틱으로 적는 `json:"bikes"`를 태그(tag)라 하며, 이것이 JSON에서의 이름을 정한다. omitempty를 덧붙이면 값이 제로값(앞 장들에서 다룬 0, 빈 문자열 등)일 때 그 필드를 JSON에서 뺀다. 아직 반납되지 않은 대여에는 to, minutes, fee가 없는 편이 자연스러우므로 이 기능을 쓴다.
주의할 점이 있다. 패키지 밖에서 읽을 수 있는 필드는 대문자로 시작하는 것뿐이다. encoding/json도 패키지 밖 코드이므로 소문자 필드는 JSON에 나타나지 않는다.
요청 본문 읽기
반대 방향은 json.NewDecoder(r.Body).Decode(&req)이다. r.Body에서 JSON을 읽어 req 구조체에 채운다. JSON이 깨져 있으면 오류 값이 돌아오므로 앞 장에서 배운 대로 err != nil을 검사한다. 읽기에 성공해도 rider가 비어 있는 등 내용이 부족할 수 있으니 값도 확인한다.
응답을 한 곳에서 쓰기
응답은 세 단계로 쓴다. 헤더를 정하고, 상태 코드를 쓰고, 본문을 쓴다. 이 순서는 바꿀 수 없다. WriteHeader나 Write를 한 번 부르면 헤더가 이미 전송된 것으로 취급되어, 그 뒤에 헤더를 바꿔도 반영되지 않는다. 이 세 단계를 writeJSON 함수 하나에 모아 두면 모든 핸들러가 같은 모양으로 답하게 된다. 오류 응답은 writeError가 writeJSON을 불러 만든다.
| 코드 | 이름 | 언제 |
|---|---|---|
| 200 | OK | 조회와 반납이 성공했을 때 |
| 201 | Created | 대여를 새로 만들었을 때 |
| 400 | Bad Request | JSON이 깨졌거나 필수 값이 없을 때 |
| 404 | Not Found | 대여소나 대여가 없을 때 |
| 409 | Conflict | 빈 대여소에서 빌리거나 이미 반납한 대여를 또 반납할 때 |
| 405 | Method Not Allowed | 경로는 있는데 메서드가 다를 때(ServeMux가 자동으로 답한다) |
httptest 로 테스트하기
net/http/httptest는 서버 없이 핸들러를 시험하는 도구를 제공한다. 크게 두 가지 방법이 있다.
NewRecorder: 핸들러를 직접 부른다
httptest.NewRequest로 요청 객체를 만들고, httptest.NewRecorder로 응답을 받아 적는 가짜 ResponseWriter를 만든다. 그 둘을 핸들러의 ServeHTTP에 넘기면 네트워크 없이 함수 호출만으로 끝난다. 결과는 rec.Code와 rec.Body에 남는다. 다음은 main_test.go에 넣을 수 있는 테스트다. 테스트 파일 구성은 패키지와 테스트를 다룬 앞 장에서 배운 그대로이다.
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGetStationNotFound(t *testing.T) {
h := newAPI().routes()
req := httptest.NewRequest("GET", "/stations/s9", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("상태 = %d, 기대 = 404", rec.Code)
}
}
func TestCreateRental(t *testing.T) {
h := newAPI().routes()
body := strings.NewReader(`{"station":"s1","rider":"kim"}`)
req := httptest.NewRequest("POST", "/rentals", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("상태 = %d, 기대 = 201", rec.Code)
}
var got Rental
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.ID != 1 || got.From != "s1" {
t.Errorf("응답 = %+v", got)
}
}
테스트마다 newAPI()를 새로 부르므로 테스트끼리 데이터를 공유하지 않는다. 이것이 api 구조체 안에 상태를 담고 전역 변수를 쓰지 않은 이유다.
NewServer: 진짜 HTTP 로 부른다
httptest.NewServer는 운영체제가 빈 포트를 골라 주는 임시 서버를 이 컴퓨터 안에 띄우고 srv.URL을 알려 준다. 요청은 실제 HTTP 클라이언트로 보낸다. 헤더 처리나 본문 길이까지 실제 경로를 지나므로 전체를 한 번 확인하기에 좋다. 완성 코드는 이 방식으로 자기 API를 호출한다. 서버를 다 쓰면 srv.Close()로 닫는데, defer로 등록해 두면 함수가 끝날 때 닫힌다.
완성 코드
아래는 main.go 한 파일이다. 모듈이 없다면 빈 디렉터리에서 go mod init bikeapi를 먼저 실행한다.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"strings"
"sync"
)
type Station struct {
ID string `json:"id"`
Name string `json:"name"`
Bikes int `json:"bikes"`
}
type Rental struct {
ID int `json:"id"`
Rider string `json:"rider"`
From string `json:"from"`
To string `json:"to,omitempty"`
Minutes int `json:"minutes,omitempty"`
Fee int `json:"fee,omitempty"`
Returned bool `json:"returned"`
}
type rentRequest struct {
Station string `json:"station"`
Rider string `json:"rider"`
}
type returnRequest struct {
Station string `json:"station"`
Minutes int `json:"minutes"`
}
type api struct {
mu sync.Mutex
stations map[string]*Station
rentals map[int]*Rental
nextID int
}
func newAPI() *api {
return &api{
stations: map[string]*Station{
"s1": {ID: "s1", Name: "시청앞", Bikes: 2},
"s2": {ID: "s2", Name: "도서관", Bikes: 0},
},
rentals: map[int]*Rental{},
}
}
func (a *api) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /stations", a.listStations)
mux.HandleFunc("GET /stations/{id}", a.getStation)
mux.HandleFunc("POST /rentals", a.createRental)
mux.HandleFunc("POST /rentals/{id}/return", a.returnRental)
return mux
}
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 fee(minutes int) int {
if minutes <= 30 {
return 1000
}
extra := (minutes - 30 + 9) / 10
return 1000 + extra*500
}
func (a *api) listStations(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
list := make([]Station, 0, len(a.stations))
for _, st := range a.stations {
list = append(list, *st)
}
a.mu.Unlock()
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
writeJSON(w, http.StatusOK, list)
}
func (a *api) getStation(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a.mu.Lock()
st, ok := a.stations[id]
var out Station
if ok {
out = *st
}
a.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "대여소를 찾을 수 없다")
return
}
writeJSON(w, http.StatusOK, out)
}
func (a *api) createRental(w http.ResponseWriter, r *http.Request) {
var req rentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Rider == "" {
writeError(w, http.StatusBadRequest, "station 과 rider 가 담긴 JSON 이 필요하다")
return
}
a.mu.Lock()
defer a.mu.Unlock()
st, ok := a.stations[req.Station]
if !ok {
writeError(w, http.StatusNotFound, "대여소를 찾을 수 없다")
return
}
if st.Bikes == 0 {
writeError(w, http.StatusConflict, "빌릴 수 있는 자전거가 없다")
return
}
st.Bikes--
a.nextID++
rent := &Rental{ID: a.nextID, Rider: req.Rider, From: st.ID}
a.rentals[rent.ID] = rent
writeJSON(w, http.StatusCreated, rent)
}
func (a *api) returnRental(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "대여 번호는 숫자여야 한다")
return
}
var req returnRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Minutes < 0 {
writeError(w, http.StatusBadRequest, "station 과 minutes 가 담긴 JSON 이 필요하다")
return
}
a.mu.Lock()
defer a.mu.Unlock()
rent, ok := a.rentals[id]
if !ok {
writeError(w, http.StatusNotFound, "대여를 찾을 수 없다")
return
}
if rent.Returned {
writeError(w, http.StatusConflict, "이미 반납된 대여다")
return
}
st, ok := a.stations[req.Station]
if !ok {
writeError(w, http.StatusNotFound, "대여소를 찾을 수 없다")
return
}
st.Bikes++
rent.To = st.ID
rent.Minutes = req.Minutes
rent.Fee = fee(req.Minutes)
rent.Returned = true
writeJSON(w, http.StatusOK, rent)
}
func call(c *http.Client, base, method, path, body string) {
req, err := http.NewRequest(method, base+path, strings.NewReader(body))
if err != nil {
fmt.Println("요청 만들기 실패:", err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.Do(req)
if err != nil {
fmt.Println("요청 실패:", err)
return
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("응답 읽기 실패:", err)
return
}
fmt.Printf("%s %s -> %d %s\n", method, path, resp.StatusCode, strings.TrimSpace(string(data)))
}
func main() {
srv := httptest.NewServer(newAPI().routes())
defer srv.Close()
c := srv.Client()
call(c, srv.URL, "GET", "/stations", "")
call(c, srv.URL, "GET", "/stations/s1", "")
call(c, srv.URL, "GET", "/stations/s9", "")
call(c, srv.URL, "POST", "/rentals", `{"station":"s1","rider":"kim"}`)
call(c, srv.URL, "POST", "/rentals", `{"station":"s2","rider":"lee"}`)
call(c, srv.URL, "POST", "/rentals", `{"station":`)
call(c, srv.URL, "POST", "/rentals/1/return", `{"station":"s2","minutes":45}`)
call(c, srv.URL, "POST", "/rentals/1/return", `{"station":"s2","minutes":10}`)
call(c, srv.URL, "GET", "/stations", "")
call(c, srv.URL, "DELETE", "/stations", "")
}
줄별 해설
타입 선언
Station과 Rental은 응답으로 나가는 데이터다. 태그가 JSON 이름을 정하고, Rental의 To, Minutes, Fee에는 omitempty가 붙어 있어 반납 전에는 JSON에 나타나지 않는다. Returned에는 붙이지 않았다. false도 앱에는 의미 있는 값이라서 항상 보내기 위해서다. rentRequest와 returnRequest는 들어오는 JSON의 모양을 나타내는 전용 구조체다. 응답용 타입과 나누어 두면, 요청에서 받지 말아야 할 필드(예: 요금)를 클라이언트가 채워 보내는 일을 막을 수 있다.
api 구조체와 routes
api는 대여소 맵, 대여 맵, 다음 번호, 그리고 이들을 지키는 sync.Mutex를 한데 묶는다. HTTP 서버는 요청마다 고루틴을 따로 띄워 핸들러를 호출하므로 여러 핸들러가 같은 맵을 동시에 만질 수 있다. 앞 장에서 배운 뮤텍스가 여기서 필요해진다. routes는 ServeMux를 만들어 패턴 네 개를 등록하고 http.Handler로 돌려준다. ServeMux 자체가 ServeHTTP 메서드를 가진 Handler이기 때문에, 서버에도 테스트에도 그대로 넘길 수 있다. 메서드 값 a.listStations를 넘기면 a가 묶인 함수가 등록된다.
writeJSON, writeError, fee
writeJSON은 헤더, 상태 코드, 본문 순서를 지킨다. Encode는 JSON 뒤에 줄바꿈을 붙이므로 호출하는 쪽에서 출력할 때 strings.TrimSpace로 정리한다. writeError는 map[string]string을 그대로 JSON 객체로 바꿔 {"error":"…"} 모양을 만든다. fee는 (minutes - 30 + 9) / 10으로 정수 나눗셈 올림을 구한다. 초과가 15분이면 (15+9)/10 = 2이므로 2단위다. 정수끼리 나누면 소수점이 버려진다는 점을 이용한 것이다.
조회 핸들러
listStations는 맵 순회 순서가 매번 다르다는 점을 고려해, 잠금 안에서 값을 복사해 슬라이스에 담고 ID로 정렬한 뒤 응답한다. 잠금은 복사가 끝나면 바로 푼다. 복사하지 않고 포인터를 그대로 내보내면 잠금이 풀린 뒤 다른 요청이 값을 바꾸는 중에 JSON으로 바꾸게 될 수 있기 때문이다. make([]Station, 0, len(...))로 만든 슬라이스는 비어 있어도 []로 나간다. getStation은 r.PathValue("id")로 경로 조각을 꺼내고, 맵 조회의 두 번째 반환값 ok로 존재 여부를 확인한다.
대여와 반납
createRental은 본문을 먼저 읽고 검증한다. 그다음 Lock과 defer Unlock으로 함수가 끝날 때까지 잠근다. 대여소가 없으면 404, 자전거가 0대면 409이다. 통과하면 자전거 수를 줄이고 번호를 올려 대여를 저장한 뒤 201로 답한다. returnRental은 strconv.Atoi로 경로의 번호를 숫자로 바꾸고 실패하면 400으로 답한다. 이어서 대여 존재 여부(404), 이미 반납했는지(409), 반납할 대여소 존재 여부(404)를 차례로 검사한다. 모든 검사를 통과한 뒤에야 상태를 바꾼다. 검사 중간에 상태를 바꾸면 오류로 끝난 요청이 데이터를 절반만 고쳐 놓을 수 있기 때문이다. 핸들러는 writeError 다음에 반드시 return해야 한다.
call 과 main
call은 요청을 만들고 보내고 읽어서 한 줄로 출력한다. 각 단계의 오류는 출력하고 돌아간다. http.NewRequest의 본문은 io.Reader여야 하므로 문자열을 strings.NewReader로 감쌌다. main은 httptest.NewServer로 서버를 띄우고 srv.Client()로 그 서버용 클라이언트를 얻는다. 요청은 위에서 아래로 차례로 보내며 각 호출이 끝난 뒤 다음 호출을 하므로 출력 순서가 고정된다.
실행 결과
$ go run main.go
GET /stations -> 200 [{"id":"s1","name":"시청앞","bikes":2},{"id":"s2","name":"도서관","bikes":0}]
GET /stations/s1 -> 200 {"id":"s1","name":"시청앞","bikes":2}
GET /stations/s9 -> 404 {"error":"대여소를 찾을 수 없다"}
POST /rentals -> 201 {"id":1,"rider":"kim","from":"s1","returned":false}
POST /rentals -> 409 {"error":"빌릴 수 있는 자전거가 없다"}
POST /rentals -> 400 {"error":"station 과 rider 가 담긴 JSON 이 필요하다"}
POST /rentals/1/return -> 200 {"id":1,"rider":"kim","from":"s1","to":"s2","minutes":45,"fee":2000,"returned":true}
POST /rentals/1/return -> 409 {"error":"이미 반납된 대여다"}
GET /stations -> 200 [{"id":"s1","name":"시청앞","bikes":1},{"id":"s2","name":"도서관","bikes":1}]
DELETE /stations -> 405 Method Not Allowed
출력을 따라가 보면 흐름이 읽힌다. 처음에는 s1에 2대, s2에 0대가 있다. kim이 s1에서 빌리자 s1이 1대가 된다. s2에서 빌리려던 lee는 409를 받는다. kim이 45분 뒤 s2에 반납하면 요금은 2000원이고 s2가 1대가 된다. 같은 대여를 다시 반납하려 하면 409이다. 마지막 405는 ServeMux가 자동으로 만든 응답이다.
실무에서 자주 틀리는 것
헤더를 상태 코드 뒤에 쓴다
틀린 코드는 WriteHeader 다음에 Content-Type을 정한다. 헤더는 이미 전송된 뒤이므로 이 줄은 아무 효과가 없고, 오류도 나지 않는다. 클라이언트는 JSON 대신 text/plain을 받는다.
// 틀린 코드
w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rent)
// 고친 코드: 헤더, 상태 코드, 본문 순서
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(rent)
이 순서를 writeJSON 한 곳에 가두면 실수할 자리가 없어진다.
오류를 쓰고 return 하지 않는다
Go의 핸들러는 writeError를 불러도 함수가 저절로 끝나지 않는다. 아래 코드는 400 응답 뒤에도 아래 줄이 계속 실행되어 본문이 두 번 쓰이고, 상태도 바뀐 채 저장될 수 있다.
// 틀린 코드
if err != nil {
writeError(w, http.StatusBadRequest, "대여 번호는 숫자여야 한다")
}
a.mu.Lock() // 계속 실행된다
// 고친 코드
if err != nil {
writeError(w, http.StatusBadRequest, "대여 번호는 숫자여야 한다")
return
}
빈 슬라이스가 null 로 나간다
선언만 하고 원소를 넣지 않은 슬라이스는 nil이며 JSON에서는 null이 된다. 앱 쪽에서는 배열을 기대하고 반복문을 돌리다 오류가 나기 쉽다.
// 틀린 코드: 대여소가 없으면 null
var list []Station
writeJSON(w, http.StatusOK, list)
// 고친 코드: 비어 있어도 []
list := make([]Station, 0, len(a.stations))
writeJSON(w, http.StatusOK, list)
잠금 없이 맵을 만진다
요청마다 고루틴이 따로 돌기 때문에, 잠금 없이 맵을 읽고 쓰면 동시에 접근하는 순간 프로그램이 오류로 멈출 수 있다. 테스트에서는 요청이 하나씩 오므로 드러나지 않다가 실제 운영에서 터지는 것이 문제다.
// 틀린 코드
st := a.stations[id]
st.Bikes--
// 고친 코드
a.mu.Lock()
defer a.mu.Unlock()
st := a.stations[id]
st.Bikes--
맵과 이를 지키는 뮤텍스를 한 구조체에 넣어 두면, 어떤 잠금이 어떤 데이터를 지키는지 코드에서 바로 보인다. 동시성 문제는 go test -race로도 찾을 수 있다.
한눈에 보기
| 주제 | 핵심 | 이 장의 코드 |
|---|---|---|
| 라우팅 | "메서드 경로" 패턴, 경로 변수는 PathValue | mux.HandleFunc("GET /stations/{id}", …) |
| JSON 내보내기 | 구조체 태그, omitempty, Encoder | writeJSON |
| JSON 읽기 | Decoder와 오류 검사, 내용 검증 | createRental, returnRental |
| 상태 코드 | 400 입력, 404 대상 없음, 409 상태 충돌 | writeError |
| 동시성 | 요청마다 고루틴, 공유 맵은 뮤텍스로 보호 | api.mu |
| 핸들러 테스트 | NewRequest, NewRecorder, ServeHTTP | main_test.go 예 |
| 서버 테스트 | NewServer, srv.Client(), defer srv.Close() | main 함수 |
연습 문제
GET /rentals/{id}핸들러를 추가해 대여 한 건을 조회하게 하라. 번호가 숫자가 아니면 400, 없으면 404로 답한다.fee함수를 테이블 기반 테스트로 검증하라. 0분, 30분, 31분, 45분, 51분을 넣는다.- 완성 코드의 서버에 다음 세 요청을 보내면 각각 어떤 상태 코드가 나오는지 코드를 실행하기 전에 예상하라. (가)
POST /rentals/abc/return(나)GET /rentals(다)GET /stations/s1/extra - 빈 대여소 s2에서 빌리려 할 때 409가 돌아오는지
httptest.NewRecorder로 검증하는 테스트를 작성하라.
정답과 해설
1번. 패턴을 routes에 등록하고 핸들러를 만든다. 잠금 안에서는 값을 복사만 하고, 응답은 잠금을 푼 뒤에 쓴다.
mux.HandleFunc("GET /rentals/{id}", a.getRental)
func (a *api) getRental(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "대여 번호는 숫자여야 한다")
return
}
a.mu.Lock()
rent, ok := a.rentals[id]
var out Rental
if ok {
out = *rent
}
a.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "대여를 찾을 수 없다")
return
}
writeJSON(w, http.StatusOK, out)
}
2번. 기대값은 0분 1000, 30분 1000, 31분 1500, 45분 2000, 51분 2500이다. 31분은 초과 1분이 올림되어 1단위, 51분은 초과 21분이 3단위가 된다.
func TestFee(t *testing.T) {
cases := []struct {
minutes int
want int
}{
{0, 1000},
{30, 1000},
{31, 1500},
{45, 2000},
{51, 2500},
}
for _, c := range cases {
if got := fee(c.minutes); got != c.want {
t.Errorf("fee(%d) = %d, 기대 = %d", c.minutes, got, c.want)
}
}
}
3번. (가) 400이다. 핸들러가 호출되어 Atoi가 실패한다. (나) 405이다. /rentals 경로에는 POST 패턴이 있으므로 경로는 존재하고 메서드만 다르다. 404로 답하리라 예상하기 쉬운 곳이다. (다) 404이다. {id}는 경로 한 조각만 받으므로 어떤 패턴과도 맞지 않는다.
4번.
func TestRentEmptyStation(t *testing.T) {
h := newAPI().routes()
body := strings.NewReader(`{"station":"s2","rider":"lee"}`)
req := httptest.NewRequest("POST", "/rentals", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("상태 = %d, 기대 = 409", rec.Code)
}
}
처음 데이터에서 s2는 0대이므로 별도의 준비 없이 409를 확인할 수 있다. 더 알아볼 내용은 net/http 공식 문서의 ServeMux 항목에서 패턴 규칙과 우선순위를 확인하면 된다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.