HTTP API와 인증
이 챕터에서 다루는 것
HTTP 경계를 만든다. Gin을 쓰지만 Gin이 이 패키지 밖으로 새어 나가지 않는다 —
서비스도 저장소도 *gin.Context를 본 적이 없다.
파트 12가 남긴 숙제 셋을 여기서 갚는다. 요청 제한과 Retry-After(12-1 연습문제 1),
클라이언트 절단을 에러로 세지 않기(12-1 연습문제 3), 로그 레벨 변경 엔드포인트
(12-2 연습문제 1).
에러를 돌려주는 핸들러
12-1에서 net/http로 했던 변형을 Gin에서도 그대로 한다.
// apiFunc는 에러를 돌려주는 핸들러다.
//
// gin.HandlerFunc는 에러를 돌려줄 수 없어서, 모든 핸들러가 각자 응답을
// 쓰고 각자 로그를 남기게 된다. 시그니처를 한 칸 바꾸면 응답 쓰기와
// 로깅을 한 곳으로 모을 수 있다. 12-1에서 net/http로 했던 그 변형이다.
type apiFunc func(c *gin.Context) error
func (a *api) wrap(fn apiFunc) gin.HandlerFunc {
return func(c *gin.Context) {
if err := fn(c); err != nil {
a.fail(c, err)
}
}
}
Gin에는 c.Error(err)와 c.Errors가 있어서 비슷한 일을 할 수 있지만, 그쪽은
에러를 모으기만 하고 응답은 여전히 핸들러가 쓴다. apiFunc 쪽이 "에러를
돌려주면 응답이 안 나간다"를 타입으로 보장한다.
fail이 응답과 로그를 나누는 유일한 지점이다.
// fail은 에러를 응답과 로그로 나눈다. 이 함수가 그 분리를 담당하는
// 유일한 지점이다.
func (a *api) fail(c *gin.Context, err error) {
status := httpStatus(err)
a.logError(c, status, err)
if wait := apperr.RetryAfterOf(err); wait > 0 {
// Retry-After는 초 정수다. 올림해야 0초가 되지 않는다.
secs := int((wait + time.Second - 1) / time.Second)
c.Header("Retry-After", strconv.Itoa(secs))
}
if status == http.StatusUnauthorized {
// 401에 WWW-Authenticate를 붙이는 것은 RFC 9110의 요구다.
c.Header("WWW-Authenticate", `Bearer realm="taskd"`)
}
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{
"kind": apperr.KindOf(err).String(),
"message": apperr.Public(err),
}})
}
응답 형식이 하나뿐이다. 실제로 어떻게 나오는지 확인해 보자.
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/v1/tasks/9999
{"error":{"kind":"not_found","message":"할 일을 찾을 수 없다"}}
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"title":""}' \
http://127.0.0.1:8080/v1/tasks
{"error":{"kind":"invalid","message":"요청 본문이 올바르지 않다"}}
kind를 함께 내보내는 것이 의도다. 클라이언트가 상태 코드만 보면 400의 종류를
구별하지 못하는데, 문자열 상수 하나를 주면 분기할 수 있다. 상태 코드보다 안정적이기도
하다 — 나중에 400을 422로 바꿔도 kind는 그대로다.
미들웨어 순서에는 이유가 있다
// New는 라우터를 조립한다.
//
// 미들웨어 순서에 이유가 있다. 바깥에서 안쪽으로
// 복구 → 요청 ID/로깅 → 타임아웃 → 제한 → 인증 → 인가 → 핸들러.
//
// - 복구가 가장 바깥이다. 다른 미들웨어에서 난 패닉도 잡아야 한다.
// - 요청 ID가 그다음이다. 이후 모든 로그에 ID가 붙어야 한다.
// - 제한이 인증보다 앞이다. 인증은 bcrypt 비교라 비싸다. 제한을
// 인증 뒤에 두면 공격자가 무료로 CPU를 태울 수 있다.
func New(d Deps) *gin.Engine {
제한이 인증보다 앞이라는 점이 가장 자주 틀리는 부분이다. 순서를 바꾸면 로그인 엔드포인트에 무제한으로 요청을 던져 bcrypt를 계속 돌리게 만들 수 있다. bcrypt는 느린 것이 목적인 함수라, 그것이 그대로 서비스 거부 공격이 된다.
라우팅은 이렇다.
v1 := r.Group("/v1")
{
v1.POST("/auth/login", a.wrap(a.login))
v1.POST("/auth/refresh", a.wrap(a.refresh))
authed := v1.Group("", a.requireAuth())
authed.GET("/me", a.wrap(a.me))
authed.POST("/tasks", a.wrap(a.createTask))
authed.GET("/tasks", a.wrap(a.listTasks))
authed.GET("/tasks/:id", a.wrap(a.getTask))
authed.PATCH("/tasks/:id", a.wrap(a.patchTask))
authed.DELETE("/tasks/:id", a.wrap(a.deleteTask))
authed.POST("/tasks/complete-all", a.wrap(a.completeAll))
admin := authed.Group("/admin", a.requireRole(auth.RoleAdmin))
admin.GET("/jobs", a.wrap(a.jobStats))
}
인증이 필요한 경로를 그룹으로 묶은 것이 요점이다. 핸들러마다
a.requireAuth()를 붙이는 방식이면, 새 핸들러를 추가하며 빠뜨리는 순간
인증 없는 엔드포인트가 조용히 생긴다. 그룹은 빠뜨리기 어렵다.
Gin의 기본 404/405 본문도 우리 형식으로 바꾼다.
r := gin.New()
// Gin의 기본 404/405 본문 대신 우리 형식을 쓴다.
r.HandleMethodNotAllowed = true
r.NoRoute(a.wrap(func(*gin.Context) error {
return apperr.E(apperr.KindNotFound, "httpapi.NoRoute", "그런 경로가 없다", nil)
}))
HandleMethodNotAllowed는 기본이 false다(파트 10에서 확인했다). 켜지 않으면
PUT /v1/tasks가 405가 아니라 404로 온다.
$ curl -s http://127.0.0.1:8080/v1/없는경로
{"error":{"kind":"not_found","message":"그런 경로가 없다"}}
JWT 인증
auth 패키지는 파트 10-7의 token 패키지를 가져와 두 군데를 바꿨다. 검증 실패를
apperr로 번역하고, Principal을 표준 context에 담는다.
// Verify는 서명과 클레임을 검증하고 Claims를 돌려준다.
//
// 실패는 전부 apperr.KindUnauthorized다. 다만 공개 메시지는 만료와
// 그 밖의 실패를 갈라 준다 — 클라이언트가 재발급을 시도할지 다시
// 로그인시킬지 판단할 수 있어야 하기 때문이다. 그 이상은 알려 주지
// 않는다. "서명이 틀렸다"와 "발급자가 다르다"를 구별해 주면 공격자에게
// 힌트가 된다.
func (v *Verifier) Verify(raw string, want Kind) (*Claims, error) {
if err != nil {
// 만료된 토큰은 ErrTokenExpired와 ErrTokenInvalidClaims 둘 다에
// 매치된다. 만료를 먼저 물어봐야 한다.
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, apperr.E(apperr.KindUnauthorized, "auth.Verify", "토큰이 만료됐다", err)
}
return nil, apperr.E(apperr.KindUnauthorized, "auth.Verify", "토큰이 올바르지 않다", err)
}
파서 옵션은 10-7과 같다. WithValidMethods가 없으면 알고리즘 혼동 공격이 열린다.
opts := []jwt.ParserOption{
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(cfg.Issuer),
jwt.WithAudience(cfg.Audience),
// exp가 없는 토큰을 거부한다. 기본값은 "없으면 무제한"이다.
jwt.WithExpirationRequired(),
// iat가 미래인 토큰을 거부한다.
jwt.WithIssuedAt(),
// 시계 차이를 몇 초 허용한다. 0이면 분산 환경에서 흔들린다.
jwt.WithLeeway(5 * time.Second),
}
주체는 Gin이 아니라 context에
파트 10에서는 c.Set("principal", p)를 썼다. 여기서는 안 쓴다.
type ctxKey int
const principalKey ctxKey = iota
// Principal은 인증된 요청 주체다.
type Principal struct {
UserID string
Role string
}
// WithPrincipal은 주체를 context에 심는다.
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, principalKey, p)
}
이유는 계층 규칙이다. c.Set에 넣으면 그 값을 꺼내려면 *gin.Context가 필요하고,
서비스 계층이 Gin을 알게 된다. 표준 context에 넣으면 아래 계층이 프레임워크를
모른 채로 주체를 볼 수 있다. 13-1의 계층 규칙 테스트가 이 결정을 강제한다.
주체가 없는 경우도 타입으로 다룬다.
// ErrNoPrincipal은 인증 미들웨어를 거치지 않은 요청이다.
//
// 라우팅 실수로 인증 없이 붙은 핸들러가 소유자 없이 저장소를 부르는 것을
// 막는다. 이 에러가 보이면 사용자 잘못이 아니라 배선 잘못이므로
// KindInternal이다.
var ErrNoPrincipal = apperr.E(apperr.KindInternal, "auth", "", errors.New("주체가 없다"))
500을 주는 것이 맞다. 인증 미들웨어를 빠뜨린 것은 우리 잘못이고, 401을 주면 그 버그가 "사용자가 로그인을 안 했다"로 위장된다.
토큰을 믿지 않는다
// 토큰이 유효해도 계정이 삭제됐을 수 있다. 토큰만 믿으면
// 탈퇴한 계정이 만료 전까지 계속 접근한다.
//
// 역할도 토큰이 아니라 저장소에서 읽는다. 그래야 권한을 회수했을 때
// 토큰 만료를 기다리지 않고 즉시 반영된다. 대가는 요청마다 조회
// 한 번이고, 그것이 아까우면 짧은 TTL 캐시를 둔다.
u, err := a.Users.ByID(c.Request.Context(), claims.Subject)
if err != nil {
a.fail(c, err)
return
}
JWT의 장점으로 흔히 "상태를 안 봐도 된다"가 꼽히는데, 그 장점을 온전히 취하려면 권한 회수가 액세스 토큰 수명만큼 늦어지는 것을 받아들여야 한다. 액세스 토큰이 15분이면 해고된 관리자가 15분 동안 관리자다. 이 서비스는 그 대신 조회 한 번을 낸다.
인가는 인증과 다른 미들웨어다
// requireRole은 역할을 요구한다. requireAuth 뒤에 놓아야 한다.
func (a *api) requireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
p, err := auth.MustPrincipal(c.Request.Context())
if err != nil {
a.fail(c, err)
return
}
if p.Role != role {
// 인증은 됐지만 권한이 없다. 401이 아니라 403이다.
a.fail(c, apperr.E(apperr.KindPermission, "httpapi.requireRole", "", nil))
return
}
c.Next()
}
}
401과 403의 구별은 "다시 로그인하면 되는가"다. 401은 자격 증명이 없거나 틀린 것이고, 403은 자격 증명은 맞는데 권한이 없는 것이다. 다시 로그인해도 소용없다.
실제 동작이다.
$ curl -H "Authorization: Bearer $ALICE" http://127.0.0.1:8080/v1/admin/jobs
{"error":{"kind":"permission","message":"권한이 없다"}} [403]
$ curl -H "Authorization: Bearer $ADMIN" http://127.0.0.1:8080/v1/admin/jobs
{"dead":0,"done":0,"pending":1,"running":0}
:::warning 역할이 있어도 남의 데이터는 못 본다
admin은 잡 통계를 볼 수 있지만 앨리스의 할 일은 못 본다. 저장소가 ownerID를
WHERE 절에 넣기 때문이고, 그 계층에는 역할이라는 개념이 아예 없다.
"관리자는 다 볼 수 있어야 한다"는 요구가 나중에 오면, 그때 명시적인 별도
엔드포인트를 만든다. 기존 핸들러에 if role == admin 분기를 넣는 것이 아니라.
분기를 넣는 순간, 소유자 격리라는 성질이 코드 전체에 흩어져 검증할 수 없게 된다.
:::
요청 검증
Gin의 바인딩 태그로 검증한다. PATCH의 필드가 포인터인 것이 요점이다.
type patchTaskRequest struct {
// 포인터인 것이 의도다. PATCH는 "안 준 필드는 그대로"여야 하는데,
// 값 타입이면 빈 문자열을 준 것과 안 준 것을 구별할 수 없다.
Title *string `json:"title" binding:"omitempty,max=200"`
Status *string `json:"status" binding:"omitempty,oneof=todo doing done"`
}
검증 실패 메시지는 그대로 내보내지 않는다.
// bindJSON은 본문을 읽고 검증한다.
//
// Gin의 binding 태그가 validator/v10로 검증한다. 검증 실패를 그대로
// 돌려주면 "Key: 'createTaskRequest.Title' Error:Field validation for
// 'Title' failed on the 'max' tag" 같은 내부 구조가 그대로 노출되므로,
// 공개 메시지는 따로 만든다.
func bindJSON(c *gin.Context, op string, dst any) error {
if err := c.ShouldBindJSON(dst); err != nil {
return apperr.E(apperr.KindInvalid, op, "요청 본문이 올바르지 않다", err)
}
return nil
}
원본 메시지는 로그에만 남는다. 실제 로그 한 줄이다.
level=WARN msg="request rejected" request_id=0133a89c-32a7-4c77-ad61-cfb465a80b89 method=POST path=/v1/tasks status=400 kind=invalid ops=[httpapi.createTask] err="httpapi.createTask: 요청 본문이 올바르지 않다: Key: 'createTaskRequest.Title' Error:Field validation for 'Title' failed on the 'required' tag"
응답에는 "요청 본문이 올바르지 않다"만 나가고, 개발자가 필요한 내용은 로그에 다 있다.
공개 메시지와 내부 원인의 분리가 이 한 줄에 다 보인다.
응답 타입도 도메인 타입과 분리한다.
// taskResponse는 바깥으로 나가는 표현이다.
//
// task.Task를 그대로 직렬화하지 않는다. 도메인 구조체에 필드를 하나
// 추가하는 것이 곧 API 변경이 되어 버리면, 내부 리팩터링을 할 때마다
// 클라이언트를 걱정해야 한다.
type taskResponse struct {
ID int64 `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
목록 응답에는 함정이 하나 더 있다.
// nil 슬라이스는 JSON에서 null이 된다. 빈 배열이어야 클라이언트가
// 분기하지 않는다.
out := make([]taskResponse, 0, len(items))
요청 제한 — 12-1 연습문제 1
토큰 버킷을 직접 만든다. golang.org/x/time/rate를 쓰는 편이 실무에서는 맞지만,
직접 만들어 보면 두 가지가 분명해진다.
// Limiter는 키마다 토큰 버킷을 하나씩 두는 요청 제한기다.
//
// 12-1 연습문제 1이 남긴 숙제다. golang.org/x/time/rate를 쓰는 편이
// 실무에서는 맞지만, 직접 만들어 보면 두 가지가 분명해진다.
//
// 1. 토큰 버킷은 타이머가 필요 없다. 마지막으로 본 시각과 지금 시각의
// 차이로 채워 넣을 토큰 수를 계산하면 된다. 버킷 수만큼 고루틴을
// 띄우는 구현을 가끔 보는데, 그러면 사용자 수만큼 고루틴이 생긴다.
// 2. 429를 줄 때는 "언제 다시 오라"까지 말해야 한다. Retry-After가
// 없으면 클라이언트는 즉시 재시도하고, 그것이 장애를 키운다.
//
// 프로세스 하나짜리 제한기라는 점은 분명히 해 둔다. 인스턴스가 셋이면
// 실제 허용량도 셋 배다. 정확한 전역 제한이 필요하면 Redis 같은 공유
// 저장소가 필요하고, 그때는 네트워크 왕복이 요청마다 하나 늘어난다.
핵심은 세 줄이다.
// 지난 시간만큼 토큰을 채운다. 타이머 없이 이 한 줄이 전부다.
elapsed := now.Sub(b.seen).Seconds()
if elapsed > 0 {
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rps)
}
b.seen = now
기다려야 할 시간을 함께 돌려준다.
// 토큰 하나가 찰 때까지 걸리는 시간. 올림해서 초 단위로 준다 —
// Retry-After 헤더는 초 정수이고, 내림하면 0초가 되어 즉시 재시도를
// 부른다.
need := (1 - b.tokens) / l.rps
wait := time.Duration(math.Ceil(need*1000)) * time.Millisecond
return false, wait
맵이 무한히 자라지 않게 하는 것도 잊으면 안 된다.
// sweepLocked는 오래 안 쓰인 버킷을 지운다. 락을 쥔 상태에서 부른다.
func (l *Limiter) sweepLocked(now time.Time) {
if now.Sub(l.lastSweep) < sweepEvery {
return
}
l.lastSweep = now
// 버킷이 가득 찰 만큼 시간이 지났다면 지워도 동작이 같다.
// 다시 오면 가득 찬 버킷이 새로 만들어진다.
full := time.Duration(l.burst/l.rps*float64(time.Second)) + time.Second
for key, b := range l.buckets {
if now.Sub(b.seen) > full {
delete(l.buckets, key)
}
}
}
IP를 키로 쓰는데 정리하지 않으면, 그것이 곧 메모리 누수다. 스캐너 하나가 IP를 바꿔 가며 두드리면 맵이 무한히 자란다.
테스트는 시계를 고정해서 결정적으로 만든다.
// TestRateLimitReturns429WithRetryAfter는 12-1 연습문제 1의 답을 확인한다.
func TestRateLimitReturns429WithRetryAfter(t *testing.T) {
t.Parallel()
// rps=1, burst=2. 시계를 고정하면 토큰이 다시 차지 않는다.
lim := httpapi.NewLimiter(1, 2, fixedClock)
w := get()
if w.Code != http.StatusTooManyRequests {
t.Fatalf("3번째 = %d, 429를 기대", w.Code)
}
if got := w.Header().Get("Retry-After"); got != "1" {
t.Errorf("Retry-After = %q, \"1\"을 기대 (rps=1이므로 1초)", got)
}
실제 서비스에 부하를 걸어 보면 이렇게 나온다. 기본 설정은 rps=20, burst=40이다.
for i in $(seq 1 60); do curl -s -o /dev/null -w '%{http_code} ' $B/v1/me; done \
| tr ' ' '\n' | sort | uniq -c
45 401
15 429
:::note 이 숫자는 실행마다 달라진다
60번을 도는 데 걸린 실제 시간만큼 토큰이 다시 차기 때문이다. 위 실행에서는
버킷 40개로 시작해 도는 동안 다섯 개쯤 더 찼다. curl 프로세스를 60번 띄우는
비용에 좌우되므로, 느린 머신에서는 429가 더 적게 나온다.
결정적인 확인은 테스트가 한다. 시계를 주입해서 토큰이 차지 않게 고정하면 "세 번째부터 429"가 정확히 재현된다. :::
Retry-After 헤더는 실제로 붙는다.
HTTP/1.1 429 Too Many Requests
Retry-After: 1
클라이언트 절단은 에러가 아니다 — 12-1 연습문제 3
12-8에서 이름만 언급하고 넘어간 숙제다. 사용자가 브라우저 탭을 닫으면 진행 중이던
DB 쿼리가 context.Canceled로 실패한다. 그것을 500으로 처리하면 어떻게 되는가.
- 5xx 비율 그래프가 사용자 이탈률을 따라 움직인다.
- 에러 알림이 계속 울린다.
- 서버는 아무 잘못도 하지 않았다.
먼저 종류를 하나 만든다.
// KindOf는 에러 사슬에서 첫 번째 Kind를 찾는다.
//
// 못 찾으면 표준 에러 두 개를 확인한 뒤 KindInternal로 떨어진다.
// context 취소와 마감 초과를 여기서 흡수해 두면, 아래 계층이 그것을
// 일일이 감쌀 필요가 없다.
func KindOf(err error) Kind {
if e, ok := errors.AsType[*Error](err); ok {
return e.Kind
}
switch {
case errors.Is(err, context.DeadlineExceeded):
return KindTimeout
case errors.Is(err, context.Canceled):
return KindCanceled
}
return KindInternal
}
DeadlineExceeded와 Canceled를 나누는 것이 핵심이다.
- 마감 초과는 우리가 건 타임아웃이다. 우리 문제이므로 504다.
- 취소는 클라이언트가 끊은 것이다. 우리 문제가 아니다.
상태 코드는 499를 쓴다.
// StatusClientClosedRequest는 nginx가 쓰는 비표준 코드 499다.
//
// 클라이언트가 이미 연결을 끊었으므로 이 코드는 네트워크로 나가지 않는다.
// 오직 접근 로그에만 남아, "5xx 비율" 그래프에서 이 요청을 빼기 위해 쓴다.
const StatusClientClosedRequest = 499
로그 레벨도 내린다.
level := slog.LevelError
msg := "request failed"
switch {
case status == StatusClientClosedRequest:
level, msg = slog.LevelInfo, "client disconnected"
case status < http.StatusInternalServerError:
// 4xx는 클라이언트가 잘못한 것이다. warn까지가 적당하다.
// error로 찍으면 오타 난 요청 하나가 알림을 울린다.
level, msg = slog.LevelWarn, "request rejected"
}
l.LogAttrs(c.Request.Context(), level, msg, attrs...)
이것이 실제로 동작하는지 테스트로 못 박는다. context.Canceled를 돌려주는
저장소를 만들어 끼운다.
// cancelStore는 무엇을 물어봐도 context.Canceled를 돌려주는 저장소다.
// 클라이언트가 연결을 끊었을 때 DB 드라이버가 내는 에러를 흉내 낸다.
type cancelStore struct{ task.Store }
func (cancelStore) List(context.Context, string, task.ListOptions) ([]task.Task, error) {
return nil, context.Canceled
}
인터페이스를 임베딩해서 한 메서드만 덮어쓰는 형태다. 나머지 메서드는 임베딩된 값으로 위임되므로, 인터페이스에 메서드가 추가돼도 이 테스트 대역은 안 깨진다.
w := h.do(t, http.MethodGet, "/v1/tasks", tok, nil)
if w.Code != httpapi.StatusClientClosedRequest {
t.Errorf("상태 = %d, %d(499)를 기대", w.Code, httpapi.StatusClientClosedRequest)
}
out := buf.String()
if !strings.Contains(out, `level=INFO msg="client disconnected"`) {
t.Errorf("INFO 레벨 로그를 기대:\n%s", out)
}
if strings.Contains(out, "level=ERROR") {
t.Errorf("ERROR 레벨 로그가 있으면 안 된다:\n%s", out)
}
요청 ID 전파
12-2의 applog를 그대로 쓰되, 미들웨어는 Gin 형태로 다시 쓴다.
// requestLog는 요청마다 ID를 붙이고 한 줄씩 접근 로그를 남긴다.
func (a *api) requestLog() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader("X-Request-Id")
if id == "" {
id = a.NextRequestID()
}
ctx := applog.WithRequestID(c.Request.Context(), id)
ctx = applog.Into(ctx, a.Logger)
c.Request = c.Request.WithContext(ctx)
c.Header("X-Request-Id", id)
클라이언트나 게이트웨이가 준 ID를 그대로 쓰는 것이 중요하다. 우리가 새로 만들어 버리면 게이트웨이 로그와 서버 로그를 이어 붙일 수 없다.
$ curl -s -D- -o/dev/null http://127.0.0.1:8080/v1/tasks | grep -i 'x-request'
X-Request-Id: 0ccac357-83d5-46f9-afe2-b3590c272414
applog.ContextHandler가 그 ID를 모든 로그 줄에 자동으로 붙인다. 위에서 본
request rejected 줄에 request_id=가 들어 있던 것이 그것이다.
통합 테스트
httptest와 메모리 저장소로 전체 흐름을 돈다. DB도 네트워크도 필요 없다.
// fixedClock은 고정 시각이다. JWT는 HMAC이라 같은 입력에 같은 서명을
// 내므로, 시계와 jti를 고정하면 토큰 문자열까지 결정적이다.
var fixedClock = func() time.Time { return time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) }
// bcrypt는 기본 계수에서 해시 하나에 수십 밀리초가 걸린다.
// 테스트에서만 MinCost로 낮춘다.
users := auth.NewMemoryUsers(bcrypt.MinCost)
가장 값진 테스트는 소유자 격리다.
// TestOwnerIsolation은 남의 할 일이 404로 보이는지 확인한다.
//
// 403이 아니라 404다. 403을 주면 "그 ID는 존재한다"는 사실이 새어 나간다.
func TestOwnerIsolation(t *testing.T) {
그리고 트랜잭션 경계가 실제로 지켜지는지도 HTTP 레벨에서 확인한다.
// 생성과 동시에 알림 잡이 큐에 들어가야 한다. 같은 트랜잭션이다.
counts, err := h.store.CountByStatus(t.Context())
if err != nil {
t.Fatalf("CountByStatus: %v", err)
}
if counts[task.JobPending] != 1 {
t.Errorf("대기 잡 = %d, 1을 기대 (%v)", counts[task.JobPending], counts)
}
$ go test -count=1 ./internal/httpapi/
ok example.com/capstone/internal/httpapi 0.780s
로그 레벨 엔드포인트 — 12-2 연습문제 1
12-2가 명세만 하고 쓰지 않은 것이다. applog.Options.Level이 *slog.LevelVar인
이유가 정확히 이것이다.
// LevelHandler는 로그 레벨을 읽고 바꾸는 관리용 엔드포인트다.
//
// 12-2 연습문제 1이 요구한 것이다. 운영 중 장애를 볼 때 debug 로그가
// 필요한데, 그것 때문에 재배포를 하면 문제 상황이 사라져 버린다.
//
// curl localhost:6060/admin/loglevel
// curl -X PUT -d debug localhost:6060/admin/loglevel
//
// 반드시 관리 포트에만 붙인다. 서비스 포트에 노출하면 아무나 레벨을
// debug로 올려 디스크를 채울 수 있다.
func LevelHandler(lv *slog.LevelVar) http.Handler {
12-2의 applog는 레벨을 패키지 전역 변수로 뒀었다. 캡스톤에서는
Options.Level로 주입받게 바꿨다 — 어느 LevelVar를 이 엔드포인트가 건드리는지
코드에 드러나야 하고, 전역이면 테스트가 병렬로 돌 때 서로의 레벨을 덮어쓴다.
실제로 동작한다.
$ curl -s http://127.0.0.1:6060/admin/loglevel
{"level":"INFO"}
$ curl -s -X PUT -d debug http://127.0.0.1:6060/admin/loglevel
{"level":"DEBUG"}
$ curl -s -X PUT -d nonsense http://127.0.0.1:6060/admin/loglevel
{"error":"slog: level string \"nonsense\": unknown name","level":"DEBUG"}
마지막 응답이 400이면서도 현재 레벨을 함께 돌려준다. 실패했을 때 지금 상태가 무엇인지 알려 주지 않으면, 장애 중에 한 번 더 물어봐야 한다.
바꾸는 것 자체도 로그에 남는다.
level=WARN msg="log level changed" level=DEBUG
WARN인 것이 의도다. 누군가 레벨을 낮춰 놓고 잊으면 로그가 안 나오는데, 그 사실을 알려 주는 줄이 낮춘 레벨보다 위에 있어야 한다.
흔한 실수
핸들러마다 응답을 쓰고 로그를 남긴다. 형식이 제각각이 되고, 어떤 에러는 두 번 찍히고 어떤 에러는 안 찍힌다.
검증 라이브러리의 에러 메시지를 그대로 내보낸다. 내부 구조체 이름과 태그가 노출된다.
요청 제한을 인증 뒤에 둔다. bcrypt를 무료로 태울 수 있게 된다.
429에 Retry-After를 안 붙인다. 클라이언트가 즉시 재시도하고 장애가 커진다.
Retry-After를 내림해서 0을 준다. 위와 같다. 올림한다.
제한기 맵을 정리하지 않는다. IP를 키로 쓰면 그것이 메모리 누수다.
context.Canceled를 500으로 센다. 사용자가 탭을 닫을 때마다 알림이 울린다.
주체를 c.Set에 넣는다. 아래 계층이 Gin을 알아야 꺼낼 수 있다.
역할을 토큰에서 읽고 끝낸다. 권한 회수가 토큰 만료만큼 늦어진다.
목록 응답으로 nil 슬라이스를 준다. JSON에서 null이 되고 클라이언트가 분기한다.
HandleMethodNotAllowed를 안 켠다. 405가 404로 온다.
정리
apiFunc가 에러를 돌려주면 응답 쓰기와 로깅이 한 곳으로 모인다.- 미들웨어 순서: 복구 → 요청 ID → 타임아웃 → 제한 → 인증 → 인가. 제한이 인증보다 앞이어야 한다.
- 인증이 필요한 경로는 그룹으로 묶는다. 빠뜨리기 어렵게 만든다.
- 주체는 표준
context로 나른다. 그래야 아래 계층이 Gin을 모른다. - 토큰이 유효해도 계정과 역할은 저장소에서 다시 읽는다.
- 401은 자격 증명 문제, 403은 권한 문제. 다시 로그인해서 해결되는가로 가른다.
- 응답 타입과 도메인 타입은 다르다. 잡 페이로드와 같은 이유다.
- 429에는
Retry-After를 올림해서 붙인다. 제한기 맵은 주기적으로 정리한다. context.Canceled는 499에 INFO다.DeadlineExceeded는 504에 ERROR다.- 로그 레벨은 관리 포트에서 바꾼다. 바꾼 사실을 WARN으로 남긴다.
연습문제
-
지금 요청 제한의 키는 IP다. 인증된 요청은 사용자 ID를 키로 쓰도록 바꿔 보자. 미들웨어를 하나 더 만들어 인증 뒤에 놓으면 된다. 두 제한기가 동시에 걸릴 때
Retry-After를 어느 쪽 값으로 줘야 하는지 생각해 보자. -
POST /v1/tasks를 멱등하게 만들어 보자. 클라이언트가Idempotency-Key헤더를 보내면, 같은 키로 온 두 번째 요청은 새로 만들지 않고 첫 번째 결과를 그대로 돌려준다. 저장은 어디에 하는가? 키의 수명은? 첫 요청이 아직 처리 중일 때 두 번째가 오면? -
refresh핸들러는 리프레시 토큰을 회전시키지만 이전 토큰을 폐기하지 않는다. 유출된 리프레시 토큰이 계속 쓰일 수 있다는 뜻이다.jti를 폐기 목록에 올리는 방법을 설계해 보자. 목록은 어디에 두고, 언제 비우는가? "회전된 토큰이 다시 쓰이면 그 계정의 모든 세션을 끊는다"는 정책은 왜 유용한가?