고 실전 #5 미들웨어 패턴

5 분 소요

#4 DB 연동에서 안쪽 데이터를 다뤘다면 — 이번엔 다시 바깥. 요청과 응답 사이의 공통 처리.

Go의 미들웨어는 — 다른 언어의 미들웨어와 본질이 같지만, 언어가 따로 지원하는 개념이 아니라 http.Handler 인터페이스만으로 표현되는 게 특징입니다. 단순한 만큼 — 강력합니다.

미들웨어의 모양 — Handler → Handler #

미들웨어 시그니처
type Middleware func(http.Handler) http.Handler

함수 하나가 — 핸들러를 받아 새 핸들러를 돌려줍니다. 그 안에서 원래 핸들러를 부르기 전후에 처리를 넣습니다.

가장 작은 예 — 로깅 #

로깅 미들웨어
func logger(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
	})
}

핵심:

  • http.HandlerFunc 어댑터로 함수를 Handler로 변환
  • **next.ServeHTTP(w, r)**가 실제 핸들러 호출 — 그 앞뒤에 로직

적용 #

등록
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", helloHandler)

handler := logger(mux)    // mux 전체를 로깅으로 감쌈

http.ListenAndServe(":8080", handler)

mux 자체도 http.Handler — 미들웨어가 둘러싸기 자연스럽습니다.

체인 #

여러 미들웨어를 — 함수 합성처럼.

체인
handler := logger(recoverer(authMiddleware(mux)))

순서: logger → recoverer → auth → mux. 안쪽이 먼저 실행되는 게 아니라 — 바깥쪽이 먼저 ServeHTTP 진입. 호출 흐름은:

  1. logger.ServeHTTP 시작 (start 기록)
  2. recoverer.ServeHTTP 시작 (defer recover)
  3. auth.ServeHTTP 시작 (인증 검사)
  4. mux.ServeHTTP (실제 라우팅)
  5. ← 응답 후 차례로 빠져나옴 (로그 기록 등)

함수 합성 모양이라 — 순서가 중요. 보통 outer부터 inner: 로그 → 복구 → 인증 → 라우팅.

체인 헬퍼 #

손으로 중첩하면 가독성이 떨어집니다. 헬퍼 하나로 평평하게.

체인 함수
type Middleware func(http.Handler) http.Handler

func chain(h http.Handler, mw ...Middleware) http.Handler {
	for i := len(mw) - 1; i >= 0; i-- {
		h = mw[i](h)
	}
	return h
}

handler := chain(mux, logger, recoverer, authMiddleware)

리스트의 앞쪽이 — 바깥쪽 (먼저 실행).

표준 미들웨어 모음 #

1) Recoverer — 패닉 복구 #

Recoverer
func recoverer(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("panic: %v\n%s", err, debug.Stack())
				http.Error(w, "internal error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

핸들러에서 panic이 일어나면 — 서버가 죽지 않게 잡고 500 응답. 모든 핸들러를 감싸는 가장 바깥쪽 미들웨어 중 하나.

2) Request ID #

요청 ID
type ctxKey string

const reqIDKey ctxKey = "reqID"

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = uuid.New().String()
		}
		w.Header().Set("X-Request-ID", id)
		ctx := context.WithValue(r.Context(), reqIDKey, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func RequestIDFrom(ctx context.Context) string {
	if v, ok := ctx.Value(reqIDKey).(string); ok {
		return v
	}
	return ""
}

분산 시스템에서 한 요청을 추적하기 위한 표준입니다. context에 넣어 두고 로그에 포함합니다.

중급 #5의 ctx.Value가 어울리는 경우 중 가장 흔한 사례.

3) 응답 상태 캡처 — accesslog 향상 #

기본 ResponseWriter는 — 응답 상태를 기억하지 않습니다. 로그에 상태/크기를 넣으려면 작은 래퍼.

status capture
type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

func (r *statusRecorder) Write(b []byte) (int, error) {
	if r.status == 0 {
		r.status = http.StatusOK
	}
	n, err := r.ResponseWriter.Write(b)
	r.bytes += n
	return n, err
}

func accessLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rec := &statusRecorder{ResponseWriter: w}
		start := time.Now()
		next.ServeHTTP(rec, r)
		log.Printf("%s %s %d %dB %s",
			r.Method, r.URL.Path, rec.status, rec.bytes, time.Since(start))
	})
}

이 패턴이 — chi, gin 같은 프레임워크 안에 똑같이 있습니다. 직접 짜봤다면 그 코드도 익숙해집니다.

4) 인증 #

인증 미들웨어
type userKey struct{}

func authMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		if token == "" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		user, err := verifyToken(token)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		ctx := context.WithValue(r.Context(), userKey{}, user)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func UserFrom(ctx context.Context) (User, bool) {
	u, ok := ctx.Value(userKey{}).(User)
	return u, ok
}

ctx 키는 — 외부에 노출 안 되는 비공개 타입으로. 다른 패키지의 같은 이름 string 키와 충돌 안 함.

5) CORS #

간단 CORS
func cors(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}

		next.ServeHTTP(w, r)
	})
}

프로덕션은 — Origin 검증, credentials 처리 등이 더 들어갑니다. rs/cors 패키지가 표준에 가깝게 처리.

6) Rate limit #

단순 rate limit
import "golang.org/x/time/rate"

func limit(next http.Handler) http.Handler {
	limiter := rate.NewLimiter(10, 30)    // 10 req/sec, burst 30
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !limiter.Allow() {
			http.Error(w, "too many", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

전체 서버 단위입니다. 사용자별로 제한하려면 — IP/사용자 ID별 limiter map을 사용합니다. 큰 트래픽이면 Redis 기반 분산 limiter를 검토합니다.

라우트 단위 미들웨어 — 표준 ServeMux의 한계 #

표준 ServeMux는 — 라우트 단위로 미들웨어를 끼우는 헬퍼가 없습니다. 손으로 감싸는 식.

개별 핸들러에 적용
mux.HandleFunc("GET /admin", authMiddleware(http.HandlerFunc(adminHandler)).ServeHTTP)

라우트가 많아지면 보일러플레이트가 거슬립니다 — chi 같은 라우터의 r.Use가 자연스러워지는 경우입니다.

chi의 라우트 단위
r.Group(func(r chi.Router) {
	r.Use(authMiddleware)
	r.Get("/admin", adminHandler)
	r.Get("/me", meHandler)
})

#2의 라우터 선택 트레이드오프와 같은 맥락입니다.

표준 패턴 모음 #

전형적인 서버의 미들웨어 스택:

실제 서버 구성
handler := chain(mux,
	requestID,           // 1) 요청 ID 부여
	accessLog,           // 2) 액세스 로그
	recoverer,           // 3) 패닉 복구
	cors,                // 4) CORS 헤더
	limit,               // 5) Rate limit
	// auth는 라우터별로
)

순서는 — recoverer가 안쪽이면 좋겠지만, 그러면 그 위 미들웨어의 panic을 못 잡습니다. 로그 → 복구 → 그 외 순서가 보통.

함정 — r.WithContext를 안 넘김 #

자주 만나는 함정
func mw(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), key, val)
		next.ServeHTTP(w, r)        // ✗ 새 ctx 안 전달
	})
}

r.WithContext(ctx)로 — 새 Request를 만들어 넘겨야 컨텍스트 값이 전달됩니다.

제대로
next.ServeHTTP(w, r.WithContext(ctx))

함정 — next.ServeHTTP 빼먹기 #

조건부 인증 같은 경우에 — 인증 실패하면 응답 보내고 return. 인증 성공하면 next.ServeHTTP 부르기. 두 경로가 헷갈리면 — 응답이 두 번 나가거나 아예 안 나가는 버그.

미들웨어의 좋은 디자인 #

  • 단일 책임 — 한 미들웨어에 한 가지 일
  • 순서 의도 명시 — 주석으로 적어 두기
  • 공유 상태 최소화 — 가능하면 ctx만 통과
  • 에러는 응답으로 표현 — 미들웨어 자체가 panic 하지 않게
  • 미들웨어도 테스트httptest로 단순 검증

마무리 #

이번 글에서 정리한 내용:

  • 미들웨어 = func(http.Handler) http.Handler
  • 로거, 복구, 요청 ID, 인증, CORS, rate limit — 표준 빌딩 블록
  • 체인 헬퍼 — 평평한 등록
  • 순서 — 바깥쪽이 먼저 실행, 보통 로그 → 복구 → 그 외
  • r.WithContext 빼먹지 말기
  • status recorder로 응답 상태/크기 캡처
  • 표준 mux는 라우트 단위 미들웨어 약함 — chi가 매끄러움

다음 글(#6 테스트와 배포)에서는 — httptest로 핸들러 테스트, Docker 멀티스테이지 빌드, 그리고 작은 서버를 운영 환경에 올리는 표준 패턴을 정리합니다.

X