Go에서 에러 처리: errors.Is, errors.As 그리고 에러 래핑

1. 개요

Go에서 에러는 일반적인 값입니다. 함수는 에러를 호출자에게 반환하고, 호출자가 직접 처리 방법을 결정합니다. 에러가 저장소, 서비스, 핸들러 등 여러 계층을 지날 때, 보통 맥락을 추가하면서도 원래 에러를 인식하고 싶어합니다.

이 글은 fmt.Errorf와 %w로 에러를 래핑하고, errors.Is로 에러를 검사하고, errors.As로 커스텀 에러를 가져오고, errors.Join으로 여러 에러를 결합하는 방법을 설명합니다. 글 끝에는 테스트와 흔한 실수들이 있습니다. 모든 예제는 표준 라이브러리만 사용하고 Go 1.20 이상에서 실행됩니다. errors.AsType은 Go 1.26이 필요합니다.

2. Sentinel error와 == 비교의 한계

Go에서 error는 아주 작은 인터페이스입니다:

type error interface {
	Error() string
}

“이름이 있는” 에러를 정의하는 가장 단순한 방법은 sentinel error를 만드는 것입니다. 이는 호출자가 직접 비교할 수 있는 패키지 수준의 변수입니다:

package main

import (
	"errors"
	"fmt"
)

var ErrOrderNotFound = errors.New("order not found")

func findOrder(id int) error {
	if id != 42 {
		return ErrOrderNotFound
	}
	return nil
}

func main() {
	err := findOrder(7)
	if err == ErrOrderNotFound {
		fmt.Println("주문을 찾을 수 없음")
	}
}
주문을 찾을 수 없음

errors.New는 새로운 포인터를 만듭니다. 따라서 err == ErrOrderNotFound는 err가 정확히 그 변수일 때만 참입니다. 이 방식은 에러에 맥락을 추가하기 시작할 때까지 잘 작동합니다.

3. fmt.Errorf와 %w로 에러 래핑

3.1. %w와 %v의 차이

서비스 계층이 주문 ID를 에러 메시지에 추가하고 싶다고 가정합시다:

func loadOrder(id int) error {
	if err := findOrder(id); err != nil {
		return fmt.Errorf("loadOrder id=%d: %w", id, err)
	}
	return nil
}

func main() {
	err := loadOrder(7)
	fmt.Println(err)
	fmt.Println(err == ErrOrderNotFound)
	fmt.Println(errors.Is(err, ErrOrderNotFound))
	fmt.Println(errors.Unwrap(err) == ErrOrderNotFound)
}
loadOrder id=7: order not found
false
true
true

각 줄을 설명하면:

  • fmt.Errorf(... %w ...) 새로운 에러를 만듭니다. 이 에러는 Unwrap() error 메서드를 통해 원래 err에 대한 참조를 유지합니다.
  • err == ErrOrderNotFound는 false를 반환합니다. err는 이제 다른 값, 즉 래핑된 에러입니다.
  • errors.Is는 래핑 체인을 탐색하고 ErrOrderNotFound를 찾습니다.
  • errors.Unwrap은 정확히 한 층을 벗기고 원래 에러를 반환합니다.

%w를 %v로 바꾸면 인쇄된 메시지는 동일합니다. 하지만 errors.Is는 false를 반환할 것입니다. %v는 텍스트 문자열만 복사하고 원래 에러를 유지하지 않기 때문입니다.

3.2. 여러 계층의 래핑 체인

각 계층이 다시 한 층을 더할 수 있습니다. errors.Is는 원래 에러가 얼마나 깊이 있든 찾을 수 있습니다:

func handleGetOrder(id int) error {
	if err := loadOrder(id); err != nil {
		return fmt.Errorf("GET /orders/%d: %w", id, err)
	}
	return nil
}

// fmt.Println(handleGetOrder(7))
// fmt.Println(errors.Is(handleGetOrder(7), ErrOrderNotFound))
GET /orders/7: loadOrder id=7: order not found
true

이제 에러 메시지는 축약된 스택 트레이스처럼 읽힙니다: 가장 바깥쪽 계층이 왼쪽에, 근본 원인이 오른쪽에 있습니다.

4. errors.Is: “이 에러가 X인가?“를 확인

errors.Is(err, target)은 다음 두 조건 중 하나를 만족하면 true를 반환합니다:

  1. ==로 비교할 때 target과 같거나,
  2. Is(error) bool 메서드를 가지고 있고, 그 메서드가 target에 대해 true를 반환합니다.

두 번째 조건은 에러 타입이 자신이 일반 sentinel과 “동등하다”고 선언할 수 있게 합니다. 예를 들어, 일반적인 ErrNotFound 에러와 고객을 위한 더 구체적인 에러 타입이 있다면:

var ErrNotFound = errors.New("not found")

type CustomerNotFoundError struct {
	CustomerID string
}

func (e CustomerNotFoundError) Error() string {
	return "customer " + e.CustomerID + " not found"
}

func (e CustomerNotFoundError) Is(target error) bool {
	return target == ErrNotFound
}

func main() {
	err := fmt.Errorf("checkout: %w", CustomerNotFoundError{CustomerID: "C-001"})
	fmt.Println(errors.Is(err, ErrNotFound))
}
true

HTTP 핸들러는 errors.Is(err, ErrNotFound)만 확인해서 404를 반환하면 됩니다. 에러가 주문, 고객, 상품 중 어디에서 왔는지 알 필요가 없습니다.

참고: Is 메서드 내에서 Unwrap을 호출하지 마세요. errors.Is가 이미 체인을 탐색하기 때문입니다. Is 메서드는 현재 에러 클래스에만 답변해야 합니다.

5. errors.As: 특정 타입의 에러 추출

Sentinel은 에러가 무엇인지만 알려줍니다. 에러 코드나 ID 같은 추가 데이터가 필요하면, 커스텀 에러 타입을 만들고 errors.As로 추출합니다.

5.1. Unwrap을 가진 커스텀 에러 타입

var ErrInsufficientFunds = errors.New("insufficient funds")

type PaymentError struct {
	OrderID int
	Code    string
	Err     error
}

func (e *PaymentError) Error() string {
	return fmt.Sprintf("payment failed for order %d (%s): %v", e.OrderID, e.Code, e.Err)
}

func (e *PaymentError) Unwrap() error { return e.Err }

type PaymentService struct{ balance int }

func (s *PaymentService) Charge(orderID, amount int) error {
	if amount > s.balance {
		return &PaymentError{OrderID: orderID, Code: "PAY-402", Err: ErrInsufficientFunds}
	}
	s.balance -= amount
	return nil
}

func checkout(s *PaymentService, orderID, amount int) error {
	if err := s.Charge(orderID, amount); err != nil {
		return fmt.Errorf("checkout: %w", err)
	}
	return nil
}

호출자는 다음과 같이 처리합니다:

func main() {
	svc := &PaymentService{balance: 100}
	err := checkout(svc, 42, 500)

	var pe *PaymentError
	if errors.As(err, &pe) {
		fmt.Println("code:", pe.Code, "order:", pe.OrderID)
	}
	fmt.Println(errors.Is(err, ErrInsufficientFunds))
	fmt.Println(err)
}
code: PAY-402 order: 42
true
checkout: payment failed for order 42 (PAY-402): insufficient funds

기억해야 할 점들:

  • errors.As는 래핑 체인을 탐색합니다. *PaymentError 타입에 할당할 수 있는 첫 번째 에러를 찾으면, 그 에러를 pe에 할당하고 true를 반환합니다.
  • PaymentError가 Unwrap()을 가지고 있기 때문에, errors.Is(err, ErrInsufficientFunds)는 여전히 Err 필드를 통해 들어갈 수 있습니다.
  • 두 번째 매개변수는 대상 변수에 대한 포인터여야 합니다. 여기서는 &pe이고, 타입은 **PaymentError입니다.

5.2. 포인터 리시버의 함정

Error() 메서드가 *PaymentError에서 선언되었으므로, *PaymentError만 error를 구현합니다. 다음과 같이 쓰는 것은 잘못입니다:

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

go vet는 빌드할 때 이 에러를 잡습니다. 쉬운 규칙은: 대상 변수의 타입이 정확히 반환한 타입과 같아야 한다는 것입니다. &PaymentError{...}를 반환했으면 대상 변수는 *PaymentError여야 합니다.

5.3. errors.AsType (Go 1.26+)

Go 1.26부터, 표준 라이브러리에 더 간결한 제네릭 버전이 있습니다. 미리 변수를 선언할 필요가 없습니다:

if pe, ok := errors.AsType[*PaymentError](err); ok {
	fmt.Println("code:", pe.Code)
}

이 방식은 컴파일 타임에 타입이 안전하므로, 섹션 5.2처럼 panic이 발생할 수 없습니다. 프로젝트가 이미 Go 1.26이면, 이것을 사용해야 합니다.

6. errors.Join으로 여러 에러 결합

Go 1.20부터, 하나의 에러가 여러 자식 에러를 래핑할 수 있습니다. 전형적인 경우는 데이터 검증인데, 첫 번째 에러에서 멈추는 대신 모든 에러를 한 번에 보고하고 싶을 때입니다:

var (
	ErrEmptyName    = errors.New("name is empty")
	ErrInvalidEmail = errors.New("email is invalid")
)

type Customer struct {
	Name  string
	Email string
}

func validateCustomer(c Customer) error {
	var errs []error
	if c.Name == "" {
		errs = append(errs, ErrEmptyName)
	}
	if !strings.Contains(c.Email, "@") {
		errs = append(errs, ErrInvalidEmail)
	}
	return errors.Join(errs...)
}

func main() {
	err := validateCustomer(Customer{Name: "", Email: "abc"})
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrEmptyName), errors.Is(err, ErrInvalidEmail))
	fmt.Println(validateCustomer(Customer{Name: "An", Email: "an@shop.vn"}) == nil)
}
name is empty
email is invalid
true true
true

import에 "strings"를 추가해야 합니다. 결과는 세 가지를 보여줍니다:

  • errors.Join은 메시지를 줄 바꿈으로 연결합니다.
  • errors.Is와 errors.As는 각 자식 에러를 검사할 수 있습니다.
  • 모든 인수가 nil이거나 리스트가 비어있으면 errors.Join은 nil을 반환합니다. 따라서 if len(errs) == 0을 추가로 쓸 필요가 없습니다.

fmt.Errorf도 같은 포맷 문자열에서 여러 %w를 받을 수 있습니다. 예를 들어 fmt.Errorf("sync order: %w; %w", errDB, errCache). 생성된 에러는 두 에러를 모두 래핑합니다.

7. 에러 동작에 대한 테스트 작성

테스트할 때, errors.Is 또는 errors.As로 에러의 정체성을 확인해야 합니다. 에러 메시지 문자열을 비교하지 마세요. 다음은 checkout에 대한 테이블 기반 테스트입니다. checkout_test.go 파일에 넣으세요:

package main

import (
	"errors"
	"testing"
)

func TestCheckout(t *testing.T) {
	tests := []struct {
		name     string
		balance  int
		amount   int
		wantErr  error
		wantCode string
	}{
		{name: "충분한 잔액", balance: 1000, amount: 500},
		{name: "잔액 부족", balance: 100, amount: 500,
			wantErr: ErrInsufficientFunds, wantCode: "PAY-402"},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := checkout(&PaymentService{balance: tc.balance}, 42, tc.amount)

			if tc.wantErr == nil {
				if err != nil {
					t.Fatalf("에러를 예상하지 않았는데 받음: %v", err)
				}
				return
			}
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("errors.Is(%v, %v) = false", err, tc.wantErr)
			}
			var pe *PaymentError
			if !errors.As(err, &pe) {
				t.Fatalf("에러가 *PaymentError를 포함하지 않음: %v", err)
			}
			if pe.Code != tc.wantCode {
				t.Errorf("code = %q, 원함 %q", pe.Code, tc.wantCode)
			}
		})
	}
}

테스트 실행:

go test -v -run TestCheckout ./...
=== RUN   TestCheckout
=== RUN   TestCheckout/충분한_잔액
=== RUN   TestCheckout/잔액_부족
--- PASS: TestCheckout (0.00s)
    --- PASS: TestCheckout/충분한_잔액 (0.00s)
    --- PASS: TestCheckout/잔액_부족 (0.00s)
PASS

나중에 누군가 에러 메시지를 수정하거나 래핑 계층을 추가해도 이 테스트는 여전히 올바르게 실행됩니다. 문자열 대신 정체성을 확인하는 이유입니다.

8. 흔한 에러와 래핑하면 안 될 때

8.1. 문자열 비교 err.Error()

if strings.Contains(err.Error(), "not found") { ... } // 부서지기 쉬움

에러 메시지를 한 번 수정하거나, 다른 에러가 우연히 “not found”를 포함하면 로직이 깨집니다. Sentinel과 함께 errors.Is를 사용하세요.

8.2. 타입된 nil

func (s *PaymentService) Refund(orderID int) error {
	var pe *PaymentError // nil
	// ... 에러 없음 ...
	return pe // nil이 아닙니다!
}

error 인터페이스가 *PaymentError 타입과 nil 값을 포함합니다. 인터페이스가 이미 타입을 가지고 있으므로, err != nil은 여전히 true를 반환합니다. 에러가 없으면 항상 명시적으로 return nil을 쓰세요.

8.3. 래핑이 내부 세부 사항 노출

sql.ErrNoRows를 %w로 래핑하면, 패키지가 암묵적으로 약속합니다. 호출자가 errors.Is(err, sql.ErrNoRows)를 확인할 수 있습니다. 그 에러는 공개 API의 일부가 됩니다. 나중에 MongoDB로 전환하면 호출자의 코드가 깨집니다.

패키지 경계에서 더 나은 두 가지 선택지가 있습니다:

if errors.Is(err, sql.ErrNoRows) {
	return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // domain 에러로 번역
}
return fmt.Errorf("find order %d: %v", id, err) // 메시지만 유지, 원래 에러 숨김

참고: %w는 API 설계에 대한 결정이지, 형식 지정만 있는 게 아닙니다. 호출자가 원래 에러를 검사하도록 하고 싶을 때만 래핑하세요.

8.4. 동시에 log와 return

if err != nil {
	log.Printf("charge failed: %v", err)
	return fmt.Errorf("charge: %w", err)
}

모든 계층에서 이렇게 하면 한 에러가 로그에 여러 번 나타납니다. 일반적인 규칙은 처리(로그) 또는 반환, 둘 다 하지 말라는 것입니다. 로깅은 가장 바깥쪽 계층, 예를 들어 HTTP 핸들러나 main 함수가 담당해야 합니다.

8.5. 반복되는 맥락, 부족한 정보

failed to load order: failed to query: failed to ... 같은 메시지는 길지만 별로 도움이 되지 않습니다. 작업 이름과 중요한 데이터, 예를 들어 loadOrder id=7: ...을 기록하세요. “failed”를 빼세요. 에러인데 실패했다는 건 당연합니다.

8.6. errors.As를 사용하면 안 될 때

에러가 어떤 종류인지만 알면 되면, sentinel과 함께 errors.Is로 충분하고 더 간단합니다. 호출자가 에러 코드, 잘못된 필드 또는 재시도 시간 같은 데이터가 실제로 필요할 때만 커스텀 에러 타입을 만드세요.

9. 결론

Go의 에러 처리 도구 세트를 경험했습니다:

  • fmt.Errorf에서 %w를 사용하면 맥락을 추가하면서 원래 에러를 유지합니다. %v는 텍스트만 유지합니다.
  • errors.Is는 에러가 sentinel과 일치하는지 확인하고, 래핑 체인 전체에 적용됩니다. 에러 타입은 Is 메서드를 정의해서 동등성을 선언할 수 있습니다.
  • errors.As (또는 Go 1.26의 errors.AsType)는 특정 타입의 에러를 가져옵니다. 대상 변수의 타입이 반환한 타입과 정확히 일치해야 합니다.
  • errors.Join과 여러 %w는 하나의 에러가 여러 자식 에러를 래핑할 수 있게 합니다.

마지막으로, 래핑을 API 설계의 일부로 생각하세요. 의도를 가지고 래핑하고, 패키지 경계에서 하부 구조 에러를 domain 에러로 번역하고, 에러 메시지 문자열 대신 에러 정체성에 기반한 테스트를 작성하세요.

golangerror handlingerrorsbest practices