고 중급 #2 에러 처리 패턴

6 분 소요

기초 #4에서 error의 기본을 봤다면, 이번 글은 그 위에 실전 패턴을 얹습니다 — wrapping, 검사, 커스텀 타입, 그리고 panic이 어울리는 경우까지 정리합니다.

에러 wrapping — %w #

fmt.Errorf%w verb가 핵심입니다. 원래 에러를 안에 끼워넣은 새 에러를 만듭니다.

wrapping 기본
func readConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("config 읽기 실패 (%s): %w", path, err)
	}
	// ...
	return nil
}

이 새 에러는:

  • 메시지가 더 자세해짐 (config 읽기 실패 (...): open ...: no such file...)
  • 원래 에러를 안에 들고 있음errors.Is/errors.As로 꺼낼 수 있음

%v vs %w 차이 #

%v vs %w
err1 := fmt.Errorf("실패: %v", origErr)   // 메시지만 합침 — 원래 에러 정보 손실
err2 := fmt.Errorf("실패: %w", origErr)   // 원래 에러 보존 + 메시지

%v는 단순 문자열 합치기. %w는 안에 원래 에러를 보존. **에러를 위로 흘릴 때는 거의 항상 %w**를 씁니다.

errors.Is — sentinel 에러 비교 #

미리 정의된 에러와 비교할 때.

errors.Is
import "errors"

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

func lookup(key string) (string, error) {
	if key == "" {
		return "", fmt.Errorf("lookup: %w", ErrNotFound)
	}
	return "value", nil
}

func main() {
	_, err := lookup("")
	if errors.Is(err, ErrNotFound) {
		fmt.Println("못 찾음")
	}
}

errors.Is에러 체인을 따라가며 비교합니다. wrapping된 에러 안의 원본 에러까지 검사합니다.

err == ErrNotFound와 차이가 뭐냐면 — 단순 ==는 wrap 된 에러 안을 못 봅니다. %w로 감싼 경우에는 errors.Is가 정답입니다.

errors.As — 커스텀 에러 타입 꺼내기 #

에러가 특정 타입의 인스턴스인지 확인하고, 맞으면 그 타입으로 변수에 담습니다.

커스텀 에러 타입
type ValidationError struct {
	Field   string
	Message string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
errors.As로 꺼내기
func processForm(form Form) error {
	if form.Email == "" {
		return &ValidationError{Field: "email", Message: "필수입니다"}
	}
	return nil
}

func main() {
	err := processForm(form)

	var verr *ValidationError
	if errors.As(err, &verr) {
		fmt.Printf("필드 %s 검증 실패: %s\n", verr.Field, verr.Message)
	}
}

errors.As(err, &verr)가 핵심:

  • 첫 인자 — 검사할 에러
  • 두 번째 인자 — 결과를 받을 포인터의 포인터

체인을 따라 그 타입의 에러를 찾으면 — 그 값을 verr에 담고 true를 반환. 그 뒤로 verr의 필드(Field, Message)에 접근 가능합니다.

커스텀 에러 타입 — 언제 만드나 #

다음 경우에 어울립니다.

  1. 추가 정보가 필요할 때 — Field, StatusCode, Retryable 같은 메타데이터
  2. 호출자가 다른 처리를 해야 할 때 — 검증 에러는 사용자에게 보여주고, DB 에러는 로깅하고 일반 메시지
HTTP 에러 패턴
type HTTPError struct {
	StatusCode int
	Message    string
	URL        string
}

func (e *HTTPError) Error() string {
	return fmt.Sprintf("HTTP %d %s (%s)", e.StatusCode, e.Message, e.URL)
}

// 호출자
var httpErr *HTTPError
if errors.As(err, &httpErr) {
	if httpErr.StatusCode == 404 {
		// 다른 처리
	}
}

Sentinel 에러 — 단순 비교 #

값으로 정의된 에러 (메타데이터 없음).

sentinel 에러
var (
	ErrNotFound      = errors.New("not found")
	ErrPermission    = errors.New("permission denied")
	ErrAlreadyExists = errors.New("already exists")
)

if errors.Is(err, ErrNotFound) {
	// ...
}

표준 라이브러리에서 자주 보이는 패턴 — io.EOF, sql.ErrNoRows 등.

Sentinel vs 커스텀 타입 — 어떤 걸 언제 #

sentinel커스텀 타입
추가 정보 필요없음있음
비교 방법errors.Iserrors.As
정의 짧음필드 정의 필요
외부 노출 시 호환단순인터페이스/타입 노출

단순한 분기에는 sentinel, 메타데이터가 필요하면 커스텀 타입.

에러를 그대로 반환할까, wrap 할까 #

가이드:

옳은 패턴
// 에러를 위로 그대로 → 일단 wrap (어디서 발생했는지 단서 추가)
data, err := os.ReadFile(path)
if err != nil {
	return fmt.Errorf("config 읽기: %w", err)
}

// 또는 — 호출자에게 명확한 메시지면 그대로
if err != nil {
	return err
}

자주 쓰는 가이드 — 계층 경계에서는 wrap, 같은 계층 안에서는 그대로 흘려도 OK.

과한 wrap — 회피
// 안 좋음 — 너무 많은 곳에서 wrap
return fmt.Errorf("foo: %w", err)
return fmt.Errorf("bar: %w", err)
return fmt.Errorf("baz: %w", err)

// 결과: foo: bar: baz: 진짜 에러

매번 wrap 하면 메시지가 길어지고 의미가 흐릿해집니다. 의미 있는 경계(함수 진입, 패키지 경계)에서만 wrap 하는 게 깔끔합니다.

에러 비교의 함정 — == vs errors.Is #

== 의 한계
var ErrFoo = errors.New("foo")

func wrap() error {
	return fmt.Errorf("wrap: %w", ErrFoo)
}

err := wrap()

err == ErrFoo                // false ← wrap 됐으니
errors.Is(err, ErrFoo)        // true  ← 체인 검사

기초에서 if err != nil만 쓸 때는 단순 비교로 충분했지만, sentinel 에러를 검사할 때는 거의 항상 errors.Is. 이게 모던 표준입니다.

errors.Join — 여러 에러 묶기 #

Go 1.20+ 의 도구. 여러 에러가 동시에 발생했을 때.

여러 에러
import "errors"

func validate(form Form) error {
	var errs []error
	if form.Name == "" {
		errs = append(errs, fmt.Errorf("name 누락"))
	}
	if form.Email == "" {
		errs = append(errs, fmt.Errorf("email 누락"))
	}
	if len(form.Password) < 8 {
		errs = append(errs, fmt.Errorf("password 8자 이상"))
	}
	return errors.Join(errs...)
}

err := validate(form)
if err != nil {
	fmt.Println(err)   // 모든 에러가 줄바꿈으로 합쳐져 출력
}

errors.Join은 nil 인 에러를 자동으로 거르고, 모두 nil이면 nil을 반환합니다.

errors.Is와도 작동합니다 — 묶음 안에 ErrFoo가 있으면 errors.Is(joined, ErrFoo)가 true.

Panic — 정말 비정상적인 상황에만 #

panic은 다른 언어의 throw와 비슷해 보이지만 — Go에서는 거의 안 씁니다.

panic
func divide(a, b int) int {
	if b == 0 {
		panic("0으로 나누기")
	}
	return a / b
}

panic이 일어나면:

  1. 함수가 즉시 종료
  2. defer 들이 실행 (LIFO)
  3. 호출자도 종료, 또 그 호출자… 결국 프로그램 전체 종료

Go의 컨벤션 — 에러는 거의 항상 error 반환. panic은 다음 경우에만 어울립니다.

  • 프로그램이 계속 실행되면 안 되는 정말 잘못된 상태 — 메모리 부족, 가정 위반
  • 자기 패키지 안에서만 — 외부 호출자에게는 노출하지 않음 (recover로 잡아 error로 변환)

recover — panic에서 살아남기 #

defer 함수 안에서 recover를 부르면 panic을 잡을 수 있습니다.

recover
func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("panic: %v", r)
		}
	}()

	return divide(a, b), nil
}

recover()는 panic이 진행 중이면 그 값을 돌려주고 panic을 멈춥니다. defer 함수 안에서만 의미가 있습니다.

라이브러리 경계에서 — 내부 panic이 외부로 새지 않게 하는 경우에 어울립니다. 일반 앱 코드에는 거의 안 만나는 도구입니다.

errors.Unwrap — 직접 풀어보기 #

가끔 wrap 된 에러를 한 단계만 풀어 보고 싶을 때 사용합니다.

Unwrap
err := fmt.Errorf("outer: %w", fmt.Errorf("middle: %w", io.EOF))

inner := errors.Unwrap(err)         // middle: EOF
inner2 := errors.Unwrap(inner)      // EOF
inner3 := errors.Unwrap(inner2)     // nil (더 안에 없음)

직접 쓸 일은 적습니다 — errors.Is/As가 내부에서 알아서 해줍니다. 디버깅이나 특수한 경우에서만.

표준 라이브러리에서 자주 만나는 에러들 #

표준 sentinel 에러
io.EOF                   // 입력 끝
io.ErrUnexpectedEOF      // 예상치 못한 끝
sql.ErrNoRows            // SQL 결과 없음
context.Canceled         // 컨텍스트 취소
context.DeadlineExceeded // 타임아웃
os.ErrNotExist           // 파일 없음
fs.ErrPermission         // 권한 없음

이걸 만나면 errors.Is(err, io.EOF) 같은 식으로 검사. 표준 라이브러리 함수의 에러는 보통 이런 sentinel 들을 wrap 해서 돌려줍니다.

자주 쓰는 패턴들 #

1) 함수 진입에 컨텍스트 wrap #

진입에 wrap
func processOrder(orderID string) error {
	if err := validateOrder(orderID); err != nil {
		return fmt.Errorf("processOrder: %w", err)
	}
	if err := saveOrder(orderID); err != nil {
		return fmt.Errorf("processOrder: %w", err)
	}
	return nil
}

함수 이름을 prefix로 — 호출 스택을 추적하기 좋게.

2) 즉시 반환 (early return) #

early return
data, err := fetch()
if err != nil { return err }

parsed, err := parse(data)
if err != nil { return err }

if err := save(parsed); err != nil { return err }

깊은 if가 아니라 평평하게. Go 코드의 거의 표준 모양입니다.

3) 일부 에러는 무시 #

의도적 무시
defer file.Close()    // 닫기 실패는 보통 무시 (성공해도 큰 의미 없음)

f.WriteString("hi")   // 반환 에러 무시 (사소한 경우)

_ = ...로 명시적 무시도 가능 (linter 경고 회피).

명시적 무시
_ = file.Close()

마무리 #

이번 글에서 정리한 내용:

  • %w로 에러 wrapping — 원래 에러 보존
  • errors.Is로 sentinel 에러 비교 (체인 검사)
  • errors.As로 커스텀 타입 꺼내기
  • 추가 정보 필요하면 커스텀 타입, 단순 분기는 sentinel
  • 함수/패키지 경계에서 wrap, 매번 과한 wrap 회피
  • errors.Join으로 여러 에러 묶기 (Go 1.20+)
  • panic은 정말 비정상적 상황만 — 일반 에러는 error 반환
  • recover는 라이브러리 경계 정도

다음 글(#3 고루틴과 채널 입문)에서는 Go의 가장 강력한 무기 — 동시성. 가벼운 고루틴과 채널을 처음부터 정리합니다.

X