Go 모놀리식 웹 애플리케이션을 위한 응집력 있고 느슨하게 결합된 코드 구조화
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
웹 개발의 활기찬 세계에서 Go는 성능, 동시성, 그리고 간결한 구문으로 유명하며 상당한 입지를 구축했습니다. 애플리케이션이 단순한 스크립트에서 복잡한 시스템으로 발전함에 따라, 깔끔하고 이해하기 쉬우며 확장 가능한 코드베이스를 유지하는 것이 가장 중요해집니다. 이는 모든 구성 요소가 단일 코드베이스 내에 상주하는 모놀리식 애플리케이션의 경우 특히 그렇습니다. 마이크로서비스가 인기 있는 대안을 제공하지만, 모놀리스는 특히 초기 단계나 단순한 배포와 통합된 개발을 우선시하는 팀의 경우 많은 프로젝트에 실용적이고 종종 선호되는 선택으로 남아 있습니다. 그러나 신중한 아키텍처 고려 없이는 이러한 모놀리스가 빠르게 복잡하게 얽힌 덩어리로 전락하여 새로운 기능 개발을 악몽으로 만들고 버그 수정 작업을 위험한 노력으로 만들 수 있습니다. 핵심 과제는 관련 부분이 함께 유지되도록 높은 응집력과 구성 요소가 독립적이고 교체 가능하도록 하는 낮은 결합도를 보장하도록 코드를 구성하는 데 있습니다. 이 글은 이러한 중요한 속성을 달성하기 위해 Go 모놀리식 웹 애플리케이션을 구조화하기 위한 효과적인 전략과 패턴을 탐구하여 잠재적인 혼돈을 유지 가능한 질서로 전환할 것입니다.
핵심 원칙 이해
특정 Go 구현에 대해 자세히 알아보기 전에 논의를 이끄는 핵심 개념을 간단히 정의해 보겠습니다.
- 응집력 (Cohesion): 모듈의 요소들이 얼마나 관련성이 있는지를 나타냅니다. 높은 응집력은 모듈의 모든 부분이 단일하고 명확하게 정의된 목적을 위해 함께 작동함을 의미합니다. 예를 들어,
UserService모듈은 주문 처리나 결제 처리가 아닌 사용자 관리에 직접 관련된 로직만 포함해야 합니다. 높은 응집력은 이해, 테스트 및 유지 관리가 더 쉬운 모듈로 이어집니다. - 결합도 (Coupling): 소프트웨어 모듈 간의 상호 의존도를 나타냅니다. 낮은 결합도는 모듈이 서로 상대적으로 독립적이어서 한 모듈의 변경이 다른 모듈의 변경을 필연적으로 요구할 가능성이 적음을 의미합니다. 예를 들어,
UserService는 이상적으로는 데이터베이스 클라이언트의 구체적인 구현에 직접 의존하는 것이 아니라 정의된 인터페이스에 의존해야 합니다. 낮은 결합도는 유연성, 재사용성 및 쉬운 디버깅을 촉진합니다.
높은 응집력과 낮은 결합도를 달성하는 것은 좋은 소프트웨어 설계의 초석이며, 보다 강력하고 확장 가능하며 유지 가능한 애플리케이션으로 이어집니다.
Go 모놀리스에서의 전략적 코드 구성
Go의 패키지 시스템과 인터페이스 기반 설계는 이러한 원칙을 적용할 수 있는 훌륭한 도구를 제공합니다. 여기서는 Go 모놀리식 웹 애플리케이션에 대한 일반적이고 효과적인 아키텍처 패턴인 "계층형 아키텍처" 또는 "클린 아키텍처" 변형을 설명하겠습니다.
1. 프로젝트 구조 - 계층형 접근 방식
잘 정의된 디렉터리 구조는 명확성을 향한 첫걸음입니다. 일반적인 Go 모놀리식 웹 애플리케이션은 다음과 같을 수 있습니다.
my-web-app/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── app/
│ │ ├── user/
│ │ │ ├── service.go
│ │ │ └── repository.go
│ │ ├── product/
│ │ │ ├── service.go
│ │ │ └── repository.go
│ │ └── common/
│ │ └── errors.go
│ ├── domain/
│ │ ├── user.go
│ │ └── product.go
│ ├── port/
│ │ ├── http/
│ │ │ ├── handler.go
│ │ │ ├── routes.go
│ │ │ └── dto.go
│ │ └── cli/
│ │ └── commands.go
│ ├── adapter/
│ │ ├── database/
│ │ │ ├── postgres/
│ │ │ │ └── user_repo.go
│ │ │ │ └── product_repo.go
│ │ │ └── redis/
│ │ │ └── cache.go
│ │ └── external/
│ │ └── payment_gateway/
│ │ └── client.go
│ └── config/
│ └── config.go
├── pkg/
│ └── utils/
│ └── validator.go
├── web/
│ └── static/
│ └── templates/
└── go.mod
└── go.sum
이 디렉터리들을 자세히 살펴보겠습니다.
cmd/: 실행 가능한 명령의 주요 진입점을 포함합니다. 웹 서버의 경우cmd/server/main.go는 일반적으로 HTTP 서버를 초기화하고 시작합니다. 이는 애플리케이션 부트스트랩 로직을 분리하고 최소한으로 유지합니다.internal/: 다른 프로젝트에서 가져오면 안 되는 애플리케이션별 코드를 포함합니다. 이는 강력한 내부 경계를 유지하는 데 중요합니다.internal/app/: 종종 기능별로 구성되는 핵심 비즈니스 로직을 포함합니다 (예:user,product).service.go: 비즈니스 규칙을 구현하고 리포지토리 및 외부 서비스와의 상호 작용을 조정합니다. 이것이 애플리케이션의 "무엇"과 "왜"의 대부분이 존재하는 곳입니다.repository.go: 데이터 작업에 대한 인터페이스를 정의합니다. 이러한 인터페이스는 구체적인 어댑터에 의해 구현됩니다.
internal/domain/: 핵심 애플리케이션 엔티티, 값 객체 및 도메인별 유형을 정의합니다. 이러한 유형은 특정 지속성 또는 전송 메커니즘에 연결된 비즈니스 로직이 없는 순수한 Go 구조체여야 합니다.internal/port/: 애플리케이션이 외부 세계와 상호 작용하는 "포트" 또는 인터페이스를 정의합니다.http/: HTTP 핸들러, 라우팅 설정 및 요청 및 응답을 위한 DTO(Data Transfer Objects)를 포함합니다. 이 계층은 애플리케이션이 HTTP를 통해 입력을 수신하고 출력을 보내는 방법을 정의합니다.cli/: 애플리케이션에 CLI 명령이 있는 경우 해당 정의는 여기에 있습니다.
internal/adapter/:internal/app및internal/port에 정의된 인터페이스를 구현하는 "어댑터"를 포함합니다. 이는 데이터베이스, 외부 API, 메시지 큐 등과 상호 작용하기 위한 구체적인 구현입니다. 외부 기술을 애플리케이션 도메인에 "적응"시킵니다.internal/config/: 애플리케이션 구성 로딩 및 구문 분석을 처리합니다.
pkg/: 다른 프로젝트에서 안전하게 가져올 수 있는 재사용 가능한 라이브러리 또는 유틸리티를 저장합니다 (단, 실제 모놀리스의 경우internal보다 덜 일반적일 수 있습니다). 일반적인 유틸리티 함수, 사용자 정의 오류 유형 또는 도우미가 예입니다.web/: Go 애플리케이션이 직접 제공하는 경우 정적 자산 또는 HTML 템플릿용입니다.
2. 기능 기반 서비스 구조를 통한 높은 응집력 달성
internal/app 내에서 기능별(예: user, product) 구성은 응집력을 크게 향상시킵니다. 각 기능 패키지에는 비즈니스 로직(service)과 데이터 액세스 인터페이스(repository)를 포함하여 해당 특정 도메인과 관련된 모든 것이 포함됩니다.
예시: internal/app/user/service.go
package user import ( "context" "my-web-app/internal/domain" "my-web-app/internal/app/common" ) // Service는 사용자 관리를 위한 비즈니스 로직을 정의합니다. type Service struct { repo Repository } // NewService는 새로운 사용자 서비스를 생성합니다. func NewService(repo Repository) *Service { return &Service{repo: repo} } // RegisterUser는 신규 사용자 등록을 처리합니다. func (s *Service) RegisterUser(ctx context.Context, email, password string) (*domain.User, error) { // 비즈니스 규칙: 이미 존재하는 사용자인지 확인 existingUser, err := s.repo.GetUserByEmail(ctx, email) if err != nil && err != common.ErrNotFound { return nil, err } if existingUser != nil { return nil, common.ErrUserAlreadyExists } // 비밀번호 해싱 (예시를 위해 단순화) hashedPassword := "hashed_" + password user := &domain.User{ Email: email, Password: hashedPassword, // ... 기타 필드 } if err := s.repo.CreateUser(ctx, user); err != nil { return nil, err } return user, nil } // GetUserByID는 ID로 사용자를 검색합니다. func (s *Service) GetUserByID(ctx context.Context, id string) (*domain.User, error) { return s.repo.GetUserByID(ctx, id) }
예시: internal/app/user/repository.go
package user import ( "context" "my-web-app/internal/domain" ) // Repository는 사용자 데이터 저장 작업에 대한 인터페이스를 정의합니다. type Repository interface { CreateUser(ctx context.Context, user *domain.User) error GetUserByID(ctx context.Context, id string) (*domain.User, error) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) UpdateUser(ctx context.Context, user *domain.User) error DeleteUser(ctx context.Context, id string) error }
여기서 user 서비스는 모든 사용자 관련 비즈니스 규칙을 캡슐화합니다. 동일한 user 패키지 내에 정의된 Repository 인터페이스를 사용하므로 응집력이 향상됩니다.
3. 인터페이스와 의존성 역전을 통한 낮은 결합도 달성
Go에서 낮은 결합도의 핵심은 인터페이스의 광범위한 사용입니다. 서비스는 데이터베이스 또는 외부 서비스의 구체적인 구현에 의존하는 것이 아니라 인터페이스에 의존합니다. 그런 다음 구체적인 구현은 더 높은 수준 (예: main.go)에서 "주입"됩니다. 이것은 의존성 역전 원칙의 직접적인 적용입니다.
예시: internal/adapter/database/postgres/user_repo.go
package postgres import ( "context" "database/sql" "fmt" "my-web-app/internal/app/user" // 인터페이스 가져오기! "my-web-app/internal/domain" ) // UserRepository는 PostgreSQL에 대한 user.Repository를 구현합니다. type UserRepository struct { db *sql.DB } // NewUserRepository는 새로운 PostgreSQL 사용자 리포지토리를 생성합니다. func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } // CreateUser는 user.Repository.CreateUser를 구현합니다. func (r *UserRepository) CreateUser(ctx context.Context, u *domain.User) error { query := `INSERT INTO users (email, password) VALUES ($1, $2) RETURNING id` err := r.db.QueryRowContext(ctx, query, u.Email, u.Password).Scan(&u.ID) if err != nil { return fmt.Errorf("failed to create user: %w", err) } return nil } // GetUserByEmail은 user.Repository.GetUserByEmail을 구현합니다. func (r *UserRepository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { u := &domain.User{} query := `SELECT id, email, password FROM users WHERE email = $1` err := r.db.QueryRowContext(ctx, query, email).Scan(&u.ID, &u.Email, &u.Password) if err != nil { if err == sql.ErrNoRows { return nil, user.ErrNotFound // app/user 레이어의 특정 오류 사용 } return nil, fmt.Errorf("failed to get user by email: %w", err) } return u, nil } // ... 기타 리포지토리 메서드
UserRepository가 user.Repository를 명시적으로 구현하는 것을 주목하십시오. user.Service는 PostgreSQL에 대해 전혀 알지 못하고 user.Repository 인터페이스와만 상호 작용합니다. 테스트를 위해 NoSQL 데이터베이스나 인메모리 리포지토리로 전환하기로 결정하면 핵심 비즈니스 로직인 internal/app/user가 아닌 internal/adapter/database만 변경하면 됩니다. 이는 결합도를 크게 줄입니다.
4. cmd/server/main.go에서 와이어링
최상위 main.go는 모든 구성 요소를 조립하고 종속성을 주입하는 책임이 있습니다.
예시: cmd/server/main.go
package main import ( "context" "database/sql" "log" "net/http" "os" "os/signal" "syscall" "time" _ "github.com/lib/pq" // PostgreSQL 드라이버 "my-web-app/internal/adapter/database/postgres" "my-web-app/internal/app/user" "my-web-app/internal/config" "my-web-app/internal/port/http" ) func main() { cfg := config.LoadConfig() // 구성 로드 // 데이터베이스 연결 초기화 db, err := sql.Open("postgres", cfg.DatabaseURL) if err != nil { log.Fatalf("Failed to connect to database: %v", err) } defer db.Close() if err = db.Ping(); err != nil { log.Fatalf("Failed to ping database: %v", err) } log.Println("Database connection established.") // --- 종속성 주입 --- // 구체적인 리포지토리 구현 생성 userRepo := postgres.NewUserRepository(db) // 주입된 리포지토리를 사용하여 서비스 계층 생성 userService := user.NewService(userRepo) // userRepo (user.Repository 구현) 주입 // 주입된 서비스를 사용하여 HTTP 핸들러 생성 userHandler := httpport.NewUserHandler(userService) // --- 종속성 주입 끝 --- // 라우트 설정 router := httpport.NewRouter(userHandler) // 라우터에 userHandler 전달 server := &http.Server{ Addr: cfg.ListenAddr, Handler: router, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 15 * time.Second, } // 고루틴에서 서버 시작 go func() { log.Printf("Server listening on %s", cfg.ListenAddr) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Could not listen on %s: %v", cfg.ListenAddr, err) } }() // 즉시 종료 quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit log.Println("Shutting down server...") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { log.Fatalf("Server forced to shutdown: %v", err) } log.Println("Server exited gracefully.") }
main.go는 모든 조각이 모이는 곳입니다. 그것은 우리의 "구성 루트"이며, 인터페이스에 의존하는 계층에 구체적인 유형을 생성하고 주입하는 책임을 맡습니다.
결론
응집력 있고 느슨하게 결합된 Go 모놀리식 웹 애플리케이션을 구성하는 것은 단순한 학문적 연습이 아니라 유지 가능하고 확장 가능하며 테스트 가능한 소프트웨어를 구축하기 위한 실질적인 필요성입니다. 계층형 아키텍처를 채택하고, 의존성 역전을 위해 Go의 인터페이스 시스템을 활용하고, 코드별로 코드를 구성함으로써 개발자는 함께 작업하기 즐거운 강력한 애플리케이션을 만들 수 있습니다. 이 접근 방식은 변경의 파급 효과를 최소화하고, 디버깅을 단순화하며, 애플리케이션의 다른 부분의 독립적인 개발을 가능하게 하여 궁극적으로보다 탄력적이고 확장 가능한 시스템으로 이어집니다. 인터페이스를 수용하고, 의도별로 코드를 구성하면 Go 모놀리스가 번영할 것입니다.

