Go 패키지와 모듈 구조 설계 - 실무 프로젝트 레이아웃 완전 가이드

Go 패키지와 모듈 구조 설계: 실무 프로젝트 레이아웃 완전 가이드

이 글에서는 Go 프로젝트의 패키지와 모듈 구조를 어떻게 설계해야 하는지를 실무 관점에서 다룹니다. 단순히 파일을 디렉토리에 나누는 수준을 넘어, 컴파일러가 강제하는 접근 제어, 순환 의존성 예방, 인터페이스 기반 의존성 주입까지 Go가 권장하는 설계 철학을 다룹니다. 이 글은 Go 언어 시리즈의 마지막 글이기도 합니다.

1. 패키지 기본 개념과 명명 규칙

Go에서 패키지(Package)는 코드 재사용과 접근 제어의 기본 단위입니다. 디렉토리 하나가 패키지 하나에 대응하며, 디렉토리 내의 .go 파일들은 모두 동일한 패키지에 속합니다.

1-1. 패키지 선언과 디렉토리 관계

// myapp/service/user.go
package service  // 디렉토리 이름과 패키지 이름을 일치시키는 것이 관례

type UserService struct {}

파일명은 무엇이든 무방하지만, 패키지 선언(package ...)은 같은 디렉토리의 모든 파일이 동일해야 합니다. 단, _test.go 파일은 package service_test처럼 외부 테스트 패키지를 사용할 수 있습니다.

1-2. 패키지 명명 규칙

Go 공식 스타일 가이드는 패키지 이름에 대해 엄격한 규칙을 제시합니다.

규칙좋은 예나쁜 예
소문자 단일 단어service, handleruserService, UserService
밑줄 사용 금지strconv, httputilhttp_util, str_conv
줄임말 허용fmt, buf, pkgformat, buffer
의미 있는 이름auth, cache, repocommon, util, misc
복수형 피하기model, handlermodels, handlers

🛠️ 실무 팁: util, common, helper 같은 이름은 “모든 것을 담는 쓰레기통” 패키지가 되기 쉽습니다. 기능별로 명확하게 분리된 이름(auth, cache, notify)을 사용하면 코드 탐색이 훨씬 쉬워집니다.

1-3. 단일 책임 원칙과 패키지 분리

패키지는 하나의 명확한 책임을 갖도록 설계해야 합니다. 이는 Go의 컴파일 속도와 테스트 가능성에도 직접 영향을 미칩니다.

// 나쁜 예: 모든 것을 담는 utils 패키지
package utils

func ValidateEmail(email string) bool { ... }
func SendSMS(phone, message string) error { ... }
func HashPassword(pw string) string { ... }
func ParseConfig(path string) Config { ... }

// 좋은 예: 책임별로 분리
// validation/email.go
package validation
func Email(email string) bool { ... }

// notification/sms.go
package notification
func SendSMS(phone, message string) error { ... }

// crypto/password.go
package crypto
func Hash(pw string) string { ... }

2. go.mod와 모듈 시스템

모듈(Module)은 Go 1.11에서 도입된 의존성 관리 시스템으로, 여러 패키지의 집합과 그 버전 정보를 담습니다.

2-1. go.mod 파일 구조

module github.com/yourname/myapp  // 모듈 경로 (import 시 사용)

go 1.22  // 최소 Go 버전 요구사항

require (
    github.com/gin-gonic/gin v1.9.1
    github.com/jackc/pgx/v5 v5.5.0
    go.uber.org/zap v1.26.0
)

require (
    // 간접 의존성 (indirect)
    github.com/bytedance/sonic v1.10.2 // indirect
    golang.org/x/net v0.19.0 // indirect
)

모듈 경로는 외부에 공개하지 않는 프로젝트라도 github.com/yourname/myapp 형식을 유지하는 것이 관례입니다. 이는 나중에 공개 라이브러리로 전환할 때 경로 변경 없이 그대로 사용할 수 있게 해줍니다.

2-2. go get과 버전 관리

# 최신 버전 추가
go get github.com/some/package

# 특정 버전 지정
go get github.com/some/package@v1.2.3

# 특정 커밋 해시
go get github.com/some/package@abc1234

# 사용하지 않는 의존성 정리
go mod tidy

# 벤더 디렉토리 생성 (오프라인 빌드용)
go mod vendor

2-3. replace 지시어 활용

로컬 개발이나 포크된 패키지를 사용할 때 replace를 활용합니다.

require (
    github.com/original/lib v1.0.0
)

replace (
    // 로컬 개발 중인 패키지로 교체
    github.com/original/lib => ../my-fork-of-lib

    // 특정 버전으로 고정
    github.com/broken/package v1.2.0 => github.com/broken/package v1.1.9
)

🛠️ 실무 팁: replace는 강력하지만 남용하면 팀 전체의 개발 환경이 맞지 않을 수 있습니다. 로컬 replace는 팀 공유 전에 반드시 go mod tidy로 원상복구해야 합니다.

2-4. Semantic Versioning과 메이저 버전

Go 모듈은 Semantic Versioning을 엄격하게 따릅니다. v2 이상의 메이저 버전은 모듈 경로 자체에 버전을 포함해야 합니다.

// v1 (경로 변경 없음)
import "github.com/yourname/myapp/service"

// v2 이상 (경로에 버전 포함)
import "github.com/yourname/myapp/v2/service"

이 때문에 Breaking Change를 피하고 하위 호환성을 유지하려는 문화가 Go 생태계 전반에 자리잡혀 있습니다.


3. Standard Go Project Layout

Go 커뮤니티가 수년간 축적한 표준 프로젝트 레이아웃은 실무에서 가장 많이 참조되는 디렉토리 구조입니다.

3-1. 전체 구조 개요

myapp/
├── cmd/                    # 실행 가능한 애플리케이션 진입점
│   ├── api/
│   │   └── main.go         # API 서버 진입점
│   └── worker/
│       └── main.go         # 백그라운드 워커 진입점
├── internal/               # 이 모듈에서만 사용 가능한 패키지
│   ├── handler/            # HTTP 핸들러
│   ├── service/            # 비즈니스 로직
│   ├── repository/         # 데이터 접근 계층
│   └── model/              # 도메인 모델
├── pkg/                    # 외부 공개 가능한 재사용 라이브러리
│   ├── logger/
│   └── validator/
├── api/                    # OpenAPI/Swagger 스펙, Protocol Buffer 정의
│   └── openapi.yaml
├── configs/                # 설정 파일 템플릿
│   └── config.yaml.example
├── scripts/                # 빌드, 배포, 마이그레이션 스크립트
│   └── migrate.sh
├── migrations/             # DB 마이그레이션 파일
├── docs/                   # 문서
├── go.mod
├── go.sum
└── Makefile

3-2. cmd/ 디렉토리 — 진입점 분리

cmd/ 아래에 실행 파일마다 하위 디렉토리를 만들고, 각각에 main.go를 둡니다.

// cmd/api/main.go
package main

import (
    "context"
    "log/slog"
    "os"
    "os/signal"
    "syscall"

    "github.com/yourname/myapp/internal/handler"
    "github.com/yourname/myapp/internal/repository"
    "github.com/yourname/myapp/internal/service"
)

func main() {
    // 의존성 조립
    userRepo := repository.NewUserRepository(db)
    userSvc := service.NewUserService(userRepo)
    userHandler := handler.NewUserHandler(userSvc)

    // 서버 시작
    srv := newServer(userHandler)
    go srv.ListenAndServe()

    // Graceful Shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    srv.Shutdown(ctx)
}

main.go오케스트레이터 역할만 합니다. 의존성을 생성하고 조립하되, 비즈니스 로직은 하나도 담지 않아야 합니다.

3-3. pkg/ vs internal/ — 공개 여부 결정

디렉토리접근 범위용도
internal/현재 모듈 내부만핵심 비즈니스 로직, 구현 세부사항
pkg/외부 모듈에서도 가능범용 라이브러리, 재사용 컴포넌트

처음 프로젝트를 시작할 때는 모든 것을 internal/에 두는 것을 권장합니다. 나중에 필요하면 pkg/로 옮길 수 있지만, 반대 방향은 Breaking Change가 됩니다.


4. internal/ 패키지의 접근 제어

internal/은 Go 컴파일러가 강제하는 특별한 디렉토리입니다.

4-1. 컴파일러 레벨 강제

github.com/yourname/myapp/
├── internal/
│   └── secret/
│       └── key.go          # package secret
└── cmd/api/
    └── main.go             # ✅ 같은 모듈 → import 가능

github.com/external/other/
└── main.go                 # ❌ 다른 모듈 → 컴파일 에러
# 다른 모듈에서 internal 패키지를 import하면
$ go build ./...
# use of internal package github.com/yourname/myapp/internal/secret
# not allowed

4-2. internal/ 내부의 추가 계층

internal/ 안에서도 접근 제어를 세분화할 수 있습니다.

internal/
├── handler/           # cmd/api에서 직접 사용
├── service/           # handler만 사용
├── repository/        # service만 사용
└── platform/
    └── database/      # repository만 사용 (더 내부적인 구현)

이렇게 구성하면 계층 간 의존 방향이 명확해집니다: handler → service → repository → database.

🛠️ 실무 팁: 프로젝트 초기에 internal/ 내부 구조를 명확히 문서화해 두면, 팀원들이 PR 리뷰 시 “이 패키지가 왜 여기 있지?”라는 질문을 줄일 수 있습니다. 간단한 ARCHITECTURE.md 파일 하나가 큰 역할을 합니다.


5. 패키지 간 의존성 계층

실무 Go 백엔드 프로젝트에서 가장 흔하게 사용되는 의존성 계층 구조를 살펴봅니다.

5-1. 4계층 아키텍처

HTTP Request

Handler (internal/handler)

Service (internal/service)       ← 비즈니스 로직 집중

Repository (internal/repository) ← 데이터 접근 추상화

Database / External API

각 계층의 역할:

5-2. 각 계층의 실제 구현

// internal/model/user.go
package model

import "time"

// User - DB 엔티티
type User struct {
    ID        int64     `db:"id"`
    Email     string    `db:"email"`
    Name      string    `db:"name"`
    CreatedAt time.Time `db:"created_at"`
}

// CreateUserRequest - Handler → Service DTO
type CreateUserRequest struct {
    Email    string `json:"email"`
    Name     string `json:"name"`
    Password string `json:"password"`
}

// UserResponse - Service → Handler DTO (비밀번호 제외)
type UserResponse struct {
    ID    int64  `json:"id"`
    Email string `json:"email"`
    Name  string `json:"name"`
}
// internal/repository/user.go
package repository

import (
    "context"
    "database/sql"
    "github.com/yourname/myapp/internal/model"
)

// UserRepository - 인터페이스 (service 패키지에서 정의하는 것이 이상적)
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*model.User, error)
    FindByEmail(ctx context.Context, email string) (*model.User, error)
    Create(ctx context.Context, user *model.User) error
    Update(ctx context.Context, user *model.User) error
    Delete(ctx context.Context, id int64) error
}

type userRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) UserRepository {
    return &userRepository{db: db}
}

func (r *userRepository) FindByID(ctx context.Context, id int64) (*model.User, error) {
    user := &model.User{}
    query := `SELECT id, email, name, created_at FROM users WHERE id = $1`
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &user.ID, &user.Email, &user.Name, &user.CreatedAt,
    )
    if err == sql.ErrNoRows {
        return nil, nil // not found
    }
    return user, err
}

func (r *userRepository) Create(ctx context.Context, user *model.User) error {
    query := `INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, created_at`
    return r.db.QueryRowContext(ctx, query, user.Email, user.Name).
        Scan(&user.ID, &user.CreatedAt)
}

// ... 나머지 메서드 구현
// internal/service/user.go
package service

import (
    "context"
    "errors"
    "fmt"

    "github.com/yourname/myapp/internal/model"
    "github.com/yourname/myapp/internal/repository"
)

var ErrUserNotFound = errors.New("user not found")
var ErrEmailAlreadyExists = errors.New("email already exists")

type UserService interface {
    GetUser(ctx context.Context, id int64) (*model.UserResponse, error)
    CreateUser(ctx context.Context, req *model.CreateUserRequest) (*model.UserResponse, error)
}

type userService struct {
    repo repository.UserRepository
}

func NewUserService(repo repository.UserRepository) UserService {
    return &userService{repo: repo}
}

func (s *userService) GetUser(ctx context.Context, id int64) (*model.UserResponse, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("userService.GetUser: %w", err)
    }
    if user == nil {
        return nil, ErrUserNotFound
    }
    return &model.UserResponse{ID: user.ID, Email: user.Email, Name: user.Name}, nil
}

func (s *userService) CreateUser(ctx context.Context, req *model.CreateUserRequest) (*model.UserResponse, error) {
    existing, err := s.repo.FindByEmail(ctx, req.Email)
    if err != nil {
        return nil, fmt.Errorf("userService.CreateUser: %w", err)
    }
    if existing != nil {
        return nil, ErrEmailAlreadyExists
    }

    user := &model.User{Email: req.Email, Name: req.Name}
    if err := s.repo.Create(ctx, user); err != nil {
        return nil, fmt.Errorf("userService.CreateUser: %w", err)
    }
    return &model.UserResponse{ID: user.ID, Email: user.Email, Name: user.Name}, nil
}
// internal/handler/user.go
package handler

import (
    "encoding/json"
    "errors"
    "net/http"
    "strconv"

    "github.com/yourname/myapp/internal/model"
    "github.com/yourname/myapp/internal/service"
)

type UserHandler struct {
    svc service.UserService
}

func NewUserHandler(svc service.UserService) *UserHandler {
    return &UserHandler{svc: svc}
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    idStr := r.PathValue("id") // Go 1.22+
    id, err := strconv.ParseInt(idStr, 10, 64)
    if err != nil {
        http.Error(w, "invalid id", http.StatusBadRequest)
        return
    }

    user, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        if errors.Is(err, service.ErrUserNotFound) {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req model.CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }

    user, err := h.svc.CreateUser(r.Context(), &req)
    if err != nil {
        if errors.Is(err, service.ErrEmailAlreadyExists) {
            http.Error(w, "email already exists", http.StatusConflict)
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

6. 순환 의존성 감지와 해결

Go 컴파일러는 순환 의존성(Circular Dependency)을 허용하지 않습니다. 이것은 언어 설계상의 의도적인 제약으로, 코드 구조를 강제로 정리하게 만듭니다.

6-1. 순환 의존성 발생 예시

// 잘못된 구조: service ↔ handler 순환 참조
service/ imports handler/   // 절대 이렇게 하면 안 됨
handler/ imports service/
$ go build ./...
import cycle not allowed:
    package github.com/yourname/myapp/internal/service
    imports github.com/yourname/myapp/internal/handler
    imports github.com/yourname/myapp/internal/service

6-2. 순환 의존성 해결 패턴

패턴 1: 인터페이스로 의존성 역전

// 순환 참조 발생 상황:
// service가 notification을 사용하고
// notification이 service의 타입을 사용하는 경우

// 해결: notification 패키지에서 인터페이스만 정의
// internal/notification/notifier.go
package notification

// UserGetter - service의 구체 타입 대신 인터페이스 사용
type UserGetter interface {
    GetUser(ctx context.Context, id int64) (*model.UserResponse, error)
}

type EmailNotifier struct {
    userGetter UserGetter
    // ...
}

패턴 2: 공유 타입을 별도 패키지로 추출

// 두 패키지가 같은 타입을 공유하여 순환이 발생하는 경우
// 해결: internal/model 패키지에 공유 타입을 모아둠

// internal/model/event.go
package model

type UserCreatedEvent struct {
    UserID int64
    Email  string
}
// service와 notification 모두 model을 import → 순환 없음

패턴 3: 의존성 방향 재검토

// 잘못된 설계: repository가 service를 알고 있음
// repository → service (역방향!)

// 올바른 설계: 항상 상위 계층이 하위 계층을 import
// handler → service → repository → model

🛠️ 실무 팁: go build ./...가 순환 참조 오류를 내면, 우선 어느 패키지끼리 순환하는지 파악하고, 공유되는 타입이 있다면 model 패키지로 추출하는 것이 가장 빠른 해결책입니다.


7. 인터페이스 정의 위치: Consumer Side

Go의 인터페이스 설계에서 가장 중요한 원칙 중 하나는 “인터페이스는 구현 측이 아닌 사용하는 측(consumer)에서 정의한다”는 것입니다.

7-1. 잘못된 패턴: Producer Side 정의

// repository/user.go - "내가 제공하는 것"을 인터페이스로 선언
package repository

// ❌ 구현체가 있는 패키지에서 인터페이스를 정의
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*model.User, error)
    Create(ctx context.Context, user *model.User) error
}

type pgUserRepository struct { ... }
// pgUserRepository가 UserRepository를 구현

이 방식은 service가 repository 패키지를 import해야 하므로 의존성이 깊어집니다.

7-2. 올바른 패턴: Consumer Side 정의

// service/user.go - "내가 필요한 것"을 인터페이스로 선언
package service

// ✅ 사용하는 측에서 인터페이스를 정의
// repository 패키지를 import하지 않아도 됨!
type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*model.User, error)
    Create(ctx context.Context, user *model.User) error
}

type userService struct {
    repo UserRepository  // 인터페이스 타입
}
// repository/user.go - 인터페이스 선언 없음, 구현만
package repository

type pgUserRepository struct {
    db *sql.DB
}

// service.UserRepository 인터페이스를 암묵적으로 만족
func (r *pgUserRepository) FindByID(ctx context.Context, id int64) (*model.User, error) { ... }
func (r *pgUserRepository) Create(ctx context.Context, user *model.User) error { ... }

func NewUserRepository(db *sql.DB) *pgUserRepository {
    return &pgUserRepository{db: db}
}
// cmd/api/main.go - 조립
userRepo := repository.NewUserRepository(db)   // *pgUserRepository
userSvc := service.NewUserService(userRepo)    // *pgUserRepository가 service.UserRepository를 만족

이 패턴의 장점:

  1. service 패키지가 repository 패키지를 import하지 않음 → 의존성 단방향 유지
  2. 테스트 시 Mock 구현체를 쉽게 주입 가능
  3. 나중에 DB를 바꿔도 service 코드 수정 불필요

8. 의존성 주입 패턴

Go에서는 별도의 DI 프레임워크 없이 생성자 주입(Constructor Injection)을 사용하는 것이 표준 패턴입니다.

8-1. 생성자 주입

// 모든 의존성을 생성자 인자로 받음
func NewUserService(
    repo UserRepository,
    emailSvc EmailService,
    logger *slog.Logger,
) *userService {
    return &userService{
        repo:     repo,
        emailSvc: emailSvc,
        logger:   logger,
    }
}

8-2. 의존성 조립 — Wire Pattern

프로젝트가 커지면 cmd/api/main.go의 조립 코드가 복잡해질 수 있습니다. 이를 별도 함수로 분리합니다.

// cmd/api/wire.go (또는 cmd/api/app.go)
package main

import (
    "database/sql"
    "log/slog"
    "net/http"

    "github.com/yourname/myapp/internal/handler"
    "github.com/yourname/myapp/internal/repository"
    "github.com/yourname/myapp/internal/service"
)

type App struct {
    router *http.ServeMux
}

func NewApp(db *sql.DB, logger *slog.Logger) *App {
    // Repository 계층
    userRepo := repository.NewUserRepository(db)

    // Service 계층
    userSvc := service.NewUserService(userRepo, logger)

    // Handler 계층
    userHandler := handler.NewUserHandler(userSvc)

    // 라우팅
    mux := http.NewServeMux()
    mux.HandleFunc("GET /users/{id}", userHandler.GetUser)
    mux.HandleFunc("POST /users", userHandler.CreateUser)

    return &App{router: mux}
}

// cmd/api/main.go
func main() {
    db := mustConnectDB()
    logger := slog.Default()
    app := NewApp(db, logger)
    // ...
}

8-3. 테스트에서의 의존성 주입

// internal/service/user_test.go
package service_test

import (
    "context"
    "testing"

    "github.com/yourname/myapp/internal/model"
    "github.com/yourname/myapp/internal/service"
)

// Mock Repository
type mockUserRepo struct {
    users map[int64]*model.User
}

func (m *mockUserRepo) FindByID(_ context.Context, id int64) (*model.User, error) {
    return m.users[id], nil
}

func (m *mockUserRepo) FindByEmail(_ context.Context, email string) (*model.User, error) {
    for _, u := range m.users {
        if u.Email == email {
            return u, nil
        }
    }
    return nil, nil
}

func (m *mockUserRepo) Create(_ context.Context, user *model.User) error {
    user.ID = int64(len(m.users) + 1)
    m.users[user.ID] = user
    return nil
}

func TestCreateUser(t *testing.T) {
    repo := &mockUserRepo{users: make(map[int64]*model.User)}
    svc := service.NewUserService(repo)  // Mock 주입

    req := &model.CreateUserRequest{Email: "test@example.com", Name: "테스트"}
    got, err := svc.CreateUser(context.Background(), req)

    if err != nil {
        t.Fatalf("예상치 못한 에러: %v", err)
    }
    if got.Email != req.Email {
        t.Errorf("이메일 불일치: got %s, want %s", got.Email, req.Email)
    }
}

9. 소규모 vs 대규모 프로젝트 레이아웃 비교

프로젝트 규모에 따라 적절한 디렉토리 구조를 선택해야 합니다. 처음부터 과도하게 복잡한 구조를 만드는 것은 오히려 생산성을 떨어뜨립니다.

9-1. 소규모 프로젝트 (마이크로서비스, 개인 프로젝트)

myapp/
├── main.go          # 진입점, 의존성 조립
├── handler.go       # HTTP 핸들러들
├── service.go       # 비즈니스 로직
├── repository.go    # DB 접근
├── model.go         # 데이터 구조체
├── go.mod
└── go.sum

패키지 하나(main)로도 충분히 시작할 수 있습니다. 코드가 1,000줄을 넘어가거나 명확한 경계가 생길 때 분리를 고려합니다.

9-2. 중규모 프로젝트 (팀 프로젝트)

myapp/
├── cmd/api/main.go
├── internal/
│   ├── handler/
│   ├── service/
│   ├── repository/
│   └── model/
├── pkg/             # 재사용 가능한 것들만
│   └── validator/
├── go.mod
└── go.sum

9-3. 대규모 프로젝트 (멀티 서비스)

myapp/
├── cmd/
│   ├── api/
│   ├── worker/
│   └── migrate/
├── internal/
│   ├── user/           # 도메인 단위 분리
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── repository.go
│   ├── order/
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── repository.go
│   └── platform/       # 공통 인프라
│       ├── database/
│       ├── cache/
│       └── middleware/
├── pkg/
│   ├── logger/
│   └── errors/
├── api/                # OpenAPI 스펙
├── migrations/
└── go.mod

🛠️ 실무 팁: “지금 당장 필요한 구조”로 시작하세요. Go 프로젝트는 리팩토링이 쉽고, 컴파일러가 잘못된 의존성을 즉시 잡아줍니다. 처음부터 대규모 구조를 강제하면 오히려 불필요한 추상화로 코드가 복잡해집니다.


10. 안티패턴 — 흔한 구조 설계 실수

10-1. God Package

// ❌ 모든 것을 담는 god package
// internal/core/everything.go
package core

type User struct { ... }
type Order struct { ... }
type Payment struct { ... }
func ProcessOrder(...) { ... }
func SendEmail(...) { ... }
func ValidateUser(...) { ... }

모든 기능을 하나의 패키지에 몰아넣으면 해당 패키지를 import하는 모든 곳이 불필요한 것까지 포함하게 됩니다.

10-2. 계층 우회

// ❌ Handler가 Repository를 직접 사용
type UserHandler struct {
    repo repository.UserRepository  // Service 계층 우회!
}

계층을 우회하면 비즈니스 로직이 여러 곳에 분산되고, 트랜잭션 관리가 어려워집니다.

10-3. 과도한 추상화

// ❌ 실제로 하나의 구현만 있는데 인터페이스 남발
type UserCreatorInterface interface {
    Create(ctx context.Context, user *model.User) error
}
type UserFinderInterface interface {
    FindByID(ctx context.Context, id int64) (*model.User, error)
}
// 10개의 단일 메서드 인터페이스...

인터페이스는 “테스트 가능성”이나 “교체 가능성”이 실제로 필요할 때만 만들어야 합니다.

10-4. init() 함수 남용

// ❌ init()에서 글로벌 상태 초기화
package database

var DB *sql.DB

func init() {
    DB, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
}

init()은 테스트에서 제어하기 어렵고, 실행 순서를 예측하기 어렵습니다. 생성자 함수(NewDB())로 교체하고 cmd/main.go에서 명시적으로 초기화하는 것이 훨씬 낫습니다.


정리

이것으로 “업무에 사용하는 Go 언어” 시리즈를 마칩니다. Go 언어 기초편부터 심화편까지 총 30개의 글을 통해 실무에서 바로 활용할 수 있는 지식을 전달하고자 했습니다. 시리즈 전체 목차는 학습 로드맵에서 확인하실 수 있습니다.