Devin.KR

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 를 가진다.

각 계층은 자기가 아는 맥락만 덧붙인다. 저장소는 어떤 주문 번호를 찾았는지, 서비스는 어떤 동작 중이었는지를 적는다. 이렇게 하면 최종 메시지가 위에서 아래로 읽히는 한 줄 경로가 된다.

각 계층이 %w 로 감싼 오류는 사슬을 이루고 errors.Is 는 사슬 끝의 센티널까지 따라간다

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 에서만 잡을 수 있다는 점도 기억해 둔다. 다음 장에서 고루틴을 본격적으로 다룰 때 이 사실이 다시 나온다.

panic 은 경계의 defer 와 recover 에서 error 로 바뀌어 바깥으로 나간다

오류 메시지 작성 관례

  • 소문자로 시작하고 마침표와 줄바꿈으로 끝내지 않는다. 오류는 다른 메시지 중간에 이어 붙기 때문이다.
  • 각 계층은 자신이 하던 동작을 명사구나 동사구로 적는다. 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 는 호출 경계에만 둔다

연습 문제

  1. assign 이 배달 구역을 벗어난 주문을 거절하도록 하고 싶다. 구역 이름을 호출자가 읽어야 한다면 센티널과 오류 타입 중 무엇으로 만들어야 하는가. 이유를 설명하라.
  2. accept 가 반환한 오류 하나에서, 검증 위반 전체의 Field 를 모아 슬라이스로 돌려주는 함수 fields(err error) []string 의 설계를 설명하라. errors.As 한 번으로 충분한가.
  3. 다음 메시지에서 관례에 어긋나는 부분을 모두 찾아 고쳐라: Failed to assign courier: Error: no courier.
  4. 고루틴을 시작하는 함수 안에 recover 를 둘 때, 호출한 쪽의 defer 에 둔 recover 로는 왜 충분하지 않은지 설명하라.

정답과 해설

  1. 오류 타입이다. 호출자가 구역 이름이라는 값을 읽어야 하므로 필드를 가진 타입이 필요하고, 검사는 errors.As 로 한다. 센티널은 값을 담지 못하므로 구역 이름을 메시지 문자열에 넣을 수밖에 없고, 그러면 문자열 파싱이 필요해진다.
  2. errors.As 는 첫 번째 일치만 돌려주므로 한 번으로는 부족하다. Join 으로 묶인 오류는 Unwrap() []error 를 구현하므로, 타입 단언으로 이 인터페이스를 확인해 하위 오류를 순회하면서 각각에 errors.As 를 적용한다. 감싼 오류가 다시 Join 을 품을 수 있으므로 재귀로 짠다. 하위 오류가 하나뿐인 경우는 Unwrap() error 도 함께 처리한다.
  3. 대문자 시작, "Failed to" 접두어, "Error:" 접두어, 끝의 마침표가 어긋난다. 고친 메시지는 assign courier: no courier available 이다.
  4. panic 은 발생한 고루틴의 스택만 되감는다. 호출한 쪽 고루틴의 defer 는 그 스택에 없으므로 panic 을 잡지 못하고, 잡히지 않은 panic 은 프로그램 전체를 종료시킨다. 따라서 새 고루틴이 시작하는 함수 자체에 defer 와 recover 를 둬야 한다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.