반복 가능한 코드베이스 구축하기 - Go 패키지 구조화 가이드
Min-jun Kim
Dev Intern · Leapcell

Go는 모듈을 통한 단순성과 명확한 종속성 관리의 강력한 강조 덕분에 확장 가능한 애플리케이션 구축에 엄청나게 강력합니다. 이의 중요한 측면은 코드베이스를 패키지로 구성하는 방법입니다. 제대로 구성된 패키지는 가독성과 유지보수성을 향상시킬 뿐만 아니라 재사용을 촉진하고 빌드 시간을 단축합니다. 이 가이드에서는 자체 Go 패키지를 효과적으로 생성하고 구성하는 과정을 안내합니다.
Go 패키지의 본질
Go에서 패키지는 같은 디렉토리에 있는 컴파일되는 소스 파일 모음입니다. 모든 Go 프로그램에는 실행 진입점 역할을 하는 main
패키지가 있어야 합니다. 다른 패키지는 일반적으로 main
이나 다른 패키지에 의해 가져와서 사용됩니다.
패키지 디자인의 핵심 원칙:
- 응집도: 패키지는 단일하고 명확하게 정의된 책임을 가져야 합니다. 패키지 내의 모든 항목은 해당 책임과 관련되어야 합니다.
- 낮은 결합도: 패키지는 다른 패키지에 대한 종속성을 최소화해야 합니다. 이렇게 하면 변경의 파급 효과가 줄어듭니다.
- 캡슐화: 패키지는 필요한 것(공개 API)만 노출하고 내부 구현 세부 정보를 숨겨야 합니다.
1단계: 모듈 초기화
패키지에 대해 생각하기도 전에 Go 모듈이 필요합니다. 모듈은 Go 코드의 최상위 컨테이너로, 버전이 지정된 패키지 모음을 나타냅니다.
# 프로젝트의 새 디렉토리 생성 mkdir my-awesome-project cd my-awesome-project # 새 Go 모듈 초기화 go mod init github.com/your-username/my-awesome-project
이 명령은 모듈의 종속성을 추적하는 go.mod
파일을 생성합니다. 모듈 경로 github.com/your-username/my-awesome-project
는 이 모듈 내의 패키지에 대한 가져오기 경로가기도 합니다.
2단계: 패키지 구조 정의
Go 프로젝트에 대한 일반적이고 강력히 권장되는 구조는 도메인 중심 접근 방식을 따르며, 디렉토리는 특정 기능에 초점을 맞춘 패키지를 나타냅니다.
사용자와 제품을 관리하는 간단한 웹 애플리케이션을 생각해 보세요.
my-awesome-project/
├── main.go # 진입점
├── go.mod
├── go.sum
├── internal/ # 내부 전용 패키지의 경우
│ └── util/
│ └── stringutil.go
├── pkg/ # 널리 재사용 가능한 공개 패키지의 경우 (작은 프로젝트에는 선택 사항)
│ └── auth/
│ └── authenticator.go
├── cmd/ # 실행 가능한 명령의 경우 (여러 바이너리가 있는 경우)
│ └── api/
│ └── main.go # API 서버의 주요 진입점이 될 수 있음
│ └── cli/
│ └── main.go # 명령줄 도구용
├── handlers/ # 웹 서버 HTTP 핸들러
│ ├── user.go
│ └── product.go
├── models/ # 데이터 구조/엔티티
│ ├── user.go
│ └── product.go
├── services/ # 비즈니스 로직
│ ├── user_service.go
│ └── product_service.go
└── store/ # 데이터 액세스 계층 (데이터베이스 상호 작용)
├── user_store.go
└── product_store.go
일반적인 디렉토리 설명:
main.go
: 루트main.go
는 일반적으로 애플리케이션 시작을 조율합니다.cmd/
: 프로젝트에서 여러 실행 파일을 생성하는 경우cmd
아래의 각 하위 디렉토리는 특정 바이너리에 대한main
패키지가 될 수 있습니다. 예를 들어,cmd/api/main.go
는 API 서버를 정의하고cmd/cli/main.go
는 명령줄 도구를 정의합니다.internal/
: 이 디렉토리는 특별합니다. Go는 다른 모듈이internal
디렉토리 내의 패키지를 가져오는 것을 방지합니다. 외부 소비를 위해 의도되지 않은 모듈에 특정한 코드에 이것을 사용하세요.pkg/
: 조직 내 또는 외부에서 널리 재사용될 의도로 패키지에 사용됩니다. 대부분의 단일 바이너리 애플리케이션의 경우pkg
를 생략하고 더 평평한 구조를 사용할 수 있습니다.models/
(또는entity/
,domain/
): 애플리케이션의 핵심 데이터 구조/엔티티를 포함합니다.services/
(또는core/
,business/
): 모델에서 작동하는 비즈니스 로직을 보유합니다.handlers/
(또는controllers/
): 웹 애플리케이션의 경우, 이러한 항목은 들어오는 요청을 처리하고 서비스와 모델 간을 조정합니다.store/
(또는repository/
,dao/
): 데이터 지속성을 관리하고 데이터베이스 상호 작용을 추상화합니다.
3단계: 패키지 이름 지정
Go의 관례는 패키지 이름이 짧고 모두 소문자이며 의미 있어야 함을 지시합니다. 패키지 이름은 일반적으로 가져오기 경로의 마지막 구성 요소입니다.
models/user.go
는package models
를 선언할 수 있습니다.services/user_service.go
는package services
입니다.store/user_store.go
는package store
입니다.
예시: models/user.go
package models // User는 시스템의 사용자를 나타냅니다. type User struct { ID string Name string Email string } // NewUser는 새 User 인스턴스를 생성합니다. func NewUser(id, name, email string) *User { return &User{ ID: id, Name: name, Email: email, } }
패키지 이름 models
에 주목하세요. 가져올 때 models.User
로 User
에 액세스하게 됩니다.
4단계: 캡슐화 및 내보낸 식별자
Go에서 대문자로 시작하는 식별자(변수, 함수, 유형, 메서드)는 "내보낸"(공개)이며 패키지 외부에서 볼 수 있습니다. 소문자로 시작하는 식별자는 "내보내지 않은"(개인)이며 패키지 내에서만 액세스할 수 있습니다.
이것은 캡슐화의 기본입니다. 소비자가 패키지와 상호 작용하기 위해 필요한 것만 노출하여 공개 API를 신중하게 설계하세요.
예시: services/user_service.go
package services import ( "fmt" "github.com/your-username/my-awesome-project/models" "github.com/your-username/my-awesome-project/store" // UserStore 인터페이스가 정의되어 있다고 가정 ) // UserService는 사용자 관련 비즈니스 작업에 대한 인터페이스를 정의합니다. type UserService interface { CreateUser(name, email string) (*models.User, error) GetUserByID(id string) (*models.User, error) // 등등. } // userService는 UserService 인터페이스를 구현합니다. UserStore에 대한 종속성을 보유합니다. type userService struct { userStore store.UserStore // 내보내지 않은 필드, 서비스 내부 } // NewUserService는 UserService의 새 인스턴스를 생성합니다. // 이것은 서비스의 공개 생성자입니다. func NewUserService(us store.UserStore) UserService { return &userService{ userStore: us, } } // CreateUser는 사용자 생성에 대한 비즈니스 로직을 처리합니다. func (s *userService) CreateUser(name, email string) (*models.User, error) { // ID 생성, 유효성 검사 수행 등 id := generateUserID() // 이것은 내부, 내보내지 않은 도우미 함수입니다. if name == "" || email == "" return nil, fmt.Errorf("name and email cannot be empty") } user := models.NewUser(id, name, email) if err := s.userStore.SaveUser(user); err != nil { return nil, fmt.Errorf("failed to save user: %w", err) } return user, nil } // generateUserID는 'services' 패키스 내에서만 액세스할 수 있는 내보내지 않은 도우미 함수입니다. func generateUserID() string { // 실제 앱에서는 적절한 UUID 생성기 사용 return fmt.Sprintf("user-%d", len(name)) // 간단한 자리 표시자 }
여기서:
UserService
와NewUserService
는 내보내졌습니다. 이들은services
패키지의 공개 API를 형성합니다.userService
(구조체)와generateUserID
는 구현 세부 정보이므로 내보내지지 않았습니다.
5단계: 패키지 가져오기 및 사용
패키지를 만든 후에는 전체 모듈 경로에 패키지 디렉토리를 추가하여 가져올 수 있습니다.
예시: main.go
package main import ( "fmt" "log" "github.com/your-username/my-awesome-project/models" "github.com/your-username/my-awesome-project/services" "github.com/your-username/my-awesome-project/store" // 구체적인 사용자 저장소 구현을 가정 ) func main() { fmt.Println("Starting my awesome project...") // --- 종속성 주입 --- // 데이터 저장소 인스턴스 생성 (예: 메모리 내, 데이터베이스 클라이언트) userStore := store.NewInMemoryUserStore() // store/inmemory_store.go에 존재한다고 가정 // 저장소 종속성을 주입하여 서비스 인스턴스 생성 userService := services.NewUserService(userStore) // 서비스 사용 newUser, err := userService.CreateUser("Alice Smith", "alice@example.com") if err != nil { log.Fatalf("Error creating user: %v", err) } fmt.Printf("Created user: ID=%s, Name=%s, Email=%s\n", newUser.ID, newUser.Name, newUser.Email) foundUser, err := userService.GetUserByID(newUser.ID) if err != nil { log.Fatalf("Error getting user: %v", err) } fmt.Printf("Found user: ID=%s, Name=%s, Email=%s\n", foundUser.ID, foundUser.Name, foundUser.Email) // 다른 모델/서비스 예시 product := models.NewProduct("prod-001", "Go T-Shirt", 29.99) fmt.Printf("Created product: Name=%s, Price=%.2f\n", product.Name, product.Price) }
참고: main.go
가 작동하려면 store.NewInMemoryUserStore
, store.UserStore
인터페이스, store.SaveUser
, store.GetUserByID
등에 대한 자리 표시자 구현이 필요합니다.
예시 store/inmemory_user_store.go
(시연 목적)
package store import ( "fmt" "sync" "github.com/your-username/my-awesome-project/models" ) // UserStore는 사용자 데이터 영속성을 위한 인터페이스를 정의합니다. type UserStore interface { SaveUser(user *models.User) error GetUserByID(id string) (*models.User, error) } // inMemoryUserStore는 맵을 사용하여 저장소를 구현합니다. type inMemoryUserStore struct { mu sync.RWMutex users map[string]*models.User } // NewInMemoryUserStore는 새로운 메모리 내 사용자 저장소를 생성합니다. func NewInMemoryUserStore() UserStore { return &inMemoryUserStore{ users: make(map[string]*models.User), } } func (s *inMemoryUserStore) SaveUser(user *models.User) error { s.mu.Lock() defer s.mu.Unlock() if _, exists := s.users[user.ID]; exists { return fmt.Errorf("user with ID %s already exists", user.ID) } s.users[user.ID] = user return nil } func (s *inMemoryUserStore) GetUserByID(id string) (*models.User, error) { s.mu.RLock() defer s.mu.RUnlock() user, ok := s.users[id] if !ok { return nil, fmt.Errorf("user with ID %s not found", id) } return user, nil }
고급 고려 사항
순환 종속성
Go의 패키지 시스템은 순환 종속성(예: 패키지 A가 B를 가져오고 B가 A를 가져오는 경우)을 엄격하게 금지합니다. 이는 더 나은 설계를 강제하므로 좋은 일입니다. 주기를 발견하면 다음과 같은 징후일 수 있습니다.
- 패키지에 책임이 너무 많습니다.
- 두 패키지가 너무 밀접하게 결합되어 있습니다.
- 한 패키지에 다른 패키지가 구현하는 인터페이스를 도입하여 직접 종속성을 끊어야 할 수 있습니다.
internal
대 pkg
internal
: 모듈의 구현의 엄격한 부분이며 다른 모듈에서 가져오면 안 되는 패키지에 사용합니다. 내부 도우미, 구성 또는 공개 API의 일부가 아닌 특정 구현에 매우 유용합니다.pkg
: 널리 재사용하려는 패키지, 잠재적으로 다른 모듈에 사용합니다. 일반적인 기능을 제공하는 라이브러리를 구축하는 경우(예: 사용자 지정 데이터 구조, 강력한 유틸리티),pkg
에 넣을 수 있습니다. 주로 단일 목적(예: 웹 API)을 제공하는 대부분의 애플리케이션의 경우pkg
가 필요하지 않을 수 있으며 최상위 디렉토리를 직접 구성할 수 있습니다.
Vendor 디렉토리
go mod vendor
를 사용하여 프로젝트 내 vendor
디렉토리에 종속성을 복사할 수 있지만, 최신 Go 모듈에서는 덜 일반적입니다. Go 프록시 및 직접 모듈 다운로드는 일반적으로 종속성을 효율적으로 처리합니다. vendor
는 주로 엄격한 빌드 제한 또는 에어 갭 네트워크가 있는 환경에서 사용됩니다.
도구 및 자동화
Go의 내장 도구를 활용하세요:
go fmt
: Go 스타일 가이드에 따라 코드를 형식화합니다.go vet
: 의심스러운 구문을 식별합니다.go test
: 테스트를 실행합니다. 테스트 대상 패키지와 동일한 디렉토리에_test.go
파일을 배치하세요.go mod tidy
: 사용되지 않는 종속성을 정리하고 누락된 종속성을 추가합니다.
결론
Go 패키지를 신중하게 구성하는 것은 강력하고 확장 가능하며 유지보수 가능한 애플리케이션을 구축하는 기본 관행입니다. 응집도, 낮은 결합도, 캡슐화 원칙을 준수하고 Go 모듈 시스템과 명명 규칙을 활용하면 자신과 다른 사람들이 작업하기 즐거운 코드베이스를 만들 수 있습니다. 단순하게 시작하되 프로젝트가 성장하고 책임이 명확해짐에 따라 구조를 리팩터링하고 발전시킬 준비를 하세요. 좋은 패키지 디자인에 투자한 노력은 장기적으로 배당금을 지급합니다.