Devin.KR

오류 처리 - 오류도 값이다

개발자KR 조회 0

이 장에서 배우는 것

프로그램은 실행 중에 자주 실패한다. 빌리려는 자전거가 없을 수도 있고, 회원 번호가 틀렸을 수도 있고, 잔액이 모자랄 수도 있다. Go 는 이런 실패를 예외(exception)라는 별도 장치로 던지지 않는다. 실패를 값으로 만들어 함수의 반환값으로 돌려준다. 이 장에서는 그 값을 만들고, 감싸고, 나중에 원인을 알아내는 방법을 대여소 프로그램에 적용해 본다. 마지막에는 값으로 처리하지 않고 패닉(panic)을 쓰는 드문 경우도 다룬다.

  • error 를 마지막 반환값으로 돌려주고 호출한 쪽에서 곧바로 검사하는 관례를 따를 수 있다.
  • fmt.Errorf 의 %w 로 오류에 맥락을 덧붙이면서 원래 오류를 보존할 수 있다.
  • errors.Is 와 errors.As 로 감싼 오류의 원인을 알아낼 수 있다.
  • 필드를 가진 사용자 정의 오류 타입을 만들 수 있다.
  • panic 과 recover 를 써야 하는 경우와 쓰지 말아야 하는 경우를 구분할 수 있다.

문제 상황

동네 대여소 프로그램이 커지면서 실패하는 경로가 늘었다. 지금까지는 실패하면 false 를 돌려주거나 화면에 문장을 찍고 넘어갔다. 그러자 운영자가 이렇게 요청했다.

  • 자전거가 없으면 이웃 대여소를 안내하고, 잔액이 모자라면 부족한 금액을 알려 주고, 없는 회원이면 가입을 안내해 달라.
  • 로그에는 "무엇을 하다가" 실패했는지도 남겨 달라.

실패의 종류마다 대응이 다르므로 호출한 쪽이 실패의 종류를 구별할 수 있어야 한다. 동시에 로그에는 맥락이 들어가야 한다. 문자열 하나만 돌려주면 이 두 가지를 함께 만족시킬 수 없다. 로그용 문장을 바꾸는 순간 종류를 가르던 문자열 비교가 깨지기 때문이다. Go 의 오류 처리는 바로 이 문제를 풀기 위해 설계되었다.

error 는 값이다

error 인터페이스와 반환 관례

앞 장에서 인터페이스는 메서드 묶음이고, 그 메서드를 가진 타입이면 선언 없이 만족한다고 배웠다. error 는 언어에 내장된 인터페이스이며 정의는 다음과 같다.

type error interface {
	Error() string
}

Error() string 메서드만 있으면 어떤 타입이든 오류가 된다. 오류가 없다는 것은 nil 로 표현한다. 그래서 실패할 수 있는 함수는 마지막 반환값으로 error 를 돌려주고, 호출한 쪽은 바로 아래에서 err != nil 을 검사한다. 이 모양이 Go 코드에서 가장 자주 보이는 패턴이다.

charge, err := st.Return("kim", 25)
if err != nil {
	return err
}

오류가 났을 때 함께 돌려주는 다른 값은 의미가 없으므로 제로값(0 등)을 돌려주는 것이 관례다. 호출한 쪽도 err 가 nil 이 아니면 다른 반환값을 쓰지 않는다.

센티널 오류와 사용자 정의 오류

errors.New 로 만든 오류를 패키지 수준 변수에 담아 두고 "이 변수와 같은 오류"로 종류를 표현하는 방식을 센티널(sentinel, 보초) 오류라고 한다. 이 장의 ErrNoBike 가 그 예다. 이름은 관례상 Err 로 시작한다.

종류만으로는 부족하고 값을 함께 전해야 할 때는 구조체로 오류 타입을 직접 만든다. 잔액 부족은 "얼마가 필요한데 얼마가 있는지"를 알려야 하므로 BalanceError 를 만든다. 타입에 Error() string 메서드를 붙이면 그 타입은 error 인터페이스를 만족한다. 포인터 리시버로 만들었으므로 *BalanceError 가 오류로 쓰인다.

%w 로 감싸기

오류가 여러 계층을 거쳐 올라오는 동안 각 계층은 "무엇을 하다가 실패했는지"를 덧붙이고 싶어 한다. fmt.Errorf 에 %w 동사를 쓰면 문장을 덧붙이면서 원래 오류를 안쪽에 보존한다.

return fmt.Errorf("대여 %s: %w", member, ErrNoBike)

결과 오류의 Error() 문자열은 "대여 choi: 대여 가능한 자전거가 없다"가 된다. 동시에 이 오류는 안쪽에 ErrNoBike 를 들고 있어서, 나중에 꺼내 볼 수 있다. %v 로 쓰면 문자열은 같지만 안쪽 오류를 들고 있지 않아 원인을 잃는다. 이 차이는 뒤의 "자주 틀리는 것"에서 다시 본다.

errors.Is 는 감싼 오류를 Unwrap 으로 한 겹씩 벗기며 대상 오류와 같은지 비교한다

errors.Is 와 errors.As

감싼 오류는 == 로 비교하면 같지 않다. 겉의 오류와 안쪽의 센티널은 서로 다른 값이기 때문이다. 그래서 errors 패키지의 두 함수를 쓴다. 둘 다 오류를 한 겹씩 벗기며(Unwrap) 찾는다.

  • errors.Is(err, ErrNoBike): 체인 어딘가에 ErrNoBike 와 같은 오류가 있는지 묻는다. 불리언을 돌려준다.
  • errors.As(err, &be): 체인 어딘가에 *BalanceError 타입이 있는지 묻고, 있으면 그 값을 be 에 담아 준다. 필드를 읽어야 할 때 쓴다.
오류를 검사하는 세 가지 도구는 묻는 것이 다르다
도구묻는 것감싼 오류쓰는 때
err == ErrX정확히 그 값인가벗기지 않는다감싸지 않은 오류를 바로 비교할 때
errors.Is체인에 그 값이 있는가끝까지 벗긴다센티널 오류로 종류를 가를 때
errors.As체인에 그 타입이 있는가끝까지 벗긴다오류 타입의 필드를 읽을 때

errors.As 의 두 번째 인자는 찾으려는 타입의 변수를 가리키는 포인터다. 이 장에서는 *BalanceError 를 찾으므로 var be *BalanceError 를 선언하고 &be 를 넘긴다. 포인터의 포인터라서 처음에는 낯설지만 "이 변수에 담아 달라"는 뜻으로 읽으면 된다.

여러 오류를 한꺼번에 모아야 할 때는 errors.Join 을 쓴다. 합쳐진 오류에 errors.Is 를 쓰면 안에 든 오류들을 모두 검사한다. 하루치 요청을 처리하고 실패를 모아서 보고할 때 편하다.

panic 과 recover 는 언제 쓰는가

패닉은 현재 함수의 실행을 멈추고, 실행 중이던 defer 함수들을 차례로 실행하면서 호출 스택을 거슬러 올라가는 동작이다. 어디에서도 멈추지 않으면 프로그램이 오류 메시지와 함께 종료된다. 슬라이스 범위를 벗어난 접근이나 nil 맵에 쓰기처럼 런타임이 스스로 일으키기도 하고, panic(값) 으로 직접 일으킬 수도 있다.

recover() 는 defer 로 실행되는 함수 안에서 호출했을 때만 패닉을 붙잡아 그 값을 돌려준다. 패닉이 없으면 nil 을 돌려준다. 붙잡으면 스택을 거슬러 올라가던 동작이 멈추고 프로그램은 계속된다.

오류는 반환값으로 한 단계씩 전달되고 패닉은 recover 를 만날 때까지 스택을 거슬러 올라간다

기준은 단순하다. 호출한 쪽이 대응할 수 있는 실패는 오류로 돌려준다. 프로그램 자체가 잘못되어 더 진행하는 것이 의미 없는 상황이면 패닉을 쓴다.

상황에 따라 오류와 패닉 중 무엇을 고를지 정한다
상황선택이유
잔액 부족, 없는 회원, 자전거 없음오류 반환정상적으로 일어나는 실패이고 호출한 쪽이 대응한다
사용자가 입력한 값이 잘못됨오류 반환프로그램의 잘못이 아니라 입력의 문제다
계산 중 음수 시간 같은 내부 모순패닉호출한 쪽 코드의 버그이므로 조용히 넘기면 잘못된 결과가 퍼진다
요청 하나의 패닉이 서버 전체를 끄면 안 될 때경계에서 recover요청 단위로 패닉을 오류로 바꿔 기록하고 계속 동작한다

마지막 줄처럼 recover 는 프로그램의 경계에서 한 번 쓰는 것이 적절하다. 이 장의 safely 함수가 그 역할이다. 함수마다 recover 를 흩뿌려 놓으면 버그가 조용히 묻힌다.

완성 코드

대여소 프로그램에 대여와 반납, 오류 분류를 넣었다. 파일 하나(main.go)이며 표준 라이브러리만 쓴다.

package main

import (
	"errors"
	"fmt"
	"sort"
)

const minDeposit = 1000

var (
	ErrNoBike        = errors.New("대여 가능한 자전거가 없다")
	ErrUnknownMember = errors.New("등록되지 않은 회원이다")
	ErrNotRented     = errors.New("대여 중이 아니다")
)

type BalanceError struct {
	Need int
	Have int
}

func (e *BalanceError) Error() string {
	return fmt.Sprintf("잔액 부족: %d원 필요, %d원 보유", e.Need, e.Have)
}

func (e *BalanceError) Shortage() int {
	return e.Need - e.Have
}

type Station struct {
	bikes   int
	balance map[string]int
	started map[string]int
}

func NewStation(bikes int, balance map[string]int) *Station {
	return &Station{bikes: bikes, balance: balance, started: make(map[string]int)}
}

func (s *Station) Rent(member string, now int) error {
	have, ok := s.balance[member]
	if !ok {
		return fmt.Errorf("대여 %s: %w", member, ErrUnknownMember)
	}
	if s.bikes == 0 {
		return fmt.Errorf("대여 %s: %w", member, ErrNoBike)
	}
	if have < minDeposit {
		return fmt.Errorf("대여 %s: %w", member, &BalanceError{Need: minDeposit, Have: have})
	}
	s.bikes--
	s.started[member] = now
	return nil
}

func fee(minutes int) int {
	if minutes < 0 {
		panic(fmt.Sprintf("fee: 이용 시간이 음수다 (%d)", minutes))
	}
	return (minutes + 9) / 10 * 500
}

func (s *Station) Return(member string, now int) (int, error) {
	start, ok := s.started[member]
	if !ok {
		return 0, fmt.Errorf("반납 %s: %w", member, ErrNotRented)
	}
	charge := fee(now - start)
	have := s.balance[member]
	if have < charge {
		return 0, fmt.Errorf("반납 %s: %w", member, &BalanceError{Need: charge, Have: have})
	}
	s.balance[member] = have - charge
	delete(s.started, member)
	s.bikes++
	return charge, nil
}

func describe(err error) string {
	var be *BalanceError
	switch {
	case err == nil:
		return "처리 완료"
	case errors.Is(err, ErrNoBike):
		return "빈 자전거 없음, 다른 대여소 안내"
	case errors.Is(err, ErrUnknownMember):
		return "가입 안내"
	case errors.Is(err, ErrNotRented):
		return "대여 기록 확인"
	case errors.As(err, &be):
		return fmt.Sprintf("충전 안내, %d원 부족", be.Shortage())
	default:
		return "원인 불명: " + err.Error()
	}
}

func safely(name string, f func()) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("%s: 패닉을 오류로 바꿨다: %v", name, r)
		}
	}()
	f()
	return nil
}

type request struct {
	member string
	at     int
}

func main() {
	st := NewStation(2, map[string]int{"kim": 3000, "lee": 500, "park": 2000, "choi": 5000})
	var all []error

	rents := []request{{"kim", 0}, {"lee", 0}, {"jung", 5}, {"park", 10}, {"choi", 12}}
	for _, r := range rents {
		err := st.Rent(r.member, r.at)
		label := fmt.Sprintf("대여 %s 성공", r.member)
		if err != nil {
			label = err.Error()
			all = append(all, err)
		}
		fmt.Printf("%s → %s\n", label, describe(err))
	}

	returns := []request{{"kim", 25}, {"park", 70}, {"jung", 30}}
	for _, r := range returns {
		charge, err := st.Return(r.member, r.at)
		label := fmt.Sprintf("반납 %s 성공 (%d원)", r.member, charge)
		if err != nil {
			label = err.Error()
			all = append(all, err)
		}
		fmt.Printf("%s → %s\n", label, describe(err))
	}

	joined := errors.Join(all...)
	fmt.Println("누적 오류:", len(all))
	fmt.Println("빈 자전거 오류 포함:", errors.Is(joined, ErrNoBike))

	err := safely("반납 park", func() { st.Return("park", 3) })
	fmt.Println(err)

	fmt.Printf("남은 자전거 %d대, 대여 중 %d명\n", st.bikes, len(st.started))
	names := make([]string, 0, len(st.balance))
	for name := range st.balance {
		names = append(names, name)
	}
	sort.Strings(names)
	for _, name := range names {
		fmt.Printf("%s %d원\n", name, st.balance[name])
	}
}

줄별 해설

오류 정의

var ( … ) 묶음의 세 변수는 센티널 오류다. 호출한 쪽이 errors.Is 로 종류를 가를 때 기준이 되는 값이며, 패키지 밖으로 공개할 것이므로 대문자로 시작한다. BalanceError 는 필요 금액과 보유 금액을 필드로 갖는다. Error() 가 *BalanceError 에 붙어 있으므로 오류 값은 &BalanceError{…} 처럼 포인터로 만든다. Shortage 는 부족액을 계산하는 보조 메서드다.

Rent 와 Return

Rent 는 실패 조건을 위에서 아래로 차례로 검사하고, 걸리면 곧바로 오류를 돌려준다. 정상 경로가 들여쓰기 없이 맨 아래로 흐르도록 하는 것이 Go 의 일반적인 모양이다. 모든 오류는 "대여 %s: %w" 로 감싸서 "무엇을 하다가"를 덧붙이되 원래 오류를 안쪽에 남긴다.

Return 은 (int, error) 두 값을 돌려준다. 오류일 때는 0 과 오류를 돌려준다. 잔액이 모자라면 상태를 바꾸지 않고 오류만 돌려주므로, 그 회원은 여전히 대여 중으로 남는다.

fee 와 패닉

fee 는 이용 시간을 10분 단위로 올림해 500원씩 계산한다. 시간이 음수라는 것은 사용자의 입력이 아니라 호출한 코드의 버그이므로 panic 을 쓴다. 이 프로그램에서 정상적으로 음수가 들어올 길은 없다.

describe

switch 에 조건식을 직접 적는 형태로 오류의 종류를 가른다. 센티널은 errors.Is, 필드가 필요한 타입은 errors.As 로 묻는다. errors.As 가 성공하면 be 에 값이 채워지므로 같은 case 안에서 be.Shortage() 를 쓸 수 있다.

safely

반환값에 이름(err)을 붙여 두었기 때문에, defer 로 실행되는 익명 함수가 err 를 바꿀 수 있다. f() 에서 패닉이 나면 recover() 가 그 값을 돌려주고, 그 값을 문장에 담아 오류로 바꾼다. 패닉이 없으면 recover() 는 nil 이므로 if 문은 건너뛰고 nil 이 반환된다.

main

대여 다섯 건을 돌면서 결과를 출력하고 실패는 all 슬라이스에 모은다. 반납 세 건도 같다. errors.Join(all...) 은 모은 오류들을 하나로 합치고, 합친 오류에 errors.Is 를 쓰면 안쪽 어느 것이든 일치하면 true 가 된다. 마지막으로 park 의 반납 시각을 대여 시각보다 앞선 3분으로 넣어 패닉을 일으키고, safely 가 오류로 바꾸는 것을 확인한다. 맵은 키를 정렬해서 출력하므로 실행할 때마다 같은 결과가 나온다.

실행 결과

$ go run main.go
대여 kim 성공 → 처리 완료
대여 lee: 잔액 부족: 1000원 필요, 500원 보유 → 충전 안내, 500원 부족
대여 jung: 등록되지 않은 회원이다 → 가입 안내
대여 park 성공 → 처리 완료
대여 choi: 대여 가능한 자전거가 없다 → 빈 자전거 없음, 다른 대여소 안내
반납 kim 성공 (1500원) → 처리 완료
반납 park: 잔액 부족: 3000원 필요, 2000원 보유 → 충전 안내, 1000원 부족
반납 jung: 대여 중이 아니다 → 대여 기록 확인
누적 오류: 5
빈 자전거 오류 포함: true
반납 park: 패닉을 오류로 바꿨다: fee: 이용 시간이 음수다 (-7)
남은 자전거 1대, 대여 중 1명
choi 5000원
kim 1500원
lee 500원
park 2000원

메시지 앞부분은 %w 로 덧붙인 맥락이고, 화살표 뒤는 describe 가 오류의 종류를 가려낸 결과다. 같은 오류 하나에서 사람이 읽을 로그와 프로그램이 가를 종류가 모두 나온다.

실무에서 자주 틀리는 것

오류 문자열을 비교한다

틀린 코드:

if err.Error() == "대여 가능한 자전거가 없다" {
	// 안내
}

맥락을 덧붙여 감싼 순간 문자열이 달라져서 이 조건은 거짓이 된다. 문구를 고쳐도 깨진다. 고친 코드:

if errors.Is(err, ErrNoBike) {
	// 안내
}

%w 대신 %v 로 감싼다

틀린 코드:

return fmt.Errorf("대여 %s: %v", member, ErrNoBike)

출력 문장은 같지만 안쪽 오류가 버려져서 errors.Is(err, ErrNoBike) 가 false 가 된다. 고친 코드:

return fmt.Errorf("대여 %s: %w", member, ErrNoBike)

nil 포인터를 error 로 돌려준다

틀린 코드:

func check(have int) error {
	var e *BalanceError
	if have < 0 {
		e = &BalanceError{Have: have}
	}
	return e
}

인터페이스 값은 타입과 값의 쌍이다. e 가 nil 포인터여도 타입 정보(*BalanceError)가 들어가므로 check(5) != nil 이 참이 된다. 오류가 없는데 있다고 판단하는 버그다. 고친 코드는 오류가 없을 때 리터럴 nil 을 돌려준다.

func check(have int) error {
	if have < 0 {
		return &BalanceError{Have: have}
	}
	return nil
}

예상되는 실패에 panic 을 쓴다

틀린 코드:

func (s *Station) Rent(member string, now int) {
	have, ok := s.balance[member]
	if !ok {
		panic("없는 회원")
	}
	_ = have
}

없는 회원은 일상적으로 일어나는 일이다. 패닉으로 처리하면 호출한 쪽이 대응할 수 없고, 잡지 않으면 프로그램이 종료된다. 고친 코드는 오류를 돌려준다.

func (s *Station) Rent(member string, now int) error {
	if _, ok := s.balance[member]; !ok {
		return fmt.Errorf("대여 %s: %w", member, ErrUnknownMember)
	}
	return nil
}

한눈에 보기

오류 처리의 규칙과 주의점을 한곳에 모았다
주제규칙코드 모양주의
반환 관례error 는 마지막 반환값, 없으면 nilv, err := f()검사 없이 _ 로 버리지 않는다
센티널 오류패키지 변수로 종류를 표현errors.New("…")Err 로 시작하는 이름을 쓴다
감싸기맥락을 덧붙이고 원인을 보존fmt.Errorf("…: %w", err)%v 는 체인을 끊는다
종류 검사체인을 벗겨 비교errors.Is(err, ErrX)문자열 비교와 == 는 감싼 오류에 통하지 않는다
타입 검사체인에서 타입을 꺼냄errors.As(err, &be)두 번째 인자는 포인터다
사용자 정의 오류Error() string 구현func (e *T) Error() string없을 때는 리터럴 nil 을 돌려준다
panic, recover버그와 경계에서만 사용defer func() { recover() }()recover 는 defer 안에서만 동작한다

연습 문제

  1. 이미 대여 중인 회원이 다시 빌리려 하면 ErrAlreadyRented 오류를 돌려주도록 Rent 를 고쳐라. describe 에서 "반납 안내"를 출력하게 하려면 어느 줄을 추가해야 하는가.
  2. Rent 의 ErrNoBike 를 감싸는 줄을 %w 에서 %v 로 바꾸고 실행하면 describe 는 "대여 choi" 줄에서 어떤 결과를 내는가. 이유도 설명하라.
  3. fee 가 패닉을 일으키지 않고 (int, error) 를 돌려주도록 바꾸려고 한다. 이렇게 바꾸는 것이 맞는 설계인지 판단하고, 기준을 한 문장으로 써라.
  4. main 의 errors.Join 결과에 errors.As 를 써서 *BalanceError 를 꺼내 Shortage() 를 출력하는 코드를 작성하라. 어떤 오류의 값이 나오는가.

정답과 해설

  1. 센티널을 추가하고 Rent 의 맨 앞에서 검사한다.

    var ErrAlreadyRented = errors.New("이미 대여 중이다")
    
    if _, rented := s.started[member]; rented {
    	return fmt.Errorf("대여 %s: %w", member, ErrAlreadyRented)
    }

    describe 에는 case errors.Is(err, ErrAlreadyRented): return "반납 안내" 를 default 위에 추가한다. 다른 case 와 겹치지 않으므로 순서는 자유롭다.

  2. default 로 떨어져 "원인 불명: 대여 choi: 대여 가능한 자전거가 없다" 가 나온다. %v 는 안쪽 오류의 문자열만 복사하고 오류 값 자체는 보존하지 않으므로 errors.Is(err, ErrNoBike) 가 false 가 되기 때문이다. 화면에 찍히는 메시지는 같아도 종류 정보가 사라진다.

  3. 이 프로그램에서는 그대로 패닉이 맞다. 음수 시간은 사용자의 입력이 아니라 호출한 코드의 모순이며 호출한 쪽이 대응할 방법이 없다. 반대로 입력에서 시간을 받아 계산한다면 입력 검사를 Return 앞에 두고 오류를 돌려주는 설계가 맞다. 기준은 "호출한 쪽이 실패를 보고 다른 행동을 고를 수 있는가"다.

  4. 예는 다음과 같다.

    var be *BalanceError
    if errors.As(joined, &be) {
    	fmt.Println(be.Shortage())
    }

    합쳐진 오류 안에서 가장 먼저 만나는 *BalanceError 가 담기며, 모은 순서상 첫 번째는 lee 의 대여 오류이므로 500 이 출력된다. park 의 반납 오류(부족액 1000)는 뒤에 있어 선택되지 않는다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.