backend

Go HTTP 서버 확장하기: 라우팅 분리와 미들웨어 개념 정리

mirabo01 2026. 1. 30. 22:22

이전 챕터에서 net/http로 가장 단순한 API 서버를 만들었다면,
이번에는 그 서버를 **조금 더 “실제 서비스에 가까운 형태”**로 확장해본다.

이번 챕터의 핵심은 다음 두 가지다.

  • 라우팅 로직을 어떻게 정리하는가
  • 공통 로직을 미들웨어 형태로 어떻게 분리하는가

프레임워크 없이도
구조적으로 정리된 서버 코드를 만드는 게 목표다.


왜 라우팅 분리가 필요한가

단순 예제에서는 이런 코드가 문제 없어 보인다.

http.HandleFunc("/ping", pingHandler)
http.HandleFunc("/users", userHandler)
http.HandleFunc("/orders", orderHandler)

하지만 엔드포인트가 늘어나면 곧 한계가 온다.

  • main 함수가 비대해짐
  • 핸들러 간 책임이 섞임
  • 테스트가 어려워짐

그래서 실무에서는
라우팅 정의와 핸들러 구현을 분리하는 게 일반적이다.


기본적인 라우팅 분리 구조

가장 단순하면서 많이 쓰이는 구조는 다음이다.

cmd/server/main.go
internal/handler/
 ├── ping.go
 └── user.go

main.go

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("/ping", handler.Ping)
    mux.HandleFunc("/users", handler.Users)

    http.ListenAndServe(":8080", mux)
}
  • 라우팅만 담당
  • 실제 로직은 handler 패키지로 위임

이렇게 하면
서버 전체 흐름이 한눈에 들어온다.

[이미지: Go 라우팅 분리 구조]


ServeMux 이해하기

http.ServeMux는 Go 기본 라우터다.

mux := http.NewServeMux()
  • URL path 기반 매칭
  • 가장 긴 패턴 우선 매칭
  • 성능, 안정성 면에서 충분히 검증됨

복잡한 REST 라우팅이 아니라면
ServeMux만으로도 충분한 경우가 많다.


Handler를 함수에서 구조체로 확장하기

의존성이 늘어나면
단순 함수형 핸들러는 한계가 있다.

type UserHandler struct {
    service *UserService
}
func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // handler logic
}

이 방식의 장점은 명확하다.

  • 의존성 주입 가능
  • 테스트 시 mock 대체 가능
  • 상태 없는 handler 구조 유지

실무에서는 이 패턴을 기본으로 가져가는 경우가 많다.


미들웨어란 무엇인가

미들웨어는
요청과 핸들러 사이에 끼어드는 공통 로직이다.

대표적인 예시는 다음과 같다.

  • 로깅
  • 인증 / 인가
  • 요청 시간 측정
  • panic 복구

모든 핸들러에서 반복되는 코드를
한 번만 작성할 수 있게 해준다.


Go에서의 미들웨어 기본 형태

Go에서 미들웨어는 보통 이런 형태를 가진다.

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Println(r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}
  • 입력: http.Handler
  • 출력: http.Handler

이 형태만 기억해두면
미들웨어는 얼마든지 조합할 수 있다.

[이미지: Go 미들웨어 호출 흐름]


미들웨어 적용하기

handler := Logging(http.HandlerFunc(pingHandler))
mux.Handle("/ping", handler)

혹은 헬퍼 함수로 감싸서 사용한다.

func wrap(h http.Handler) http.Handler {
    return Logging(h)
}

이렇게 하면
라우팅 정의부에서 미들웨어 적용 여부가 한눈에 보인다.


여러 미들웨어 조합하기

handler := Logging(Auth(pingHandler))

실행 순서는 다음과 같다.

  1. Logging
  2. Auth
  3. 실제 handler

⚠️ 주의할 점

  • 순서가 의미를 가진다
  • 인증 → 로깅 → 비즈니스 로직 같은 흐름을 의식해야 한다

미들웨어 체인은
읽기 쉬운 순서로 구성하는 것이 중요하다.


context를 통한 값 전달

미들웨어에서 값을 전달할 때는
context.Context를 사용한다.

ctx := context.WithValue(r.Context(), "userID", id)
next.ServeHTTP(w, r.WithContext(ctx))

핸들러에서는 다음처럼 꺼내 쓴다.

userID := r.Context().Value("userID")

⚠️ 주의할 점

  • context는 요청 수명 동안만 유효
  • 키는 충돌 방지를 위해 전용 타입 사용 권장

REST API 형태로 정리해보기

이제 구조를 REST 형태로 보면 다음과 같다.

GET    /users
POST   /users
GET    /users/{id}

ServeMux는 path parameter를 직접 지원하지 않기 때문에
이 단계에서 보통 선택지는 두 가지다.

  • 직접 파싱해서 처리
  • 라우팅 프레임워크 도입

중요한 건
프레임워크 도입이 “문제 해결”을 위한 선택이어야지,
출발점이 되면 안 된다는 점이다.


이 단계에서 얻어야 할 감각

이 챕터에서 가장 중요한 건 문법이 아니다.

  • 라우팅은 조립
  • 핸들러는 역할
  • 미들웨어는 공통 관심사 분리

이 개념들이 머릿속에 자리 잡으면,
Gin이나 Echo를 쓰더라도
“왜 이런 구조인지”가 자연스럽게 보인다.


정리

Go HTTP 서버 구조화의 핵심은
책임 분리와 흐름 가시성이다.

  • 라우팅과 로직 분리
  • 미들웨어로 공통 코드 제거
  • context로 요청 범위 데이터 전달

여기까지 왔다면,
단순 예제를 넘어서 실제 서비스 코드의 형태가 보이기 시작했을 것이다.

다음 챕터에서는 이 구조 위에서
간단한 CRUD API 구현 + 테스트 코드 작성으로 이어가면
실전 감각을 확실히 잡을 수 있다.