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를 반환합니다:
==로 비교할 때target과 같거나,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 에러로 번역하고, 에러 메시지 문자열 대신 에러 정체성에 기반한 테스트를 작성하세요.