Go 언어 패키지 모듈 시스템과 접근 제어 분석

Go 언어 패키지: 모듈 시스템, 접근 제어, init 함수 완전 정복

Go 언어의 모든 코드는 패키지(Package) 안에 있습니다. 처음 Go를 시작할 때부터 항상 작성해온 package main이 바로 그 증거입니다. 패키지는 단순히 파일을 모아두는 폴더가 아니라, Go 언어의 코드 조직화, 접근 제어, 의존성 관리의 기반입니다.

이 글에서는 패키지의 기본 개념부터 시작하여 Go 모듈(Module) 시스템, 대문자/소문자를 통한 접근 제어, internal 패키지, init() 함수의 실행 순서, 그리고 순환 의존성(Circular Dependency) 방지 전략까지 실무에서 반드시 알아야 할 내용을 모두 다룹니다.

🛠️ 실무에서 패키지 설계의 중요성

필자가 팀 프로젝트에 Go를 도입했을 때, 패키지 구조 설계가 프로젝트의 유지보수성을 좌우했습니다. 명확한 패키지 경계가 없으면 시간이 지날수록 코드가 뒤엉키고, “이 함수가 어디 있더라?”라는 질문이 반복됩니다. 반면 잘 설계된 패키지 구조는 팀원 모두가 코드의 역할과 위치를 직관적으로 파악할 수 있게 합니다.


1. 패키지(Package): 코드를 논리적으로 묶는 단위

패키지는 관련된 함수, 타입, 변수를 하나의 논리적 단위로 묶는 메커니즘입니다. 모든 Go 파일의 첫 번째 줄은 반드시 자신이 속한 패키지를 선언해야 합니다.

package main   // 실행 가능한 프로그램의 진입점
package fmt    // 표준 라이브러리 패키지
package utils  // 사용자 정의 패키지

1-1. package main의 특별한 의미

package mainGo 런타임이 프로그램을 시작하는 진입점입니다. main 패키지에는 반드시 main() 함수가 있어야 하며, go build 또는 go run 명령 실행 시 이 함수부터 실행됩니다.

package main

import "fmt"

func main() {
    fmt.Println("프로그램 시작")
}

main 패키지가 아닌 다른 패키지는 단독으로 실행될 수 없고, 다른 패키지에서 import하여 사용됩니다.

1-2. 하나의 폴더 = 하나의 패키지

Go의 핵심 규칙: 같은 디렉토리의 모든 .go 파일은 동일한 패키지명을 가져야 합니다.

myproject/
├── main.go          // package main
├── user/
│   ├── user.go      // package user
│   └── user_test.go // package user (또는 user_test)
└── order/
    └── order.go     // package order

2. 패키지 사용하기: import 키워드

다른 패키지를 사용하려면 import 키워드로 가져옵니다.

// 단일 import
import "fmt"

// 다중 import (권장 방식)
import (
    "fmt"
    "math"
    "strings"
    "net/http"
)

import 후에는 패키지명.함수명 형태로 접근합니다.

fmt.Println("Hello, Go")
strings.ToUpper("hello")
math.Sqrt(16.0)

2-1. import 별칭(Alias)

패키지명이 충돌하거나 너무 길 때 별칭을 지정할 수 있습니다.

import (
    "fmt"
    mrand "math/rand"      // 별칭: mrand
    crand "crypto/rand"    // 별칭: crand
)

func main() {
    mrand.Intn(100)  // math/rand 사용
    crand.Read(...)  // crypto/rand 사용
}

2-2. Blank Import (_)

패키지를 직접 사용하지 않지만 init() 함수만 실행시키고 싶을 때 _를 사용합니다.

import (
    "database/sql"
    _ "github.com/lib/pq" // PostgreSQL 드라이버 등록 (init만 실행)
)

이 패턴은 데이터베이스 드라이버나 이미지 포맷 디코더를 등록할 때 자주 사용됩니다.


3. Go 모듈(Module): 현대적인 의존성 관리

Go 1.11에서 도입된 Go 모듈은 패키지 의존성 관리 시스템입니다. Python의 가상환경(venv)과 pip를 합친 것과 유사하게, 프로젝트별로 독립적인 의존성 환경을 구성할 수 있습니다.

3-1. 모듈 초기화

go mod init example.com/myproject

이 명령은 현재 디렉토리에 go.mod 파일을 생성합니다.

module example.com/myproject

go 1.21

3-2. go.modgo.sum의 역할

파일역할
go.mod모듈 이름, Go 버전, 직접 의존성 목록
go.sum각 의존성의 암호화 해시 (무결성 검증)
// go.mod 예시
module example.com/myproject

go 1.21

require (
    github.com/gin-gonic/gin v1.9.1
    github.com/redis/go-redis/v9 v9.2.1
)

3-3. 외부 패키지 관리

# 패키지 추가
go get github.com/gin-gonic/gin@v1.9.1

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

# 의존성을 vendor 디렉토리에 복사
go mod vendor

🛠️ 실무 팁

팀 프로젝트에서 go mod tidy를 정기적으로 실행하여 불필요한 의존성을 제거하세요. CI/CD 파이프라인에 go mod verify를 추가하면 의존성 무결성을 자동으로 검증할 수 있습니다.


4. 접근 제어: 대문자 vs 소문자

Go 언어의 접근 제어는 식별자의 첫 글자 대소문자로 결정됩니다. 별도의 public, private 키워드가 없습니다.

package mathutil

// ✅ 외부 공개 (Exported): 대문자로 시작
func Add(a, b int) int {
    return a + b
}

var Pi = 3.14159

type Calculator struct {
    Result float64 // 공개 필드
    cache  float64 // 비공개 필드
}

// ❌ 패키지 내부 전용 (Unexported): 소문자로 시작
func subtract(a, b int) int {
    return a - b
}

4-1. 접근 제어 적용 범위

대상공개 예시비공개 예시
함수func Connect()func connect()
타입type User structtype user struct
변수var MaxRetry = 3var maxRetry = 3
상수const Version = "1.0"const version = "1.0"
필드Name stringname string
메서드func (u *User) Save()func (u *user) save()

💡 설계 원칙

공개할 것만 공개하세요. 처음에는 모든 것을 비공개로 시작하고, 외부에서 실제로 필요할 때만 공개합니다. 한 번 공개한 API는 변경이 어려우므로 신중하게 결정해야 합니다.


5. internal 패키지: 모듈 내 접근 제어

internal이라는 이름의 디렉토리 안에 패키지를 두면, 해당 디렉토리의 부모 디렉토리 내에서만 접근할 수 있습니다.

myproject/
├── main.go
├── api/
│   └── handler.go        // internal/db 사용 가능
├── internal/
│   ├── db/
│   │   └── connection.go // 외부 모듈에서 import 불가
│   └── config/
│       └── config.go     // 외부 모듈에서 import 불가
└── utils/
    └── helper.go         // internal 패키지 사용 가능
// ✅ 같은 모듈 내에서 사용 가능
import "example.com/myproject/internal/db"

// ❌ 외부 모듈에서 import 시 컴파일 에러
// import "example.com/myproject/internal/db"
// → use of internal package ... not allowed

🛠️ 실무 활용

internal 패키지는 라이브러리를 개발할 때 특히 유용합니다. 외부 사용자에게 노출하고 싶지 않은 구현 세부사항을 internal 아래에 두면, 라이브러리 내부 리팩터링 시 외부 API를 깨뜨리지 않고 자유롭게 변경할 수 있습니다.


6. init() 함수: 패키지 초기화의 핵심

init() 함수는 패키지가 import될 때 자동으로 실행되는 특별한 함수입니다.

package mathutil

import "fmt"

func init() {
    fmt.Println("mathutil 패키지가 초기화되었습니다")
}

6-1. init() 함수의 특징

6-2. 실행 순서: 패키지 초기화 체인

Go 프로그램의 초기화 순서는 다음과 같습니다:

의존 패키지 초기화 → 패키지 변수 초기화 → init() 실행 → main() 실행
package main

import (
    "fmt"
    "utils/mathutil" // mathutil 패키지의 init()이 먼저 실행됨
)

func init() {
    fmt.Println("2. main 패키지 init()")
}

func main() {
    fmt.Println("3. main() 함수")
}
// mathutil 패키지의 init()
1. mathutil 패키지가 초기화되었습니다

// main 패키지의 init()
2. main 패키지 init()

// main() 함수
3. main() 함수

6-3. init() 함수의 실무 활용

package config

import (
    "log"
    "os"
)

var AppConfig *Config

func init() {
    // 환경 변수 로딩
    AppConfig = &Config{
        DBHost: os.Getenv("DB_HOST"),
        DBPort: os.Getenv("DB_PORT"),
    }

    if AppConfig.DBHost == "" {
        log.Fatal("DB_HOST 환경 변수가 설정되지 않았습니다")
    }
}

🛠️ 주의사항

init() 함수는 강력하지만 남용하면 초기화 순서가 복잡해집니다. 가능하면 명시적인 초기화 함수(func New(), func Setup())를 사용하고, init()은 정말 패키지 로드 시점에 반드시 실행되어야 하는 경우에만 사용하세요.


7. 순환 의존성(Circular Dependency) 방지

Go 컴파일러는 순환 의존성을 허용하지 않습니다. A 패키지가 B를 import하고, B 패키지가 다시 A를 import하면 컴파일 에러가 발생합니다.

// ❌ 순환 의존성
packageA → import packageB
packageB → import packageA
// Error: import cycle not allowed

7-1. 순환 의존성 해결 패턴

패턴 1: 공통 타입을 별도 패키지로 분리

// ❌ 순환
user/user.go    → import order/
order/order.go  → import user/

// ✅ 공통 타입 분리
types/types.go  // User, Order 타입 정의
user/user.go    → import types/
order/order.go  → import types/

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

// order 패키지
type UserFetcher interface {
    GetUser(id int) (*types.User, error)
}

type OrderService struct {
    userFetcher UserFetcher // user 패키지를 직접 의존하지 않음
}

8. 실무 패키지 구조: 표준 레이아웃

Go 커뮤니티에서 널리 사용되는 표준 프로젝트 레이아웃입니다.

myapp/
├── cmd/                    # 실행 파일 (main 패키지)
│   └── server/
│       └── main.go
├── internal/               # 외부 공개 불가 패키지
│   ├── handler/            # HTTP 핸들러
│   ├── service/            # 비즈니스 로직
│   └── repository/         # 데이터 접근
├── pkg/                    # 외부 공개 가능한 유틸리티
│   └── validator/
├── configs/                # 설정 파일
├── go.mod
└── go.sum
디렉토리역할
cmd/각 실행 파일의 main.go
internal/외부에 공개하지 않는 코어 로직
pkg/다른 프로젝트에서도 사용 가능한 패키지
configs/설정 파일 (YAML, JSON 등)

9. 패키지 문서화: godoc 활용

Go는 코드 주석이 곧 문서가 되는 godoc 시스템을 지원합니다.

// Package mathutil은 수학 연산을 위한 유틸리티 함수를 제공합니다.
package mathutil

// Add는 두 정수를 더한 결과를 반환합니다.
// a와 b는 더할 정수이며, 결과값의 오버플로우에 주의하세요.
func Add(a, b int) int {
    return a + b
}

공개 함수, 타입, 변수에는 반드시 주석을 작성하는 것이 Go의 관례입니다.


마치며: 패키지는 Go 프로젝트의 뼈대

Go 언어에서 패키지는 단순한 파일 정리 도구가 아니라, 코드의 책임과 경계를 명확히 하는 설계 언어입니다. 잘 설계된 패키지 구조는 코드 변경의 영향 범위를 최소화하고, 팀 전체의 생산성을 높입니다.

오늘 배운 핵심을 요약하면:

  1. 하나의 디렉토리는 하나의 패키지이며, package main만 독립 실행이 가능하다.
  2. 대문자 식별자는 외부 공개, 소문자는 패키지 내부 전용으로 접근 제어한다.
  3. internal 패키지로 모듈 내부에서만 사용하는 코드를 완벽하게 캡슐화한다.
  4. init() 함수는 의존성 순서에 따라 자동 실행되며, 패키지 변수 초기화 후 호출된다.
  5. 순환 의존성은 공통 타입 분리 또는 인터페이스로 해결한다.
  6. Blank import(_)로 사이드 이펙트(드라이버 등록 등)만 필요한 패키지를 초기화한다.

다음 글에서는 Go 언어의 에러(Error) 처리를 심층적으로 다루며, error 인터페이스의 내부 구조, 커스텀 에러 타입 설계, errors.Iserrors.As를 활용한 에러 체이닝, 그리고 panicrecover의 실무 패턴을 알아보겠습니다. Go의 명시적 에러 처리가 왜 런타임 예외보다 강력한지 확인해보세요!