출시·고도화 중
Go 안내서 · 5/6
Go에는 예외(exception)가 없습니다. 오류는 평범한 값으로 돌려주고, 호출한 쪽이 그 자리에서 확인합니다. 테스트 역시 별도 프레임워크 없이 표준 testing 패키지와 go test 명령으로 충분합니다. 이 장에서는 오류 값, 오류 감싸기, panic과 recover, 그리고 테스트와 벤치마크 작성법을 다룹니다.
error는 Error() string 메서드 하나만 가진 내장 인터페이스입니다. 실패할 수 있는 함수는 마지막 반환값으로 error를 돌려주고, 성공하면 nil을 돌려줍니다. 호출한 쪽은 바로 if err != nil로 확인합니다.
import (
"errors"
"fmt"
"strconv"
)
var ErrEmpty = errors.New("빈 입력") // 센티널 오류: 비교 대상으로 공개한다
func parseAge(s string) (int, error) {
if s == "" {
return 0, ErrEmpty
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("나이 %q 해석 실패: %w", s, err)
}
if n < 0 {
return 0, fmt.Errorf("나이는 음수일 수 없습니다: %d", n)
}
return n, nil
}오류를 무시하지 않고 하나하나 처리하는 것이 Go의 기본 태도입니다. 오류 메시지는 소문자로 시작하고 마침표를 붙이지 않는 것이 관례인데, 다른 메시지 안에 이어 붙여지기 때문입니다.
fmt.Errorf에서 %w 동사를 쓰면 원래 오류를 감싼(wrap) 새 오류가 만들어집니다. 감싼 오류에는 맥락이 더해지면서도 원래 오류를 다시 찾을 수 있습니다. 비교에는 == 대신 errors.Is를, 특정 타입의 오류를 꺼낼 때는 errors.As를 씁니다. 두 함수 모두 감싼 사슬을 따라가며 찾습니다.
type NotFoundError struct {
ID string
}
func (e *NotFoundError) Error() string { return "찾을 수 없음: " + e.ID }
func load(id string) error {
return fmt.Errorf("load: %w", &NotFoundError{ID: id})
}
func main() {
_, err := parseAge("")
if errors.Is(err, ErrEmpty) {
fmt.Println("입력이 비어 있습니다")
}
err = load("42")
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Println("없는 ID:", nf.ID)
}
fmt.Println(err) // load: 찾을 수 없음: 42
}%v로 오류를 넣으면 메시지만 이어 붙고 원래 오류와의 연결은 끊깁니다. 호출자가 원래 오류를 확인해야 한다면 %w를, 내부 구현을 감추고 싶다면 %v를 고릅니다. 여러 오류를 하나로 합칠 때는 errors.Join을 쓸 수 있습니다.
panic은 정상적인 오류 처리로 복구할 수 없는 상황, 즉 프로그래머의 실수나 불가능해야 할 상태를 알릴 때 씁니다. 배열 범위를 벗어난 접근이나 nil 맵 쓰기도 런타임 패닉을 일으킵니다. 패닉이 나면 함수가 차례로 풀리면서 defer가 실행되고, 아무도 잡지 않으면 프로그램이 종료됩니다.
recover는 defer된 함수 안에서만 효과가 있으며, 진행 중인 패닉을 멈추고 그 값을 돌려줍니다. 서버가 요청 하나의 패닉 때문에 전체가 죽지 않도록 경계에서 잡는 용도로 주로 씁니다.
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("복구된 패닉: %v", r)
}
}()
return a / b, nil // b 가 0 이면 런타임 패닉
}
func main() {
_, err := safeDivide(1, 0)
fmt.Println(err) // 복구된 패닉: runtime error: integer divide by zero
}라이브러리는 패닉을 호출자에게 넘기지 말고 error를 돌려주는 것이 원칙입니다. 이름 붙은 반환값 err를 defer 안에서 바꾸면 복구 결과를 호출자에게 전달할 수 있습니다.
테스트 파일은 이름이 _test.go로 끝나고, 테스트 함수는 Test로 시작하며 *testing.T를 받습니다. go test가 이 함수들을 찾아 실행합니다. t.Errorf는 실패를 기록하고 계속 진행하며, t.Fatalf는 그 테스트를 즉시 멈춥니다.
// 파일: age_test.go
package main
import (
"errors"
"testing"
)
func TestParseAgeEmpty(t *testing.T) {
_, err := parseAge("")
if !errors.Is(err, ErrEmpty) {
t.Fatalf("ErrEmpty 를 기대했지만 %v", err)
}
}Go에서 가장 흔한 테스트 형태는 입력과 기대값을 표(슬라이스)로 늘어놓고 반복하는 표 기반 테스트입니다. t.Run으로 각 경우를 하위 테스트로 실행하면 실패한 경우의 이름이 보고되고, 특정 경우만 골라 실행할 수도 있습니다.
func TestParseAge(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr bool
}{
{"정상", "30", 30, false},
{"숫자 아님", "abc", 0, true},
{"음수", "-1", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseAge(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("parseAge(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}벤치마크 함수는 Benchmark로 시작하고 *testing.B를 받습니다. 측정할 코드를 b.Loop()가 참인 동안 반복하면, 테스트 도구가 반복 횟수를 알아서 정합니다. 오래된 코드에서는 for i := 0; i < b.N; i++ 형태도 볼 수 있습니다.
func BenchmarkParseAge(b *testing.B) {
for b.Loop() {
parseAge("42")
}
}go test ./... # 모든 패키지 테스트
go test -v -run 'TestParseAge/음수' . # 특정 하위 테스트만
go test -race ./... # 데이터 경쟁 검사와 함께
go test -cover ./... # 코드 커버리지
go test -bench . -benchmem # 벤치마크와 메모리 할당 측정error로 돌려주고 if err != nil로 바로 확인합니다.fmt.Errorf("...: %w", err)로 맥락을 더해 감싸고, errors.Is/errors.As로 확인합니다.panic은 복구 불가능한 상황에만 쓰고, recover는 defer 안에서 경계에서만 씁니다._test.go 파일의 TestXxx(t *testing.T)이며, 표 기반 테스트와 t.Run이 관례입니다.BenchmarkXxx(b *testing.B)와 go test -bench로 성능을 측정합니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.