본문 바로가기
개발의 기록/Server

Java/Kotlin Spring 개발자의 Go 전환기 #1: Spring Controller가 Go의 Router와 Handler로 분리된 이유

by prographer J 2025. 11. 23.
728x90

"Go에는 왜 Controller가 없지? Router와 Handler가 뭔데?"

 

📌 이 글의 핵심

  • Spring의 @Controller와 Go의 Router+Handler 패턴 비교
  • Annotation 마법 vs 명시적 라우팅 등록의 차이점
  • MSA 전환 시 Router 분리 패턴의 실전 활용법
  • Chi Router를 사용한 실제 Go 백엔드 구조 설계

🎯 이런 분들께 추천합니다

  • Spring MVC 패턴에 익숙한 Java 백엔드 개발자
  • Go 웹 애플리케이션의 라우팅 구조를 이해하고 싶은 개발자
  • REST API 설계 시 Controller vs Handler 선택으로 고민하는 분

⏱️ 읽는 시간: 약 10분


Spring Boot에서 Go 언어로 넘어온 첫날, 가장 당황스러웠던 점은 익숙한 @Controller가 보이지 않는다는 것이었습니다. 대신 Router와 Handler라는 낯선 개념이 저를 맞이했죠.

Spring의 Controller: 모든 것을 한 곳에서

Spring에서는 URL 매핑부터 HTTP 처리까지 모두 Controller 하나로 해결합니다.

@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {

    @Autowired
    private LoginUseCase loginUseCase;

    @PostMapping("/login")  // ← URL 매핑
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {  // ← HTTP 처리
        // 1. HTTP → Domain 변환
        LoginResult result = loginUseCase.execute(request.getEmail(), request.getPassword());

        // 2. Domain → HTTP 변환
        LoginResponse response = new LoginResponse(result.getToken(), result.getUser());

        // 3. HTTP 상태 코드 결정
        return ResponseEntity.ok(response);
    }
}

 

Spring Controller의 3가지 역할:

  1. URL 매핑 (@PostMapping("/login"))
  2. HTTP 처리 (Request 파싱, Response 생성)
  3. UseCase 호출 (비즈니스 로직 실행)

편리하죠? 하지만 이게 가능한 이유는 Spring의 마법 때문입니다.

Spring의 마법: Annotation 기반 자동화

@PostMapping("/login")

이 한 줄의 annotation 뒤에는 엄청난 일들이 일어납니다:

  1. Component Scan: Spring이 시작 시 모든 클래스 스캔
  2. Reflection: Annotation 정보 읽기
  3. Proxy 생성: AOP, Transaction 등을 위한 프록시 객체 생성
  4. URL 매핑 등록: RequestMappingHandlerMapping에 자동 등록

문제는? 이 모든 게 런타임에 일어난다는 것입니다.

Spring 시작 시간: 5-10초 (작은 앱 기준)
Bean 생성 → Proxy 생성 → URL 매핑 등록 → ...

Go의 선택: 명시적 등록

Go는 다른 철학을 선택했습니다.

"마법은 없다. 모든 것을 명시적으로."

// 1. Router: URL 매핑 담당
func (rt *Router) setupAuthRoutes(r chi.Router) {
    r.Route("/api/v1/auth", func(r chi.Router) {
        r.Post("/login", rt.authHandler.Login)  // ← 명시적 등록
    })
}

// 2. Handler: HTTP 처리 담당
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
    // HTTP → Domain
    var req dto.LoginRequest
    json.NewDecoder(r.Body).Decode(&req)

    // UseCase 호출
    result, err := h.loginUC.Execute(r.Context(), req.Email, req.Password)

    // Domain → HTTP
    response := dto.LoginResponse{Token: result.Token, User: result.User}
    dto.RespondJSON(w, http.StatusOK, response)
}

왜 이렇게 나뉘었을까?

Go에는 Annotation이 없습니다. 정확히는, 의도적으로 만들지 않았습니다.

// Java: Annotation으로 자동화
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(...) { }

// Go: 명시적 등록
r.Post("/login", handler.Login)

 

Go 설계자들은 "컴파일 타임에 모든 것이 결정되어야 한다"고 믿었습니다.

Reflection 기반의 런타임 마법보다 명시적이고 예측 가능한 코드를 선택한 것이죠.

Router와 Handler의 역할 분리

Router의 역할: "URL을 어디로 보낼까?"

type Router struct {
    authHandler    *handler.AuthHandler
    userHandler    *handler.UserHandler
    authMiddleware *middleware.AuthMiddleware
}

func (rt *Router) Setup() *chi.Mux {
    r := chi.NewRouter()

    // Global middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    // Domain routes
    rt.setupAuthRoutes(r)
    rt.setupUserRoutes(r)

    return r
}

 

Router는 교통정리만 합니다:

  • URL → Handler 매핑
  • Middleware 체인 구성
  • Route 그룹화

Handler의 역할: "HTTP를 어떻게 처리할까?"

type AuthHandler struct {
    loginUC auth.LoginExecutor
}

func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
    // 1. HTTP → Domain 변환
    var req dto.LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        dto.RespondError(w, errors.WrapError("INVALID_REQUEST", err))
        return
    }

    // 2. UseCase 실행
    result, err := h.loginUC.Execute(r.Context(), req.Email, req.Password)
    if err != nil {
        dto.RespondError(w, err)
        return
    }

    // 3. Domain → HTTP 변환
    response := dto.LoginResponse{
        Token: result.Token,
        User:  dto.UserResponse{
            ID:    result.User.ID,
            Email: result.User.Email,
        },
    }

    // 4. HTTP 상태 코드와 함께 응답
    dto.RespondJSON(w, http.StatusOK, response)
}

 

Handler는 HTTP 처리에만 집중합니다:

  • Request 파싱 & 검증
  • UseCase 호출
  • Response 생성
  • 에러 처리

비교 정리

관점 Spring Controller Go Router + Handler
URL 매핑 Annotation (@PostMapping) Router에서 명시적 등록
HTTP 처리 Controller 메서드 Handler 메서드
등록 시점 런타임 (Component Scan) 컴파일 타임
시작 속도 느림 (5-10초) 빠름 (< 1초)
가독성 높음 (한 곳에 모여있음) 중간 (역할별로 분산)
테스트 Mock 많이 필요 Handler만 독립 테스트 가능

 

실전 매핑 가이드

Spring Controller → Go 변환

Spring:

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    @GetMapping("/me")
    public ResponseEntity<UserResponse> getCurrentUser(
        @AuthenticationPrincipal User user
    ) {
        return ResponseEntity.ok(new UserResponse(user));
    }

    @PatchMapping("/me")
    public ResponseEntity<UserResponse> updateUser(
        @AuthenticationPrincipal User user,
        @RequestBody UpdateUserRequest request
    ) {
        User updated = userService.update(user.getId(), request);
        return ResponseEntity.ok(new UserResponse(updated));
    }
}

 

Go:

// 1. Router (user_routes.go)
func (rt *Router) setupUserRoutes(r chi.Router) {
    r.Route("/api/v1/users", func(r chi.Router) {
        r.Use(rt.authMiddleware.Authenticate)  // Spring의 @AuthenticationPrincipal 역할

        r.Get("/me", rt.userHandler.GetCurrentUser)
        r.Patch("/me", rt.userHandler.UpdateCurrentUser)
    })
}

// 2. Handler (user_handler.go)
type UserHandler struct {
    updateUserUC *user.UpdateUserUseCase
}

func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
    // Context에서 인증 정보 추출 (Spring의 @AuthenticationPrincipal과 동일)
    userID, _ := middleware.GetUserID(r.Context())
    email, _ := middleware.GetEmail(r.Context())

    response := dto.UserResponse{ID: userID, Email: email}
    dto.RespondJSON(w, http.StatusOK, response)
}

func (h *UserHandler) UpdateCurrentUser(w http.ResponseWriter, r *http.Request) {
    userID, _ := middleware.GetUserID(r.Context())

    var req dto.UpdateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        dto.RespondError(w, errors.WrapError("INVALID_REQUEST", err))
        return
    }

    result, err := h.updateUserUC.Execute(r.Context(), userID, req)
    if err != nil {
        dto.RespondError(w, err)
        return
    }

    dto.RespondJSON(w, http.StatusOK, dto.UserResponse{
        ID:    result.ID,
        Email: result.Email,
    })
}

MSA 전환 시 진가를 발휘하는 분리

이렇게 분리된 구조는 Microservices로 전환할 때 빛을 발합니다.

Monolith:
router/
├── auth_routes.go      → MSA: auth-service/router/router.go
├── user_routes.go      → MSA: user-service/router/router.go
└── project_routes.go   → MSA: project-service/router/router.go

 

각 도메인의 route 파일이 독립 서비스의 전체 라우터가 됩니다.

Spring에서는 Controller를 복사해야 하지만, Go는 파일 하나만 옮기면 끝입니다.

결론: 명시성 vs 편리성

Spring의 Controller:

  • ✅ 편리함 (Annotation 기반 자동화)
  • ✅ 익숙함 (한 곳에서 모든 처리)
  • ❌ 런타임 오버헤드
  • ❌ 마법에 대한 의존

Go의 Router + Handler:

  • ✅ 명시성 (모든 것이 코드로 드러남)
  • ✅ 빠른 시작 속도
  • ✅ MSA 전환 용이
  • ❌ 초기 학습 곡선
  • ❌ 코드가 조금 더 길어짐

처음에는 "왜 이렇게 복잡하게 나눠놨지?"라고 생각했지만, 이제는 이해합니다.

Go는 마법을 거부하고, 명시성을 선택했습니다.

그리고 그 선택이 대규모 시스템에서 얼마나 강력한지는,
여러분이 직접 경험해보시면 알게 될 것입니다.


다음 편 예고:

"Chi? Gin? Echo? - Go 웹 프레임워크 선택의 기로에서"

반응형

댓글