Go · 기본
작고 명확한 Go 프로그램
오류 처리 - 오류도 값이다
error 반환 관례, fmt.Errorf %w 감싸기, errors.Is·As, 사용자 정의 오류, panic·recover 를 쓸 때
개발자KR · 원고 갱신
이 장에서 배우는 것
프로그램은 실행 중에 자주 실패한다. 빌리려는 자전거가 없을 수도 있고, 회원 번호가 틀렸을 수도 있고, 잔액이 모자랄 수도 있다. 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 와 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 는 프로그램의 경계에서 한 번 쓰는 것이 적절하다. 이 장의 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 는 마지막 반환값, 없으면 nil | v, 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 안에서만 동작한다 |
연습 문제
- 이미 대여 중인 회원이 다시 빌리려 하면
ErrAlreadyRented오류를 돌려주도록Rent를 고쳐라.describe에서 "반납 안내"를 출력하게 하려면 어느 줄을 추가해야 하는가. Rent의ErrNoBike를 감싸는 줄을%w에서%v로 바꾸고 실행하면describe는 "대여 choi" 줄에서 어떤 결과를 내는가. 이유도 설명하라.fee가 패닉을 일으키지 않고(int, error)를 돌려주도록 바꾸려고 한다. 이렇게 바꾸는 것이 맞는 설계인지 판단하고, 기준을 한 문장으로 써라.main의errors.Join결과에errors.As를 써서*BalanceError를 꺼내Shortage()를 출력하는 코드를 작성하라. 어떤 오류의 값이 나오는가.
정답과 해설
센티널을 추가하고
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와 겹치지 않으므로 순서는 자유롭다.default로 떨어져 "원인 불명: 대여 choi: 대여 가능한 자전거가 없다" 가 나온다.%v는 안쪽 오류의 문자열만 복사하고 오류 값 자체는 보존하지 않으므로errors.Is(err, ErrNoBike)가false가 되기 때문이다. 화면에 찍히는 메시지는 같아도 종류 정보가 사라진다.이 프로그램에서는 그대로 패닉이 맞다. 음수 시간은 사용자의 입력이 아니라 호출한 코드의 모순이며 호출한 쪽이 대응할 방법이 없다. 반대로 입력에서 시간을 받아 계산한다면 입력 검사를
Return앞에 두고 오류를 돌려주는 설계가 맞다. 기준은 "호출한 쪽이 실패를 보고 다른 행동을 고를 수 있는가"다.예는 다음과 같다.
var be *BalanceError if errors.As(joined, &be) { fmt.Println(be.Shortage()) }합쳐진 오류 안에서 가장 먼저 만나는
*BalanceError가 담기며, 모은 순서상 첫 번째는lee의 대여 오류이므로500이 출력된다.park의 반납 오류(부족액 1000)는 뒤에 있어 선택되지 않는다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.