Table of contents
- 코드를 정리하는 방법
- 패키지 — 코드의 기본 단위
- 대문자와 소문자 — Go의 가시성 규칙
- go mod — 모듈 관리
- init() — 패키지 초기화
- 프로젝트 구조 컨벤션
- internal 패키지 — 컴파일러가 지키는 경계
- 임포트 정리
코드를 정리하는 방법
지금까지 예제들은 전부 package main의 main() 함수 하나에 몰아넣었다. 연습할 때는 괜찮지만, 실제 프로젝트에서는 코드가 수천 줄로 불어나면서 구조가 필요해진다. Go에서 코드를 구조화하는 단위가 패키지(package)이고, 패키지들의 의존성을 관리하는 단위가 모듈(module)이다.
패키지 — 코드의 기본 단위
Go에서 모든 .go 파일은 반드시 패키지에 속해야 한다. 같은 디렉터리의 파일들은 같은 패키지 이름을 써야 하고, 다른 디렉터리는 다른 패키지가 된다.
myproject/
├── go.mod
├── main.go // package main
├── calc/
│ ├── math.go // package calc
│ └── math_test.go // package calc
└── greeting/
└── hello.go // package greeting
calc/math.go를 만들어보자.
// calc/math.go
package calc
// Add는 두 정수를 더한다.
func Add(a, b int) int {
return a + b
}
// subtract는 내부에서만 쓰는 함수다.
func subtract(a, b int) int {
return a - b
}
main.go에서 이 패키지를 가져다 쓴다.
// main.go
package main
import (
"fmt"
"myproject/calc"
)
func main() {
fmt.Println(calc.Add(3, 5)) // 8
// fmt.Println(calc.subtract(3, 5)) // 컴파일 에러 — 소문자라 안 보임
}
calc.Add는 접근 가능하지만 calc.subtract는 불가능하다. 이유는 바로 다음 섹션에서.
대문자와 소문자 — Go의 가시성 규칙
Go의 접근 제어는 놀라울 정도로 단순하다. **이름의 첫 글자가 대문자면 외부에서 접근 가능(exported), 소문자면 패키지 내부 전용(unexported)**이다. 이게 전부다.
package user
// User — 대문자로 시작하므로 다른 패키지에서 접근 가능
type User struct {
Name string // 대문자 — 외부 접근 가능
Email string // 대문자 — 외부 접근 가능
age int // 소문자 — 이 패키지 안에서만 접근 가능
}
// NewUser — 대문자로 시작하므로 외부에서 호출 가능
func NewUser(name, email string, age int) *User {
return &User{Name: name, Email: email, age: age}
}
// validate — 소문자로 시작하므로 내부에서만 사용
func validate(email string) bool {
return len(email) > 0
}
public, private, protected 같은 키워드가 없다. 대문자/소문자라는 규칙 하나로 끝나니 코드를 읽을 때 즉시 가시성을 파악할 수 있다. 처음에는 낯설 수 있는데, 한번 적응하면 이보다 더 간결한 접근 제어가 있을까 싶어진다.
go mod — 모듈 관리
Go 모듈은 패키지들의 묶음이다. go.mod 파일이 모듈의 루트를 표시하고, 모듈 이름과 Go 버전, 외부 의존성을 관리한다.
새 프로젝트를 시작할 때는 이렇게 한다.
mkdir myproject && cd myproject
go mod init github.com/username/myproject
이 명령이 go.mod 파일을 생성한다.
module github.com/username/myproject
go 1.22
외부 패키지를 가져다 쓰면 자동으로 의존성이 추가된다.
go get golang.org/x/text
go.mod에 의존성이 기록되고, go.sum에 체크섬이 저장된다. go.sum은 직접 편집할 일이 없지만, 버전 관리에 반드시 포함시켜야 한다. 다른 사람이 같은 코드를 빌드할 때 동일한 의존성을 받을 수 있도록 보장하기 때문이다.
자주 쓰는 모듈 관련 명령을 정리하면 다음과 같다.
go mod init MODULE_NAME # 새 모듈 초기화
go mod tidy # 사용하지 않는 의존성 정리, 빠진 의존성 추가
go get PACKAGE@VERSION # 특정 버전의 패키지 추가
go list -m all # 모든 의존성 목록
go mod tidy는 습관처럼 실행하는 게 좋다. 코드에서 import를 추가하거나 삭제한 후 실행하면, go.mod와 go.sum을 깔끔하게 정리해준다.
init() — 패키지 초기화
각 패키지에는 init() 함수를 정의할 수 있다. 이 함수는 프로그램이 시작될 때, main() 전에 자동으로 실행된다.
package config
import "fmt"
var DefaultPort int
func init() {
DefaultPort = 8080
fmt.Println("config 패키지 초기화됨")
}
package main
import (
"fmt"
"myproject/config"
)
func main() {
// "config 패키지 초기화됨"이 먼저 출력된다
fmt.Println("서버 포트:", config.DefaultPort) // 8080
}
실행 순서는 이렇다.
- import된 패키지들의
init()(의존성 순서대로) - main 패키지의
init() main()
init()은 패키지 수준 변수의 초기화, 설정 파일 로딩, 드라이버 등록 같은 용도로 쓴다. 데이터베이스 드라이버 패키지를 import _ "github.com/lib/pq" 식으로 빈 임포트하는 게 대표적인 예시다. 패키지를 직접 사용하지 않지만, init()의 부작용(드라이버 등록)이 필요하기 때문이다.
다만 init()의 남용은 주의해야 한다. 프로그램의 초기화 순서가 암묵적이라 디버깅이 어려워지고, 테스트할 때도 원치 않는 부작용이 일어날 수 있다. 꼭 필요한 경우가 아니면 명시적인 초기화 함수를 만드는 게 더 나은 선택일 때가 많다.
프로젝트 구조 컨벤션
Go에는 공식적으로 강제되는 프로젝트 구조가 없지만, 커뮤니티에서 널리 쓰이는 관례가 있다.
myproject/
├── cmd/
│ └── server/
│ └── main.go # 실행 진입점
├── internal/
│ ├── handler/
│ │ └── user.go # HTTP 핸들러
│ └── service/
│ └── user.go # 비즈니스 로직
├── pkg/
│ └── validator/
│ └── email.go # 외부에서 재사용 가능한 유틸
├── go.mod
└── go.sum
각 디렉터리의 역할은 이렇다.
- cmd/: 실행 가능한 바이너리의 진입점.
main패키지가 위치한다. 여러 바이너리가 있으면cmd/server/,cmd/worker/식으로 나눈다 - internal/: 프로젝트 내부에서만 쓰는 패키지. Go 컴파일러가 외부 모듈의 import를 차단해준다
- pkg/: 외부에서 가져다 써도 되는 라이브러리성 코드. 다만 최근에는
pkg/없이 루트 레벨에 두는 프로젝트도 많다
작은 프로젝트에서는 이 구조를 억지로 따를 필요 없다. 패키지 하나로 충분하다면 디렉터리를 나누지 않는 게 오히려 깔끔하다. 구조는 코드가 커지면서 자연스럽게 잡아가는 것이지, 처음부터 빈 디렉터리를 만들어두는 건 과잉 설계다.
internal 패키지 — 컴파일러가 지키는 경계
internal 디렉터리는 Go에서 특별한 의미를 가진다. 이 디렉터리 안의 패키지는 internal의 부모 디렉터리 트리에서만 import할 수 있다.
myproject/
├── cmd/
│ └── server/
│ └── main.go # internal/handler 사용 가능
├── internal/
│ └── handler/
│ └── user.go # 이 프로젝트 안에서만 접근 가능
└── go.mod
// cmd/server/main.go — OK
import "myproject/internal/handler"
// 다른 모듈에서 — 컴파일 에러
// import "myproject/internal/handler"
// use of internal package myproject/internal/handler not allowed
대문자/소문자 규칙이 패키지 내부의 가시성을 제어한다면, internal은 모듈 경계에서의 가시성을 제어한다. 외부에 공개하고 싶지 않은 구현 세부사항을 internal에 넣으면, 실수로 외부에서 가져다 쓰는 일을 컴파일러가 막아준다.
라이브러리를 만들 때 특히 유용하다. 공개 API는 루트나 pkg/에 두고, 내부 구현은 internal/에 숨기면 API 표면적을 깔끔하게 관리할 수 있다.
임포트 정리
Go 파일의 import 블록은 보통 세 그룹으로 나눈다. 빈 줄로 구분하면 goimports 도구가 자동으로 정렬해준다.
import (
// 표준 라이브러리
"fmt"
"net/http"
// 외부 패키지
"github.com/gorilla/mux"
"go.uber.org/zap"
// 프로젝트 내부 패키지
"myproject/internal/handler"
"myproject/internal/service"
)
Go에서는 import하고 사용하지 않으면 컴파일 에러가 난다. 이런 강제 규칙이 처음에는 불편하게 느껴질 수 있지만, 사용하지 않는 코드가 쌓이는 걸 원천 차단해주기 때문에 프로젝트가 커질수록 고마워진다.
에디터(VS Code, GoLand)에 Go 플러그인을 설치해두면 저장할 때마다 자동으로 import를 정리해주니, 수동으로 관리할 일은 거의 없다.
패키지와 모듈은 Go 프로젝트의 뼈대다. 대문자/소문자로 가시성을 제어하고, go.mod로 의존성을 관리하며, internal로 경계를 지킨다. 이 규칙들이 단순해 보이지만, 프로젝트가 커질수록 일관된 코드 구조를 유지하는 데 큰 힘을 발휘한다. 처음부터 완벽한 구조를 짜려고 하기보다, 코드가 늘어나면서 자연스럽게 패키지를 분리해가는 접근이 Go스러운 방식이다.
다음 편에서는 Go 1.18에서 도입된 제네릭과, 테이블 드리븐 테스트, 빌드 태그, go generate 같은 실전 패턴을 다룬다. 시리즈의 마지막 편이니 그동안 배운 것들을 종합하는 시간이 될 거다.
Loading comments...