Go 웹 애플리케이션의 유지보수 및 확장을 위한 구조화
Wenhao Wang
Dev Intern · Leapcell

소개
웹 애플리케이션이 복잡해짐에 따라 깨끗하고 체계적이며 확장 가능한 코드베이스를 유지하는 것이 무엇보다 중요합니다. 비즈니스 로직을 HTTP 핸들러에 무분별하게 던져 넣거나 애플리케이션의 모든 지점에서 직접 데이터베이스와 상호 작용하는 것은 빠르게 "스파게티 코드(spaghetti code)"로 알려진 뒤죽박죽 혼란으로 이어집니다. 이러한 구조 부족은 디버깅을 악몽으로 만들고, 밀접하게 연결된 구성 요소를 도입하며, 새 기능을 도입하거나 애플리케이션을 확장하는 능력을 심각하게 저해합니다. 간결함과 효율성으로 유명한 Go 생태계에서 강력한 웹 서비스를 구축하려면 잘 정의된 아키텍처 접근 방식이 필수적입니다. 이 글에서는 Go 웹 애플리케이션을 위한 일반적이고 매우 효과적인 계층형 아키텍처를 안내하며, 유지보수성, 테스트 용이성 및 궁극적으로 보다 즐거운 개발 경험을 촉진하기 위해 핸들러, 서비스 및 리포지토리를 전략적으로 구성하는 방법을 설명합니다.
계층형 Go 웹 앱의 빌딩 블록 이해
구조 패턴 자체에 대해 자세히 알아보기 전에 Go 웹 애플리케이션 계층을 구성하는 핵심 구성 요소를 정의해 봅시다. 이러한 책임을 이해하는 것이 이 분리의 이점을 이해하는 열쇠입니다.
- 핸들러 (또는 컨트롤러): 들어오는 HTTP 요청의 진입점입니다. 주요 책임은 요청을 구문 분석하고, 입력을 검증하고 (필수 필드 확인과 같은 기본 유효성 검사), 적절한 서비스 계층 메서드를 호출하고, 클라이언트에 다시 보낼 응답을 형식화하는 것입니다. 핸들러는 "웹" 측면에만 집중하고, HTTP 특정 사항을 의미 있는 함수 호출로 변환하고 그 반대로 해야 합니다. 얇게 유지하고 복잡한 비즈니스 로직을 포함하는 것을 피해야 합니다.
- 서비스 (또는 비즈니스 로직 계층): 서비스 계층은 애플리케이션의 핵심 비즈니스 로직을 캡슐화합니다. 이것은 애플리케이션이 수행하는 작업을 정의하는 곳입니다. (예: HTTP, gRPC 또는 CLI를 통해) 어떻게 노출되는지에 관계없이 서비스는 다양한 리포지토리 간의 상호 작용을 조율하고, 복잡한 유효성 검사 규칙을 적용하며, 트랜잭션을 처리하고, 비즈니스 정책을 시행합니다. 서비스 메서드는 일반적으로 도메인별 입력을 받아 도메인별 출력을 반환하여 기본 데이터 저장 메커니즘을 추상화합니다.
- 리포지토리 (또는 데이터 액세스 계층): 리포지토리 계층은 데이터 저장소 위에 추상화 역할을 합니다. 데이터베이스 (또는 외부 API, 파일 시스템 등과 같은 기타 지속성 메커니즘)와 직접 상호 작용하여 데이터를 저장하고 검색하는 역할을 합니다. 리포지토리는 도메인 개체를 데이터베이스 레코드로 매핑하고 그 반대로 매핑합니다. 데이터베이스 상호 작용 (예: SQL 쿼리, ORM 호출)의 세부 사항을 숨기고 특정 개체에 대한 기본 CRUD (Create, Read, Update, Delete) 작업을 수행하는 메서드를 노출해야 합니다.
- 모델 (또는 도메인 계층): (호출 스택에서 별도의 "계층"은 아니지만) 모델은 기본입니다. Go 웹 애플리케이션이 주로 사용하는 데이터 구조와 비즈니스 엔터티를 나타냅니다. 이러한 구조체는 데이터의 모양을 정의하고 해당 데이터와 직접 관련된 유효성 검사 메서드 또는 동작을 포함할 수 있습니다. 모델을 순수하게 유지하고 특정 계층에서 독립적으로 유지하면 재사용성과 명확성이 향상됩니다.
계층형 아키텍처 실제 적용
이제 이러한 구성 요소가 어떻게 통합되어 일관된 계층형 아키텍처를 형성하는지 살펴보겠습니다. 요청의 일반적인 흐름은 명확한 경로를 따릅니다.
HTTP 요청 -> 핸들러 -> 서비스 -> 리포지토리 -> 데이터베이스
응답이 다시 흐릅니다.
데이터베이스 -> 리포지토리 -> 서비스 -> 핸들러 -> HTTP 응답
이 단방향 흐름은 명확한 종속성을 촉진하고 디버깅을 단순화합니다. 간단한 "사용자 관리" 애플리케이션에 대한 실제 예를 살펴봅시다.
프로젝트 구조
이 아키텍처를 구현하는 일반적인 프로젝트 구조는 다음과 같습니다.
my-web-app/
├── main.go
├── config/
│ └── config.go
├── internal/
│ ├── auth/
│ │ ├── handler.go
│ │ ├── service.go
│ │ └── repository.go
│ ├── user/
│ │ ├── handler.go
│ │ ├── service.go
│ │ └── repository.go
│ ├── models/
│ │ └── user.go
│ │ └── product.go
│ └── database/
│ └── postgres.go
└── pkg/
└── utils/
└── errors.go
internal
디렉터리에는 다른 애플리케이션에서 가져와서는 안 되는 애플리케이션별 코드가 포함되어 있어 내부 구조를 깔끔하게 유지합니다. auth
및 user
와 같은 기능은 도메인별로 구성됩니다.
모델 (internal/models/user.go
)
package models import "time" type User struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` Password string `json:"-"` // 보안을 위해 JSON 출력에서 생략 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // UserCreateRequest는 새 사용자 생성에 사용됩니다 (입력 DTO). type UserCreateRequest struct { Username string `json:"username" validate:"required,min=3,max=30"` Email string `json:"email" validate:"required,email"` Password string `json:"password" validate:"required,min=6"` } // UserUpdateRequest는 사용자 세부 정보 업데이트용입니다 (입력 DTO). type UserUpdateRequest struct { Username *string `json:"username,omitempty" validate:"omitempty,min=3,max=30"` Email *string `json:"email,omitempty" validate:"omitempty,email"` }
여기서 User
는 핵심 도메인 모델입니다. UserCreateRequest
및 UserUpdateRequest
는 입력 유효성 검사에 사용되며 입력 구조를 내부 도메인 모델에서 분리하는 데이터 전송 개체 (DTO)입니다.
리포지토리 (internal/user/repository.go
)
package user import ( "context" "database/sql" "fmt" "my-web-app/internal/models" ) // UserRepository는 사용자 데이터 작업에 대한 인터페이스를 정의합니다. type UserRepository interface { CreateUser(ctx context.Context, user models.User) (*models.User, error) GetUserByID(ctx context.Context, id string) (*models.User, error) GetUserByEmail(ctx context.Context, email string) (*models.User, error) UpdateUser(ctx context.Context, user models.User) (*models.User, error) DeleteUser(ctx context.Context, id string) error } // postgresUserRepository는 PostgreSQL용 UserRepository를 구현합니다. type postgresUserRepository struct { db *sql.DB } // NewPostgresUserRepository는 새 PostgreSQL 사용자 리포지토리를 생성합니다. func NewPostgresUserRepository(db *sql.DB) UserRepository { return &postgresUserRepository{db: db} } func (r *postgresUserRepository) CreateUser(ctx context.Context, user models.User) (*models.User, error) { stmt := `INSERT INTO users (id, username, email, password, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id` err := r.db.QueryRowContext(ctx, stmt, user.ID, user.Username, user.Email, user.Password, user.CreatedAt, user.UpdatedAt).Scan(&user.ID) if err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } return &user, nil } func (r *postgresUserRepository) GetUserByID(ctx context.Context, id string) (*models.User, error) { var user models.User stmt := `SELECT id, username, email, password, created_at, updated_at FROM users WHERE id = $1` err := r.db.QueryRowContext(ctx, stmt, id).Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt) if err != nil { if err == sql.ErrNoRows { return nil, nil // 사용자를 찾을 수 없음 } return nil, fmt.Errorf("failed to get user by ID: %w", err) } return &user, nil } // ... 기타 리포지토리 메서드 (GetUserByEmail, UpdateUser, DeleteUser)
리포지토리는 인터페이스 (UserRepository
)를 정의합니다. 이는 종속성 역전 및 테스트 용이성에 매우 중요합니다. 구체적인 구현 (postgresUserRepository
)은 SQL 쿼리를 이 계층에 국한시키는 데이터베이스 상호 작용을 처리합니다.
서비스 (internal/user/service.go
)
package user import ( "context" "fmt" "time" "my-web-app/internal/models" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) // UserService는 사용자 관련 비즈니스 로직에 대한 인터페이스를 정의합니다. type UserService interface { RegisterUser(ctx context.Context, req models.UserCreateRequest) (*models.User, error) GetUserProfile(ctx context.Context, userID string) (*models.User, error) UpdateUserProfile(ctx context.Context, userID string, req models.UserUpdateRequest) (*models.User, error) } // userService는 UserService를 구현합니다. type userService struct { repo UserRepository } // NewUserService는 새 사용자 서비스를 생성합니다. func NewUserService(repo UserRepository) UserService { return &userService{repo: repo} } func (s *userService) RegisterUser(ctx context.Context, req models.UserCreateRequest) (*models.User, error) { // 1. 이메일로 사용자 존재 여부 확인 existingUser, err := s.repo.GetUserByEmail(ctx, req.Email) if err != nil { return nil, fmt.Errorf("failed to check existing user: %w", err) } if existingUser != nil { return nil, fmt.Errorf("user with email %s already exists", req.Email) } // 2. 비밀번호 해싱 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("failed to hash password: %w", err) } // 3. 새 사용자 모델 생성 now := time.Now() newUser := models.User{ ID: uuid.New().String(), Username: req.Username, Email: req.Email, Password: string(hashedPassword), CreatedAt: now, UpdatedAt: now, } // 4. 사용자 저장 createdUser, err := s.repo.CreateUser(ctx, newUser) if err != nil { return nil, fmt.Errorf("failed to save new user: %w", err) } // 5. 반환 전에 비밀번호 생략 createdUser.Password = "" return createdUser, nil } func (s *userService) GetUserProfile(ctx context.Context, userID string) (*models.User, error) { user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to get user profile: %w", err) } if user == nil { return nil, fmt.Errorf("user not found") } user.Password = "" // 프로필 보기를 위해 비밀번호 생략 return user, nil } // ... 기타 서비스 메서드 (UpdateUserProfile)
서비스 계층에는 핵심 비즈니스 로직이 포함됩니다. 기존 사용자를 확인하고, 비밀번호를 해싱하고, 사용자를 생성하는 작업을 조율합니다. UserRepository
인터페이스에 종속되어 있으며, 구체적인 구현에는 종속되지 않아 모의 리포지토리를 사용한 테스트가 가능합니다.
핸들러 (internal/user/handler.go
)
package user import ( "encoding/json" "net/http" "my-web-app/internal/models" "github.com/go-playground/validator/v10" "github.com/gorilla/mux" // 라우터 예시 ) // UserHandler는 사용자와 관련된 HTTP 요청을 처리합니다. type UserHandler struct { svc UserService validator *validator.Validate } // NewUserHandler는 새 사용자 핸들러를 생성합니다. func NewUserHandler(svc UserService) *UserHandler { return &UserHandler{ svc: svc, validator: validator.New(), } } // RegisterUser는 새 사용자를 등록하기 위해 POST /users 요청을 처리합니다. func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { var req models.UserCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request payload", http.StatusBadRequest) return } if err := h.validator.Struct(req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } user, err := h.svc.RegisterUser(r.Context(), req) if err != nil { // 사용자 대면 오류와 내부 오류 구분 http.Error(w, err.Error(), http.StatusInternalServerError) // 예시, 더 나은 오류 처리가 필요합니다. return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(user) } // GetUserProfile은 GET /users/{id} 요청을 처리합니다. func (h *UserHandler) GetUserProfile(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) userID := vars["id"] user, err := h.svc.GetUserProfile(r.Context(), userID) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) // 예시, 더 나은 오류 처리가 필요합니다. return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } // ... 기타 핸들러 메서드 (UpdateUserProfile)
핸들러의 역할은 HTTP 요청을 수신하고, 본문을 구문 분석하고, h.validator.Struct(req)
를 사용하여 기본 입력 유효성 검사를 수행하고, 적절한 서비스 메서드 (h.svc.RegisterUser
)를 호출하고, HTTP 응답을 다시 보내는 것입니다. 사용자가 어떻게 저장되는지 또는 비밀번호 해싱 메커니즘에 대해서는 아무것도 알지 못합니다.
연결 (main.go
)
마지막으로 main.go
는 데이터베이스 연결을 초기화하고, 리포지토리, 서비스 및 핸들러 인스턴스를 생성한 다음, HTTP 라우터를 설정하는 책임이 있습니다.
package main import ( "database/sql" "log" "net/http" "time" "my-web-app/internal/user" "my-web-app/internal/database" // 데이터베이스 패키지가 있다고 가정 "github.com/gorilla/mux" _ "github.com/lib/pq" // PostgreSQL 드라이버 ) func main() { // 데이터베이스 연결 초기화 db, err := database.NewPostgresDB("postgres://user:password@localhost:5432/mydb?sslmode=disable") if err != nil { log.Fatalf("failed to connect to database: %v", err) } defer db.Close() // 리포지토리, 서비스 및 핸들러 초기화 userRepo := user.NewPostgresUserRepository(db) userService := user.NewUserService(userRepo) userHandler := user.NewUserHandler(userService) // 라우터 설정 r := mux.NewRouter() r.HandleFunc("/users", userHandler.RegisterUser).Methods("POST") r.HandleFunc("/users/{id}", userHandler.GetUserProfile).Methods("GET") // 필요에 따라 더 많은 경로 추가 // 서버 시작 serverAddr := ":8080" log.Printf("Server starting on %s", serverAddr) srv := &http.Server{ Handler: r, Addr: serverAddr, WriteTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed to start: %v", err) } }
이 main.go
는 종속성 주입 패턴을 보여주며, 런타임에 인터페이스에 대한 구체적인 구현이 제공됩니다.
이점 및 적용
이 계층형 아키텍처는 상당한 이점을 제공합니다.
- 관심사 분리: 각 계층에는 고유한 책임이 있어 코드베이스를 더 쉽게 이해하고 관리할 수 있습니다.
- 테스트 용이성: 계층은 인터페이스에 의존하므로 모의 종속성을 사용하여 단위 테스트를 쉽게 수행할 수 있습니다. 예를 들어 모의 리포지토리를 제공하여 실제 데이터베이스 없이 서비스를 테스트할 수 있습니다.
- 유지보수성: 한 계층의 변경 사항이 다른 계층을 중단시킬 가능성이 적습니다. PostgreSQL에서 MySQL로 전환하는 경우 리포지토리 계층만 수정하면 됩니다.
- 확장성: 명확한 경계는 병목 현상을 식별하고 특정 구성 요소를 독립적으로 확장하는 데 도움이 됩니다.
- 재사용성: 서비스 계층의 비즈니스 로직은 다른 인터페이스(예: HTTP API, gRPC 서비스, 명령줄 도구)를 통해 재사용될 수 있습니다.
이 아키텍처는 소규모 마이크로서비스부터 대규모 모놀리식 애플리케이션에 이르기까지 거의 모든 Go 웹 애플리케이션에 적용할 수 있습니다. 유지보수 가능하고 확장 가능한 시스템을 구축하기 위한 강력한 기반을 제공합니다.
결론
Go 웹 애플리케이션을 핸들러, 서비스 및 리포지토리의 별도 계층으로 구성하는 것은 강력하고 확장 가능하며 유지보수 가능한 소프트웨어를 구축하기 위한 강력한 프레임워크를 제공합니다. 각 계층의 책임을 엄격하게 준수함으로써 명확한 관심사 분리를 달성하고, 테스트 용이성을 향상시키며, 장기적인 개발을 단순화합니다. 이 계층형 접근 방식은 개발자가 자신감과 우아함으로 복잡한 애플리케이션을 구축할 수 있도록 지원하는 검증된 패턴입니다.