Go · 심화
동시성과 서버 설계로 깊어지는 Go
종합 실습 - 배달 주문 중계 API
주문 접수·배차·상태 조회 API, 워커 풀 배차, context 시간 제한, 구조화 로그, httptest 로 시나리오 전체 실행
개발자KR · 원고 갱신
이 장에서 배우는 것
이 책은 앞 장에서 제네릭, 인터페이스와 오류 설계, 워커 풀, context 취소, 경쟁 상태 검출, 테스트와 벤치마크, net/http 서버 구조, JSON 처리, 구조화 로그를 하나씩 다뤘다. 이 장은 그 도구를 한 프로그램에 모아, 동네 가게의 배달 주문을 받아 배달원에게 넘기는 중계 API를 만든다. 새 문법은 나오지 않는다. 대신 각 도구를 어디에 놓고 서로 어떻게 맞물리게 할지를 정한다.
완성한 프로그램은 파일 하나(main.go)이고, 포트를 열지 않는다. 라우터를 만들고 httptest 로 요청을 직접 넣어 접수, 배차, 조회 시나리오를 처음부터 끝까지 실행한 뒤 종료한다. 출력은 매번 같다.
- 주문 접수·배차·조회 API 를 net/http 의 메서드 지정 라우팅으로 구성한다.
- 워커 풀로 여러 주문을 동시에 배차하되 결과 순서를 입력 순서로 고정한다.
- 요청 하나의 context 에 시간 제한을 걸어 느린 배차를 끊고, 실패를 응답과 로그에 반영한다.
- 미들웨어와 slog 로 요청마다 한 줄씩 구조화 로그를 남긴다.
- httptest 로 서버를 띄우지 않고 시나리오 전체를 결정적으로 검증한다.
문제 상황
가게 네 곳에서 주문이 들어왔다고 하자. 중계 서비스는 주문을 받아 두고, 운영자가 배차를 요청하면 배달원을 한 명씩 지정한다. 배차 과정에는 배달원 앱에 질의하는 것과 같은 외부 호출이 들어간다. 대부분은 몇 밀리초에 끝나지만 어떤 가게의 호출은 응답이 한참 걸린다.
가장 단순한 구현은 주문을 for 문으로 하나씩 처리하는 것이다. 이 방식에는 두 가지 문제가 있다. 첫째, 느린 주문 하나가 뒤의 모든 주문을 붙잡는다. 둘째, 요청에 마감이 없어서 외부 호출이 멈추면 HTTP 요청도 함께 멈춘다. 고루틴을 주문마다 하나씩 띄우면 첫 번째 문제는 줄지만, 주문이 수천 건이면 동시에 나가는 외부 호출 수를 통제할 수 없다. 두 번째 문제도 그대로 남는다.
그래서 이 장의 구현은 다음 원칙을 따른다.
- 동시에 배차하는 수는 워커 수로 제한한다.
- 배차 요청 하나에 마감 시간을 정하고, 모든 외부 호출이 같은 context 를 본다.
- 마감을 넘긴 주문은 실패로 기록하고, 나머지 주문의 결과는 그대로 돌려준다.
- 관찰할 수 있는 흔적은 구조화 로그와 응답 본문에만 남긴다.
API 와 계층 나누기
서비스는 세 층으로 나눈다. 바깥은 요청 하나마다 로그를 남기는 미들웨어다. 안쪽은 메서드와 경로로 핸들러를 고르는 라우터와 핸들러다. 가장 안쪽은 주문 목록을 들고 있는 저장소와 배차를 맡는 워커 풀이다. 핸들러는 입력을 검사하고 저장소와 워커 풀을 부르는 일만 한다.
제공하는 엔드포인트는 네 개다. Go 1.22 부터 ServeMux 는 패턴에 메서드와 경로 변수를 쓸 수 있어서, 별도 라우터 없이 표준 라이브러리만으로 충분하다. 경로 변수는 r.PathValue("id") 로 읽는다.
| 메서드와 경로 | 역할 | 성공 | 실패 |
|---|---|---|---|
| POST /orders | 주문 접수 | 201 | 400 (본문 오류, 필수 값 누락) |
| GET /orders | 전체 주문을 접수 순서로 조회 | 200 | 없음 |
| GET /orders/{id} | 주문 하나 조회 | 200 | 404 |
| POST /dispatch | 배차가 끝나지 않은 주문을 워커 풀로 배차 | 200 | 없음 (실패 주문은 본문에 나열) |
배차 요청이 200 을 돌려주는 점에 주목한다. 요청 자체는 정상 처리됐고, 일부 주문의 배차만 실패한 것이므로 실패한 주문 번호를 본문의 failed 목록에 담는다. 호출한 쪽은 이 목록으로 재시도 여부를 정한다.
주문 상태
주문은 세 가지 상태 중 하나다. 접수 직후는 "접수", 배차에 성공하면 "배차완료", 마감을 넘기는 등 오류로 끝나면 "배차실패"다. 배차 요청은 "배차완료"가 아닌 주문을 모두 대상으로 삼으므로, 실패한 주문은 다음 요청에서 다시 시도된다.
워커 풀 배차와 시간 제한
결과를 슬롯에 쓰기
워커 풀은 앞 장에서 다룬 모양 그대로다. 작업 번호를 채널로 흘려보내고 워커 고루틴이 받아 처리한다. 달라지는 점은 결과를 모으는 방법이다. 워커가 결과 채널로 보내면 도착 순서가 실행마다 달라진다. 그래서 주문 개수만큼 슬라이스를 미리 만들고, 번호가 i 인 작업의 결과는 항상 results[i] 에 쓴다. 고루틴마다 쓰는 칸이 다르므로 뮤텍스가 필요 없고, wg.Wait() 이 끝나면 모든 쓰기가 읽는 쪽에 보인다. 결과 순서는 입력 순서와 같아서 응답과 로그가 결정적이다.
Go 1.25 에서 추가된 sync.WaitGroup.Go 는 Add(1), 고루틴 시작, defer Done() 을 한 번에 처리한다. 이 장의 워커 시작에 그대로 쓴다.
마감과 취소
배차 핸들러는 context.WithTimeout(r.Context(), timeout) 으로 요청 context 에서 파생한 context 를 만들고, 그 값을 워커 풀과 assign 까지 넘긴다. 부모를 r.Context() 로 잡으면 클라이언트가 연결을 끊었을 때도 배차가 함께 멈춘다. assign 은 외부 호출 대신 타이머를 쓴다. 일반 가게는 2ms 뒤에, "먼가게"의 주문은 500ms 뒤에 끝나도록 해서 느린 호출을 흉내 낸다. 두 경우 모두 select 로 타이머와 ctx.Done() 을 함께 기다린다. 마감이 먼저 오면 ctx.Err() 를 돌려주므로 느린 작업은 마감에서 끊긴다.
작업을 넘기는 쪽도 마감을 봐야 한다. 모든 워커가 느린 작업에 묶여 있으면 jobs <- i 는 영원히 막힐 수 있다. 그래서 전송도 select 로 감싸서 ctx.Done() 이 먼저 닫히면 남은 주문을 실패로 표시한다. 이 분기가 없으면 워커 수가 작을 때 요청이 마감을 넘겨서 끝난다.
워커 안에서는 로그를 남기지 않는다
워커가 직접 로그를 남기면 줄 순서가 스케줄러에 따라 바뀐다. 이 장은 결과를 모두 모은 뒤, 핸들러가 입력 순서대로 한 번에 기록한다. 운영 환경에서도 순서가 고정된 로그가 사건을 추적하기 쉽다.
| 도구 | 쓰이는 곳 | 하는 일 | 없으면 |
|---|---|---|---|
| 채널 jobs | 핸들러에서 워커로 | 작업 번호 전달 | 워커 수 제한이 사라진다 |
| sync.WaitGroup | 워커 종료 대기 | 모든 결과가 쓰인 뒤 반환 | 결과가 비거나 덜 쓰인 채 읽는다 |
| context.WithTimeout | 배차 요청 하나 | 마감 시각 공유 | 느린 외부 호출이 요청을 붙잡는다 |
| sync.Mutex | 주문 저장소 | 목록 접근 직렬화 | 동시 요청에서 데이터 경쟁이 난다 |
완성 코드
아래 코드를 main.go 로 저장한다. 표준 라이브러리만 쓰고 go.mod 없이 go run main.go 로 실행된다.
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"time"
)
const (
statusReceived = "접수"
statusDispatched = "배차완료"
statusFailed = "배차실패"
)
var couriers = []string{"민수", "지아", "도윤"}
type Order struct {
ID string `json:"id"`
Shop string `json:"shop"`
Item string `json:"item"`
Address string `json:"address"`
Status string `json:"status"`
Courier string `json:"courier,omitempty"`
Seq int `json:"-"`
}
type orderInput struct {
Shop string `json:"shop"`
Item string `json:"item"`
Address string `json:"address"`
}
type orderStore struct {
mu sync.Mutex
orders []Order
}
func (s *orderStore) add(in orderInput) Order {
s.mu.Lock()
defer s.mu.Unlock()
seq := len(s.orders) + 1
o := Order{
ID: fmt.Sprintf("o-%d", seq),
Shop: in.Shop,
Item: in.Item,
Address: in.Address,
Status: statusReceived,
Seq: seq,
}
s.orders = append(s.orders, o)
return o
}
func (s *orderStore) get(id string) (Order, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, o := range s.orders {
if o.ID == id {
return o, true
}
}
return Order{}, false
}
func (s *orderStore) list() []Order {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Order, len(s.orders))
copy(out, s.orders)
return out
}
func (s *orderStore) pending() []Order {
s.mu.Lock()
defer s.mu.Unlock()
var out []Order
for _, o := range s.orders {
if o.Status != statusDispatched {
out = append(out, o)
}
}
return out
}
func (s *orderStore) update(id, status, courier string) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.orders {
if s.orders[i].ID == id {
s.orders[i].Status = status
s.orders[i].Courier = courier
return
}
}
}
func assign(ctx context.Context, o Order) (string, error) {
delay := 2 * time.Millisecond
if o.Shop == "먼가게" {
delay = 500 * time.Millisecond
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return couriers[(o.Seq-1)%len(couriers)], nil
case <-ctx.Done():
return "", ctx.Err()
}
}
type assignment struct {
order Order
courier string
err error
}
func dispatchAll(ctx context.Context, orders []Order, workers int) []assignment {
results := make([]assignment, len(orders))
jobs := make(chan int)
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for i := range jobs {
c, err := assign(ctx, orders[i])
results[i] = assignment{order: orders[i], courier: c, err: err}
}
})
}
for i := range orders {
select {
case jobs <- i:
case <-ctx.Done():
results[i] = assignment{order: orders[i], err: ctx.Err()}
}
}
close(jobs)
wg.Wait()
return results
}
type dispatchResult struct {
Dispatched int `json:"dispatched"`
Failed []string `json:"failed"`
}
type server struct {
store *orderStore
log *slog.Logger
workers int
timeout time.Duration
}
func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", s.createOrder)
mux.HandleFunc("GET /orders", s.listOrders)
mux.HandleFunc("GET /orders/{id}", s.getOrder)
mux.HandleFunc("POST /dispatch", s.dispatch)
return s.logRequests(mux)
}
func (s *server) writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
s.log.Error("encode_failed", "err", err)
}
}
func (s *server) writeError(w http.ResponseWriter, code int, msg string) {
s.writeJSON(w, code, map[string]string{"error": msg})
}
func (s *server) createOrder(w http.ResponseWriter, r *http.Request) {
var in orderInput
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
s.writeError(w, http.StatusBadRequest, "요청 본문을 읽을 수 없다")
return
}
if in.Shop == "" || in.Item == "" || in.Address == "" {
s.writeError(w, http.StatusBadRequest, "shop, item, address는 모두 필요하다")
return
}
o := s.store.add(in)
s.log.Info("order_received", "order", o.ID, "shop", o.Shop)
s.writeJSON(w, http.StatusCreated, o)
}
func (s *server) listOrders(w http.ResponseWriter, r *http.Request) {
s.writeJSON(w, http.StatusOK, s.store.list())
}
func (s *server) getOrder(w http.ResponseWriter, r *http.Request) {
o, ok := s.store.get(r.PathValue("id"))
if !ok {
s.writeError(w, http.StatusNotFound, "주문을 찾을 수 없다")
return
}
s.writeJSON(w, http.StatusOK, o)
}
func (s *server) dispatch(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), s.timeout)
defer cancel()
results := dispatchAll(ctx, s.store.pending(), s.workers)
resp := dispatchResult{Failed: make([]string, 0)}
for _, a := range results {
id := a.order.ID
if a.err != nil {
s.store.update(id, statusFailed, "")
s.log.Warn("assign_failed", "order", id, "err", a.err)
resp.Failed = append(resp.Failed, id)
continue
}
s.store.update(id, statusDispatched, a.courier)
s.log.Info("assigned", "order", id, "courier", a.courier)
resp.Dispatched++
}
s.writeJSON(w, http.StatusOK, resp)
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (s *server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status)
})
}
func newLogger() *slog.Logger {
opts := &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{}
}
return a
},
}
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
func call(h http.Handler, method, path, body string) {
fmt.Printf("> %s %s\n", method, path)
req := httptest.NewRequest(method, path, strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Printf("< %d %s\n", rec.Code, strings.TrimSpace(rec.Body.String()))
}
func main() {
srv := &server{
store: &orderStore{},
log: newLogger(),
workers: 3,
timeout: 100 * time.Millisecond,
}
h := srv.routes()
steps := []struct {
method string
path string
body string
}{
{"POST", "/orders", `{"shop":"한결분식","item":"떡볶이","address":""}`},
{"POST", "/orders", `{"shop":"한결분식","item":"떡볶이","address":"해바라기길 3"}`},
{"POST", "/orders", `{"shop":"달빛빵집","item":"소금빵 2개","address":"느티로 12"}`},
{"POST", "/orders", `{"shop":"먼가게","item":"수제 도시락","address":"언덕길 99"}`},
{"POST", "/orders", `{"shop":"푸른약국","item":"소화제","address":"별빛로 7"}`},
{"GET", "/orders/o-2", ""},
{"GET", "/orders/o-9", ""},
{"POST", "/dispatch", ""},
{"GET", "/orders", ""},
{"GET", "/orders/o-3", ""},
}
for _, st := range steps {
call(h, st.method, st.path, st.body)
}
}
줄별 해설
상태와 저장소
상태 문자열은 상수로 묶었다. Order 의 Seq 는 json:"-" 태그로 응답에서 뺀 내부 값이며, 배달원을 고르는 데만 쓴다. courier,omitempty 덕분에 배차 전에는 courier 필드가 응답에 나오지 않는다. orderStore 의 메서드는 모두 뮤텍스를 잡고 값 복사본을 돌려준다. 포인터를 밖으로 내보내면 락 밖에서 수정될 수 있기 때문이다. list 는 make 로 길이를 정해 복사하므로 주문이 없을 때도 null 이 아닌 빈 배열 [] 이 된다. pending 은 "배차완료"가 아닌 주문만 순서대로 모은다.
assign 과 dispatchAll
assign 은 가게 이름으로 지연을 정하고 타이머와 ctx.Done() 중 먼저 오는 쪽을 따른다. 배달원은 (Seq-1)%len(couriers) 로 고르므로 같은 주문은 항상 같은 배달원이 된다. 타이머는 time.After 대신 NewTimer 와 Stop 으로 정리해서 마감으로 끝난 경우에 타이머가 남지 않게 했다.
dispatchAll 은 먼저 결과 슬라이스를 만들고 for range workers 로 워커를 시작한다. 정수 range 는 Go 1.22 부터 쓸 수 있다. 워커는 jobs 채널이 닫힐 때까지 번호를 받아 results[i] 에 쓴다. 이어지는 for 문이 작업 번호를 보내는데, select 의 두 번째 분기는 마감이 지난 뒤 아직 보내지 못한 주문을 실패로 채운다. 어느 분기가 선택되든 각 번호는 정확히 한 고루틴이 한 번 쓴다. 마지막으로 close(jobs) 로 워커에게 끝을 알리고 wg.Wait() 으로 기다린다.
서버와 핸들러
routes 는 메서드가 붙은 패턴 네 개를 등록하고, 완성한 mux 를 로그 미들웨어로 감싸 돌려준다. writeJSON 은 헤더를 먼저 정하고 WriteHeader 를 부른 뒤 본문을 쓴다. 이 순서가 뒤바뀌면 헤더가 전송되지 않는다. 오류 응답은 항상 {"error": …} 한 가지 모양이다.
createOrder 는 알 수 없는 필드를 거부하는 디코더로 본문을 읽고, 세 필드 중 하나라도 비면 400 을 돌려준다. 검증에 실패한 요청은 저장소를 건드리지 않으므로 번호가 낭비되지 않는다. getOrder 는 경로 변수를 읽고 없으면 404 를 쓴다.
dispatch 는 defer cancel() 로 context 자원을 반드시 풀어 준다. 워커 풀이 돌려준 결과를 입력 순서대로 훑으며 저장소를 갱신하고, 성공은 Info, 실패는 Warn 으로 기록한다. 실패 목록은 make([]string, 0) 으로 만들어 실패가 없을 때 null 이 아닌 [] 이 나가게 했다.
로그와 미들웨어
statusRecorder 는 http.ResponseWriter 를 내장하고 WriteHeader 만 가로채 상태 코드를 기억한다. 미들웨어는 안쪽 핸들러가 끝난 뒤에 메서드, 경로, 상태 코드를 한 줄로 남긴다. newLogger 는 ReplaceAttr 로 최상위 time 속성을 지운다. 시각이 들어가면 실행마다 출력이 달라지기 때문이다. 운영 환경에서는 이 설정을 빼고 시각을 그대로 남긴다.
시나리오 실행
call 은 요청을 만들어 핸들러에 직접 넘기고 상태 코드와 본문을 출력한다. 네트워크를 거치지 않으므로 호출이 동기적으로 끝나서 로그와 출력 순서가 고정된다. main 의 시나리오는 필수 값 누락, 주문 네 건 접수, 조회 성공과 404, 배차, 배차 뒤 전체 조회, 실패 주문 조회 순서다. 요청 하나가 끝나야 다음 요청을 보내므로 접수 번호도 고정된다.
실행 결과
$ go run main.go
> POST /orders
level=INFO msg=request method=POST path=/orders status=400
< 400 {"error":"shop, item, address는 모두 필요하다"}
> POST /orders
level=INFO msg=order_received order=o-1 shop=한결분식
level=INFO msg=request method=POST path=/orders status=201
< 201 {"id":"o-1","shop":"한결분식","item":"떡볶이","address":"해바라기길 3","status":"접수"}
> POST /orders
level=INFO msg=order_received order=o-2 shop=달빛빵집
level=INFO msg=request method=POST path=/orders status=201
< 201 {"id":"o-2","shop":"달빛빵집","item":"소금빵 2개","address":"느티로 12","status":"접수"}
> POST /orders
level=INFO msg=order_received order=o-3 shop=먼가게
level=INFO msg=request method=POST path=/orders status=201
< 201 {"id":"o-3","shop":"먼가게","item":"수제 도시락","address":"언덕길 99","status":"접수"}
> POST /orders
level=INFO msg=order_received order=o-4 shop=푸른약국
level=INFO msg=request method=POST path=/orders status=201
< 201 {"id":"o-4","shop":"푸른약국","item":"소화제","address":"별빛로 7","status":"접수"}
> GET /orders/o-2
level=INFO msg=request method=GET path=/orders/o-2 status=200
< 200 {"id":"o-2","shop":"달빛빵집","item":"소금빵 2개","address":"느티로 12","status":"접수"}
> GET /orders/o-9
level=INFO msg=request method=GET path=/orders/o-9 status=404
< 404 {"error":"주문을 찾을 수 없다"}
> POST /dispatch
level=INFO msg=assigned order=o-1 courier=민수
level=INFO msg=assigned order=o-2 courier=지아
level=WARN msg=assign_failed order=o-3 err="context deadline exceeded"
level=INFO msg=assigned order=o-4 courier=민수
level=INFO msg=request method=POST path=/dispatch status=200
< 200 {"dispatched":3,"failed":["o-3"]}
> GET /orders
level=INFO msg=request method=GET path=/orders status=200
< 200 [{"id":"o-1","shop":"한결분식","item":"떡볶이","address":"해바라기길 3","status":"배차완료","courier":"민수"},{"id":"o-2","shop":"달빛빵집","item":"소금빵 2개","address":"느티로 12","status":"배차완료","courier":"지아"},{"id":"o-3","shop":"먼가게","item":"수제 도시락","address":"언덕길 99","status":"배차실패"},{"id":"o-4","shop":"푸른약국","item":"소화제","address":"별빛로 7","status":"배차완료","courier":"민수"}]
> GET /orders/o-3
level=INFO msg=request method=GET path=/orders/o-3 status=200
< 200 {"id":"o-3","shop":"먼가게","item":"수제 도시락","address":"언덕길 99","status":"배차실패"}
배차 요청은 마감 100ms 를 채우므로 이 지점에서 잠깐 멈춘 뒤 끝난다. 워커가 세 개여도 o-3 하나가 마감까지 자리를 차지할 뿐 o-1, o-2, o-4 는 막히지 않는다.
실무에서 자주 틀리는 것
워커가 공유 슬라이스에 append 한다
결과를 모으려고 여러 고루틴이 같은 슬라이스에 append 하면 데이터 경쟁이 생긴다. 항목이 사라지거나 순서가 실행마다 바뀐다. go run -race 로 돌리면 경고가 나온다.
// 틀린 코드
var results []assignment
for range workers {
wg.Go(func() {
for i := range jobs {
c, err := assign(ctx, orders[i])
results = append(results, assignment{order: orders[i], courier: c, err: err})
}
})
}
// 고친 코드
results := make([]assignment, len(orders))
for range workers {
wg.Go(func() {
for i := range jobs {
c, err := assign(ctx, orders[i])
results[i] = assignment{order: orders[i], courier: c, err: err}
}
})
}
마감 없는 context 를 쓴다
context.Background() 에서 시작하면 클라이언트가 떠나도, 외부 호출이 멈춰도 배차가 끝나지 않는다. 요청 context 에서 파생하고 cancel 은 반드시 부른다.
// 틀린 코드
ctx := context.Background()
results := dispatchAll(ctx, s.store.pending(), s.workers)
// 고친 코드
ctx, cancel := context.WithTimeout(r.Context(), s.timeout)
defer cancel()
results := dispatchAll(ctx, s.store.pending(), s.workers)
WriteHeader 뒤에 헤더를 정한다
상태 코드를 쓰면 헤더가 전송된다. 그 뒤의 Header().Set 은 응답에 반영되지 않아, 클라이언트는 Content-Type 을 추측해야 한다.
// 틀린 코드
w.WriteHeader(code)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(v)
// 고친 코드
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
락을 잡은 채 느린 작업을 한다
저장소 락 안에서 assign 을 부르면 배차 중에 모든 조회와 접수가 대기한다. 락은 메모리 갱신 구간에서만 잡고, 느린 호출은 락 밖에서 한다. 이 장의 코드가 배차 결과를 모두 받은 뒤 update 로 짧게 갱신하는 이유다.
// 틀린 코드
func (s *orderStore) dispatchLocked(ctx context.Context, i int) {
s.mu.Lock()
defer s.mu.Unlock()
c, _ := assign(ctx, s.orders[i])
s.orders[i].Courier = c
}
// 고친 코드
c, err := assign(ctx, order) // 락 밖에서 느린 작업
if err == nil {
s.store.update(order.ID, statusDispatched, c) // 락은 갱신 순간만
}
한눈에 보기
| 주제 | 사용한 도구 | 이 장의 선택 | 이유 |
|---|---|---|---|
| 라우팅 | http.ServeMux 의 메서드·경로 패턴 | 패턴 네 개 | 표준 라이브러리만으로 충분하다 |
| 배차 동시성 | 채널, WaitGroup.Go | 워커 3개 | 동시 외부 호출 수를 제한한다 |
| 결과 수집 | 인덱스 슬롯 슬라이스 | results[i] 에 쓰기 | 락 없이 순서를 고정한다 |
| 시간 제한 | context.WithTimeout | 요청당 100ms | 느린 주문이 요청 전체를 막지 않는다 |
| 로그 | slog 와 미들웨어 | 요청마다 한 줄, 워커 안은 무로그 | 줄 순서를 입력 순서로 고정한다 |
| 검증 | httptest.NewRecorder | 핸들러 직접 호출 | 포트 없이 결정적으로 실행한다 |
| 상황 | 주문 상태 | 응답에서의 위치 | 다음 배차 요청 |
|---|---|---|---|
| 마감 안에 assign 성공 | 배차완료 | dispatched 개수에 포함 | 대상 아님 |
| 마감을 넘겨 ctx 오류 | 배차실패 | failed 목록 | 다시 대상이 됨 |
| 마감 뒤라 워커에 전달 못 함 | 배차실패 | failed 목록 | 다시 대상이 됨 |
연습 문제
- main 에서
workers를 1 로 바꾸면POST /dispatch의 응답은 어떻게 되는가. 이유와 함께 답하라. - 시나리오에서 첫 번째 배차 직후
POST /dispatch를 한 번 더 보내면 응답 본문은 무엇인가. GET /orders?status=배차실패처럼 상태로 걸러 조회하도록listOrders를 고치려 한다. 어디를 어떻게 바꾸겠는가.- 주소에 "섬"이 들어간 주문은 배달원이 없어 즉시 실패해야 한다.
errors.Is로 분류하려면assign과dispatch를 어떻게 바꾸겠는가.
정답과 해설
- 응답은
{"dispatched":2,"failed":["o-3","o-4"]}이다. 워커 하나가 o-1 과 o-2 를 빠르게 끝낸 뒤 o-3 에서 마감까지 묶인다. 작업을 넘기는 쪽의jobs <- i는 받을 워커가 없어 대기하다가ctx.Done()분기로 빠지고, o-4 는 워커에 닿지 못한 채 실패로 표시된다. 워커 수는 처리량과 마감 안에서 끝나는 주문 수를 함께 정한다. - 첫 배차 뒤 "배차완료"가 아닌 주문은 o-3 하나이므로 대상은 그 주문뿐이다. o-3 은 같은 가게라 다시 마감을 넘기고, 응답은
{"dispatched":0,"failed":["o-3"]}이다. 이 요청도 100ms 가 걸린다. - 핸들러에서
want := r.URL.Query().Get("status")로 값을 읽는다. 값이 비어 있으면 전체를 돌려주고, 아니면store.list()결과를 훑어Status == want인 주문만 새 슬라이스에 담아 응답한다. 새 슬라이스는make([]Order, 0)으로 만들어 결과가 없을 때[]이 나가게 한다. 걸러내는 일은 락을 잡을 필요가 없는 복사본에서 하면 된다. - 패키지 수준에
var errUnreachable = errors.New("배달 불가 지역")를 두고,assign맨 앞에서strings.Contains(o.Address, "섬")이면fmt.Errorf("주문 %s: %w", o.ID, errUnreachable)를 돌려준다.dispatch의 실패 분기에서errors.Is(a.err, errUnreachable)와errors.Is(a.err, context.DeadlineExceeded)로 갈라 로그에 reason 속성을 붙인다. 앞 장에서 다룬 오류 감싸기와 분류를 그대로 쓰는 것이며, 두 실패는 재시도할 가치가 다르므로 구분해 기록해 둘 만하다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.