크고 복잡한 Gin/Echo 핸들러를 작고 유지보수 가능한 서비스 및 함수로 단계별 리팩토링
Olivia Novak
Dev Intern · Leapcell

소개
웹 개발의 빠른 세상에서 Gin과 Echo 같은 프레임워크는 Go에서 고성능 API 구축의 초석이 되었습니다. 그 단순성과 속도는 부인할 수 없습니다. 그러나 애플리케이션이 복잡해짐에 따라 일반적인 안티 패턴이 나타납니다: "팻 핸들러(fat handler)"입니다. 이는 단일 HTTP 핸들러 함수가 요청 구문 분석 및 유효성 검사부터 비즈니스 로직 실행 및 데이터베이스 상호 작용에 이르기까지 모든 것을 담당하는 거대한 괴물이 되는 경우입니다. 이러한 핸들러는 읽고, 테스트하고, 유지보수하고, 확장하기가 지독히 어렵습니다. 종종 스파게티 코드의 복잡한 엉킴으로 이어져 개발 속도를 늦추고 버그 위험을 증가시킵니다. 이 글에서는 이러한 모놀리식 핸들러와 관련된 문제점을 조명할 뿐만 아니라, 더 작고 집중된 서비스와 함수를 사용하여 모듈화되고, 테스트 가능하며, 유지보수 가능한 아키텍처로 리팩토링하기 위한 구조화된 단계별 방법론을 제공합니다. 이러한 복잡한 함수를 우아하게 풀어나가는 방법을 탐구하여 Go 애플리케이션을 더욱 견고하고 진화하기 쉽게 만들 것입니다.
핵심 개념 이해
리팩토링 프로세스에 뛰어들기 전에 논의할 핵심 용어 및 아키텍처 패턴에 대한 공통된 이해를 확립해 봅시다.
HTTP 핸들러
Gin 또는 Echo와 같은 웹 프레임워크의 컨텍스트에서 HTTP 핸들러는 들어오는 HTTP 요청을 처리하고 HTTP 응답을 생성하는 함수입니다. Gin에서는 일반적으로 func(c *gin.Context)이고 Echo에서는 func(c echo.Context) error입니다. 이러한 핸들러는 일반적으로 특정 API 엔드포인트의 진입점에 위치합니다.
비즈니스 로직
이는 애플리케이션이 데이터를 어떻게 처리, 저장 및 변경하는지를 정의하는 핵심 규칙 및 연산을 참조합니다. API를 통해 노출되거나 데이터베이스에 저장되는 "방법"과 독립적으로 애플리케이션이 "무엇"을 하는지에 관한 것입니다.
서비스 계층
서비스 계층(때로는 "서비스 객체" 또는 "사용 사례"라고도 함)은 HTTP 핸들러와 데이터 액세스 계층(예: 리포지토리) 간의 중개자 역할을 합니다. 관련 비즈니스 로직을 캡슐화하고, 여러 구성 요소 간의 상호 작용을 조정하며, 특정 작업에 대한 단일 진입점 역할을 합니다. 서비스는 비즈니스 로직을 HTTP 문제와 분리하고 재사용성을 촉진하는 데 중요합니다.
리포지토리 계층
리포지토리 계층은 데이터 지속성의 세부 사항을 추상화합니다. 애플리케이션의 나머지 부분이 데이터가 검색, 저장 또는 업데이트되는 "방법"에 대한 구체적인 내용을 알 필요 없이 데이터 소스(데이터베이스, 외부 API, 파일 등)와 상호 작용하기 위한 인터페이스를 제공합니다. 이 분리는 데이터 소스를 교체하거나 비즈니스 로직을 격리하여 테스트하기 쉽게 만듭니다.
의존성 주입
의존성 주입(DI)은 객체 간의 하드코딩된 의존성을 제거할 수 있는 소프트웨어 디자인 패턴입니다. 객체가 자체 의존성을 생성하는 대신, 종종 생성자 매개변수를 통해 객체에 주입됩니다. 이는 느슨한 결합을 촉진하여 구성 요소를 더 독립적이고, 테스트 가능하며, 재사용 가능하게 만듭니다.
팻 핸들러의 문제점
유기적으로 성장한 애플리케이션의 일반적인 "사용자 생성" 핸들러를 고려해 봅시다:
// 리팩토링 전: 팻 핸들러 package main import ( "log" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" "gorm.io/gorm" ) type User struct { ID uint `json:"id" gorm:"primaryKey"` Name string `json:"name"` Email string `json:"email" gorm:"unique"` Password string `json:"-"` // JSON 출력에서 비밀번호 제외 IsActive bool `json:"isActive"` AdminData string `json:"-"` // 민감한 데이터 } var db *gorm.DB func init() { var err error db, err = gorm.Open(sqlite.Open("test.db"), &gorm.Config{}) if err != nil { log.Fatalf("failed to connect database: %v", err) } // 스키마 마이그레이션 db.AutoMigrate(&User{}) } type CreateUserRequest struct { Name string `json:"name" binding:"required"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` } // CreateUserHandler는 새 사용자 생성 처리 func CreateUserHandler(c *gin.Context) { var req CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 사용자 이미 존재하는지 확인 var existingUser User if err := db.Where("email = ?", req.Email).First(&existingUser).Error; err == nil { c.JSON(http.StatusConflict, gin.H{"error": "User with this email already exists"}) return } else if err != gorm.ErrRecordNotFound { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error checking existing user"}) return } // 비밀번호 해싱 (예시용으로 단순화) hashedPassword := "hashed_" + req.Password // 실제 앱에서는 bcrypt와 같은 강력한 해싱 라이브러리 사용 user := User{ Name: req.Name, Email: req.Email, Password: hashedPassword, IsActive: true, // 기본값으로 활성 } // 데이터베이스에 사용자 저장 if err := db.Create(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return } // 생성 로깅 (감사에 관련된 비즈니스 로직) log.Printf("User created: %s (%s)", user.Name, user.Email) // 환영 이메일 전송 (다른 비즈니스 로직) - 시뮬레이션 go func() { log.Printf("Sending welcome email to %s", user.Email) // 실제 이메일 전송 로직이 여기에 들어갈 것입니다. }() c.JSON(http.StatusCreated, user) } func main() { r := gin.Default() r.POST("/users", CreateUserHandler) r.Run(":8080") }
이 CreateUserHandler는 다음과 같은 여러 문제를 나타냅니다.
- 단일 책임 원칙(SRP) 위반: 요청 구문 분석, 유효성 검사, 중복 이메일 확인, 비밀번호 해싱, 데이터베이스 상호 작용, 로깅, 심지어 "이메일 전송"까지 처리합니다.
- 낮은 테스트 용이성: 이 핸들러를 테스트하려면 전체 Gin 컨텍스트와 잠재적으로 실제 데이터베이스 연결을 설정해야 하므로 단위 테스트가 어렵고 느립니다.
- 낮은 재사용성: 비즈니스 로직(예: 기존 사용자 확인, 비밀번호 해싱)은 HTTP 컨텍스트에 단단히 결합되어 다른 곳(예: CLI 도구 또는 다른 API 엔드포인트)에서 쉽게 재사용할 수 없습니다.
- 유지보수 악몽: 비즈니스 로직, 데이터베이스 스키마 또는 요청 구조의 변경은 이 단일 대형 함수를 수정해야 하므로 버그 도입 위험이 증가합니다.
- 책임 분리 부족: HTTP 특정 세부 사항이 핵심 애플리케이션 로직과 혼합됩니다.
단계별 리팩토링 프로세스
CreateUserHandler를 더욱 구조화된 디자인으로 리팩토링해 보겠습니다.
단계 1: 요청 유효성 검사 및 바인딩 추출
첫 번째 단계는 HTTP 요청 관련 처리를 분리하는 것입니다. ShouldBindJSON 및 후속 오류 처리는 순전히 HTTP 관련입니다. gin.Context는 이미 바인딩을 제공하지만, 핸들러를 단순화하기 위해 첫 몇 줄을 순수하게 유효한 입력 상태로 만드는 것으로 시작할 수 있습니다.
// (이전 User 구조체, db 설정, main 함수는 변경되지 않음) // CreateUserRequest는 동일하게 유지 type CreateUserRequest struct { Name string `json:"name" binding:"required"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` } // 유효성 검사가 추출된 핸들러 func CreateUserHandlerStep1(c *gin.Context) { var req CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // ... 나머지 로직 // 이제 `req`는 바인딩 태그에 따라 유효한 것으로 보장됩니다. // 사용자 생성에 대한 핸들러의 나머지 원래 로직이 여기에 따를 것입니다. // 예를 들어, 원래 로직을 주석 처리하고 플레이스홀더를 호출할 수 있습니다: // handleUserCreationLogic(c, req) }
이 단계는 새 파일을 도입하지 않지만, 입력 수집 단계와 핵심 로직을 정신적으로(그리고 논리적으로) 분리합니다.
단계 2: 리포지토리 계층 도입
다음으로, 모든 데이터베이스 관련 작업을 전용 UserRepository로 추출합니다. 이는 GORM의 특정 세부 정보를 핸들러에서 추상화합니다.
// repository/user_repository.go package repository import ( "errors" "gorm.io/gorm" ) // User는 User 모델을 나타냅니다(공유하거나 여기에 정의할 수 있음). type User struct { ID uint `json:"id" gorm:"primaryKey"` Name string `json:"name"` Email string `json:"email" gorm:"unique"` Password string `json:"-"` IsActive bool `json:"isActive"` AdminData string `json:"-"` } //go:generate mockgen -source=user_repository.go -destination=mocks/mock_user_repository.go -package=mocks type UserRepository interface { CreateUser(user *User) error FindByEmail(email string) (*User, error) // 기타 사용자 관련 작업 추가: GetByID, UpdateUser, DeleteUser 등. } type userRepository struct { db *gorm.DB } func NewUserRepository(db *gorm.DB) UserRepository { return &userRepository{db: db} } func (r *userRepository) CreateUser(user *User) error { return r.db.Create(user).Error } func (r *userRepository) FindByEmail(email string) (*User, error) { var user User err := r.db.Where("email = ?", email).First(&user).Error if err != nil { return nil, err // 호출자가 gorm.ErrRecordNotFound를 처리하도록 함 } return &user, nil }
이제 CreateUserHandler는 이 리포지토리를 사용할 수 있습니다.
// handler/user_handler.go (핸들러용 `handler` 패키지 가정) package handler import ( "net/http" "log" // 로깅용 "your_module/repository" // 가져오기 경로 조정 "your_module/model" // User 구조체가 `model` 패키지에 있다고 가정 "github.com/gin-gonic/gin" "gorm.io/gorm" // gorm.ErrRecordNotFound용 ) type CreateUserRequest struct { Name string `json:"name" binding:"required"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` } // 이제 핸들러에는 의존성: UserRepository가 필요합니다. type UserHandler struct { userRepo repository.UserRepository } func NewUserHandler(userRepo repository.UserRepository) *UserHandler { return &UserHandler{userRepo: userRepo} } func (h *UserHandler) CreateUserHandlerStep2(c *gin.Context) { var req CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 리포지토리를 사용하여 사용자 이미 존재하는지 확인 _, err := h.userRepo.FindByEmail(req.Email) if err == nil { c.JSON(http.StatusConflict, gin.H{"error": "User with this email already exists"}) return } else if err != gorm.ErrRecordNotFound { // _다른_ 오류인지 확인하는 것이 중요합니다. log.Printf("Error checking for existing user: %v", err) // 실제 오류 로깅 c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error checking existing user"}) return } // 비밀번호 해싱 (아직 핸들러에 있음) hashedPassword := "hashed_" + req.Password newUser := &model.User{ // 모델 패키지를 만드는 경우 model.User 사용 Name: req.Name, Email: req.Email, Password: hashedPassword, IsActive: true, } // 리포지토리를 사용하여 데이터베이스에 사용자 저장 if err := h.userRepo.CreateUser(newUser); err != nil { log.Printf("Error creating user: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return } log.Printf("User created: %s (%s)", newUser.Name, newUser.Email) go func() { log.Printf("Sending welcome email to %s", newUser.Email) }() c.JSON(http.StatusCreated, newUser) } // main.go에서 다음과 같이 초기화합니다. /* func main() { r := gin.Default() // ... db 초기화 ... userRepo := repository.NewUserRepository(db) userHandler := handler.NewUserHandler(userRepo) r.POST("/users", userHandler.CreateUserHandlerStep2) r.Run(":8080") } */
이제 CreateUserHandlerStep2는 데이터베이스 특정 세부 정보에 덜 신경 쓰고, 데이터베이스 상호 작용에 대한 테스트 용이성이 향상되었습니다.
단계 3: 서비스 계층 구현
이것이 가장 중요한 단계입니다. 중복 확인, 비밀번호 해싱, 사용자 생성 조정과 같은 모든 비즈니스 로직을 UserService로 추출합니다.
// service/user_service.go package service import ( "errors" "log" // 서비스 내 로깅용 "your_module/model" // User 구조체가 `model` 패키지에 있다고 가정 "your_module/repository" // 가져오기 경로 조정 "gorm.io/gorm" // gorm 오류 확인용 ) // 더 나은 오류 처리를 위한 사용자 정의 오류 var ( ErrUserAlreadyExists = errors.New("user with this email already exists") ErrPasswordWeak = errors.New("password is too weak") ) type UserService interface { CreateUser(name, email, password string) (*model.User, error) // GetUser, UpdateUser, DeleteUser와 같은 다른 서비스 메서드 추가 } type userService struct { userRepo repository.UserRepository // 이메일 서비스, 로거 인터페이스 등 다른 의존성 추가 } func NewUserService(userRepo repository.UserRepository) UserService { return &userService{userRepo: userRepo} } func (s *userService) CreateUser(name, email, password string) (*model.User, error) { // 1. 입력 유효성 검사 (예: 이메일에 대한 정규식 등 더 복잡할 수 있음) if len(password) < 6 { // 예: 비밀번호 강도에 대한 비즈니스 규칙 return nil, ErrPasswordWeak } // 2. 중복 사용자 확인 _, err := s.userRepo.FindByEmail(email) if err == nil { return nil, ErrUserAlreadyExists } if err != gorm.ErrRecordNotFound { log.Printf("Error checking for existing user in service: %v", err) return nil, errors.New("internal server error") // 데이터베이스 오류 숨기기 } // 3. 비밀번호 해싱 (비즈니스 로직) hashedPassword := "hashed_" + password // 실제 앱에서는 bcrypt 사용 // 4. 사용자 모델 생성 newUser := &model.User{ Name: name, Email: email, Password: hashedPassword, IsActive: true, } // 5. 사용자 저장 if err := s.userRepo.CreateUser(newUser); err != nil { log.Printf("Error persisting new user in service: %v", err) return nil, errors.New("failed to create user") } // 6. 생성 후 작업 (예: 로깅, 이벤트 전송) log.Printf("User created by service: %s (%s)", newUser.Name, newUser.Email) // 실제 애플리케이션에서는 이메일과 같은 비동기 작업에 메시지 큐를 사용할 수 있습니다: go func() { log.Printf("Simulating sending welcome email to %s via service", newUser.Email) // emailService.SendWelcomeEmail(newUser.Email, newUser.Name) }() return newUser, nil }
이제 핸들러가 훨씬 가벼워집니다.
// handler/user_handler.go (업데이트됨) package handler import ( "errors" "net/http" "your_module/service" // 가져오기 경로 조정 "github.com/gin-gonic/gin" ) type CreateUserRequest struct { Name string `json:"name" binding:"required"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` } type UserHandler struct { userService service.UserService // 의존성 주입된 서비스 } func NewUserHandler(userService service.UserService) *UserHandler { return &UserHandler{userService: userService} } func (h *UserHandler) CreateUserHandlerRefactored(c *gin.Context) { var req CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 모든 비즈니스 로직을 서비스 계층에 위임 user, err := h.userService.CreateUser(req.Name, req.Email, req.Password) if err != nil { if errors.Is(err, service.ErrUserAlreadyExists) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } if errors.Is(err, service.ErrPasswordWeak) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 다른 내부 오류를 깔끔하게 처리 c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return } // 핸들러는 HTTP 요청/응답만 처리합니다. c.JSON(http.StatusCreated, user) } // main.go에서: /* func main() { r := gin.Default() // ... 데이터베이스 연결 ... userRepo := repository.NewUserRepository(db) userService := service.NewUserService(userRepo) // 리포지토리를 서비스에 주입 userHandler := handler.NewUserHandler(userService) // 서비스를 핸들러에 주입 r.POST("/users", userHandler.CreateUserHandlerRefactored) r.Run(":8080") } */
CreateUserHandlerRefactored는 이제 놀랍도록 깔끔합니다. 요청을 받고, 적절한 서비스 메서드를 호출하고, 서비스의 결과(성공 또는 오류)를 HTTP 응답으로 변환합니다. 모든 복잡한 비즈니스 로직, 데이터베이스 상호 작용 및 내부 오류 처리는 서비스 및 리포지토리 계층으로 푸시됩니다.
단계 4: 보조(부수 효과) 함수 리팩토링
원본 핸들러의 "환영 이메일 전송" 부분은 부수 효과입니다. 이 예제에서는 서비스 계층 시뮬레이션이 괜찮지만, 더 큰 애플리케이션에서는 별도의 EmailService이거나 이벤트 기반 아키텍처에 의해 처리될 수 있습니다.
// service/email_service.go (새 파일) package service import "log" type EmailService interface { SendWelcomeEmail(toEmail, username string) error // 기타 이메일 관련 메서드 } type emailService struct { // 이메일 클라이언트, 로거 등의 의존성 } func NewEmailService() EmailService { return &emailService{} } func (s *emailService) SendWelcomeEmail(toEmail, username string) error { log.Printf("Successfully sent welcome email to %s for user %s", toEmail, username) // 실제 앱에서는 제3자 이메일 API를 호출하는 것을 포함할 것입니다. return nil }
이제 UserService에 EmailService를 주입합니다.
// service/user_service.go (업데이트됨) package service import ( "errors" "log" "your_module/model" "your_module/repository" "gorm.io/gorm" ) // (ErrUserAlreadyExists, ErrPasswordWeak 유지) type UserService interface { CreateUser(name, email, password string) (*model.User, error) } type userService struct { userRepo repository.UserRepository emailService EmailService // <--- 새로운 의존성 } func NewUserService(userRepo repository.UserRepository, emailService EmailService) UserService { return &userService{userRepo: userRepo, emailService: emailService} } func (s *userService) CreateUser(name, email, password string) (*model.User, error) { // ... (이전 단계의 로직) ... if err := s.userRepo.CreateUser(newUser); err != nil { log.Printf("Error persisting new user in service: %v", err) return nil, errors.New("failed to create user") } // 이메일 서비스 사용 go func() { // 여전히 비동기적으로 실행 if err := s.emailService.SendWelcomeEmail(newUser.Email, newUser.Name); err != nil { log.Printf("Failed to send welcome email to %s: %v", newUser.Email, err) } }() return newUser, nil }
그리고 main.go에서:
// main.go (업데이트됨) package main import ( "log" "your_module/handler" "your_module/repository" "your_module/service" // 모든 패키지가 가져와졌는지 확인 "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // User, db, init() (db 설정을 위해) 이전과 동일 func main() { r := gin.Default() // 의존성 초기화 userRepo := repository.NewUserRepository(db) emailService := service.NewEmailService() userService := service.NewUserService(userRepo, emailService) // emailService 주입 userHandler := handler.NewUserHandler(userService) // 라우트 등록 r.POST("/users", userHandler.CreateUserHandlerRefactored) log.Println("Server starting on :8080") if err := r.Run(":8080"); err != nil { log.Fatalf("Server failed to start: %v", err) } }
리팩토링된 아키텍처의 이점
이 구조화된 접근 방식은 상당한 이점을 가져옵니다.
- 향상된 가독성 및 이해: 각 구성 요소는 명확하고 단일한 책임을 가집니다. 핸들러는 얇고, 서비스는 비즈니스 로직을 처리하며, 리포지토리는 데이터 액세스를 관리합니다.
- 향상된 테스트 용이성:
- 핸들러는
UserService인터페이스를 모킹하여 단위 테스트할 수 있습니다. - 서비스는
UserRepository(및EmailService) 인터페이스를 모킹하여 단위 테스트할 수 있습니다. - 리포지토리는 인메모리 데이터베이스에 대해 테스트하거나
gorm.DB를 직접 모킹할 수 있습니다(하지만 종종 실제 DB에 대한 통합 테스트가 선호됨). 이는 포괄적인 테스트를 작성하는 노력을 크게 줄입니다.
- 핸들러는
- 더 큰 유지보수성: 데이터베이스 기술의 변경은 리포지토리 계층에만 영향을 미칩니다. 비즈니스 규칙의 변경은 주로 서비스 계층에 영향을 미칩니다. HTTP 관련 변경은 핸들러에 국한됩니다.
- 증가된 재사용성:
UserService내의 비즈니스 로직은 다른 핸들러, CLI 명령 또는 백그라운드 작업자에서 복제 없이 재사용될 수 있습니다. - 더 쉬운 확장성: 잘 정의된 서비스 계층은 마이크로 서비스로의 발판을 마련하여 개별 구성 요소를 더 쉽게 확장할 수 있도록 합니다.
결론
"팻" Gin 또는 Echo 핸들러를 리팩토링하는 것은 단순히 코드를 이동하는 것이 아니라 구조, 명확성 및 유지보수성을 도입하는 것입니다. 전용 리포지토리 및 서비스 계층과 적절한 의존성 주입으로 우려 사항을 체계적으로 추출함으로써 복잡하게 얽힌 코드를 견고하고, 테스트 가능하며, 확장 가능한 애플리케이션으로 전환합니다. 이 모듈식 접근 방식은 Go 애플리케이션이 민첩하고 적응력을 유지하여 요구 사항이 변경되고 복잡성이 증가함에 따라 우아하게 진화할 수 있도록 보장합니다. 더 작고 집중된 함수와 서비스를 수용하여 더욱 탄력적이고 즐거운 개발 경험을 구축하십시오.

