본문으로 건너뛰기

에러 감싸기와 검사

이 챕터에서 다루는 것

4-6에서 에러를 만들고 돌려주는 법을 봤다. 문제는 그다음이다. 에러가 다섯 계층을 거쳐 올라오는 동안 맥락을 잃지 않으면서 종류는 식별 가능하게 유지해야 한다. %werrors 패키지가 그 일을 한다.

문제 — 맥락과 식별은 상충한다

에러를 위로 올릴 때 선택지가 셋 있었다.

// 1. 그대로 올린다 — 어디서 났는지 모른다
return err

// 2. 새 에러로 갈아 끼운다 — 원인을 잃는다
return errors.New("프로필 로드 실패")

// 3. 문자열로 합친다 — 읽을 수는 있지만 식별할 수 없다
return fmt.Errorf("프로필 로드 %s: %v", id, err)

3번이 가장 나아 보이지만, 호출자가 "이게 '찾을 수 없음'인가?"를 물을 방법이 없다. 문자열 안에 파묻혔기 때문이다.

%w는 문자열을 합치면서 원본 에러 값을 안에 보존한다. 그것이 유일한 차이다.

%w와 래핑 체인

examples/04-interfaces-and-errors/07-wrapping/main.go
package main

import (
"errors"
"fmt"
)

// 센티널 — 종류를 식별하는 값
var ErrNotFound = errors.New("찾을 수 없음")

// QueryError는 맥락을 필드로 들고 있는 커스텀 에러 타입이다.
// Unwrap이 있어야 errors.Is/AsType이 안쪽까지 본다.
type QueryError struct {
Query string
Err error
}

func (e *QueryError) Error() string { return "쿼리 " + e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }

// 가장 안쪽 계층
func fetch(id string) error {
if id != "u1" {
return &QueryError{Query: "SELECT * FROM users WHERE id=" + id, Err: ErrNotFound}
}
return nil
}

// 중간 계층 — %w로 맥락을 덧붙인다
func loadProfile(id string) error {
if err := fetch(id); err != nil {
return fmt.Errorf("프로필 로드 %s: %w", id, err)
}
return nil
}

// %v로 감싸면 문자열만 남고 체인이 끊긴다
func loadProfileBroken(id string) error {
if err := fetch(id); err != nil {
return fmt.Errorf("프로필 로드 %s: %v", id, err)
}
return nil
}

func main() {
err := loadProfile("u9")
fmt.Println("메시지:", err)

// 종류 확인 — 몇 겹을 감쌌든 찾아낸다
fmt.Println("errors.Is(err, ErrNotFound):", errors.Is(err, ErrNotFound))

// 타입으로 꺼내기 — 제네릭 버전이 현재 기본형이다
if qe, ok := errors.AsType[*QueryError](err); ok {
fmt.Println("실패한 쿼리:", qe.Query)
}

// 체인을 직접 걸어 보기
fmt.Println("--- Unwrap 체인")
for e := err; e != nil; e = errors.Unwrap(e) {
fmt.Printf(" %T: %v\n", e, e)
}

// %v로 감싸면 위 두 검사가 모두 실패한다
broken := loadProfileBroken("u9")
fmt.Println("--- v 동사로 감싼 경우")
fmt.Println("메시지:", broken)
fmt.Println("errors.Is:", errors.Is(broken, ErrNotFound))
_, ok := errors.AsType[*QueryError](broken)
fmt.Println("errors.AsType:", ok)

// 옛 코드에서 보게 될 형태 — errors.As
var qe *QueryError
fmt.Println("--- errors.As (기존 형태)")
fmt.Println("errors.As:", errors.As(err, &qe))
if qe != nil {
fmt.Println("같은 결과:", qe.Query)
}

// 성공 경로
fmt.Println("--- 성공")
fmt.Println("loadProfile(\"u1\"):", loadProfile("u1"))
}
go run ./07-wrapping
메시지: 프로필 로드 u9: 쿼리 SELECT * FROM users WHERE id=u9: 찾을 수 없음
errors.Is(err, ErrNotFound): true
실패한 쿼리: SELECT * FROM users WHERE id=u9
--- Unwrap 체인
*fmt.wrapError: 프로필 로드 u9: 쿼리 SELECT * FROM users WHERE id=u9: 찾을 수 없음
*main.QueryError: 쿼리 SELECT * FROM users WHERE id=u9: 찾을 수 없음
*errors.errorString: 찾을 수 없음
--- v 동사로 감싼 경우
메시지: 프로필 로드 u9: 쿼리 SELECT * FROM users WHERE id=u9: 찾을 수 없음
errors.Is: false
errors.AsType: false
--- errors.As (기존 형태)
errors.As: true
같은 결과: SELECT * FROM users WHERE id=u9
--- 성공
loadProfile("u1"): <nil>

읽어야 할 것

체인은 연결 리스트다.

*fmt.wrapError ──Unwrap──▶ *main.QueryError ──Unwrap──▶ *errors.errorString
"프로필 로드 u9: …" "쿼리 SELECT …: …" "찾을 수 없음"

fmt.Errorf%w가 있으면 *fmt.wrapError를 만든다. 이 타입은 Unwrap() error를 가지고 있어서 안쪽 에러를 내놓는다. QueryErrorUnwrap을 직접 구현했으므로 체인이 끝까지 이어진다.

%v%w의 출력은 똑같다. 두 "메시지:" 줄을 비교해 보라. 글자 하나 다르지 않다. 다른 것은 errors.Iserrors.AsTypefalse가 된다는 것뿐이다. 그래서 이 실수는 눈으로 찾기가 어렵고, 종류 분기가 조용히 안 먹는 형태로 나타난다.

Unwrap이 없으면 체인이 끊긴다. 커스텀 에러 타입에 Err error 필드를 두고 Unwrap을 안 붙이는 것이 흔한 실수다. Error()에서 안쪽 메시지를 이어 붙였으니 사람 눈에는 정상으로 보인다.

errors.Is — 종류를 묻는다

func Is(err, target error) bool

체인을 따라가며 target과 같은 것이 있는지 본다. 센티널 에러에 쓴다.

if errors.Is(err, os.ErrNotExist) { ... }
if errors.Is(err, sql.ErrNoRows) { ... }
if errors.Is(err, context.Canceled) { ... }

== 대신 항상 errors.Is를 쓴다. 이유는 둘이다. 감싼 에러를 찾아 주고, 4-4에서 본 비교 불가능 타입 패닉을 피한다.

타입이 Is(error) bool 메서드를 구현하면 커스텀 매칭도 할 수 있다. os.ErrNotExist가 그 방식으로 여러 플랫폼의 에러를 하나로 묶는다.

errors.AsType[T] — 값을 꺼낸다

종류만 알면 되는 것이 아니라 필드를 읽어야 할 때 쓴다.

func AsType[E error](err error) (E, bool)
if qe, ok := errors.AsType[*QueryError](err); ok {
fmt.Println(qe.Query)
}

체인에서 *QueryError인 것을 찾아 그 값과 true를 돌려준다. 없으면 제로값과 false다.

:::note 제네릭은 Part 5에서 다룬다 errors.AsType[*QueryError](err)의 대괄호가 타입 파라미터다. 제네릭 전반은 Part 5의 주제이고, 지금은 이 형태만 익히면 된다errors.AsType[찾을타입](err)을 쓰면 (그 타입의 값, bool)이 나온다. comma-ok와 같은 모양이라 읽기 어렵지 않다. :::

옛 형태 errors.As도 알아 둬야 한다. Go 1.26 이전 코드는 전부 이쪽이다.

var qe *QueryError
if errors.As(err, &qe) {
fmt.Println(qe.Query)
}

target포인터의 포인터를 넘겨야 하고, 잘못된 타입을 넘기면 컴파일이 아니라 런타임 패닉이다. errors.AsType은 이 두 문제를 모두 없앤다. 새 코드에는 errors.AsType을 쓰고, errors.As는 읽을 줄만 알면 된다.

errors.Aserrors.AsType[T]
형태errors.As(err, &target)v, ok := errors.AsType[T](err)
타입 안전런타임 검사컴파일 시점
변수 선언미리 필요불필요
언제기존 코드새 코드

errors.Unwrap — 한 겹 벗긴다

func Unwrap(err error) error

한 단계만 벗기고, 더 없으면 nil이다. 위 예제의 순회 루프가 용례의 거의 전부다. 실무에서 직접 쓸 일은 드물다. errors.Is/AsType이 대신 순회해 준다. 디버깅 도구를 만들 때나 쓴다.

센티널이냐 타입이냐

에러 종류를 표현하는 두 가지 방법이다.

센티널 (var ErrX = errors.New(...))커스텀 타입 (type XError struct)
언제종류만 구분하면 될 때호출자가 필드를 읽어야 할 때
검사errors.Iserrors.AsType[T]
비용값 하나타입 + Error() + Unwrap()
API 부담공개하면 영구 계약필드까지 영구 계약

기본은 센티널이다. 필드가 필요해질 때 타입으로 올린다.

커스텀 타입을 만들 때의 체크리스트.

type QueryError struct {
Query string
Err error // 원인을 담는다면
}

func (e *QueryError) Error() string { ... } // 필수
func (e *QueryError) Unwrap() error { ... } // Err 필드가 있으면 필수
  • Error()의 리시버를 포인터로 할지 값으로 할지 정한다. 포인터로 했다면 반환도 &QueryError{}여야 하고, errors.AsType[*QueryError]로 찾는다. 4-1의 메서드 집합 문제가 여기서 다시 나온다.
  • Err 필드가 있으면 Unwrap을 반드시 붙인다.
  • 공개 API라면 필드가 계약이 된다. 신중하게 고른다.

:::warning 센티널을 공개하는 것은 되돌리기 어렵다 var ErrNotFound = errors.New(...)를 export하면, 사용자가 errors.Is로 그것에 의존하기 시작한다. 나중에 그 에러를 안 내려면 호환성이 깨진다. 공개할 센티널은 적을수록 좋다. 표준 라이브러리도 io.EOF, os.ErrNotExist, sql.ErrNoRows처럼 정말 필요한 것만 공개한다. :::

errors.Join — 여러 에러를 하나로

검증처럼 실패를 전부 모아 보고해야 하는 경우다.

examples/04-interfaces-and-errors/07-join/main.go
package main

import (
"errors"
"fmt"
"strings"
)

var (
errNameRequired = errors.New("이름은 필수다")
errBadEmail = errors.New("이메일 형식이 아니다")
errAgeRange = errors.New("나이는 0~150이어야 한다")
)

type User struct {
Name string
Email string
Age int
}

// validate는 실패한 것을 전부 모아 하나의 error로 돌려준다.
// errors.Join은 전부 nil이면 nil을 돌려주므로 별도 분기가 필요 없다.
func validate(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errNameRequired)
}
if !strings.Contains(u.Email, "@") {
errs = append(errs, fmt.Errorf("%w: %q", errBadEmail, u.Email))
}
if u.Age < 0 || u.Age > 150 {
errs = append(errs, fmt.Errorf("%w: %d", errAgeRange, u.Age))
}
return errors.Join(errs...)
}

func main() {
good := User{Name: "김개발", Email: "dev@example.com", Age: 30}
fmt.Printf("정상 입력: %v\n", validate(good))

bad := User{Name: "", Email: "nope", Age: 999}
err := validate(bad)

// Join된 에러의 Error()는 줄바꿈으로 이어 붙인 것이다.
fmt.Println("--- 전체 메시지")
fmt.Println(err)

fmt.Println("--- 종류별 확인")
fmt.Println("이름 문제:", errors.Is(err, errNameRequired))
fmt.Println("이메일 문제:", errors.Is(err, errBadEmail))
fmt.Println("나이 문제:", errors.Is(err, errAgeRange))

// 하나의 Errorf에 %w를 여러 개 쓸 수도 있다.
combined := fmt.Errorf("가입 실패: %w / %w", errNameRequired, errBadEmail)
fmt.Println("--- w 동사 두 개")
fmt.Println(combined)
fmt.Println("둘 다 찾아진다:",
errors.Is(combined, errNameRequired) && errors.Is(combined, errBadEmail))

// Join된 에러는 Unwrap() []error를 가진다. 하나짜리 Unwrap()과 다르다.
fmt.Println("--- 트리 순회")
printTree(err, 0)
}

func printTree(err error, depth int) {
if err == nil {
return
}
fmt.Printf("%s%T: %v\n", strings.Repeat(" ", depth), err, firstLine(err))

switch x := err.(type) {
case interface{ Unwrap() error }:
printTree(x.Unwrap(), depth+1)
case interface{ Unwrap() []error }:
for _, e := range x.Unwrap() {
printTree(e, depth+1)
}
}
}

func firstLine(err error) string {
s := err.Error()
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i] + " …"
}
return s
}
go run ./07-join
정상 입력: <nil>
--- 전체 메시지
이름은 필수다
이메일 형식이 아니다: "nope"
나이는 0~150이어야 한다: 999
--- 종류별 확인
이름 문제: true
이메일 문제: true
나이 문제: true
--- w 동사 두 개
가입 실패: 이름은 필수다 / 이메일 형식이 아니다
둘 다 찾아진다: true
--- 트리 순회
*errors.joinError: 이름은 필수다 …
*errors.errorString: 이름은 필수다
*fmt.wrapError: 이메일 형식이 아니다: "nope"
*errors.errorString: 이메일 형식이 아니다
*fmt.wrapError: 나이는 0~150이어야 한다: 999
*errors.errorString: 나이는 0~150이어야 한다

알아 둘 것

errors.Joinnil을 걸러 낸다. 전부 nil이면 결과도 nil이다. 그래서 validate에 "에러가 하나라도 있는가" 분기가 필요 없다. errs가 빈 슬라이스여도 nil이 나온다.

Error()는 줄바꿈으로 이어 붙인다. 사람이 읽기에는 좋지만 한 줄 로그에는 안 맞는다. 필요하면 직접 순회해서 원하는 형태로 만든다.

체인이 트리가 된다. errors.Join이 만드는 값은 Unwrap() []error를 가진다. Unwrap() error(하나짜리)와 다른 메서드다. errors.Is/AsType은 둘 다 처리하지만, 직접 순회할 때는 위 printTree처럼 두 경우를 다 봐야 한다.

fmt.Errorf%w를 여러 개 쓸 수도 있다. Go 1.20부터다. 메시지 형태를 직접 제어하고 싶을 때 errors.Join 대신 쓴다.

어느 계층에서 감싸고 어디서 로깅하는가

기술보다 판단이 중요한 부분이다.

examples/04-interfaces-and-errors/07-layers/main.go
package main

import (
"errors"
"fmt"
"log/slog"
"os"
)

// ---- 도메인 계층: 센티널을 정의한다 ----

var (
ErrUserNotFound = errors.New("사용자를 찾을 수 없음")
ErrForbidden = errors.New("권한 없음")
)

// ---- 저장소 계층: 원인을 만든다. 로깅하지 않는다 ----

type repo struct {
rows map[string]string
}

func (r *repo) findName(id string) (string, error) {
name, ok := r.rows[id]
if !ok {
// 자기 맥락(어느 테이블, 어느 키)만 붙인다.
return "", fmt.Errorf("repo.findName id=%s: %w", id, ErrUserNotFound)
}
return name, nil
}

// ---- 서비스 계층: 맥락을 덧붙여 올린다. 로깅하지 않는다 ----

type service struct {
repo *repo
admin map[string]bool
}

func (s *service) profile(actor, target string) (string, error) {
if actor != target && !s.admin[actor] {
return "", fmt.Errorf("service.profile actor=%s target=%s: %w", actor, target, ErrForbidden)
}
name, err := s.repo.findName(target)
if err != nil {
return "", fmt.Errorf("service.profile target=%s: %w", target, err)
}
return name, nil
}

// ---- 전달 계층: 여기서 한 번만 로깅하고, 종류를 사용자 응답으로 번역한다 ----

type response struct {
status int
body string
}

func handle(log *slog.Logger, s *service, actor, target string) response {
name, err := s.profile(actor, target)
if err != nil {
// 로깅은 여기 한 곳에서만. 전체 체인이 메시지에 들어 있다.
log.Error("프로필 조회 실패", "actor", actor, "target", target, "err", err)

switch {
case errors.Is(err, ErrUserNotFound):
return response{404, "사용자를 찾을 수 없습니다"}
case errors.Is(err, ErrForbidden):
return response{403, "권한이 없습니다"}
default:
// 내부 사정은 사용자에게 노출하지 않는다.
return response{500, "일시적인 오류입니다"}
}
}
return response{200, name}
}

func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
// 시각은 실행할 때마다 달라지므로 출력에서 지운다.
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}))

svc := &service{
repo: &repo{rows: map[string]string{"u1": "김개발", "u2": "이배포"}},
admin: map[string]bool{"admin": true},
}

for _, tc := range []struct{ actor, target string }{
{"u1", "u1"},
{"admin", "u9"},
{"u1", "u2"},
} {
res := handle(log, svc, tc.actor, tc.target)
fmt.Printf("=> %d %s\n\n", res.status, res.body)
}
}
go run ./07-layers
=> 200 김개발

level=ERROR msg="프로필 조회 실패" actor=admin target=u9 err="service.profile target=u9: repo.findName id=u9: 사용자를 찾을 수 없음"
=> 404 사용자를 찾을 수 없습니다

level=ERROR msg="프로필 조회 실패" actor=u1 target=u2 err="service.profile actor=u1 target=u2: 권한 없음"
=> 403 권한이 없습니다

:::note slog는 Part 12의 주제다 여기서는 "구조화된 로거"의 예시로만 썼다. 시각 필드는 실행할 때마다 달라지므로 ReplaceAttr로 지웠다. 그것을 빼면 각 로그 줄 앞에 time=...이 붙는다. :::

규칙 네 가지

1. 감싸는 것은 각 계층이, 자기 맥락만.

repo.findName id=u9service.profile target=u9 → 최종 메시지가 호출 경로를 그린다. 각 계층은 자기가 아는 것(어느 함수, 어떤 인자)만 붙인다. 아래 계층이 이미 붙인 것을 반복하지 않는다.

2. 로깅은 한 곳에서, 최상위에서.

각 계층이 로깅하면 에러 하나에 로그가 다섯 줄 남고, 실제 개수를 세는 알림이 망가진다. 처리할 수 있는 곳까지 올리고, 거기서 한 번 남긴다. 웹이라면 핸들러나 미들웨어, CLI라면 main이다.

3. 사용자에게는 번역해서, 로그에는 그대로.

err의 전체 체인에는 SQL 쿼리, 내부 ID, 파일 경로가 들어 있다. 그대로 응답에 넣으면 정보 노출이다. 로그에는 전체를, 응답에는 종류에 대응하는 메시지를 준다. 위 코드의 switch errors.Is가 그 번역기다.

4. default는 500이다.

모르는 에러를 404로 처리하지 않는다. 종류를 알 수 없다면 서버 잘못으로 본다.

감싸지 말아야 할 때

1. 맥락을 더할 것이 없을 때.

if err := doThing(); err != nil {
return fmt.Errorf("doThing 실패: %w", err) // 붙인 것이 없다
}

doThing이 이미 자기 이름을 붙였다면 이 줄은 "doThing 실패: doThing: ..."을 만든다. 더할 것이 없으면 return err.

2. 안쪽 에러를 감추고 싶을 때.

패키지 경계에서 내부 구현을 숨기는 것은 정당한 설계다.

if err := db.Query(...); err != nil {
// 사용자가 database/sql 에러에 의존하게 만들지 않는다
return ErrStorageUnavailable
}

이때는 %w를 일부러 안 쓴다. 다만 로그에는 원인을 남긴다.

3. 같은 에러를 두 번 감쌀 때.

if err != nil { return fmt.Errorf(...) }가 함수 안에 여러 번 있으면서 같은 접두사를 붙이고 있다면, 한 곳으로 모으는 편이 낫다.

흔히 하는 실수

1. %v로 감싼다

가장 흔하고, 출력이 똑같아서 가장 안 보인다. 감쌀 때는 %w다. go vet%wfmt.Errorf 밖에서 쓰면 잡아 주지만, %v%w로 바꿔야 한다고 알려 주지는 않는다.

2. 커스텀 에러 타입에 Unwrap을 안 붙인다

Err error 필드가 있는데 Unwrap이 없으면 체인이 거기서 끊긴다. 필드가 있으면 반드시 붙인다.

3. err == ErrNotFound로 비교한다

감싼 에러를 못 찾고, 비교 불가능한 동적 타입이면 패닉이다. errors.Is를 쓴다.

4. 모든 계층에서 로깅한다

로그가 폭발하고 실제 발생 횟수를 알 수 없게 된다.

5. errors.As에 잘못된 것을 넘긴다

var qe QueryError // 포인터가 아니다
errors.As(err, &qe) // *QueryError를 반환했다면 매칭되지 않는다

var s string
errors.As(err, &s) // panic: errors: *target must be interface or implement error

두 번째는 런타임 패닉이다. errors.AsType을 쓰면 컴파일러가 잡는다.

6. 에러 메시지에 민감한 정보를 넣는다

토큰, 비밀번호, 개인정보가 에러 메시지로 로그에 남고 응답으로 나간다. 감쌀 때 무엇을 붙이는지 항상 본다.

정리

  • %w는 문자열을 합치면서 원본 에러를 보존한다. %v는 출력이 같지만 체인을 끊는다.
  • 체인은 Unwrap() error로 이어진 연결 리스트다. 커스텀 타입에 Err 필드가 있으면 Unwrap을 반드시 붙인다.
  • 종류를 물을 때는 errors.Is, ==가 아니다.
  • 값을 꺼낼 때는 errors.AsType[T](err). errors.As는 기존 코드를 읽기 위한 것이다.
  • 센티널이 기본, 필드가 필요하면 커스텀 타입. 공개 센티널은 영구 계약이다.
  • errors.Join은 여러 에러를 모은다. nil을 걸러 내고, 트리(Unwrap() []error)를 만든다.
  • 감싸는 것은 각 계층이 자기 맥락만. 로깅은 최상위에서 한 번.
  • 로그에는 전체 체인을, 사용자에게는 번역된 메시지를. 모르는 에러는 500이다.

연습문제

  1. QueryErrorUnwrap 메서드를 지우고 07-wrapping을 다시 돌려 보자. errors.Is(err, ErrNotFound)가 어떻게 되는가? 출력 메시지는 바뀌는가? 이 실수를 컴파일 시점에 막을 방법이 있는가?

  2. 07-layersrepo에 "연결 실패"를 흉내 내는 경로를 추가하고, 그 에러는 ErrUserNotFoundErrForbidden도 아니게 만들어 보자. handledefault 가지가 500을 주는지 확인하고, 로그에는 원인이 남고 응답에는 안 남는지 비교해 보자.

  3. errors.Join으로 모은 에러에서 errBadEmail에 해당하는 에러만 꺼내 그 메시지를 출력해 보자. errors.Is는 "있는가"만 알려 준다. 힌트: Unwrap() []error를 직접 순회하거나, 커스텀 타입으로 감싸 errors.AsType으로 찾는 두 가지 방법이 있다. 어느 쪽이 나은가?