모놀리스에서 모듈러로: Go 웹 애플리케이션 리팩토링
James Reed
Infrastructure Engineer · Leapcell

소개
활기찬 Go 개발 세계에서 프로젝트는 종종 단일 main.go 파일을 중심으로 하는 간단한 구조로 시작되는 것이 일반적입니다. 이 접근 방식은 특히 소규모 애플리케이션의 경우 빠른 프로토타이핑과 신속한 개발 주기를 제공합니다. 그러나 이러한 프로젝트가 발전하고 복잡성이 증가하며 더 많은 기능과 기여자를 유치함에 따라 초기 단순성은 종종 상당한 병목 현상으로 변모합니다. 방대한 main.go 파일은 탐색하기 어렵고, 디버깅하기 까다로우며, 효율적으로 확장하거나 유지보수하기가 거의 불가능해집니다. 이 상황은 미적인 문제일 뿐만 아니라 개발자 생산성에 직접적인 영향을 미치고, 기술 부채를 발생시키며, 미래 성장을 방해합니다. 이 문서는 이러한 모놀리식 구조를 해체하고 모듈화되고 유지보수 가능한 Go 웹 프로젝트로 재구축하여 잠재력을 최대한 발휘하도록 안내할 것입니다.
모놀리스 해체
리팩토링 프로세스에 들어가기 전에, 모듈화를 위한 핵심 개념에 대한 공통된 이해를 확립해 봅시다.
핵심 개념
- 모놀리식 애플리케이션: 모든 구성 요소(UI, 비즈니스 로직, 데이터 액세스)가 긴밀하게 얽혀 있고 단일하고 분할할 수 없는 단위로 배포되는 애플리케이션입니다. 시작하기는 간단하지만, 성장함에 따라 확장성이 떨어지고 유지보수가 어렵고 결합도가 높아지는 문제를 겪습니다.
- 모듈식 애플리케이션: 특정 기능을 담당하는 별개의 독립적인 모듈 또는 패키지로 나뉜 애플리케이션입니다. 이 모듈들은 잘 정의된 인터페이스를 통해 통신하여 결합도를 줄이고 유지보수성을 향상시킵니다.
- 패키지(Go): Go의 코드 구성의 기본 단위입니다. 패키지는 관련 기능을 캡슐화하여 코드 재사용을 가능하게 하고 관심사 분리를 촉진합니다.
- 계층형 아키텍처: 애플리케이션이 특정 역할을 가진 별개의 계층으로 나뉘는 구조 패턴입니다. 일반적인 계층에는 프레젠테이션(HTTP 핸들러), 서비스(비즈니스 로직), 리포지토리(데이터 액세스)가 포함됩니다. 이는 관심사 분리를 촉진하고 테스트 용이성을 향상시킵니다.
- 의존성 주입(DI): 컴포넌트가 직접 생성하는 대신 종속성(예: 데이터베이스 연결)이 컴포넌트에 제공되는 기술입니다. 이는 결합도를 줄이고, 컴포넌트를 더 독립적으로 만들며, 테스트를 단순화합니다.
단일 main.go파일의 문제점
성장하는 프로젝트의 일반적인 main.go 파일은 다음과 같은 모습을 보일 수 있습니다.
// main.go (리팩토링 전) package main import ( "database/sql" "encoding/json" "fmt" "log" "net/http" _ "github.com/go-sql-driver/mysql" // 예시 데이터베이스 드라이버 ) // User represents a user in the system type User struct { ID int `json:"id"` Name string `json:"name"` Email string `json:"email"` } var db *sql.DB func initDB() { var err error db, err = sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/database") if err != nil { log.Fatalf("Failed to open database: %v", err) } if err = db.Ping(); err != nil { log.Fatalf("Failed to connect to database: %v", err) } fmt.Println("Connected to database successfully!") } func createUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var user User err := json.NewDecoder(r.Body).Decode(&user) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } stmt, err := db.Prepare("INSERT INTO users(name, email) VALUES(?,?)") if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) log.Printf("Error preparing statement: %v", err) return } defer stmt.Close() result, err := stmt.Exec(user.Name, user.Email) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) log.Printf("Error executing statement: %v", err) return } id, _ := result.LastInsertId() user.ID = int(id) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } func getUsersHandler(w http.ResponseWriter, r *http.Request) { rows, err := db.Query("SELECT id, name, email FROM users") if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) log.Printf("Error querying users: %v", err) return } defer rows.Close() var users []User for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) log.Printf("Error scanning user: %v", err) return } users = append(users, u) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(users) } func main() { initDB() defer db.Close() http.HandleFunc("/users", getUsersHandler) http.HandleFunc("/users/create", createUserHandler) fmt.Println("Server listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
이 간단한 예제는 이미 여러 문제를 보여줍니다.
- 긴밀한 결합: 핸들러는 데이터베이스와 직접 상호 작용합니다.
- 재사용성 부족: 데이터베이스 로직, 비즈니스 로직, HTTP 처리가 모두 혼합되어 있습니다.
- 테스트 어려움: 개별 컴포넌트(예: 데이터 로직만)를 테스트하기 어렵습니다. HTTP 서버를 설정하지 않으면 더 어렵습니다.
- 확장성 저하: 새 기능을 추가하는 것은
main.go에서 공간을 찾는 게임이 되며 잠재적인 회귀를 도입합니다.
모듈식 구조로 리팩토링
이를 더 구조화된 애플리케이션으로 리팩토링해 봅시다. 계층형 아키텍처를 목표로 할 것입니다: handler(프레젠테이션), service(비즈니스 로직), repository(데이터 액세스).
1단계: 애플리케이션 구조 정의
명확한 디렉터리 구조를 설정하는 것이 좋은 시작점입니다.
├── cmd/
│ └── api/
│ └── main.go // API 진입점
├── internal/
│ ├── config/
│ │ └── config.go // 애플리케이션 구성
│ ├── models/
│ │ └── user.go // 데이터 구조 (예: User 구조체)
│ ├── repository/
│ │ └── user_repository.go // 사용자 데이터 액세스 로직
│ ├── service/
│ │ └── user_service.go // 사용자 비즈니스 로직
│ └── handler/
│ └── user_handler.go // 사용자 HTTP 요청 핸들러
└── go.mod
└── go.sum
cmd/api: 웹 API의 메인 진입점을 포함합니다.internal/: 다른 애플리케이션에서 공개적으로 가져올 수 없는 모든 애플리케이션별 코드를 포함합니다.config/: 애플리케이션 구성을 관리합니다.models/: 데이터 구조를 정의합니다.repository/: 데이터 저장 및 검색을 추상화합니다.service/: 비즈니스 로직을 구현합니다.handler/: HTTP 요청 핸들러를 포함합니다.
2단계: 모델 추출 (internal/models/user.go)
먼저 User 구조체를 전용 models 패키지로 옮깁니다.
// internal/models/user.go package models // User represents a user in the system type User struct { ID int `json:"id"` Name string `json:"name"` Email string `json:"email"` }
3단계: 데이터베이스 구성 추상화 (internal/config/config.go)
구성을 중앙 집중화하는 것이 좋은 습관입니다.
// internal/config/config.go package config import ( "database/sql" "fmt" "log" _ "github.com/go-sql-driver/mysql" // 예시 데이터베이스 드라이버 ) // DBConfig holds database connection details type DBConfig struct { User string Password string Host string Port string Database string } // NewDBConfig creates a new default database config func NewDBConfig() DBConfig { return DBConfig{ User: "user", Password: "password", Host: "127.0.0.1", Port: "3306", Database: "database", } } // InitDatabase initializes and returns a database connection pool func InitDatabase(cfg DBConfig) (*sql.DB, error) { connStr := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database) db, err := sql.Open("mysql", connStr) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } if err = db.Ping(); err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } log.Println("Connected to database successfully!") return db, nil }
4단계: 리포지토리 계층 구현 (internal/repository/user_repository.go)
리포지토리는 User 객체에 대한 모든 데이터베이스 상호 작용을 처리합니다. 저장 메커니즘을 추상화하기 위한 인터페이스를 정의합니다.
// internal/repository/user_repository.go package repository import ( "database/sql" "fmt" "your_module_name/internal/models" // your_module_name을 모듈 이름으로 변경하세요. ) // UserRepository defines the interface for user data operations type UserRepository interface { CreateUser(user *models.User) (*models.User, error) GetUsers() ([]models.User, error) } // MySQLUserRepository implements UserRepository for MySQL type MySQLUserRepository struct { db *sql.DB } // NewMySQLUserRepository creates a new MySQLUserRepository func NewMySQLUserRepository(db *sql.DB) *MySQLUserRepository { return &MySQLUserRepository{db: db} } // CreateUser inserts a new user into the database func (r *MySQLUserRepository) CreateUser(user *models.User) (*models.User, error) { stmt, err := r.db.Prepare("INSERT INTO users(name, email) VALUES(?,?)") if err != nil { return nil, fmt.Errorf("error preparing statement: %w", err) } defer stmt.Close() result, err := stmt.Exec(user.Name, user.Email) if err != nil { return nil, fmt.Errorf("error executing statement: %w", err) } id, _ := result.LastInsertId() user.ID = int(id) return user, nil } // GetUsers retrieves all users from the database func (r *MySQLUserRepository) GetUsers() ([]models.User, error) { rows, err := r.db.Query("SELECT id, name, email FROM users") if err != nil { return nil, fmt.Errorf("error querying users: %w", err) } defer rows.Close() var users []models.User for rows.Next() { var u models.User if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil { return nil, fmt.Errorf("error scanning user: %w", err) } users = append(users, u) } return users, nil }
5단계: 서비스 계층 구현 (internal/service/user_service.go)
서비스 계층은 애플리케이션의 비즈니스 로직을 포함합니다. 핸들러와 리포지토리 간의 상호 작용을 조정합니다.
// internal/service/user_service.go package service import ( "fmt" "your_module_name/internal/models" // your_module_name을 모듈 이름으로 변경하세요. "your_module_name/internal/repository" // your_module_name을 모듈 이름으로 변경하세요. ) // UserService defines the interface for user-related business logic type UserService interface { CreateUser(name, email string) (*models.User, error) GetAllUsers() ([]models.User, error) } // UserServiceImpl implements UserService type UserServiceImpl struct { userRepo repository.UserRepository } // NewUserService creates a new UserService func NewUserService(repo repository.UserRepository) *UserServiceImpl { return &UserServiceImpl{userRepo: repo} } // CreateUser handles business logic for creating a user func (s *UserServiceImpl) CreateUser(name, email string) (*models.User, error) { if name == "" || email == "" { return nil, fmt.Errorf("name and email cannot be empty") } // 예시: 기존 이메일 확인 비즈니스 로직 // (간결성을 위해 여기서는 구현되지 않았지만, 또 다른 repo 호출이 필요할 것입니다) user := &models.User{Name: name, Email: email} createdUser, err := s.userRepo.CreateUser(user) if err != nil { return nil, fmt.Errorf("failed to create user in repository: %w", err) } return createdUser, nil } // GetAllUsers retrieves all users with potential business logic func (s *UserServiceImpl) GetAllUsers() ([]models.User, error) { users, err := s.userRepo.GetUsers() if err != nil { return nil, fmt.Errorf("failed to retrieve users from repository: %w", err) } return users, nil }
6단계: 핸들러 계층 구현 (internal/handler/user_handler.go)
핸들러 계층은 HTTP 요청 및 응답을 처리하며, 비즈니스 로직을 서비스 계층에 위임합니다.
// internal/handler/user_handler.go package handler import ( "encoding/json" "net/http" "log" "your_module_name/internal/models" // your_module_name을 모듈 이름으로 변경하세요. "your_module_name/internal/service" // your_module_name을 모듈 이름으로 변경하세요. ) // UserHandler handles HTTP requests related to users type UserHandler struct { userService service.UserService } // NewUserHandler creates a new UserHandler func NewUserHandler(svc service.UserService) *UserHandler { return &UserHandler{userService: svc} } // CreateUserHandler handles POST requests to create a new user func (h *UserHandler) CreateUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var reqUser struct { Name string `json:"name"` Email string `json:"email"` } err := json.NewDecoder(r.Body).Decode(&reqUser) if err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } user, err := h.userService.CreateUser(reqUser.Name, reqUser.Email) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) // 비즈니스 로직 오류의 경우 종종 400 log.Printf("Error creating user: %v", err) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(user) } // GetUsersHandler handles GET requests to retrieve all users func (h *UserHandler) GetUsersHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } users, err := h.userService.GetAllUsers() if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) log.Printf("Error getting users: %v", err) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(users) }
7단계: main.go 재구성 (cmd/api/main.go)
main.go 파일은 이제 오케스트레이터 역할을 하여 종속성을 설정하고 구성 요소를 연결합니다. 이것이 의존성 주입이 빛을 발하는 곳입니다.
// cmd/api/main.go (리팩토링 후) package main import ( "fmt" "log" "net/http" "your_module_name/internal/config" // your_module_name을 모듈 이름으로 변경하세요. "your_module_name/internal/handler" // your_module_name을 모듈 이름으로 변경하세요. "your_module_name/internal/repository" // your_module_name을 모듈 이름으로 변경하세요. "your_module_name/internal/service" // your_module_name을 모듈 이름으로 변경하세요. ) func main() { // 1. 구성 초기화 dbConfig := config.NewDBConfig() // 2. 데이터베이스 초기화 db, err := config.InitDatabase(dbConfig) if err != nil { log.Fatalf("Failed to initialize database: %v", err) } defer db.Close() // 3. 리포지토리 계층 설정 userRepo := repository.NewMySQLUserRepository(db) // 4. 서비스 계층 설정 userService := service.NewUserService(userRepo) // 5. 핸들러 계층 설정 userHandler := handler.NewUserHandler(userService) // 6. 경로 등록 http.HandleFunc("/users", userHandler.GetUsersHandler) http.HandleFunc("/users/create", userHandler.CreateUserHandler) fmt.Println("Server listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
리팩토링된 구조의 주요 특징:
- 명확한 관심사 분리: 각 패키지는 단일하고 잘 정의된 책임을 갖습니다.
- 결합도 감소: 컴포넌트는 인터페이스(예:
UserRepository,UserService)를 통해 상호 작용하여 구체적인 구현에 대한 의존성을 줄입니다. MySQL에서 PostgreSQL로 데이터베이스를 변경하면PostgreSQLUserRepository를 생성하고main.go에서 한 줄만 변경하면 됩니다. - 향상된 테스트 용이성: 각 계층을 독립적으로 테스트할 수 있습니다. 데이터베이스 연결 없이
UserService를 테스트하기 위해UserRepository를 mock할 수 있으며, 복잡한 비즈니스 로직 없이UserHandler를 테스트하기 위해UserService를 mock할 수 있습니다. - 유지보수성 향상: 버그를 찾기 쉬워지고, 코드베이스의 관련 없는 부분을 광범위하게 수정하지 않고도 새 기능을 추가할 수 있습니다.
- 확장성: 특정 서비스의 수평적 확장을 필요에 따라 쉽게 할 수 있습니다(이 패턴은 모놀리식 환경에서도 유용합니다).
사용 및 애플리케이션
이 구조화된 애플리케이션을 실행하려면 go.mod 파일이 있는지 확인하세요.
go mod init your_module_name # 실제 모듈 이름으로 변경하세요. 예: github.com/yourusername/webapp go mod tidy
그런 다음 프로젝트 루트에서 다음을 실행합니다.
go run cmd/api/main.go
그런 다음 curl 또는 Postman과 같은 도구를 사용하여 엔드포인트를 테스트할 수 있습니다.
- 사용자 생성 (POST):
curl -X POST -H "Content-Type: application/json" -d '{"name": "Alice", "email": "alice@example.com"}' http://localhost:8080/users/create - 사용자 가져오기 (GET):
curl http://localhost:8080/users
이 계층형 아키텍처는 단일, 방대한 main.go 파일의 한계를 넘어 유지보수 가능하고 확장 가능한 Go 웹 애플리케이션을 구축하기 위한 견고한 기반을 제공합니다.
결론
모놀리식 main.go 파일을 잘 구조화된 모듈식 Go 웹 프로젝트로 리팩토링하는 것은 장기적인 프로젝트 건강을 위한 중요한 단계입니다. 계층형 아키텍처를 채택하고 패키지 및 인터페이스와 같은 개념을 활용함으로써 명확한 관심사 분리를 달성하고, 결합도를 줄이며, 테스트 용이성과 유지보수성을 크게 향상시킵니다. 이러한 변환은 개발 팀이 변화하는 요구 사항에 우아하게 적응하는 더 강력하고 확장 가능한 애플리케이션을 구축할 수 있도록 지원합니다.

