Go · 심화
동시성과 서버 설계로 깊어지는 Go
오류 설계 - 감싸고 분류하기
fmt.Errorf %w, errors.Is·As·Join, 센티널 오류와 오류 타입 선택, panic·recover 를 쓰는 경계, 오류 메시지 작성 관례
개발자KR · 원고 갱신
이 장에서 배우는 것
기본서에서는 함수가 error 를 돌려주고 호출한 쪽이 nil 인지 확인하는 흐름까지 다뤘다. 서비스가 커지면 질문이 달라진다. 저장소에서 난 오류를 서비스 계층이 어떻게 덧붙여 올려 보내는지, 맨 위의 HTTP 계층이 그 오류를 어떻게 상태 코드로 바꾸는지가 문제가 된다. 이 장은 배달 주문 중계 서비스의 주문 접수 경로에 오류를 감싸고 분류하는 구조를 입힌다.
- fmt.Errorf 의 %w 로 맥락을 덧붙이면서 원인을 보존한다.
- errors.Is, errors.As, errors.Join 으로 오류 사슬을 검사하고 여러 오류를 묶는다.
- 센티널 오류와 오류 타입 중 무엇을 고를지 기준을 세운다.
- panic 과 recover 를 써도 되는 경계를 구분한다.
- 읽기 좋은 오류 메시지를 쓰는 관례를 익힌다.
문제 상황
중계 서비스에 주문이 들어오면 저장소에서 주문을 찾고, 내용을 검증하고, 배달원을 배정한다. 처음에는 각 단계가 오류를 그대로 반환했다. 그러자 로그에는 not found 한 줄만 남았고, 어떤 주문을 찾다가 난 오류인지 알 수 없었다.
반대로 맥락을 붙이려고 fmt.Errorf("접수 실패: %v", err) 로 문자열만 이어 붙이면, 맨 위 계층이 "주문이 없는 경우는 404, 배달원이 없는 경우는 503" 처럼 원인별로 나눠 응답하려 할 때 문자열을 뒤져야 한다. 메시지 문구를 고치는 순간 분기가 조용히 깨진다.
검증 단계도 문제다. 상품이 비어 있고 주소도 비어 있는 주문이 들어오면 첫 번째 위반만 알려 주는 것보다 위반을 모두 모아 한 번에 돌려주는 편이 점주에게 친절하다. 이 세 가지, 곧 맥락 추가, 원인별 분류, 여러 오류 묶기가 이 장이 다루는 내용이다.
오류를 감싸고 꺼내 보기
%w 로 감싸기
fmt.Errorf 의 서식 동사 %w 는 메시지에 오류 문자열을 넣으면서, 반환되는 오류가 원본을 가리키도록 만든다. 반환값에는 Unwrap 메서드가 있어서 한 겹 벗기면 원본이 나온다. %v 로 넣으면 문자열만 들어가고 원본과의 연결은 끊어진다. Go 1.20 부터는 한 번의 Errorf 에 %w 를 여러 번 쓸 수 있고, 이때 반환값은 Unwrap() []error 를 가진다.
각 계층은 자기가 아는 맥락만 덧붙인다. 저장소는 어떤 주문 번호를 찾았는지, 서비스는 어떤 동작 중이었는지를 적는다. 이렇게 하면 최종 메시지가 위에서 아래로 읽히는 한 줄 경로가 된다.
errors.Is 와 errors.As
errors.Is(err, target) 는 사슬을 바깥에서 안쪽으로 따라가며 target 과 같은 값이 있는지 확인한다. 센티널 오류처럼 "이 값인가"를 묻는 데 쓴다. errors.As(err, &x) 는 사슬에서 x 의 타입과 일치하는 첫 오류를 찾아 x 에 넣고 true 를 돌려준다. 오류 안의 필드를 읽어야 할 때 쓴다.
이전에 쓰던 err == ErrNotFound 비교는 감싼 오류에서 false 가 된다. 감싸는 설계를 택했다면 비교는 항상 errors.Is 로 한다. 타입 단언 err.(*ValidationError) 도 같은 이유로 errors.As 로 바꾼다.
errors.Join 으로 묶기
errors.Join 은 여러 오류를 하나로 합친다. 메시지는 각 오류 메시지를 줄바꿈으로 이은 것이고, 넘긴 오류가 모두 nil 이면 nil 을 돌려준다. 이 성질 덕분에 검증 함수는 위반을 슬라이스에 모았다가 마지막에 errors.Join(errs...) 한 줄로 끝낼 수 있다. Is 와 As 는 묶인 오류 각각을 검사한다.
센티널 오류와 오류 타입
호출자가 오류에서 무엇을 알아야 하는지가 선택 기준이다.
| 구분 | 선언 | 검사 방법 | 알맞은 경우 |
|---|---|---|---|
| 센티널 오류 | errors.New 로 만든 패키지 변수 | errors.Is | 상황 이름만 알면 되는 경우 |
| 오류 타입 | Error 메서드를 가진 구조체 | errors.As | 필드 값이 필요한 경우 |
주문이 없다는 사실에는 덧붙일 정보가 없으므로 센티널로 충분하다. 반면 검증 오류는 어느 필드가 왜 틀렸는지를 호출자가 읽어야 하므로 타입으로 만든다. 센티널과 타입은 공개 API 의 일부가 된다는 점을 기억해야 한다. 외부에 노출하는 순간 이름을 바꾸기 어려워지므로, 호출자가 분기할 이유가 있는 것만 공개한다. 분기할 일이 없다면 맥락만 담은 평범한 오류로 둔다.
오류 타입은 포인터 리시버로 Error 를 정의하고 포인터로 반환하는 편이 일반적이다. errors.As 의 대상 변수도 같은 포인터 타입이어야 한다.
panic 과 recover 의 경계
panic 은 호출 스택을 되감으며 defer 를 실행하다가, recover 를 만나지 못하면 프로그램을 종료시킨다. 오류 반환과 역할이 다르다. 입력이 잘못됐거나 상대 서비스가 응답하지 않는 것처럼 정상 운영 중에 일어나는 실패는 error 로 돌려준다. panic 은 인덱스 범위 초과, nil 역참조처럼 프로그램의 버그이거나, 초기화 단계에서 필수 설정이 없어 시작 자체가 의미 없는 경우에 한정한다.
recover 는 호출 경계에만 둔다. 요청을 처리하는 가장 바깥 함수, 또는 고루틴이 시작하는 지점이 그 자리다. 한 요청의 버그가 서버 전체를 내리지 않도록 막고, 잡은 값을 error 로 바꿔 로그에 남기는 것이 목적이다. 라이브러리 내부에서 panic 으로 흐름을 제어하거나 recover 로 오류를 삼키는 방식은 피한다. 고루틴 안에서 난 panic 은 그 고루틴 안의 defer 에서만 잡을 수 있다는 점도 기억해 둔다. 다음 장에서 고루틴을 본격적으로 다룰 때 이 사실이 다시 나온다.
오류 메시지 작성 관례
- 소문자로 시작하고 마침표와 줄바꿈으로 끝내지 않는다. 오류는 다른 메시지 중간에 이어 붙기 때문이다.
- 각 계층은 자신이 하던 동작을 명사구나 동사구로 적는다.
find order "A-999"처럼 식별자를 따옴표와 함께 넣는다. - "failed to", "error:" 같은 접두어를 쓰지 않는다. 오류라는 사실은 반환 위치가 이미 말해 준다.
- 원인은 %w 뒤쪽에 둔다. 결과는
동작: 하위 동작: 원인순서로 읽힌다. - 같은 오류를 로그로 남기고 다시 반환하지 않는다. 처리하는 한 곳에서만 기록한다.
- 비밀번호, 토큰 같은 민감한 값은 메시지에 넣지 않는다.
| 선택 | 방법 | 원인 보존 | 쓰는 곳 |
|---|---|---|---|
| 그대로 반환 | return err | 예 | 덧붙일 맥락이 없을 때 |
| 감싸서 반환 | %w | 예 | 대부분의 중간 계층 |
| 문자열로 변환 | %v | 아니오 | 내부 구현을 숨기려는 경계 |
| 처리하고 종료 | 로그 기록, 응답 생성 | 해당 없음 | 최상위 계층 |
완성 코드
주문 접수 경로 전체를 한 파일에 담았다. 저장소는 맵 하나로 대신한다.
package main
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("not found")
ErrNoCourier = errors.New("no courier available")
)
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return "invalid " + e.Field + ": " + e.Reason
}
type Order struct {
ID string
Shop string
Items []string
Addr string
}
var orders = map[string]Order{
"A-100": {ID: "A-100", Shop: "분식집", Items: []string{"떡볶이"}, Addr: "해오름로 12"},
"A-101": {ID: "A-101", Shop: "빵집"},
}
func findOrder(id string) (Order, error) {
o, ok := orders[id]
if !ok {
return Order{}, fmt.Errorf("find order %q: %w", id, ErrNotFound)
}
return o, nil
}
func validate(o Order) error {
var errs []error
if len(o.Items) == 0 {
errs = append(errs, &ValidationError{Field: "items", Reason: "empty"})
}
if o.Addr == "" {
errs = append(errs, &ValidationError{Field: "addr", Reason: "empty"})
}
return errors.Join(errs...)
}
func assign(o Order, free int) error {
if free == 0 {
return fmt.Errorf("assign order %s: %w", o.ID, ErrNoCourier)
}
return nil
}
func accept(id string, free int) error {
o, err := findOrder(id)
if err != nil {
return fmt.Errorf("accept: %w", err)
}
if err := validate(o); err != nil {
return fmt.Errorf("accept %s: %w", id, err)
}
if err := assign(o, free); err != nil {
return fmt.Errorf("accept: %w", err)
}
return nil
}
func classify(err error) string {
var ve *ValidationError
switch {
case err == nil:
return "ok"
case errors.Is(err, ErrNotFound):
return "404"
case errors.As(err, &ve):
return "422"
case errors.Is(err, ErrNoCourier):
return "503"
default:
return "500"
}
}
func pick(items []string, i int) string {
return items[i]
}
func safely(name string, fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%s: recovered: %v", name, r)
}
}()
fn()
return nil
}
func main() {
cases := []struct {
id string
free int
}{
{"A-100", 1},
{"A-100", 0},
{"A-101", 1},
{"A-999", 1},
}
for _, c := range cases {
err := accept(c.id, c.free)
fmt.Printf("%s free=%d -> %s\n", c.id, c.free, classify(err))
if err != nil {
fmt.Printf(" %v\n", err)
}
}
err := accept("A-101", 1)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("first field:", ve.Field)
}
wrapped := accept("A-999", 1)
plain := fmt.Errorf("accept: %v", ErrNotFound)
fmt.Println("wrapped:", errors.Is(wrapped, ErrNotFound))
fmt.Println("plain:", errors.Is(plain, ErrNotFound))
perr := safely("pick", func() { pick(nil, 2) })
fmt.Println(perr)
}
줄별 해설
센티널과 오류 타입
ErrNotFound 와 ErrNoCourier 는 상황 이름만 담은 센티널이다. ValidationError 는 Field 와 Reason 을 가진 타입이고, Error 메서드를 포인터 리시버로 정의했다. 따라서 이 오류를 만들 때는 &ValidationError{…} 처럼 포인터로 만든다.
저장소와 검증
findOrder 는 맵에 없을 때 %q 로 주문 번호를 따옴표와 함께 넣고 %w 로 ErrNotFound 를 감싼다. validate 는 위반마다 errs 에 추가하고 마지막에 errors.Join 으로 묶는다. 위반이 없으면 errs 가 비어 있으므로 Join 이 nil 을 돌려주고, 호출자는 평소처럼 err != nil 로 검사한다. "A-101" 주문은 상품과 주소가 모두 비어 있어 위반이 둘이다.
접수와 분류
accept 는 세 단계를 차례로 부르고, 각 오류를 자기 맥락으로 감싸서 올린다. 어느 단계에서도 원본을 문자열로 바꾸지 않으므로 사슬이 유지된다. classify 는 switch 안에서 Is 와 As 를 조합한다. 검사 순서는 의미가 있다. 묶인 오류 하나에 여러 종류가 섞일 수 있으므로, 어느 오류를 우선할지를 case 순서로 정한다. 검증 오류 사슬에는 ErrNotFound 가 없으므로 "A-101" 은 422 로 분류된다.
recover 경계
safely 는 이름 있는 반환값 err 를 쓴다. 이는 defer 안에서 반환값을 바꾸기 위해서다. fn 에서 panic 이 나면 defer 의 recover 가 값을 받아 error 로 바꾼다. pick(nil, 2) 는 길이가 0 인 슬라이스의 인덱스 2 를 읽어 런타임 panic 을 일으킨다. 그 메시지는 recover 가 돌려주는 값을 %v 로 찍으면 그대로 나온다.
main
네 가지 입력을 돌며 분류 결과와 전체 메시지를 찍는다. 이어서 As 로 첫 위반의 필드를 꺼내고, %w 로 감싼 오류와 %v 로 감싼 오류에 같은 Is 검사를 해서 차이를 보인다.
실행 결과
$ go run main.go
A-100 free=1 -> ok
A-100 free=0 -> 503
accept: assign order A-100: no courier available
A-101 free=1 -> 422
accept A-101: invalid items: empty
invalid addr: empty
A-999 free=1 -> 404
accept: find order "A-999": not found
first field: items
wrapped: true
plain: false
pick: recovered: runtime error: index out of range [2] with length 0
A-101 의 메시지가 두 줄로 나오는 것은 Join 이 각 오류를 줄바꿈으로 잇기 때문이다. 둘째 줄은 들여쓰기 없이 출력된다. 로그에 한 줄로 남겨야 한다면 최상위에서 줄바꿈을 치환하거나, 각 오류를 따로 기록한다.
실무에서 자주 틀리는 것
%v 로 감싸서 사슬을 끊는다
틀린 코드는 다음과 같다.
return fmt.Errorf("accept: %v", err)
메시지는 비슷해 보이지만 errors.Is 와 errors.As 가 모두 실패한다. 호출자의 분기가 아무 경고 없이 기본값 쪽으로 빠지는 것이 문제다. 고친 코드는 다음과 같다.
return fmt.Errorf("accept: %w", err)
센티널을 == 로 비교한다
틀린 코드는 다음과 같다.
if err == ErrNotFound {
// 감싼 오류에서는 실행되지 않는다
}
계층이 맥락을 덧붙이기 시작하면 이 분기는 죽은 코드가 된다. 고친 코드는 다음과 같다.
if errors.Is(err, ErrNotFound) {
// 사슬 어디에 있어도 실행된다
}
errors.As 의 대상 타입을 잘못 잡는다
Error 메서드가 포인터 리시버인데 값 타입 변수를 넘기면 errors.As 는 런타임에 panic 을 일으킨다. 대상 타입이 error 를 구현하지 않기 때문이다. 틀린 코드는 다음과 같다.
var ve ValidationError
errors.As(err, &ve)
고친 코드는 다음과 같다. go vet 도 이 실수를 알려 주므로 습관적으로 돌려 본다.
var ve *ValidationError
errors.As(err, &ve)
메시지에 접두어를 쌓고 로그를 중복한다
틀린 코드는 다음과 같다.
log.Printf("Failed to find order: %v", err)
return fmt.Errorf("Failed to accept order: %w", err)
대문자와 "Failed to" 가 계층마다 반복되어 "Failed to accept order: Failed to find order: …" 처럼 읽기 어려워지고, 같은 오류가 여러 번 기록된다. 고친 코드는 다음과 같다.
return fmt.Errorf("accept: %w", err)
기록은 오류를 최종 처리하는 최상위에서 한 번만 한다.
한눈에 보기
| 도구 | 하는 일 | 주의할 점 |
|---|---|---|
| fmt.Errorf %w | 맥락을 붙이고 원인을 보존 | %v 는 사슬을 끊는다 |
| errors.Is | 사슬에서 특정 값을 찾음 | == 대신 쓴다 |
| errors.As | 사슬에서 특정 타입을 꺼냄 | 대상은 error 를 구현하는 타입의 포인터 |
| errors.Join | 여러 오류를 하나로 묶음 | 모두 nil 이면 nil, 메시지는 여러 줄 |
| panic, recover | 버그와 시작 불가 상황 처리 | recover 는 호출 경계에만 둔다 |
연습 문제
- assign 이 배달 구역을 벗어난 주문을 거절하도록 하고 싶다. 구역 이름을 호출자가 읽어야 한다면 센티널과 오류 타입 중 무엇으로 만들어야 하는가. 이유를 설명하라.
- accept 가 반환한 오류 하나에서, 검증 위반 전체의 Field 를 모아 슬라이스로 돌려주는 함수
fields(err error) []string의 설계를 설명하라. errors.As 한 번으로 충분한가. - 다음 메시지에서 관례에 어긋나는 부분을 모두 찾아 고쳐라:
Failed to assign courier: Error: no courier. - 고루틴을 시작하는 함수 안에 recover 를 둘 때, 호출한 쪽의 defer 에 둔 recover 로는 왜 충분하지 않은지 설명하라.
정답과 해설
- 오류 타입이다. 호출자가 구역 이름이라는 값을 읽어야 하므로 필드를 가진 타입이 필요하고, 검사는 errors.As 로 한다. 센티널은 값을 담지 못하므로 구역 이름을 메시지 문자열에 넣을 수밖에 없고, 그러면 문자열 파싱이 필요해진다.
- errors.As 는 첫 번째 일치만 돌려주므로 한 번으로는 부족하다. Join 으로 묶인 오류는 Unwrap() []error 를 구현하므로, 타입 단언으로 이 인터페이스를 확인해 하위 오류를 순회하면서 각각에 errors.As 를 적용한다. 감싼 오류가 다시 Join 을 품을 수 있으므로 재귀로 짠다. 하위 오류가 하나뿐인 경우는 Unwrap() error 도 함께 처리한다.
- 대문자 시작, "Failed to" 접두어, "Error:" 접두어, 끝의 마침표가 어긋난다. 고친 메시지는
assign courier: no courier available이다. - panic 은 발생한 고루틴의 스택만 되감는다. 호출한 쪽 고루틴의 defer 는 그 스택에 없으므로 panic 을 잡지 못하고, 잡히지 않은 panic 은 프로그램 전체를 종료시킨다. 따라서 새 고루틴이 시작하는 함수 자체에 defer 와 recover 를 둬야 한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.