헥사고날 아키텍처를 활용한 견고한 Go 애플리케이션 구축
Min-jun Kim
Dev Intern · Leapcell

소개
끊임없이 진화하는 소프트웨어 개발 환경에서 기능적일 뿐만 아니라 유지보수 가능하고, 테스트 가능하며, 변화에 적응력 있는 애플리케이션을 구축하는 것이 무엇보다 중요합니다. 프로젝트의 복잡성이 증가함에 따라 시스템의 초기 우아함은 빠르게 얽히고설킨 구성 요소의 뒤죽박죽으로 변질되어 수정이 위험한 일이 될 수 있습니다. 이는 종종 핵심 비즈니스 로직이 데이터베이스, UI 또는 외부 API와 같은 기술 세부 정보와 깊이 얽혀 있는 아키텍처에서 비롯됩니다. 이 블로그 게시물은 특히 Go 생태계 내에서 이러한 문제에 대한 해결책을 제공하는 강력한 패러다임인 헥사고날 아키텍처에 대해 자세히 알아봅니다. 이 아키텍처 스타일, 즉 포트 및 어댑터라고도 알려진 것이 명확한 비즈니스 경계를 정의하고, 도메인을 인프라 문제에서 분리하며, 궁극적으로 더 탄력적이고 미래 지향적인 Go 애플리케이션을 구축할 수 있도록 하는 방법을 살펴보겠습니다.
헥사고날 아키텍처 이해
실질적인 내용을 살펴보기 전에 헥사고날 아키텍처의 핵심 개념에 대한 공통된 이해를 확립해 보겠습니다.
핵심 용어
- 헥사고날 아키텍처 (포트 및 어댑터): 핵심 애플리케이션 로직( "도메인" 또는 "비즈니스 로직")을 외부 문제(데이터베이스, 사용자 인터페이스 또는 타사 서비스 등)에서 분리하는 아키텍처 패턴입니다. 애플리케이션이 핵심을 변경하지 않고도 다양한 외부 행위자가 애플리케이션을 구동할 수 있도록 "제어 역전" 원칙을 강조합니다. "육각형"은 애플리케이션과 상호 작용할 수 있는 다양한 방법을 시각적으로 나타내는 은유일 뿐입니다.
- 포트: 애플리케이션이 외부 세계와 상호 작용하는 방법을 정의하는 인터페이스입니다. 외부 행위자가 사용하기 위해 핵심 애플리케이션에서 노출되는 "구동 포트"(애플리케이션에서 제공하는 API)이거나 핵심 애플리케이션이 외부 세계에서 필요로 하는 서비스(데이터베이스 등)인 "구동 포트"일 수 있습니다. 이를 애플리케이션의 "소켓"으로 생각하십시오.
- 어댑터: 포트의 구현입니다. 특정 기술 또는 프로토콜을 애플리케이션의 핵심이 이해하는 형식으로 번역하거나(구동 포트의 경우) 애플리케이션의 응답을 외부 구성 요소가 이해하는 형식으로 번역합니다(구동 포트의 경우). 이들은 소켓에 맞는 "플러그"입니다.
- 도메인 / 애플리케이션 코어: 순수한 비즈니스 로직과 규칙을 포함하는 애플리케이션의 핵심입니다. 데이터베이스, 웹 프레임워크 또는 특정 UI 기술에 대해 전혀 알지 못합니다. 포트를 통해서만 상호 작용합니다.
Go에서의 원칙 및 구현
헥사고날 아키텍처의 주요 목표는 도메인 로직을 외부 변경으로부터 보호하는 것입니다. Go에서 인터페이스는 이러한 분리을 달성하는 데 중요한 역할을 합니다.
사용자를 생성하고 검색할 수 있는 간단한 "사용자 관리" 애플리케이션을 고려해 보겠습니다.
1. 도메인 코어 정의
먼저 핵심 비즈니스 엔티티와 기본 작업을 정의합니다. 이 부분에는 데이터베이스 또는 웹 프레임워크 관련 내용이 포함되어서는 안 됩니다.
// internal/domain/user.go package domain import "errors" var ErrUserNotFound = errors.New("user not found") type User struct { ID string Name string Email string } // UserRepository는 사용자 지속성을 상호 작용하기 위한 인터페이스를 정의합니다. // 이것은 애플리케이션 코어가 외부 세계에서 사용자를 쿼리해야 하기 때문에 "구동 포트"입니다. type UserRepository interface { Save(user User) error FindByID(id string) (User, error) FindByEmail(email string) (User, error) } // UserService는 사용자 관리를 위한 비즈니스 작업을 정의합니다. // 이것도 우리 도메인 코어의 일부입니다. type UserService struct { userRepo UserRepository // 포트에 의존 } func NewUserService(repo UserRepository) *UserService { return &UserService{userRepo: repo} } func (s *UserService) RegisterUser(name, email string) (User, error) { // 비즈니스 규칙: 이메일이 이미 존재하는 사용자가 있는지 확인 _, err := s.userRepo.FindByEmail(email) if err == nil { return User{}, errors.New("user with this email already exists") } if err != domain.ErrUserNotFound { return User{}, err // 기타 지속성 오류 } newUser := User{ ID: generateUUID(), // 예시를 위해 간소화 Name: name, Email: email, } if err := s.userRepo.Save(newUser); err != nil { return User{}, err } return newUser, nil } func (s *UserService) GetUser(id string) (User, error) { return s.userRepo.FindByID(id) } func generateUUID() string { // 실제 사용 시: "github.com/google/uuid"와 같은 UUID 라이브러리 사용 return "some-uuid" }
UserService
가 구체적인 데이터베이스 구현이 아닌 UserRepository
인터페이스와만 상호 작용한다는 점에 유의하십시오. 이것이 분리의 본질입니다.
2. 포트 정의
위 예시에서 UserRepository
는 구동 포트입니다. 사용자 생성을 허용하는 구동 포트에 대한 포트를 상상해 봅시다.
// internal/application/ports/user_api.go package ports import "example.com/myapp/internal/domain" // UserAPIService는 구동 포트입니다. 외부 행위자는 이 인터페이스를 통해 애플리케이션을 "구동"합니다. type UserAPIService interface { RegisterUser(name, email string) (domain.User, error) GetUser(id string) (domain.User, error) }
애플리케이션 코어는 이 UserAPIService
인터페이스를 구현합니다.
// internal/application/service.go package application import "example.com/myapp/internal/domain" // ApplicationService는 UserAPIService 포트를 구현합니다. // 도메인 서비스에 대한 호출을 조정합니다. type ApplicationService struct { userService *domain.UserService } func NewApplicationService(userService *domain.UserService) *ApplicationService { return &ApplicationService{userService: userService} } func (s *ApplicationService) RegisterUser(name, email string) (domain.User, error) { return s.userService.RegisterUser(name, email) } func (s *ApplicationService) GetUser(id string) (domain.User, error) { return s.userService.GetUser(id) }
3. 어댑터 구현
이제 애플리케이션 코어를 특정 기술과 연결하기 위한 어댑터를 만듭니다.
**데이터베이스 어댑터 (UserRepository
구동 포트 구현):
// internal/adapters/repository/inmem_user_repo.go package repository import ( "errors" "sync" "example.com/myapp/internal/domain" ) // InMemoryUserRepository는 인메모리 데이터베이스에 대한 어댑터입니다. type InMemoryUserRepository struct { users map[string]domain.User mu sync.RWMutex } func NewInMemoryUserRepository() *InMemoryUserRepository { return &InMemoryUserRepository{ users: make(map[string]domain.User), } } func (r *InMemoryUserRepository) Save(user domain.User) error { r.mu.Lock() defer r.mu.Unlock() r.users[user.ID] = user return nil } func (r *InMemoryUserRepository) FindByID(id string) (domain.User, error) { r.mu.RLock() defer r.mu.RUnlock() user, ok := r.users[id] if !ok { return domain.User{}, domain.ErrUserNotFound } return user, nil } func (r *InMemoryUserRepository) FindByEmail(email string) (domain.User, error) { r.mu.RLock() defer r.mu.RUnlock() for _, user := range r.users { if user.Email == email { return user, nil } } return domain.User{}, domain.ErrUserNotFound }
이 InMemoryUserRepository
를 PostgreSQLUserRepository
또는 MongoDBUserRepository
로 쉽게 교체할 수 있으며, domain.UserRepository
인터페이스를 구현하는 한 domain.UserService
를 건드리지 않고도 가능합니다.
**웹 API 어댑터 (UserAPIService
구동 포트 구동):
// cmd/main.go (간소화된 진입점) package main import ( "encoding/json" "log" "net/http" "example.com/myapp/internal/adapters/repository" "example.com/myapp/internal/application" "example.com/myapp/internal/application/ports" "example.com/myapp/internal/domain" ) type RegisterUserRequest struct { Name string `json:"name"` Email string `json:"email"` } // UserAPIAdapter는 애플리케이션의 UserAPIService 포트를 소비하는 구동 어댑터(예: HTTP 핸들러)입니다. type UserAPIAdapter struct { appService ports.UserAPIService } func NewUserAPIAdapter(service ports.UserAPIService) *UserAPIAdapter { return &UserAPIAdapter{appService: service} } func (a *UserAPIAdapter) RegisterUserHandler(w http.ResponseWriter, r *http.Request) { var req RegisterUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } user, err := a.appService.RegisterUser(req.Name, req.Email) if err != nil { // 실제 앱에서는 더 나은 오류 처리를 위해 오류 유형을 구분 if err == domain.ErrUserNotFound { http.Error(w, err.Error(), http.StatusNotFound) } else { http.Error(w, err.Error(), http.StatusInternalServerError) } return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } // ... GetUser 등의 다른 핸들러
4. 모든 것을 연결하기
main
함수 또는 종속성 주입 계층에서 구성 요소를 조립합니다.
// cmd/app/main.go package main import ( "log" "net/http" "example.com/myapp/internal/adapters/repository" "example.com/myapp/internal/application" "example.com/myapp/internal/domain" ) func main() { // 1. 어댑터 초기화 (구동 측 - 리포지토리) userRepo := repository.NewInMemoryUserRepository() // 또는 NewPostgreSQLUserRepository() // 2. 도메인 서비스 초기화 (코어 로직) userService := domain.NewUserService(userRepo) // 3. 애플리케이션 서비스 초기화 (외부 행위자를 위한 구동 포트 구현) appService := application.NewApplicationService(userService) // 4. 어댑터 초기화 (구동 측 - 웹 API) userAPIAdapter := &UserAPIAdapter{appService: appService} // 웹 클라이언트를 위한 구동 포트 구현 // 웹 경로 설정 http.HandleFunc("/users", userAPIAdapter.RegisterUserHandler) // http.HandleFunc("/users/{id}", userAPIAdapter.GetUserHandler) // 예시 log.Println("Server starting on :8080") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatalf("Server failed: %v", err) } }
이점 및 적용
- 분리: 핵심 도메인 로직은 인프라 세부 정보에 대해 전혀 알지 못한 채로 유지됩니다. 이를 통해 자체적으로 매우 테스트 가능하고 이식 가능합니다.
- 테스트 용이성: 포트의 모의 구현(예:
UserRepository
에 대한InMemoryUserRepository
)을 사용하여 도메인 및 애플리케이션 서비스를 쉽게 테스트할 수 있습니다. 이는 빠르고 안정적인 단위 및 통합 테스트를 촉진합니다. - 유지보수성 및 적응성: 관계형 데이터베이스에서 NoSQL 데이터베이스로 전환하거나 메시징 큐를 변경하기로 결정하면 해당 어댑터만 수정하거나 교체하면 됩니다. 핵심 비즈니스 로직은 변경되지 않습니다.
- 명확한 경계: 아키텍처는 관심사의 명확한 분리를 강제하여 개발자가 새 로직을 배치하거나 기존 코드를 찾는 것을 더 쉽게 만듭니다.
결론
헥사고날 아키텍처 또는 포트 및 어댑터는 견고하고 테스트 가능하며 변경에 탄력적인 Go 애플리케이션을 구축하는 데 매우 효과적인 방법을 제공합니다. 인터페이스를 포트로 세심하게 정의하고 특정 기술을 어댑터로 구현함으로써 귀중한 비즈니스 로직을 외부 문제의 불안정한 특성으로부터 보호하는 유연한 시스템을 만듭니다. 이 아키텍처 스타일은 개발자가 품질이 높고 적응력 있는 소프트웨어를 제공할 수 있도록 하여 명확한 비즈니스 경계와 쉬운 진화를 보장합니다.