패키지 설계 — 아키텍처와 구조 원칙
좋은 패키지 설계는 유지보수하기 쉽고 확장 가능한 Go 코드의 핵심입니다. Go 커뮤니티에서 축적된 패턴과 원칙을 살펴봅니다.
internal 패키지
internal 디렉터리에 있는 패키지는 동일한 모듈 내부에서만 임포트할 수 있습니다.
myproject/
├── go.mod
├── main.go
├── api/
│ └── handler.go # import "myproject/internal/auth" ✅
├── internal/
│ ├── auth/
│ │ └── auth.go # 외부에서 임포트 불가
│ ├── db/
│ │ └── db.go
│ └── config/
│ └── config.go
└── pkg/
└── validator/
└── validator.go # 외부에서 임포트 가능
// internal/auth/auth.go
package auth
import (
"errors"
"time"
)
// Token — 비공개여야 하지만 같은 모듈 내 공개
type Token struct {
UserID string
ExpiresAt time.Time
}
var ErrExpiredToken = errors.New("만료된 토큰")
func Validate(token string) (*Token, error) {
// 내부 검증 로직
if token == "" {
return nil, ErrExpiredToken
}
return &Token{UserID: "user-123", ExpiresAt: time.Now().Add(1 * time.Hour)}, nil
}
// 외부 모듈에서 임포트 시도 시 컴파일 에러
// import "myproject/internal/auth"
// → use of internal package myproject/internal/auth not allowed
순환 의존성 방지
Go는 순환 의존성을 허용하지 않습니다.
❌ 순환 의존성:
package user → import package order
package order → import package user ← 컴파일 에러!
✅ 해결 방법 1: 인터페이스 추출
package user → import package store (인터페이스)
package order → import package store (인터페이스)
package userstore → implements store interface
package orderstore → implements store interface
✅ 해결 방법 2: 공통 타입 패키지
package types (공통 타입만 포함)
package user → import types
package order → import types
// ✅ 인터페이스로 순환 의존성 해결
// pkg/store/store.go — 인터페이스만 정의
package store
type UserStore interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}
type User struct {
ID string
Name string
}
type OrderStore interface {
GetOrder(id string) (*Order, error)
SaveOrder(order *Order) error
}
type Order struct {
ID string
UserID string
Amount float64
}
// internal/service/user.go — store 인터페이스에 의존
package service
import "myproject/pkg/store"
type UserService struct {
userStore store.UserStore
orderStore store.OrderStore
}
func NewUserService(us store.UserStore, os store.OrderStore) *UserService {
return &UserService{userStore: us, orderStore: os}
}
func (s *UserService) GetUserWithOrders(userID string) (*store.User, []*store.Order, error) {
user, err := s.userStore.GetUser(userID)
if err != nil {
return nil, nil, err
}
// 주문 조회...
return user, nil, nil
}
레이어드 아키텍처
실제 프로젝트에서 많이 사용하는 레이어드 아키텍처입니다.
┌─────────────────────────────────────┐
│ Handler (API Layer) │ ← HTTP 요청/응답 처리
├─────────────────────────────────────┤
│ Service (Business Logic) │ ← 비즈니스 규칙
├─────────────────────────────────────┤
│ Repository (Data Access) │ ← DB/외부 API 접근
├─────────────────────────────────────┤
│ Domain (Models) │ ← 핵심 도메인 타입
└─────────────────────────────────────┘
// domain/user.go
package domain
import "time"
type User struct {
ID string
Email string
Name string
CreatedAt time.Time
}
type UserRepository interface {
FindByID(id string) (*User, error)
FindByEmail(email string) (*User, error)
Save(user *User) error
Delete(id string) error
}
// repository/user_repo.go
package repository
import (
"database/sql"
"myproject/domain"
)
type userRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) domain.UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) FindByID(id string) (*domain.User, error) {
var u domain.User
err := r.db.QueryRow(
"SELECT id, email, name, created_at FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func (r *userRepository) FindByEmail(email string) (*domain.User, error) {
// 구현...
return nil, nil
}
func (r *userRepository) Save(user *domain.User) error {
// 구현...
return nil
}
func (r *userRepository) Delete(id string) error {
// 구현...
return nil
}
// service/user_service.go
package service
import (
"errors"
"myproject/domain"
)
type UserService struct {
repo domain.UserRepository
}
func NewUserService(repo domain.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(id string) (*domain.User, error) {
if id == "" {
return nil, errors.New("사용자 ID가 필요합니다")
}
return s.repo.FindByID(id)
}
func (s *UserService) RegisterUser(email, name string) (*domain.User, error) {
// 중복 체크
existing, _ := s.repo.FindByEmail(email)
if existing != nil {
return nil, errors.New("이미 등록된 이메일입니다")
}
user := &domain.User{
ID: generateID(),
Email: email,
Name: name,
}
return user, s.repo.Save(user)
}
func generateID() string {
return "user-" + "generated-id" // 실제로는 UUID 생성
}
// api/handler.go
package api
import (
"encoding/json"
"net/http"
"myproject/service"
)
type UserHandler struct {
userService *service.UserService
}
func NewUserHandler(us *service.UserService) *UserHandler {
return &UserHandler{userService: us}
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id") // Go 1.22+
user, err := h.userService.GetUser(userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
패키지 설계 원칙
1. 단일 책임 원칙
// ❌ 너무 많은 책임
package utils
func ParseJSON(data []byte) (interface{}, error) { ... }
func SendEmail(to, subject, body string) error { ... }
func HashPassword(pw string) string { ... }
func ValidatePhone(phone string) bool { ... }
// ✅ 책임에 따라 분리
package json // JSON 파싱
package email // 이메일 발송
package crypto // 암호화
package validator // 유효성 검사
2. 패키지명으로 역할 표현
// ❌ 동사형 패키지명 (Go 관례 위반)
package convertstring
package handleusers
// ✅ 명사형, 짧고 명확하게
package strconv // string conversion
package user // 사용자 도메인
3. 작은 인터페이스
// ❌ 너무 큰 인터페이스
type UserManager interface {
Create(user User) error
Read(id string) (User, error)
Update(user User) error
Delete(id string) error
List() ([]User, error)
Authenticate(email, password string) (Token, error)
ResetPassword(email string) error
// ... 계속
}
// ✅ 필요한 만큼만
type UserReader interface {
FindByID(id string) (*User, error)
}
type UserWriter interface {
Save(user *User) error
Delete(id string) error
}
// 필요할 때 조합
type UserRepository interface {
UserReader
UserWriter
}
디렉터리 구조 — 표준 레이아웃
Go 커뮤니티에서 많이 사용하는 프로젝트 구조입니다.
myproject/
├── cmd/ # 실행 가능한 바이너리
│ └── server/
│ └── main.go
├── internal/ # 내부 패키지
│ ├── config/
│ ├── middleware/
│ └── repository/
├── pkg/ # 외부 공개 패키지
│ └── validator/
├── api/ # API 정의 (OpenAPI, Proto 등)
├── web/ # 프론트엔드 에셋
├── scripts/ # 빌드/배포 스크립트
├── docs/ # 문서
├── go.mod
├── go.sum
└── Makefile
핵심 정리
- internal 패키지: 모듈 외부 임포트 차단으로 API 경계 보호
- 순환 의존성 방지: 인터페이스 추출 또는 공통 타입 패키지로 해결
- 레이어드 아키텍처: Handler → Service → Repository → Domain
- 작은 인터페이스: 필요한 메서드만 포함, 조합으로 확장
- 패키지명은 명사형: 짧고 명확하게, 복수형·공통 접두어 피하기