
"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가지 역할:
- URL 매핑 (
@PostMapping("/login")) - HTTP 처리 (Request 파싱, Response 생성)
- UseCase 호출 (비즈니스 로직 실행)
편리하죠? 하지만 이게 가능한 이유는 Spring의 마법 때문입니다.
Spring의 마법: Annotation 기반 자동화
@PostMapping("/login")
이 한 줄의 annotation 뒤에는 엄청난 일들이 일어납니다:
- Component Scan: Spring이 시작 시 모든 클래스 스캔
- Reflection: Annotation 정보 읽기
- Proxy 생성: AOP, Transaction 등을 위한 프록시 객체 생성
- 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 웹 프레임워크 선택의 기로에서"
댓글