본문으로 건너뛰기

Gin 미들웨어

이 챕터에서 다루는 것

9-7에서 func(http.Handler) http.Handler 미들웨어를 직접 만들었다. Gin의 미들웨어는 모양이 다르다. 무엇이 쉬워지고 무엇이 어려워지는지 본다.

그리고 9-7이 파트 10으로 미뤄 둔 CORS를 여기서 갚는다.

시그니처가 다르다

9-7에서는 미들웨어에 이름까지 붙였다.

examples/09-standard-library/07-http-server/web/middleware.go
// Middleware는 핸들러를 감싸 핸들러를 돌려주는 함수다.
// 시그니처가 하나뿐이라 프레임워크 없이도 조합이 된다.
type Middleware func(http.Handler) http.Handler

// Chain은 미들웨어를 순서대로 적용한다.
// Chain(h, a, b, c)는 요청이 a → b → c → h 순으로 지나가게 만든다.
// 뒤에서부터 감싸야 그 순서가 나온다.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}

Gin에는 Chain에 해당하는 것이 없다. 대신 r.Use(...)가 있고, 미들웨어는 gin.HandlerFunc — 그냥 func(*gin.Context)다.

차이는 "다음"을 어디서 받느냐다.

  • 표준 미들웨어는 next를 인자로 받아 클로저에 가둔다. 조립은 함수 합성이다.
  • Gin은 엔진이 핸들러 슬라이스를 들고 있고, c.Next()가 그 슬라이스의 다음 항목을 부른다. 조립은 r.Use(...)로 슬라이스에 추가하는 일이다.

결과적으로 두 가지가 달라진다.

  1. 미들웨어가 c.Next()를 부르지 않으면 뒤가 전부 안 돈다. 표준 미들웨어에서 next.ServeHTTP를 안 부르는 것과 같지만, Gin은 실수하기가 더 쉽다 — 그냥 return하면 되기 때문이다.
  2. c.Abort()라는 명시적 중단 수단이 있다. 뒤의 핸들러를 건너뛰라고 표시한다.

c.Abort를 안 부르면 핸들러가 돈다

이게 Gin에서 가장 자주 나는 사고다.

examples/10-web-frameworks/04-gin-middleware/mw/mw.go
// RequireToken은 인증 미들웨어의 최소형이다.
// 실패하면 c.Abort로 체인을 끊는다 — c.JSON만 쓰면 핸들러가 그대로 실행된다.
func RequireToken(token string) gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetHeader("X-Token") != token {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": "unauthorized", "message": "토큰이 필요하다",
})
return
}
c.Next()
}
}

c.AbortWithStatusJSONc.Abort() + c.JSON()이다. 만약 여기서 c.JSON(401, ...)만 쓰고 return했다면, 미들웨어 함수는 끝나지만 체인은 안 끊긴다. Gin이 다음 핸들러를 이어서 부르고, 그 핸들러가 또 응답을 쓰려다 headers were already written 경고를 낸다. 그리고 인증이 실패했는데도 비즈니스 로직이 실행된다.

:::danger return은 체인을 끊지 않는다 미들웨어에서 조기 종료할 때는 반드시 c.Abort() 계열을 쓴다. c.AbortWithStatusJSON, c.AbortWithStatus, c.Abort 중 하나다. :::

순서 — 양파 구조

c.Next() 앞은 "들어갈 때", 뒤는 "나올 때"다.

examples/10-web-frameworks/04-gin-middleware/mw/mw.go
// Trace는 체인의 실행 순서를 관찰하기 위한 미들웨어다.
// 이름을 c.Next() 앞뒤에 한 번씩 기록한다.
func Trace(w io.Writer, name string) gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Fprintf(w, "→ %s\n", name)
c.Next()
fmt.Fprintf(w, "← %s\n", name)
}
}

Trace(A), Trace(B) 순으로 등록하면 이렇게 나온다.

→ A
→ B
← B
← A

9-7의 중첩 미들웨어와 완전히 같은 그림이다. 표기만 바뀌었다.

로거 — 상태 코드를 세지 않아도 된다

9-7에서는 상태 코드를 알기 위해 ResponseWriter를 감싸는 recorder 타입을 직접 만들어야 했다. Gin은 이미 감싸 놨다.

examples/10-web-frameworks/04-gin-middleware/mw/mw.go
// Logger는 접근 로그를 남긴다.
//
// gin.Logger()는 stdout에 색까지 넣어 찍는다. 형식과 목적지를 통제하려면
// 직접 쓰는 편이 낫다 — 파트 12에서 이 자리를 log/slog가 차지한다.
// 시간을 찍지 않는 이유는 이 예제의 출력을 결정적으로 만들기 위해서다.
func Logger(w io.Writer) gin.HandlerFunc {
return func(c *gin.Context) {
// c.Next() 앞은 "요청이 들어올 때", 뒤는 "응답이 나간 뒤"다.
c.Next()

// c.Writer가 상태 코드와 바이트 수를 이미 세고 있다.
// 9-7에서 ResponseWriter를 직접 감싸 만들던 recorder가 필요 없다.
fmt.Fprintf(w, "%s %s %d %dB id=%s\n",
c.Request.Method, c.Request.URL.Path,
c.Writer.Status(), c.Writer.Size(), c.GetString(RequestIDKey))
}
}

이건 Gin이 실제로 덜어 주는 일이다. gin.ResponseWriterhttp.ResponseWriter를 감싸면서 Status(), Size(), Written()을 제공한다.

패닉 복구

examples/10-web-frameworks/04-gin-middleware/mw/mw.go
// Recover는 패닉을 500으로 바꾼다.
//
// gin.Recovery()도 같은 일을 하지만 본문이 비어 있고 스택을 stderr에 찍는다.
// 에러 응답 형식을 하나로 고정하려면 직접 쓴다.
func Recover(w io.Writer) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
v := recover()
if v == nil {
return
}
if v == http.ErrAbortHandler {
panic(v)
}
fmt.Fprintf(w, "패닉 %s %s: %v\n", c.Request.Method, c.Request.URL.Path, v)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"code": "internal", "message": "내부 오류",
})
}()
c.Next()
}
}

http.ErrAbortHandler를 다시 던지는 이유는 9-7과 같다. 그건 "연결을 조용히 끊으라"는 net/http의 약속된 신호이므로 가로채면 안 된다.

요청 ID — 두 군데에 넣는다

examples/10-web-frameworks/04-gin-middleware/mw/mw.go
// RequestID는 요청마다 번호를 매긴다.
//
// 값을 두 군데에 넣는다:
// - c.Set — 같은 요청의 다른 Gin 핸들러가 c.GetString으로 꺼낸다.
// - r.Context() — Gin을 모르는 하위 계층(저장소, 외부 클라이언트)이 꺼낸다.
//
// 둘 중 하나만 하면 반드시 나중에 아쉬워진다. c.Set은 Gin 밖으로 못 나가고,
// context만 쓰면 핸들러에서 c.Request.Context()를 매번 타야 한다.
func RequestID(counter *atomic.Int64) gin.HandlerFunc {
return func(c *gin.Context) {
id := fmt.Sprint(counter.Add(1))

c.Set(RequestIDKey, id)
c.Request = c.Request.WithContext(
context.WithValue(c.Request.Context(), requestIDCtxKey, id))
c.Header("X-Request-ID", id)

c.Next()
}
}

c.Request = c.Request.WithContext(...)에서 대입을 빼먹으면 안 된다. 9-7의 "r.WithContext(ctx)의 반환값을 안 쓴다"와 정확히 같은 실수다. WithContext는 새 *http.Request를 만들어 반환할 뿐이다.

키 타입도 9-7과 같은 규칙을 따른다.

examples/10-web-frameworks/04-gin-middleware/mw/mw.go
// ctxKey는 context.Context에 넣을 때 쓰는 키 타입이다.
type ctxKey int

const requestIDCtxKey ctxKey = iota

// RequestIDKey는 c.Set/c.Get에서 쓰는 키다.
const RequestIDKey = "request_id"

c.Set의 키는 문자열밖에 못 쓴다. Gin이 map[string]any로 들고 있기 때문이다. 그래서 c.Set은 같은 애플리케이션 안에서만 안전하고, 라이브러리 미들웨어끼리 키가 부딪힐 수 있다. context는 타입으로 격리된다 — 이게 두 군데에 넣는 두 번째 이유다.

하위 계층은 Gin을 모른다

examples/10-web-frameworks/04-gin-middleware/mw/mw.go
// RequestIDFrom은 Gin에 의존하지 않고 요청 ID를 꺼낸다.
// 서비스·저장소 계층은 이 함수만 알면 된다.
func RequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDCtxKey).(string)
return id
}

핸들러가 c.Request.Context()를 서비스에 넘기면, 서비스는 context.Context만 안다. 10-1에서 말한 "잠금 비용의 핵심"이 바로 이 경계다.

examples/10-web-frameworks/04-gin-middleware/mw/app.go
admin := r.Group("/admin")
admin.Use(RequireToken("s3cret"))
{
admin.GET("/stats", func(c *gin.Context) {
// 하위 계층에 넘길 것은 c가 아니라 context다.
c.JSON(http.StatusOK, gin.H{"seen_by_service": describe(c.Request.Context())})
})
}

CORS — 9-7이 미뤄 둔 것

9-7에서 다룬 net/http.CrossOriginProtectionCSRF 방어였다. 교차 출처에서 오는 상태 변경 요청을 서버가 거부한다.

CORS는 방향이 반대다. 브라우저는 기본적으로 교차 출처 응답을 스크립트에 넘겨주지 않는데, 서버가 헤더로 "이 출처는 읽어도 된다"고 허락하는 것이 CORS다. 둘은 대체재가 아니라 서로 다른 문제를 푼다.

examples/10-web-frameworks/04-gin-middleware/mw/cors.go
// CORSConfig는 CORS 정책이다.
//
// 9-7의 CrossOriginProtection은 "교차 출처 쓰기 요청을 막는" CSRF 방어였다.
// CORS는 정반대 방향이다 — 브라우저에게 "이 출처는 허용한다"고 알려 주어
// 기본적으로 막혀 있는 교차 출처 읽기를 열어 준다. 둘은 대체재가 아니다.
type CORSConfig struct {
// AllowOrigins는 허용할 출처 목록이다. 빈 목록이면 아무것도 허용하지 않는다.
// "*"를 쓸 수 있지만 AllowCredentials와 함께 쓰면 브라우저가 거부한다.
AllowOrigins []string
// AllowMethods는 프리플라이트 응답에 실을 메서드 목록이다.
AllowMethods []string
// AllowHeaders는 클라이언트가 보낼 수 있는 요청 헤더 목록이다.
AllowHeaders []string
// ExposeHeaders는 브라우저 JS가 읽을 수 있게 열어 줄 응답 헤더다.
// 이걸 비워 두면 X-Request-ID를 응답에 실어도 fetch에서 읽지 못한다.
ExposeHeaders []string
// AllowCredentials는 쿠키·Authorization 헤더 동반을 허용한다.
AllowCredentials bool
// MaxAge는 프리플라이트 결과를 브라우저가 캐시할 시간이다.
MaxAge time.Duration
}

두 종류의 요청

CORS를 헷갈리게 만드는 것은 요청이 두 종류라는 점이다.

단순 요청(simple request)프리플라이트가 필요한 요청
조건GET/HEAD/POST + 제한된 헤더·Content-Type그 외 전부
흐름바로 보냄OPTIONS를 먼저 보내고, 허락받아야 본 요청을 보냄
<form> 전송Content-Type: application/json인 POST, Authorization 헤더가 붙은 GET

JSON API는 거의 전부 프리플라이트 대상이다. Content-Type: application/json 자체가 단순 요청의 조건을 벗어난다.

구현

examples/10-web-frameworks/04-gin-middleware/mw/cors.go
return func(c *gin.Context) {
origin := c.GetHeader("Origin")

// Origin이 없으면 브라우저 교차 출처 요청이 아니다. 그냥 통과시킨다.
if origin == "" {
c.Next()
return
}

// Vary를 빼먹으면 캐시(CDN, 브라우저)가 한 출처용 응답을
// 다른 출처에 그대로 내준다. 조용히 터지는 버그다.
c.Writer.Header().Add("Vary", "Origin")

if !allowed(cfg.AllowOrigins, origin) {
// 허용하지 않는 출처에는 CORS 헤더를 아예 붙이지 않는다.
// 403을 줄 필요가 없다 — 헤더가 없으면 브라우저가 알아서 막는다.
// 프리플라이트는 여기서 끊어야 핸들러가 안 돈다.
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
return
}

c.Header("Access-Control-Allow-Origin", origin)
if cfg.AllowCredentials {
c.Header("Access-Control-Allow-Credentials", "true")
}
if exposeHeaders != "" {
c.Header("Access-Control-Expose-Headers", exposeHeaders)
}

// 프리플라이트: OPTIONS + Access-Control-Request-Method.
// 본문 없이 204로 끝내고 실제 핸들러는 부르지 않는다.
if c.Request.Method == http.MethodOptions && c.GetHeader("Access-Control-Request-Method") != "" {
c.Writer.Header().Add("Vary", "Access-Control-Request-Method")
c.Writer.Header().Add("Vary", "Access-Control-Request-Headers")
c.Header("Access-Control-Allow-Methods", allowMethods)
if allowHeaders != "" {
c.Header("Access-Control-Allow-Headers", allowHeaders)
}
if cfg.MaxAge > 0 {
c.Header("Access-Control-Max-Age", maxAge)
}
c.AbortWithStatus(http.StatusNoContent)
return
}

c.Next()
}
}

여기서 다섯 가지를 짚어야 한다.

  1. Origin이 없으면 손대지 않는다. curl, 서버 간 호출, 같은 출처 요청에는 CORS 헤더가 아예 필요 없다.
  2. Vary: Origin은 필수다. 없으면 CDN이 app.example.com용 응답을 evil.example.com에 캐시된 채로 내준다.
  3. 허용하지 않는 출처에 403을 주지 않는다. CORS는 서버 보안이 아니라 브라우저 정책이다. 헤더를 안 붙이면 브라우저가 응답을 스크립트에 넘기지 않는다. 서버는 요청을 정상 처리했고, 그게 맞다. (막아야 한다면 그건 인증의 일이다.)
  4. 프리플라이트만 예외로 끊는다. OPTIONS를 통과시키면 라우터가 405를 내거나 핸들러가 이상하게 돈다.
  5. Access-Control-Expose-Headers를 빼먹으면 X-Request-ID를 못 읽는다. 응답에 헤더를 실어도 브라우저 JS는 기본 목록 밖의 헤더를 볼 수 없다.

:::warning AllowOrigins: ["*"] + AllowCredentials: true는 동작하지 않는다 브라우저 명세상 금지다. 자격 증명을 동반한 요청에는 Access-Control-Allow-Origin에 와일드카드를 쓸 수 없다. 반드시 요청의 Origin을 되돌려 줘야 하고, 그러려면 허용 목록이 있어야 한다. :::

순서 — CORS가 맨 바깥이다

examples/10-web-frameworks/04-gin-middleware/mw/app.go
// 순서가 곧 의미다. 바깥부터 안쪽으로:
// CORS → 요청 ID → 접근 로그 → 패닉 복구 → 핸들러
//
// CORS가 맨 바깥인 이유: 프리플라이트는 인증도 로깅도 필요 없이
// 즉시 끝나야 한다.
// Recover가 Logger 안쪽인 이유는 9-7과 같다 — 그래야 500도 로그에 남는다.
r.Use(
CORS(CORSConfig{
AllowOrigins: []string{"https://app.example.com"},
AllowMethods: []string{"GET", "POST", "PATCH", "DELETE"},
AllowHeaders: []string{"Content-Type", "X-Token"},
ExposeHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAge: 10 * time.Minute,
}),
RequestID(&counter),
Logger(logs),
Recover(logs),
)

실행

go run ./04-gin-middleware
--- 응답 ---
GET /public 평범한 요청 → 200 (CORS 헤더 없음)
GET /admin/stats 토큰 없음 → Abort → 401 (CORS 헤더 없음)
GET /admin/stats 토큰 있음 → 200 (CORS 헤더 없음)
GET /boom 패닉 → 500 → 500 (CORS 헤더 없음)
GET /traced 체인 순서 관찰 → 200 (CORS 헤더 없음)
OPTIONS /public 허용 출처 프리플라이트 → 204 Allow-Origin=https://app.example.com Allow-Methods=GET, POST, PATCH, DELETE Allow-Credentials=true Expose-Headers=X-Request-ID Max-Age=600 Vary=3개
OPTIONS /public 차단 출처 프리플라이트 → 403 Vary=1개
GET /public 허용 출처 실제 요청 → 200 Allow-Origin=https://app.example.com Allow-Credentials=true Expose-Headers=X-Request-ID Vary=1개

--- 미들웨어가 남긴 로그 ---
GET /public 200 18B id=1
GET /admin/stats 401 58B id=2
GET /admin/stats 200 34B id=3
패닉 GET /boom: 의도된 패닉
GET /boom 500 45B id=4
→ A
→ B
← B
← A
GET /traced 200 7B id=5
GET /public 200 18B id=6

세 가지를 확인할 수 있다.

  • 프리플라이트는 접근 로그에 없다. 요청 ID가 1,2,3,4,5,6으로 이어지는데 요청은 여덟 개다. CORS 미들웨어가 RequestID보다 바깥에서 끊었기 때문이다. 의도한 동작이지만, 프리플라이트도 세고 싶다면 순서를 바꿔야 한다.
  • 실제 요청에는 Allow-Methods가 안 붙는다. 프리플라이트 응답에만 필요하다.
  • 패닉 로그가 접근 로그보다 먼저다. RecoverLogger 안쪽이라 먼저 끝난다. 9-7에서 확인한 것과 같다.

고루틴에 넘길 때는 c.Copy()

*gin.Context는 요청이 끝나면 풀로 돌아가 다음 요청에 재사용된다.

examples/10-web-frameworks/04-gin-middleware/mw/mw_test.go
// TestGinContextIsNotSafeAfterHandlerReturns는 c.Copy()가 왜 있는지 보여 준다.
// 핸들러가 끝나면 *gin.Context는 풀로 돌아가 다음 요청에 재사용된다.
func TestGinContextCopyForGoroutine(t *testing.T) {
gin.SetMode(gin.ReleaseMode)
r := gin.New()

done := make(chan string, 1)
r.GET("/async", func(c *gin.Context) {
c.Set("who", "요청자")

// 반드시 c.Copy()다. c를 그대로 넘기면 핸들러가 끝난 뒤
// 다른 요청이 같은 객체를 쓰고 있을 수 있다.
cp := c.Copy()
go func() { done <- cp.GetString("who") }()

c.String(http.StatusOK, "ok")
})

rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/async", nil))

if got := <-done; got != "요청자" {
t.Errorf("복사본에서 읽은 값 = %q", got)
}
if body, err := io.ReadAll(rec.Result().Body); err != nil || string(body) != "ok" {
t.Errorf("body = %q err = %v", body, err)
}
}

c.Copy()가 만드는 복사본은 Writer가 비활성화되어 있어 응답을 쓸 수 없다. 읽기 전용 스냅숏이다. 표준 net/http에는 이 문제가 없다 — *http.Request는 풀링되지 않는다. Gin이 할당을 줄이려고 선택한 설계의 대가다.

테스트

미들웨어는 로그를 io.Writer로 주입받게 만들었으므로 테스트가 전부 관찰할 수 있다.

examples/10-web-frameworks/04-gin-middleware/mw/mw_test.go
func TestAbortStopsTheChain(t *testing.T) {
var logs strings.Builder
r := mw.NewEngine(&logs)

rec := do(t, r, call{http.MethodGet, "/admin/stats", nil})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if body := strings.TrimSpace(rec.Body.String()); body != `{"code":"unauthorized","message":"토큰이 필요하다"}` {
t.Errorf("body = %s", body)
}
// Abort로 끊겨도 로그는 남아야 한다. Logger가 인증보다 바깥에 있기 때문이다.
if !strings.Contains(logs.String(), "GET /admin/stats 401") {
t.Errorf("접근 로그가 없다:\n%s", logs.String())
}
}

CORS는 헤더 하나하나를 확인한다.

examples/10-web-frameworks/04-gin-middleware/mw/mw_test.go
func TestCORSPreflight(t *testing.T) {
var logs strings.Builder
r := mw.NewEngine(&logs)

rec := do(t, r, call{http.MethodOptions, "/public", map[string]string{
"Origin": "https://app.example.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Content-Type",
}})

if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
checks := map[string]string{
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE",
"Access-Control-Allow-Headers": "Content-Type, X-Token",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "600",
}
for k, want := range checks {
if got := rec.Header().Get(k); got != want {
t.Errorf("%s = %q, want %q", k, got, want)
}
}
if got := rec.Header().Values("Vary"); len(got) != 3 {
t.Errorf("Vary = %v, want 3개", got)
}
if rec.Body.Len() != 0 {
t.Errorf("프리플라이트 본문이 비어 있지 않다: %q", rec.Body.String())
}
}

차단된 출처에서 서버가 막지 않는다는 것도 테스트로 못 박아 둔다. 이건 버그가 아니라 설계라는 사실을 6개월 뒤의 나에게 남기는 방법이다.

examples/10-web-frameworks/04-gin-middleware/mw/mw_test.go
func TestCORSRejectsUnknownOrigin(t *testing.T) {
var logs strings.Builder
r := mw.NewEngine(&logs)

// 실제 요청: 헤더를 안 붙일 뿐 서버는 정상 처리한다. 막는 것은 브라우저다.
rec := do(t, r, call{http.MethodGet, "/public", map[string]string{
"Origin": "https://evil.example.com",
}})
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (서버는 막지 않는다)", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("허용하지 않은 출처에 헤더가 붙었다: %q", got)
}

// 프리플라이트: 여기서는 끊는다. 안 끊으면 핸들러가 OPTIONS로 돈다.
rec = do(t, r, call{http.MethodOptions, "/public", map[string]string{
"Origin": "https://evil.example.com",
"Access-Control-Request-Method": "POST",
}})
if rec.Code != http.StatusForbidden {
t.Errorf("프리플라이트 status = %d, want 403", rec.Code)
}
}

gin-contrib를 쓸 것인가

github.com/gin-contrib/cors가 있고 잘 만들어져 있다. 위 코드는 60줄이다. 판단 기준은 이렇다.

  • 정책이 고정이고 출처가 한두 개 → 직접 쓴다. 의존성이 하나 줄고, 무슨 헤더가 왜 나가는지 코드에 다 보인다.
  • 와일드카드 서브도메인, 정규식 매칭, 동적 출처 목록 → 라이브러리를 쓴다. 직접 쓰면 미묘한 실수가 난다.

어느 쪽이든 위 다섯 가지 규칙을 알고 있어야 라이브러리 설정을 제대로 채울 수 있다.

흔한 실수

1. c.Abort 없이 c.JSON만 쓴다

인증 실패인데 핸들러가 실행된다. 가장 위험한 실수다.

2. c.Request = c.Request.WithContext(...)의 대입을 빼먹는다

WithContext는 새 요청을 반환할 뿐이다. 9-7과 같은 함정이다.

3. Vary: Origin을 빼먹는다

CDN이 한 출처의 응답을 다른 출처에 내준다. 개발 중에는 절대 안 보인다.

4. AllowOrigins: ["*"]AllowCredentials: true를 켠다

브라우저가 거부한다. 콘솔 에러를 읽기 전까지 원인을 모른다.

5. Access-Control-Expose-Headers를 잊는다

서버는 X-Request-ID를 보내는데 프런트엔드에서 undefined가 나온다.

6. 프리플라이트를 c.Next()로 흘려보낸다

라우터가 OPTIONS에 405를 내거나, 핸들러가 빈 본문으로 실행된다.

7. 고루틴에 c를 그대로 넘긴다

핸들러가 끝난 뒤 다른 요청의 데이터를 읽는다. -race로도 안 잡힐 수 있다. c.Copy()를 쓴다.

8. RecoverLogger보다 바깥에 둔다

500 응답이 접근 로그에 안 남는다. 9-7과 같은 이유다.

정리

  • Gin 미들웨어는 gin.HandlerFunc이고, c.Next()가 체인의 나머지를 부른다. 조기 종료에는 반드시 c.Abort 계열을 쓴다 — return은 체인을 안 끊는다.
  • c.Next() 앞은 들어갈 때, 뒤는 나올 때. 순서는 9-7의 양파 구조와 같다.
  • c.Writer.Status()/Size()가 이미 있다. 9-7의 recorder가 필요 없다.
  • 요청 ID는 c.Setcontext 양쪽에 넣는다. 전자는 Gin 안에서, 후자는 Gin을 모르는 하위 계층을 위해.
  • CORS 다섯 규칙: Origin 없으면 손대지 않기, Vary: Origin 붙이기, 차단 출처에 403 주지 않기(프리플라이트만 예외), 프리플라이트를 204로 끊기, Expose-Headers 채우기.
  • CORS와 CSRF는 다른 문제다. 9-7의 CrossOriginProtection과 병행할 수 있다.
  • 고루틴에는 c.Copy()를 넘긴다. *gin.Context는 풀링된다.

연습문제

  1. CORS 미들웨어를 RequestID보다 안쪽으로 옮겨 보자. 프리플라이트가 접근 로그에 남는가? X-Request-ID 헤더는 프리플라이트 응답에도 붙는가? 어느 순서가 옳은지 결정하고 그 이유를 테스트 이름에 적어 보자.

  2. AllowOriginshttps://*.example.com 같은 서브도메인 와일드카드를 지원하도록 allowed를 고쳐 보자. https://evil.com#.example.com이나 https://example.com.evil.com이 통과하지 않는지 테스트로 확인한다. 왜 단순 문자열 접미사 비교가 위험한가?

  3. 레이트리밋 미들웨어를 만들어 보자. IP당 1초에 5회, 초과하면 429와 Retry-After 헤더. 7-5의 sync.Mutex와 3-3의 맵을 쓰되, 맵이 무한히 커지지 않게 하려면 무엇이 더 필요한가? testing/synctest(8-7)로 시간을 밀어 가며 테스트해 보자.