고 중급 #2 에러 처리 패턴
기초 #4에서 error의 기본을 봤다면, 이번 글은 그 위에 실전 패턴을 얹습니다 — wrapping, 검사, 커스텀 타입, 그리고 panic이 어울리는 경우까지 정리합니다.
에러 wrapping — %w
#
fmt.Errorf의 %w verb가 핵심입니다. 원래 에러를 안에 끼워넣은 새 에러를 만듭니다.
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 차이
#
err1 := fmt.Errorf("실패: %v", origErr) // 메시지만 합침 — 원래 에러 정보 손실
err2 := fmt.Errorf("실패: %w", origErr) // 원래 에러 보존 + 메시지%v는 단순 문자열 합치기. %w는 안에 원래 에러를 보존. **에러를 위로 흘릴 때는 거의 항상 %w**를 씁니다.
errors.Is — sentinel 에러 비교
#
미리 정의된 에러와 비교할 때.
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)
}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)에 접근 가능합니다.
커스텀 에러 타입 — 언제 만드나 #
다음 경우에 어울립니다.
- 추가 정보가 필요할 때 — Field, StatusCode, Retryable 같은 메타데이터
- 호출자가 다른 처리를 해야 할 때 — 검증 에러는 사용자에게 보여주고, DB 에러는 로깅하고 일반 메시지
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 에러 — 단순 비교 #
값으로 정의된 에러 (메타데이터 없음).
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.Is | errors.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
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에서는 거의 안 씁니다.
func divide(a, b int) int {
if b == 0 {
panic("0으로 나누기")
}
return a / b
}panic이 일어나면:
- 함수가 즉시 종료
- defer 들이 실행 (LIFO)
- 호출자도 종료, 또 그 호출자… 결국 프로그램 전체 종료
Go의 컨벤션 — 에러는 거의 항상 error 반환. panic은 다음 경우에만 어울립니다.
- 프로그램이 계속 실행되면 안 되는 정말 잘못된 상태 — 메모리 부족, 가정 위반
- 자기 패키지 안에서만 — 외부 호출자에게는 노출하지 않음 (recover로 잡아 error로 변환)
recover — panic에서 살아남기
#
defer 함수 안에서 recover를 부르면 panic을 잡을 수 있습니다.
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 된 에러를 한 단계만 풀어 보고 싶을 때 사용합니다.
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가 내부에서 알아서 해줍니다. 디버깅이나 특수한 경우에서만.
표준 라이브러리에서 자주 만나는 에러들 #
io.EOF // 입력 끝
io.ErrUnexpectedEOF // 예상치 못한 끝
sql.ErrNoRows // SQL 결과 없음
context.Canceled // 컨텍스트 취소
context.DeadlineExceeded // 타임아웃
os.ErrNotExist // 파일 없음
fs.ErrPermission // 권한 없음이걸 만나면 errors.Is(err, io.EOF) 같은 식으로 검사. 표준 라이브러리 함수의 에러는 보통 이런 sentinel 들을 wrap 해서 돌려줍니다.
자주 쓰는 패턴들 #
1) 함수 진입에 컨텍스트 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) #
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의 가장 강력한 무기 — 동시성. 가벼운 고루틴과 채널을 처음부터 정리합니다.