Go 웹 애플리케이션의 유지보수성 및 적응성을 위한 아키텍처 설계
Ethan Miller
Product Engineer · Leapcell

소개
견고하고 확장 가능한 웹 애플리케이션을 구축하려면 단순히 기능하는 코드를 작성하는 것 이상이 필요합니다. 프로젝트가 복잡해짐에 따라 비즈니스 로직과 기본 프레임워크 간의 긴밀한 결합은 종종 유지보수성, 테스트 용이성 및 미래 진화에 상당한 장애물이 됩니다. 이러한 긴밀한 결합으로 인해 새로운 요구 사항에 적응하거나 프레임워크를 교체하는 작업은 엄청난 작업이 되며, 일반적으로 광범위한 리팩터링이 필요합니다. 이 글에서는 Go 웹 프로젝트에서 클린 아키텍처를 구현하여 핵심 비즈니스 로직을 외부 종속성에서 분리하는 방법을 탐구합니다. 이를 통해 변화에 탄력적이고 테스트하기 쉬우며 궁극적으로 더 지속 가능한 애플리케이션을 구축하는 것을 목표로 합니다. 클린 아키텍처의 원칙을 살펴보고 Go에서의 실제 적용을 시연하여 명확한 관심사 분리를 달성하는 방법을 보여줍니다.
핵심 개념 이해
구현 세부 사항을 살펴보기 전에 클린 아키텍처의 핵심 개념에 대한 일반적인 이해를 확립해 보겠습니다.
- 클린 아키텍처: Robert C. Martin(Uncle Bob)이 제안한 클린 아키텍처는 동심원 계층을 주장하는 아키텍처 철학으로, 가장 안쪽 계층은 핵심 비즈니스 로직을 나타내고 가장 바깥쪽 계층은 데이터베이스, UI 및 프레임워크와 같은 외부 문제를 처리합니다. 기본 원칙은 종속성 규칙입니다. "종속성은 안쪽으로만 가리킬 수 있습니다." 이는 내부 계층이 외부 계층에 절대 의존해서는 안 된다는 것을 의미합니다.
- 엔티티: 이는 엔터프라이즈 전체의 비즈니스 규칙입니다. 특정 애플리케이션에 영향을 받지 않는 가장 일반적이고 높은 수준의 규칙을 캡슐화합니다. Go에서 이러한 규칙은 종종 핵심 도메인 객체를 나타내는 간단한 구조체입니다.
- 유스 케이스 (인터랙터): 여기에는 애플리케이션별 비즈니스 규칙이 포함됩니다. 엔티티로의 데이터 흐름을 조율하여 애플리케이션의 작동 방식을 정의합니다. 유스 케이스는 UI, 데이터베이스 또는 기타 외부 문제에 대해 알지 못합니다. 입력과 출력을 다루며 애플리케이션의 특정 작업 또는 기능을 나타냅니다.
- 인터페이스 어댑터: 이 계층은 유스 케이스와 외부 세계 사이에 위치합니다. 유스 케이스와 엔티티에 가장 편리한 형식의 데이터를 데이터베이스 또는 웹 프레임워크와 같은 외부 에이전트에 가장 편리한 형식으로 조정합니다. 여기에는 컨트롤러, 프레젠터 및 게이트웨이가 포함됩니다.
- 프레임워크 및 드라이버: 이는 Gin 또는 Echo와 같은 프레임워크, 데이터베이스, 웹 서버 및 기타 외부 도구로 구성된 가장 바깥쪽 계층입니다. 이 계층은 구현 세부 사항입니다. 핵심 비즈니스 로직(엔티티 및 유스 케이스)은 존재를 알지 못해야 합니다.
이 계층식 접근 방식(종종 동심원 이미지로 시각화됨)의 아름다움은 가장 바깥쪽 계층의 변경 사항이 내부 계층에 최소한의 영향을 미쳐 유연성과 테스트 용이성을 극대화한다는 것입니다.
Go 웹 프로젝트에서 클린 아키텍처 구현하기
실용적인 예시, 즉 간단한 "할 일 목록" 애플리케이션으로 이러한 개념을 설명해 보겠습니다. "새로운 할 일 항목 만들기"의 핵심 기능에 중점을 두겠습니다.
프로젝트 구조
클린 아키텍처를 따르는 일반적인 프로젝트 구조는 다음과 같습니다.
├── cmd/
│ └── main.go
├── internal/
│ ├── adapters/
│ │ ├── http/
│ │ │ └── todoHandler.go
│ │ └── repository/
│ │ └── todoRepository.go
│ ├── application/
│ │ └── usecase/
│ │ └── createTodo.go
│ └── domain/
│ ├── entity/
│ │ └── todo.go
│ └── repository/
│ └── todo.go // Interfaces for repository
└── pkg/
└── utils/
1. 도메인 계층: 엔티티 및 리포지토리 인터페이스
domain
계층은 핵심 비즈니스 객체와 이를 상호 작용하기 위한 계약을 정의합니다.
internal/domain/entity/todo.go
:
package entity import "time" // ToDo represents a single to-do item. type ToDo struct { ID string `json:"id"` Title string `json:"title"` Completed bool `json:"completed"` CreatedAt time.Time `json:"createdAt"` } // NewToDo creates a new ToDo item with default values. func NewToDo(id, title string) *ToDo { return &ToDo{ ID: id, Title: title, Completed: false, CreatedAt: time.Now(), } }
internal/domain/repository/todo.go
:
package repository import "context" import "your-app/internal/domain/entity" // Using absolute path for clarity // ToDoRepository defines the interface for interacting with ToDo storage. type ToDoRepository interface { Save(ctx context.Context, todo *entity.ToDo) error FindByID(ctx context.Context, id string) (*entity.ToDo, error) // Add other methods like FindAll, Update, Delete }
domain
계층이 특정 데이터베이스 구현(예: PostgreSQL, MongoDB)에 대해 전혀 알지 못한다는 점에 유의하십시오. 영속성을 위한 계약만 정의합니다.
2. 애플리케이션 계층: 유스 케이스
application
계층에는 애플리케이션별 비즈니스 로직이 포함됩니다. 리포지토리 인터페이스를 사용하여 도메인 엔티티를 조율합니다.
internal/application/usecase/createTodo.go
:
package usecase import ( "context" "your-app/internal/domain/entity" "your-app/internal/domain/repository" "github.com/google/uuid" // For generating unique IDs ) // CreateToDoInput defines the input data for creating a ToDo. type CreateToDoInput struct { Title string `json:"title"` } // CreateToDoOutput defines the output data after creating a ToDo. type CreateToDoOutput struct { ID string `json:"id"` Title string `json:"title"` } // CreateToDo represents the use case for creating a new ToDo item. type CreateToDo struct { repo repository.ToDoRepository } // NewCreateToDo creates a new CreateToDo use case. func NewCreateToDo(repo repository.ToDoRepository) *CreateToDo { return &CreateToDo{repo: repo} } // Execute performs the logic for creating a ToDo. func (uc *CreateToDo) Execute(ctx context.Context, input CreateToDoInput) (*CreateToDoOutput, error) { // Business rule: Title cannot be empty if input.Title == "" { return nil, entity.ErrInvalidToDoTitle // Assuming entity.ErrInvalidToDoTitle is defined } todoID := uuid.New().String() todo := entity.NewToDo(todoID, input.Title) err := uc.repo.Save(ctx, todo) if err != nil { return nil, err } return &CreateToDoOutput{ ID: todo.ID, Title: todo.Title, }, nil }
CreateToDo
유스 케이스는 웹 프레임워크나 특정 데이터베이스와 완전히 독립적입니다. ToDoRepository
인터페이스와 ToDo
엔티티와만 상호 작용합니다.
3. 인터페이스 어댑터 계층: 리포지토리 구현 및 HTTP 핸들러
이 계층은 애플리케이션 계층을 외부 세계와 연결합니다.
internal/adapters/repository/todoRepository.go
(간단함을 위해 메모리 내 사용 예시):
package repository import ( "context" "fmt" "sync" "your-app/internal/domain/entity" "your-app/internal/domain/repository" ) // InMemoryToDoRepository implements the ToDoRepository interface. type InMemoryToDoRepository struct { mu sync.RWMutex store map[string]*entity.ToDo } // NewInMemoryToDoRepository creates a new in-memory repository. func NewInMemoryToDoRepository() *InMemoryToDoRepository { return &InMemoryToDoRepository{ store: make(map[string]*entity.ToDo), } } // Save stores a ToDo item in memory. func (r *InMemoryToDoRepository) Save(ctx context.Context, todo *entity.ToDo) error { r.mu.Lock() defer r.mu.Unlock() r.store[todo.ID] = todo return nil } // FindByID retrieves a ToDo item from memory. func (r *InMemoryToDoRepository) FindByID(ctx context.Context, id string) (*entity.ToDo, error) { r.mu.RLock() defer r.mu.RUnlock() todo, ok := r.store[id] if !ok { return nil, fmt.Errorf("todo with ID %s not found", id) } return todo, nil }
이 리포지토리 구현은 repository.ToDoRepository
인터페이스를 충족합니다. 이것을 PostgreSQL 또는 MongoDB 구현으로 쉽게 교체할 수 있으며 application
또는 domain
계층을 변경하지 않고도 가능합니다.
internal/adapters/http/todoHandler.go
(Gin/Echo와 유사한 가상 HTTP 프레임워크 사용):
package http import ( "encoding/json" "net/http" "your-app/internal/application/usecase" "your-app/internal/domain/entity" // For error handling example ) // ToDoHandler handles HTTP requests related to ToDo items. type ToDoHandler struct { createToDoUseCase *usecase.CreateToDo // other use cases } // NewToDoHandler creates a new ToDoHandler. func NewToDoHandler(createToDoUC *usecase.CreateToDo) *ToDoHandler { return &ToDoHandler{ createToDoUseCase: createToDoUC, } } // CreateToDo handles the HTTP POST request to create a new ToDo. func (h *ToDoHandler) CreateToDo(w http.ResponseWriter, r *http.Request) { var req usecase.CreateToDoInput if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } output, err := h.createToDoUseCase.Execute(r.Context(), req) if err != nil { switch err { case entity.ErrInvalidToDoTitle: // Example of handling domain-specific error http.Error(w, err.Error(), http.StatusBadRequest) default: http.Error(w, "Failed to create ToDo", http.StatusInternalServerError) } return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(output) }
이 HTTP 핸들러는 웹 프레임워크(여기서는 표준 net/http
)에 특화되어 있습니다. HTTP 요청을 유스 케이스 입력으로, 유스 케이스 출력을 HTTP 응답으로 변환합니다. usecase.CreateToDo
에 의존하지만 내부 구현이나 ToDo
가 어떻게 지속되는지는 알지 못합니다.
4. 프레임워크 계층: 모든 것 연결하기
마지막으로 cmd/main.go
는 "메인" 구성 요소 역할을 하여 모든 조각을 조립합니다.
cmd/main.go
:
package main import ( "log" "net/http" "your-app/internal/adapters/http" "your-app/internal/adapters/repository" "your-app/internal/application/usecase" ) func main() { // Frameworks & Drivers Layer (Main Composition) // Initialize Repository (Database) todoRepo := repository.NewInMemoryToDoRepository() // Initialize Use Cases createToDoUC := usecase.NewCreateToDo(todoRepo) // Initialize HTTP Handlers todoHandler := http.NewToDoHandler(createToDoUC) // Configure HTTP server mux := http.NewServeMux() mux.HandleFunc("/todos", todoHandler.CreateToDo) log.Println("Server starting on port 8080...") if err := http.ListenAndServe(":8080", mux); err != nil { log.Fatalf("Server failed to start: %v", err) } }
main.go
파일은 구체적인 구현을 인스턴스화하고 "연결"하는 곳입니다. main.go
가 모든 계층에 의존하지만 내부 계층은 독립적으로 유지된다는 점에 유의하십시오.
애플리케이션 시나리오 및 이점
이 구조는 몇 가지 확실한 이점을 제공합니다.
- 테스트 용이성: 각 계층은 독립적으로 테스트할 수 있습니다. 웹 서버를 시작하거나 실제 데이터베이스에 연결하지 않고
ToDoRepository
인터페이스를 단순히 모의하여 유스 케이스를 단위 테스트할 수 있습니다. 이를 통해 테스트 속도를 크게 높이고 비즈니스 로직에 대한 신뢰도를 높일 수 있습니다. - 유지보수성: UI(예: REST에서 GraphQL로 전환) 또는 데이터베이스(예: PostgreSQL에서 MongoDB로 변경)의 변경 사항은
Interface Adapters
계층의 변경만 필요하며 핵심Application
및Domain
계층은 그대로 유지됩니다. - 유연성: 애플리케이션은 프레임워크에 구애받지 않습니다. 새로운 혁신적인 Go 웹 프레임워크가 등장하면 주로 HTTP 어댑터를 리팩터링해야 하며 핵심 비즈니스 로직은 변경되지 않습니다.
- 명확성: 관심사 분리는 다양한 종류의 로직이 어디에 해당되는지 매우 명확하게 보여줍니다. 비즈니스 규칙은
domain
및application
에 있고, 외부 인터페이스는adapters
에 있습니다.
결론
Go 웹 프로젝트에서 클린 아키텍처를 구현하고 비즈니스 로직을 프레임워크 종속성에서 엄격하게 분리함으로써 본질적으로 테스트 용이성, 유지보수성 및 적응성이 뛰어난 애플리케이션을 얻을 수 있습니다. 종속성 규칙을 따르고 도메인, 애플리케이션 및 인터페이스 어댑터와 같은 별도의 계층으로 코드를 구성함으로써 소프트웨어 개발의 필연적인 변화와 복잡성에 탄력적인 견고한 기반을 만듭니다. 이 아키텍처 원칙에 투자한 초기 노력은 장기적으로 보상되며 애플리케이션이 진화하는 요구 사항과 기술 변화에 유연하고 탄력적으로 유지되도록 보장합니다.
클린 아키텍처는 Go 웹 애플리케이션을 기능하는 것이 아니라 오래 지속되도록 구축하는 데 도움이 됩니다.