1. 글의 목적


Go 의 인터페이스는 문법 자체는 단순하지만, 실제 코드에서 어떻게 조립해서 쓰는지 가 핵심이다. 이 글은 인터페이스를 활용한 대표 패턴(Wrapping, Middleware Chain) 과, 그것을 받쳐주는 인접 개념(Context 전파, DI) 을 함께 정리한다.

인터페이스 자체의 문법 (declaration / duck typing / embedding / type assertion 등) 은 Interface 참고.

2. 인터페이스 — 한 줄 복습


"이 메서드들을 가지고 있으면 충족된다" 는 약속.

Java 의 implements 키워드 없이, 메서드 시그니처만 맞으면 자동으로 인터페이스를 만족한다 (duck typing).

// 표준 라이브러리에 정의된 인터페이스
type Handler interface {
    ServeHTTP(http.ResponseWriter, *http.Request)
}

// 우리 struct 가 ServeHTTP 만 가지면 자동으로 Handler
type MyHandler struct{}
func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ... }

3. 핵심 패턴: 감싸기 (Wrapping)


Go 에서 합성/확장이 필요한 거의 모든 코드는 다음 한 문장으로 환원된다.

원본 인터페이스를 받아 → 앞뒤에 동작을 끼워넣고 → 원본에 위임한다.

미들웨어, 데코레이터, 어댑터, 인터셉터 — 이름은 다르지만 본질은 같다. 핵심은 wrapper 도 원본과 동일한 인터페이스를 만족 하기 때문에 호출자 입장에서는 차이가 없다는 점이다.

3.1 HTTP Middleware (http.Handler 감싸기)

http.Handler 를 받아서 http.Handler 를 돌려준다. 입출력 타입이 같으니 무한히 체이닝 가능하다.

요청 → Trace → AccessLog → 실제 핸들러
        ↑         ↑            ↑
        ───── 이 셋이 모두 http.Handler ─────
func Trace(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 1) 전처리: trace ID 를 context 에 주입
        ctx := applog.WithTraceID(r.Context(), traceID)

        // 2) 원본 호출
        next.ServeHTTP(w, r.WithContext(ctx))

        // 3) (선택) 후처리도 여기서 가능
    })
}

체이닝: Trace(AccessLog(mux)) 로 쓰면 바깥이 먼저 실행된다.

  • 요청 진입 순서: Trace → AccessLog → mux
  • 응답 반환 순서: mux → AccessLog → Trace (역순)

3.2 ResponseWriter 감싸기 (status code 가로채기)

http.ResponseWriter 를 임베딩하면 Header() / Write() 는 자동 위임되고, 우리는 관심 있는 메서드만 오버라이드 하면 된다.

type statusWriter struct {
    http.ResponseWriter  // 임베딩 — 다른 메서드는 그대로 위임
    status int
}

func (w *statusWriter) WriteHeader(code int) {
    w.status = code                    // 기록해두고
    w.ResponseWriter.WriteHeader(code) // 원본에 전달
}

핸들러는 평소대로 w.WriteHeader(404) 만 호출하면, 실제로는 statusWriter 가 가로채서 status 를 기록한 뒤 원본에 넘긴다. 이후 미들웨어가 sw.status 로 응답 코드를 읽을 수 있다.

3.3 slog.Handler (로그 포맷 감싸기)

표준 라이브러리 slog.Handler 인터페이스는 4 개의 메서드를 요구한다.

type Handler interface {
    Enabled(context.Context, Level) bool
    Handle(context.Context, Record) error
    WithAttrs(attrs []Attr) Handler
    WithGroup(name string) Handler
}

우리 log.Handler struct 가 이 4 개를 모두 구현하면 slog.New(handler) 에 그대로 넘길 수 있다. 실제로 사용하지 않는 WithAttrs / WithGroup 도 인터페이스 충족을 위해 빈 구현이라도 반드시 정의해야 한다 (Go 는 부분 구현을 허용하지 않음).

4. Context 전파


context.Context요청 단위로 데이터를 옮겨다니는 가방이다. 미들웨어에서 넣고, 핸들러나 로거에서 꺼낸다.

Trace middleware       → context 에 trace ID 주입
  ↓
AccessLog middleware   → context 에서 trace ID 꺼내 로그에 포함
  ↓
Handler / Service      → slog.InfoContext(ctx, ...) 호출 시 자동 포함
// 넣기 (Trace 안에서)
ctx := applog.WithTraceID(r.Context(), traceID)

// 꺼내기 (log handler 안에서)
traceID := applog.TraceIDFromContext(ctx)

4.1 미들웨어 순서가 중요한 이유

r.WithContext(ctx)새로운 request 인스턴스 를 반환한다. 즉 바깥쪽 미들웨어가 들고 있는 r 은 변하지 않는다.

→ Trace 가 trace ID 를 ctx 에 넣어도, "Trace 보다 바깥" 에 있는 미들웨어는 그 변경을 보지 못한다. → 따라서 Trace 를 더 바깥, AccessLog 를 더 안쪽 으로 두어야 AccessLog 가 trace ID 를 읽을 수 있다.

5. 의존성 주입 (DI)


Spring 의 @Autowired 를 수동으로 하는 것. 상위 계층이 하위 의존성을 만들어서 주입 한다.

serverCmd (DB, kubeClient 생성)
  → Store (DB 받음)
    → Service (Store 받음)
      → Handler (Service 받음)
        → mux 에 route 등록
taskStore   := task.NewStore(rwDB)
taskService := task.NewService(taskStore)
taskHandler := task.NewHandler(taskService)
taskHandler.RegisterRoutes(mux)

요청 흐름은 정확히 반대 방향이다.

HTTP 요청 → mux → Handler → Service → Store → DB

5.1 인터페이스가 받쳐주는 부분

DI 의 진짜 가치는 "인터페이스로 의존하면 교체 가능성이 생긴다" 는 점이다.

// Service 가 *PostgresStore 를 직접 의존하면 → 테스트할 때도 진짜 DB 필요
type Service struct {
    store *PostgresStore
}

// Service 가 Store interface 를 의존하면 → 테스트에서 mock 으로 교체 가능
type Store interface {
    Get(id int) (*Task, error)
    Save(t *Task) error
}

type Service struct {
    store Store  // *PostgresStore, *MockStore 둘 다 들어감
}

요약: Wrapping 패턴이 "인터페이스로 합성" 이라면, DI 는 "인터페이스로 교체 가능성 확보". 둘 다 인터페이스라는 같은 도구를 다른 각도로 활용하는 것.