Go · 기본
작고 명확한 Go 프로그램
패키지 나누기와 go test
디렉터리와 패키지, 공개·비공개 이름, 표 주도 테스트, go test -cover
개발자KR · 원고 갱신
이 장에서 배우는 것
지금까지 만든 대여소 프로그램은 한 파일에 모든 것이 들어 있었다. 요금 계산, 출력, 입력 처리가 한 곳에 섞여 있으면 요금 규칙만 확인하고 싶을 때도 프로그램 전체를 돌려 눈으로 결과를 읽어야 한다. 이 장에서는 요금 계산을 별도 패키지(package)로 떼어 내고, 그 패키지를 Go 가 기본으로 제공하는 테스트 도구로 검사한다. 이때 쓰는 것은 표 주도 테스트(table-driven test)라는 관례와 go test -cover 명령이다.
- 디렉터리 하나가 패키지 하나가 되는 규칙과 모듈 경로로 패키지를 가져오는 방법을 설명할 수 있다.
- 대문자로 시작하는 이름은 공개, 소문자로 시작하는 이름은 비공개라는 규칙을 코드 설계에 적용할 수 있다.
_test.go파일에 표 주도 테스트를 작성하고go test로 실행할 수 있다.go test -cover가 알려 주는 수치가 무엇이고 무엇이 아닌지 구분할 수 있다.
문제 상황
대여소 요금 규칙이 이렇게 정해졌다고 하자. 기본 30분까지는 1000원이고, 30분을 넘으면 10분(끝수는 올림)마다 500원을 더 받는다. 한 번 빌리는 요금은 10000원을 넘지 않는다. 이용 시간이 음수로 들어오면 계산하지 않고 오류를 돌려준다.
이 규칙을 main 함수 안에 그대로 써 두면 두 가지가 불편하다. 첫째, 규칙을 바꿀 때마다 정산 출력 코드까지 함께 읽어야 한다. 둘째, 30분 정각, 31분, 상한 직전처럼 경계에 있는 값을 확인하려면 입력을 바꿔 가며 프로그램을 여러 번 돌려야 한다. 사람이 눈으로 확인한 결과는 다음 수정 때 다시 확인해야 하므로, 한 번 확인한 사실이 코드에 남지 않는다.
해결 방법은 두 단계다. 요금 규칙을 fare 패키지로 분리해 이름과 경계를 붙이고, 그 경계 값들을 테스트 코드로 적어 둔다. 그러면 go test 한 줄이 언제든 같은 확인을 반복해 준다.
디렉터리와 패키지
한 디렉터리에는 한 패키지
Go 에서 패키지는 같은 디렉터리에 있는 .go 파일의 묶음이다. 한 디렉터리 안의 파일은 모두 첫 줄에 같은 package 이름을 적어야 한다. 관례상 패키지 이름은 디렉터리 이름과 같게 짓고, 짧은 소문자 한 단어로 쓴다. fare 디렉터리에는 package fare 를 적는다.
패키지 여러 개를 묶는 단위는 모듈(module)이다. 1장에서 go mod init 으로 만든 go.mod 파일이 모듈의 뿌리에 놓이고, 그 안의 첫 줄 module 뒤에 적은 이름이 모듈 경로가 된다. 다른 패키지를 가져올 때는 상대 경로가 아니라 모듈 경로 + 디렉터리 경로를 쓴다. 이 장의 모듈 경로는 bikeshop 이므로 fare 디렉터리는 "bikeshop/fare" 로 가져온다.
프로그램의 시작점은 package main 이면서 main 함수를 가진 패키지 하나뿐이다. 나머지 패키지는 main 이나 다른 패키지가 가져다 쓰는 부품이다. 패키지끼리 서로를 가져오는 순환 참조는 컴파일 오류가 되므로, 가져오는 방향은 한쪽으로만 흐르게 설계해야 한다.
공개 이름과 비공개 이름
다른 언어에서는 public, private 같은 키워드로 접근 범위를 정한다. Go 는 키워드 대신 이름의 첫 글자를 본다. 대문자로 시작하면 패키지 밖에서 쓸 수 있는 공개(exported) 이름이고, 소문자나 밑줄로 시작하면 같은 패키지 안에서만 쓸 수 있는 비공개 이름이다. 함수, 상수, 변수, 타입, 구조체 필드, 메서드에 모두 같은 규칙이 적용된다.
| 이름 | 첫 글자 | 어디서 보이는가 | 이 장의 예 |
|---|---|---|---|
| 공개 | 대문자 | 다른 패키지에서도 fare.Charge 처럼 사용 | Charge, ErrNegativeMinutes |
| 비공개 | 소문자 | 같은 패키지 안에서만 | extraBlocks, capped, dailyCap |
공개 이름은 다른 사람이 의지하는 약속이므로 나중에 바꾸기 어렵다. 반대로 비공개 이름은 패키지 안에서 자유롭게 고칠 수 있다. 그래서 처음에는 가능한 한 적게 공개하고, 밖에서 꼭 써야 하는 것만 대문자로 시작하게 한다. 요금 패키지에서 밖에 필요한 것은 "시간을 주면 요금과 오류를 돌려주는 함수"와 "음수 시간 오류를 구분할 값" 둘뿐이다. 상한 금액이나 올림 계산은 내부 사정이다.
표 주도 테스트
go test 의 규칙
go test 는 이름이 _test.go 로 끝나는 파일을 찾아 컴파일한 뒤, Test 로 시작하고 그 다음 글자가 대문자인 함수를 실행한다. 함수의 모양은 func TestXxx(t *testing.T) 로 정해져 있다. 테스트 파일은 일반 빌드에는 포함되지 않으므로 프로그램 크기에 영향을 주지 않는다. 테스트 파일이 package fare 로 선언되면 같은 패키지의 비공개 이름까지 볼 수 있다. 공개 이름만으로 검사하고 싶으면 package fare_test 로 선언하는 방법도 있다.
검사가 어긋났을 때는 t.Errorf 나 t.Fatalf 를 부른다. Errorf 는 실패로 기록하고 계속 진행하고, Fatalf 는 기록한 뒤 그 테스트를 즉시 끝낸다. 뒤의 검사가 앞의 결과에 의존할 때 Fatalf 를 쓴다.
표로 입력과 기대값 나열하기
같은 함수를 입력만 바꿔 여러 번 검사할 때는 검사 코드를 복사하지 않고, 입력과 기대값을 구조체 슬라이스에 나열한 뒤 반복문 하나로 돌린다. 이것이 표 주도 테스트다. 케이스를 추가하려면 표에 한 줄만 적으면 된다. 각 케이스는 t.Run(이름, 함수) 로 하위 테스트(subtest)로 실행하면 실패했을 때 어느 케이스인지 이름으로 바로 알 수 있다.
좋은 표는 경계를 골고루 담는다. 요금 규칙에서 경계는 음수, 0분, 기본 시간의 마지막인 30분, 처음으로 초과하는 31분, 올림이 걸리는 45분, 상한에 닿는 큰 값이다. 평범한 값 한두 개보다 경계 값 여러 개가 실수를 더 잘 잡아낸다.
go test -cover
go test -cover 는 테스트가 실행되는 동안 소스의 각 구문이 실행되었는지 기록해 "실행된 구문 비율"을 출력한다. 비율이 낮으면 한 번도 실행되지 않은 분기가 있다는 뜻이므로 테스트 표에 케이스를 더해야 할 곳을 찾는 데 쓸 수 있다. 다만 100% 는 "모든 줄이 한 번은 실행되었다"는 뜻일 뿐 "결과가 모두 맞다"는 뜻이 아니다. 이 수치는 검사 자체의 품질이 아니라 검사가 닿은 범위를 알려 준다. 자세한 옵션은 testing 패키지 문서와 Go 코드 구성 안내에서 확인할 수 있다.
| 명령 | 하는 일 | 기억할 점 |
|---|---|---|
go test ./... | 모듈 안 모든 패키지의 테스트 실행 | 테스트 파일이 없으면 그렇다고 알려 줌 |
go test -v ./fare | 하위 테스트까지 이름과 결과 출력 | 실패 원인을 찾을 때 유용 |
go test -run Charge ./fare | 이름이 맞는 테스트만 실행 | 정규식으로 해석됨 |
go test -cover ./... | 실행된 구문 비율 출력 | 정확성이 아니라 범위를 나타냄 |
완성 코드
디렉터리는 다음과 같이 만든다. 모듈 루트에 go.mod 와 main.go, 하위 디렉터리 fare 에 나머지 두 파일을 둔다.
go.mod
module bikeshop
go 1.25
main.go
package main
import (
"errors"
"fmt"
"maps"
"slices"
"bikeshop/fare"
)
type rental struct {
rider string
minutes int
}
func main() {
rentals := []rental{
{"kim", 25},
{"lee", 45},
{"kim", 90},
{"park", 400},
{"choi", -5},
}
totals := map[string]int{}
for _, r := range rentals {
won, err := fare.Charge(r.minutes)
if err != nil {
if errors.Is(err, fare.ErrNegativeMinutes) {
fmt.Printf("%s: 건너뜀 (%v)\n", r.rider, err)
} else {
fmt.Printf("%s: 오류 (%v)\n", r.rider, err)
}
continue
}
fmt.Printf("%s %d분 %d원\n", r.rider, r.minutes, won)
totals[r.rider] += won
}
fmt.Println("--- 정산 ---")
sum := 0
for _, name := range slices.Sorted(maps.Keys(totals)) {
fmt.Printf("%s %d원\n", name, totals[name])
sum += totals[name]
}
fmt.Printf("합계 %d원\n", sum)
}
fare/fare.go
// Package fare 는 대여소의 이용 요금 규칙을 담는다.
package fare
import (
"errors"
"fmt"
)
const (
baseMinutes = 30
baseWon = 1000
extraUnit = 10
extraWon = 500
dailyCap = 10000
)
// ErrNegativeMinutes 는 이용 시간이 음수일 때 Charge 가 돌려주는 오류다.
var ErrNegativeMinutes = errors.New("이용 시간이 음수다")
// Charge 는 이용 시간(분)에 해당하는 요금(원)을 계산한다.
func Charge(minutes int) (int, error) {
if minutes < 0 {
return 0, fmt.Errorf("Charge(%d): %w", minutes, ErrNegativeMinutes)
}
if minutes <= baseMinutes {
return baseWon, nil
}
return capped(baseWon + extraBlocks(minutes-baseMinutes)*extraWon), nil
}
func extraBlocks(over int) int {
return (over + extraUnit - 1) / extraUnit
}
func capped(won int) int {
if won > dailyCap {
return dailyCap
}
return won
}
fare/fare_test.go
package fare
import (
"errors"
"testing"
)
func TestCharge(t *testing.T) {
cases := []struct {
name string
minutes int
want int
wantErr error
}{
{"음수", -5, 0, ErrNegativeMinutes},
{"0분", 0, 1000, nil},
{"기본 시간 끝", 30, 1000, nil},
{"1분 초과", 31, 1500, nil},
{"15분 초과", 45, 2000, nil},
{"60분 초과", 90, 4000, nil},
{"상한 도달", 400, 10000, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := Charge(tc.minutes)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("Charge(%d) 오류 = %v, 기대 %v", tc.minutes, err, tc.wantErr)
}
if got != tc.want {
t.Errorf("Charge(%d) = %d, 기대 %d", tc.minutes, got, tc.want)
}
})
}
}
줄별 해설
fare/fare.go
첫 줄의 package fare 가 이 디렉터리의 패키지 이름을 정한다. 그 위의 // Package fare ... 주석은 패키지 설명이며, 문서 도구가 읽는다. 관례상 공개 이름 바로 위에는 그 이름으로 시작하는 주석을 단다.
const 묶음의 다섯 상수는 모두 소문자로 시작하므로 비공개다. 요금표의 숫자를 한곳에 모아 두었으니 규칙이 바뀌면 이 블록만 고친다. 상수 이름을 붙이면 minutes <= baseMinutes 처럼 조건문이 규칙을 그대로 읽어 준다.
ErrNegativeMinutes 는 errors.New 로 만든 오류 값이고 대문자로 시작하므로 밖에서 볼 수 있다. 호출하는 쪽이 "이 오류가 음수 시간 때문인가"를 errors.Is 로 물을 수 있게 하려고 공개했다.
Charge 는 요금과 오류를 함께 돌려준다. 음수이면 fmt.Errorf 의 %w 동사로 위 오류 값을 감싸 돌려준다. 감싸기 때문에 메시지에는 어떤 입력이었는지가 붙고, errors.Is 는 감싼 안쪽 값을 찾아 비교한다. 30분 이하는 기본 요금을 바로 돌려주고, 그보다 길면 초과 시간을 extraBlocks 로 블록 수로 바꿔 요금을 더한 뒤 capped 로 상한을 씌운다.
extraBlocks 의 (over + extraUnit - 1) / extraUnit 은 정수 나눗셈으로 올림을 구하는 흔한 방법이다. 정수 나눗셈은 소수점 이하를 버리므로, 나누기 전에 나누는 수보다 1 작은 값을 더해 두면 나머지가 있을 때만 몫이 하나 늘어난다. 15분 초과라면 (15+9)/10 이 2 가 되어 블록 두 개다. capped 는 값이 상한을 넘으면 상한을 돌려준다.
fare/fare_test.go
테스트 파일도 package fare 이므로 같은 패키지로 컴파일되고, 비공개 상수와 함수를 볼 수 있다. 이 테스트는 공개 함수만 쓰므로 package fare_test 로 써도 동작하지만, 이 장에서는 한 가지 규칙만 다루려고 같은 이름을 썼다.
cases 는 이름 없는 구조체 슬라이스다. 필드는 케이스 이름, 입력 시간, 기대 요금, 기대 오류이고 오류가 없어야 하는 케이스에는 nil 을 적는다. 각 줄이 표의 한 행이다.
for 문은 표를 한 줄씩 꺼내 t.Run 에 넘긴다. 하위 테스트 안에서 Charge 를 호출하고, 먼저 오류를 비교한다. errors.Is(err, tc.wantErr) 는 둘 다 nil 일 때도 참이 되므로 "오류가 없어야 하는 경우"와 "특정 오류여야 하는 경우"를 한 식으로 검사한다. 오류가 다르면 뒤의 요금 비교는 의미가 없으므로 Fatalf 로 끝낸다. 요금이 다르면 Errorf 로 실패를 기록한다. 실패 메시지에 입력, 얻은 값, 기대 값을 모두 넣어 두면 화면만 보고도 원인을 짚을 수 있다.
main.go
import 문은 표준 라이브러리 묶음과 우리 모듈의 패키지 묶음을 빈 줄로 나눠 적었다. "bikeshop/fare" 는 모듈 경로 bikeshop 에 디렉터리 이름을 붙인 것이다. 가져온 뒤에는 패키지 이름 fare 를 앞에 붙여 fare.Charge 로 쓴다. 이 이름이 main.go 에서 보이는 것은 대문자로 시작하기 때문이다.
rentals 는 대여 기록 다섯 건이다. 오류가 나면 종류에 따라 메시지를 나눠 출력하고 continue 로 다음 기록으로 넘어간다. 정상이면 요금을 출력하고 사람별 합계 맵 totals 에 더한다. 맵을 그냥 순회하면 순서가 실행마다 달라질 수 있으므로, maps.Keys 로 키를 꺼내 slices.Sorted 로 정렬한 뒤 그 순서로 출력한다. 그래서 출력이 항상 같다.
실행 결과
모듈 루트에서 실행한다. 프로그램은 go run . 으로 돌린다.
$ go run .
kim 25분 1000원
lee 45분 2000원
kim 90분 4000원
park 400분 10000원
choi: 건너뜀 (Charge(-5): 이용 시간이 음수다)
--- 정산 ---
kim 5000원
lee 2000원
park 10000원
합계 17000원
테스트는 go test -cover ./... 로 실행한다. 패키지 이름 뒤의 소요 시간은 실행할 때마다 다르므로 아래 값과 같지 않아도 된다.
$ go test -cover ./...
? bikeshop [no test files]
ok bikeshop/fare 0.002s coverage: 100.0% of statements
첫 줄은 main 패키지에 테스트 파일이 없다는 안내다. 둘째 줄이 fare 의 결과이며, 표의 일곱 케이스가 모두 통과했고 구문 100% 가 실행되었다는 뜻이다. 100% 가 나오는 이유는 표에 음수, 기본 요금, 상한 미만, 상한 초과 케이스가 모두 있어 Charge 와 capped 의 모든 분기를 지나기 때문이다. 표에서 400 케이스를 지우면 capped 안의 상한 반환 줄이 실행되지 않아 수치가 100% 밑으로 내려간다.
실무에서 자주 틀리는 것
비공개 이름을 다른 패키지에서 쓰려 한다
요금 상한이 필요해서 main.go 에서 이렇게 쓰면 컴파일이 되지 않는다.
fmt.Println(fare.dailyCap) // 소문자로 시작해서 밖에서 쓸 수 없다
컴파일러는 이 이름을 패키지 밖에서 참조할 수 없다고 거절한다. 밖에서 값이 꼭 필요하다면 이름만 대문자로 바꾸지 말고, 정말 약속으로 내놓아도 되는지부터 따져야 한다. 약속하기로 했다면 상수를 공개하거나 값을 돌려주는 공개 함수를 둔다.
// fare 패키지
const DailyCap = 10000
// main.go
fmt.Println(fare.DailyCap)
테스트 함수 이름의 대소문자
테스트를 만들었는데 go test 가 아무것도 실행하지 않는 경우가 있다. 함수 이름이 규칙에서 벗어났기 때문이다.
func Testcharge(t *testing.T) { // Test 다음이 소문자라서 실행되지 않는다
// ...
}
Test 다음 글자는 대문자여야 하고, 파일 이름은 _test.go 로 끝나야 한다. 고치면 다음과 같다.
func TestCharge(t *testing.T) {
// ...
}
새 테스트를 쓴 뒤에는 일부러 기대 값을 틀리게 적어 실패하는지 한 번 확인하면, 이 테스트가 실제로 실행되고 있는지 알 수 있다.
상대 경로로 패키지를 가져온다
다른 언어의 습관대로 파일 위치를 적으면 가져오기가 실패한다.
import "./fare" // 상대 경로는 모듈 방식에서 쓰지 않는다
모듈 안에서는 항상 go.mod 에 적은 모듈 경로에서 시작한다.
import "bikeshop/fare"
모듈 이름을 나중에 바꾸면 모든 import 를 함께 고쳐야 하므로, 처음 go mod init 할 때 이름을 신중히 정한다.
커버리지 100% 를 정답으로 착각한다
다음 테스트는 모든 줄을 실행하지만 아무것도 확인하지 않는다.
func TestChargeNoCheck(t *testing.T) {
Charge(-5)
Charge(0)
Charge(400)
}
세 호출이 모든 분기를 지나므로 수치는 높게 나오지만, 요금 계산이 틀려도 통과한다. 실행 여부와 검증은 다른 일이다. 고친 코드는 반드시 결과를 기대 값과 비교한다.
got, err := Charge(400)
if err != nil || got != 10000 {
t.Errorf("Charge(400) = %d, %v", got, err)
}
커버리지는 "아직 검사가 닿지 않은 곳"을 찾는 데 쓰고, 통과 여부의 근거로 삼지 않는다.
한눈에 보기
| 주제 | 규칙 | 예 |
|---|---|---|
| 패키지 | 한 디렉터리에 한 패키지, 이름은 디렉터리와 같게 | fare/ 는 package fare |
| 가져오기 | 모듈 경로 + 디렉터리 경로 | "bikeshop/fare" |
| 공개 여부 | 첫 글자가 대문자면 공개 | Charge 공개, capped 비공개 |
| 테스트 파일 | 이름이 _test.go 로 끝남 | fare_test.go |
| 테스트 함수 | Test + 대문자로 시작, 인자는 *testing.T | TestCharge |
| 표 주도 테스트 | 케이스 슬라이스 + 반복문 + t.Run | 경계 값을 한 줄씩 추가 |
| 커버리지 | 실행된 구문 비율이며 정확성 보증이 아님 | go test -cover |
연습 문제
- 요금 규칙에 "이용 시간이 1440분(하루)을 넘으면 오류"를 추가하려 한다.
fare패키지에 새 오류 값을 어떤 이름으로 둘지, 그 오류가 밖에서 필요한지 이유와 함께 답하라. 그런 다음 표에 추가할 케이스 두 개를 적어라. fare/fare.go의extraBlocks를ExtraBlocks로 바꾸면 무엇이 달라지는가. 이 변경이 바람직한지 설계 관점에서 한 문장으로 답하라.- 표 주도 테스트에서
t.Run없이for문 안에서 바로 검사하면 실패 메시지에서 어떤 정보가 줄어드는지, 그리고Fatalf를 쓰면 반복문에 어떤 영향이 있는지 설명하라. - 표에서
{"1분 초과", 31, 1500, nil}케이스를 지우고go test -cover를 돌렸다. 수치가 100% 로 유지될지 예측하고 이유를 써라. 이어서{"상한 도달", 400, 10000, nil}을 지웠을 때는 어떻게 되는지 써라.
정답과 해설
- 이름은
ErrTooLong처럼 대문자로 시작하는 공개 값이 알맞다. 호출하는 쪽이errors.Is로 "너무 긴 이용"과 "음수 시간"을 구분해 서로 다른 안내를 출력할 수 있어야 하기 때문이다. 밖에서 구분할 필요가 없다면 소문자 비공개 값으로 두는 편이 약속을 줄인다. 추가 케이스는 경계인{"하루 끝", 1440, 10000, nil}과{"하루 초과", 1441, 0, ErrTooLong}이다. 하루 끝 요금은 상한이므로 10000원이 된다. 이때Charge안의 검사 순서는 음수 검사 다음, 기본 요금 검사 앞이다. extraBlocks가 공개 이름이 되어 다른 패키지가 쓸 수 있게 된다. 올림 계산은 내부 사정이라 밖에 약속으로 내놓을 이유가 없고, 공개하면 나중에 계산 방식을 바꾸기 어려워지므로 바람직하지 않다.t.Run없이 돌리면 실패 메시지에 케이스 이름이 없다. 메시지에 입력값을 직접 넣어야 어느 행인지 알 수 있고,-run으로 특정 케이스만 골라 돌릴 수도 없다. 또한Fatalf는 그 테스트 함수 전체를 끝내므로,t.Run없이 반복문 안에서 쓰면 첫 실패 뒤의 나머지 케이스가 실행되지 않는다.t.Run안에서 쓰면 그 하위 테스트만 끝나고 다음 케이스가 계속된다.- 31분 케이스를 지워도 수치는 100% 로 유지된다. 45분, 90분 케이스가 같은 분기를 지나기 때문이다. 이는 커버리지가 경계를 확인했는지는 알려 주지 못한다는 예이므로, 31분 같은 경계 케이스는 수치와 상관없이 남겨 둔다. 400분 케이스를 지우면
capped안의return dailyCap줄이 실행되지 않아 수치가 100% 밑으로 내려간다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.