Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

20장. 패키지와 모듈

지금까지는 거의 모든 코드를 main.go 한 파일에 몰아 적었다. 작은 예제일 땐 그래도 괜찮지만, 프로젝트가 조금만 커져도 금세 한계가 온다.

코드를 여러 파일과 디렉터리로 나누고, 나눈 것끼리 잘 연결하는 도구가 필요하다. Go 에서는 패키지(package)모듈(module) 이 이 역할을 맡는다.

이 장의 목표는 다음과 같다.

  • 패키지가 왜 필요한지 이해하기
  • 직접 패키지를 만들고 가져다 써 보기
  • 대문자/소문자 한 글자로 공개 범위가 결정되는 규칙 익히기
  • 모듈, go.mod, go.sum 의 역할 알기
  • 외부 패키지를 받아 쓰는 절차 익히기

20.1 패키지가 왜 필요한가

지금 우리가 짠 프로그램들이 다음 셋 중 하나라도 겪는다면 패키지가 필요하다.

  • 파일 하나가 너무 길어졌다
  • 같은 코드를 여러 프로그램에서 쓰고 싶다
  • 함수 이름이 서로 충돌한다

코드를 정리한다

한 파일에 함수 50개가 들어 있으면, 어디서 무엇을 하는지 찾기 힘들다.

user 관련 코드는 user 패키지에, order 관련 코드는 order 패키지에 둔다. 이렇게 영역을 나누면 코드의 지도가 생긴다.

코드를 재사용한다

한번 잘 만든 greetings 패키지를 이 프로그램에서도, 저 프로그램에서도 쓸 수 있다. 복사-붙여넣기 대신 임포트만 하면 된다.

이름 충돌을 피한다

세상에는 Parse 라는 함수가 수도 없이 많다.

  • json.Parse
  • xml.Parse
  • url.Parse

패키지가 이름 앞에 점 하나로 붙어 누구의 Parse 인지 구분해 준다.

패키지는 “코드를 담는 폴더 + 이름표“라고 생각하면 편하다.


20.2 패키지 만들기

규칙: 디렉터리가 곧 패키지

Go 의 규칙은 단순하다.

  • 한 디렉터리 = 한 패키지
  • 같은 디렉터리의 .go 파일은 모두 같은 패키지에 속해야 한다
  • 디렉터리가 다르면 패키지도 다르다

패키지 이름은 보통 디렉터리 이름과 똑같이 짓는다. 꼭 그래야 하는 건 아니지만, 다르게 지으면 혼란만 커진다.

예제: greetings 패키지

작은 프로젝트를 하나 만들어 본다.

mkdir myapp
cd myapp
go mod init example.com/myapp
mkdir greetings

디렉터리 구조는 이렇게 된다.

myapp/
├── go.mod
└── greetings/

이제 greetings/hello.go 를 만든다.

package greetings

import "fmt"

func Hello(name string) string {
    return fmt.Sprintf("Hello, %s!", name)
}

첫 줄의 package greetings 가 중요하다. 이 파일은 greetings 패키지에 속한다고 컴파일러에게 알린다.

같은 패키지에 파일 더 추가하기

같은 디렉터리에 greetings/bye.go 를 만들어 보자.

package greetings

import "fmt"

func Bye(name string) string {
    return fmt.Sprintf("Goodbye, %s!", name)
}

두 파일 모두 package greetings 다. 이러면 HelloBye 는 같은 패키지에 속한 두 함수가 된다.

만약 한쪽에서 package greetings 라 적고 다른 쪽에서 package farewell 이라 적으면,

found packages greetings and farewell in /myapp/greetings

컴파일러가 바로 거부한다.


20.3 import 와 사용

한 모듈 안에서 다른 패키지 쓰기

방금 만든 greetingsmain.go 에서 가져다 써 보자.

myapp/main.go:

package main

import (
    "fmt"

    "example.com/myapp/greetings"
)

func main() {
    msg := greetings.Hello("Alice")
    fmt.Println(msg)
}

이제 디렉터리는 이런 모습이다.

myapp/
├── go.mod
├── main.go
└── greetings/
    └── hello.go

실행해 본다.

go run .

출력:

Hello, Alice!

import 경로의 정체

가져올 때 적은 경로를 보자.

import "example.com/myapp/greetings"

이 경로는 두 조각으로 이루어져 있다.

부분의미
example.com/myapp모듈 이름 (go.mod 의 module)
/greetings모듈 안의 디렉터리 경로

즉 import 경로는 모듈 이름 + 디렉터리 경로 다. 폴더 트리를 그대로 따라간다.

임포트한 패키지 부르기

임포트한 뒤에는 패키지 이름.식별자 로 접근한다.

greetings.Hello("Alice")

여기서 앞의 greetings 는 import 경로의 마지막 조각이자 파일 안의 package greetings 다. 보통 둘이 일치한다.

import 별칭

길거나 충돌이 날 땐 별칭을 쓸 수 있다.

import g "example.com/myapp/greetings"

func main() {
    fmt.Println(g.Hello("Alice"))
}

별칭은 가끔 필요할 때만 쓰고, 평소엔 원래 이름을 그대로 쓰는 게 좋다.


20.4 대문자/소문자 export 규칙

Go 에는 public 이나 private 같은 키워드가 없다. 대신 이름의 첫 글자가 그 역할을 한다.

대문자로 시작하면 외부 공개, 소문자로 시작하면 패키지 내부 전용.

이 규칙이 함수, 타입, 필드, 변수, 상수 모두에 똑같이 적용된다.

함수 예제

greetings/hello.go:

package greetings

import "fmt"

// 외부에서 쓸 수 있음 (대문자)
func Hello(name string) string {
    return format(name, "Hello")
}

// 패키지 내부 전용 (소문자)
func format(name, word string) string {
    return fmt.Sprintf("%s, %s!", word, name)
}

main.go 에서 호출해 보자.

greetings.Hello("Alice")  // OK
greetings.format("A", "B") // 컴파일 에러

두 번째 줄은 다음과 같은 에러가 난다.

cannot refer to unexported name greetings.format

구조체 필드도 똑같다

package user

type User struct {
    Name string // 외부 접근 가능
    age  int    // 같은 패키지 안에서만 보임
}

다른 패키지에서 User 를 쓰면,

u := user.User{Name: "Alice"}
fmt.Println(u.Name)  // OK
fmt.Println(u.age)   // 컴파일 에러

age 는 보이지도 않는다.

한눈에 정리

첫 글자의미예시
대문자exported (공개)Hello, Name
소문자unexported (내부)format, age

가독성을 위해서가 아니다. 컴파일러가 진짜로 첫 글자를 본다.


20.5 모듈이란

패키지가 코드를 담는 폴더라면, 모듈 은 그 폴더들을 한 단위로 묶은 큰 상자다.

모듈의 정의

모듈은 다음 세 가지를 한꺼번에 가지는 묶음이다.

  • 여러 개의 패키지
  • 하나의 버전 (예: v1.2.3)
  • 외부 의존성 목록

모듈 하나는 보통 하나의 git 저장소에 대응한다. GitHub 리포지토리 하나가 모듈 하나라고 보면 편하다.

go.mod 가 모듈을 정의한다

디렉터리 어딘가에 go.mod 파일이 있으면 거기서부터가 한 모듈이다.

myapp/                <- 여기가 모듈 루트
├── go.mod
├── main.go
├── greetings/        <- 패키지
└── user/             <- 패키지

go mod init 명령으로 만들었던 그 파일이다.

패키지와 모듈의 관계

이 둘은 다른 층위의 개념이다.

개념단위비유
패키지디렉터리 하나책의 한 챕터
모듈여러 패키지의 묶음책 한 권

작은 프로젝트는 모듈 하나에 패키지도 하나(main)만 있을 수도 있다. 큰 프로젝트는 모듈 하나에 패키지 수십 개가 들어 있을 수도 있다.


20.6 go.mod 와 go.sum

go.mod 의 구조

go mod init example.com/myapp 으로 만든 파일이다.

module example.com/myapp

go 1.22

처음엔 단 두 줄이다. 외부 패키지를 받기 시작하면 점점 늘어난다.

module example.com/myapp

go 1.22

require (
    github.com/google/uuid v1.6.0
    github.com/stretchr/testify v1.9.0
)

각 줄의 역할:

역할
module ...이 모듈의 이름 (import 경로의 뿌리)
go ...어떤 Go 언어 버전을 가정하는지
require ...어떤 외부 모듈을 어느 버전으로 쓰는지

go.sum 의 역할

go.sum 은 외부 모듈을 처음 받을 때 함께 생긴다. 안을 열어 보면 알 수 없는 해시값이 줄줄이 적혀 있다.

github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

이 파일의 역할은 두 가지다.

  • 보안 — 다운로드한 의존성이 변조되지 않았는지 검증
  • 재현성 — 다른 컴퓨터에서도 정확히 같은 코드를 받게 함

손으로 편집하지 않는다

go.modgo.sum 모두 직접 텍스트 에디터로 고치는 일은 거의 없다. 다음 명령들이 알아서 갱신해 준다.

go get github.com/...      # 의존성 추가
go mod tidy                # 안 쓰는 것 정리, 누락된 것 추가
go mod download            # 의존성 미리 받아두기

go.modgo.sum 은 둘 다 git 에 커밋한다. 빠지면 다른 사람이 같은 빌드를 만들 수 없다.


20.7 외부 패키지 사용 (go get)

표준 라이브러리만으로는 부족할 때가 있다. 그때는 다른 사람이 만든 패키지를 가져다 쓴다.

패키지 추가

예를 들어 UUID(고유 식별자) 생성 라이브러리를 써 보자.

go get github.com/google/uuid

이 한 줄로 일어나는 일:

  1. 인터넷에서 해당 모듈 최신 버전을 받음
  2. go.modrequire 에 항목 추가
  3. go.sum 에 해시 추가
  4. 로컬 캐시($GOPATH/pkg/mod)에 저장

코드에서 쓰기

package main

import (
    "fmt"

    "github.com/google/uuid"
)

func main() {
    id := uuid.New()
    fmt.Println(id)
}

import 경로가 곧 GitHub 주소다. Go 의 모듈 시스템은 “import 경로 = 가져올 위치” 라는 단순한 규칙을 따른다.

특정 버전 지정

버전을 직접 지정하고 싶으면 @ 뒤에 적는다.

go get github.com/google/uuid@v1.6.0
go get github.com/google/uuid@latest

정리하기: go mod tidy

코드를 짜다 보면 go.mod 가 실제 코드와 어긋날 때가 있다.

  • 코드는 안 쓰는데 require 에 남아 있거나
  • 코드는 쓰는데 require 가 누락되었거나

이럴 땐 한 줄이면 된다.

go mod tidy

소스 코드를 훑어서 필요한 건 추가하고 안 쓰는 건 지운다. 새 외부 패키지를 import 한 다음 습관적으로 한 번 돌려 주면 좋다.

자주 쓰는 명령 정리

명령역할
go mod init <name>새 모듈 만들기
go get <path>의존성 추가 / 업데이트
go mod tidygo.mod 와 실제 코드 일치시키기
go mod download의존성 미리 받기
go list -m all의존성 목록 보기

20.8 init 함수

패키지가 처음 사용될 때 딱 한 번만 실행되는 특별한 함수가 있다.

기본 사용

package config

import "fmt"

var Settings map[string]string

func init() {
    Settings = make(map[string]string)
    Settings["env"] = "development"
    fmt.Println("config 패키지 초기화")
}

init 함수의 규칙:

  • 매개변수도 반환값도 없다
  • 직접 호출할 수 없다
  • 패키지가 로드될 때 Go 런타임이 알아서 부른다
  • 한 패키지 안에 여러 개를 둘 수 있다

호출 순서

여러 init 함수가 있을 때의 순서는 정해져 있다.

  1. 임포트한 패키지의 init 이 먼저 다 끝남
  2. 그다음 현재 패키지의 패키지 수준 변수가 초기화됨
  3. 그다음 같은 패키지 안의 init 들이 차례로 실행됨
  4. 마지막으로 main.main 이 호출됨

같은 패키지 안에 여러 파일이 있고 각 파일에 init 이 있다면, 파일 이름 순서대로 실행된다.

신중히 쓰라

init 은 편리해 보이지만 함정도 많다.

  • 어디서 무엇이 실행되는지 추적이 어려워진다
  • 테스트할 때 부수 효과가 자꾸 따라온다
  • 임포트만 했는데 실행이 일어난다

가능하면 init 대신 명시적으로 호출되는 Setup() / New() 함수를 쓰자.

init 의 좋은 용도는 한정적이다.

  • 미리 계산해 두는 룩업 테이블 만들기
  • 정규표현식 컴파일 결과 캐싱
  • 표준 라이브러리에 자기 자신 등록하기 (예: 이미지 디코더, DB 드라이버)

20.9 internal 패키지

대문자 한 글자만으로 공개 여부를 가르는 방식은 단순하지만, 가끔 더 강한 캡슐화가 필요하다.

“이 패키지는 우리 모듈 안에서만 쓰고, 바깥 모듈은 절대 쓰지 못하게 막고 싶다.”

Go 는 이를 위해 internal 디렉터리 규칙을 둔다.

규칙

경로 어딘가에 internal 이라는 디렉터리가 있으면, 그 안의 패키지는 다음 영역에서만 임포트할 수 있다.

internal 의 부모 디렉터리, 그리고 그 아래의 모든 코드.

밖에서는 import 자체가 거부된다.

예제

myapp/
├── go.mod
├── main.go
├── api/
│   └── handler.go
└── internal/
    └── db/
        └── db.go

이 구조에서

  • main.go, api/handler.gointernal/db 를 임포트할 수 있다
  • myapp 바깥의 다른 모듈은 example.com/myapp/internal/db 를 임포트하면 컴파일 에러가 난다

언제 쓰는가

  • 라이브러리 작성자가 “이건 구현 세부사항이니 손대지 마세요“라고 선언할 때
  • 모듈 내부에서만 공유하는 헬퍼 패키지를 둘 때

오픈 소스 라이브러리들에 자주 보인다. 표준 라이브러리에도 곳곳에 internal 이 들어 있다.


20.10 정리

이 장에서 살펴본 내용:

  • 패키지는 같은 디렉터리에 모인 .go 파일들의 묶음이다
  • import 경로는 모듈 이름 + 디렉터리 경로
  • 이름의 첫 글자가 대문자면 외부 공개, 소문자면 패키지 내부 전용이다
  • 모듈은 여러 패키지를 묶은 단위이며 go.mod 가 그 정의를 담는다
  • go.sum 은 의존성 해시로 보안과 재현성을 보장한다
  • 외부 패키지는 go get 으로 받고 go mod tidy 로 정리한다
  • init 함수는 패키지 초기화에 쓰이지만 신중히 쓴다
  • internal 디렉터리로 강한 캡슐화를 만들 수 있다

이로써 코드를 어떻게 나누고 어떻게 모아 쓸지 도구를 다 갖췄다.

다음 장에서는 또 다른 큰 주제, “에러를 어떻게 다룰 것인가“를 살펴본다. Go 는 다른 언어와는 사뭇 다른 길을 택했는데, 그 사고방식을 차근차근 풀어 본다.