21장. 에러 처리
프로그램은 자주 실패한다.
- 열려고 한 파일이 없거나
- 네트워크가 끊겼거나
- 사용자 입력이 이상하거나
- DB 가 잠깐 응답을 안 한다거나
이런 상황을 어떻게 표현하고 어떻게 처리할지가 언어마다 다르다. Java, Python, JavaScript 는 예외(exception) 를 쓴다. Go 는 그 길을 택하지 않았다.
이 장의 목표는 다음과 같다.
- Go 의 에러 처리 철학을 이해하기
error인터페이스의 정체 알기- 에러 반환 / 검사 / 래핑 패턴 익히기
- sentinel 에러와 사용자 정의 에러 만들기
panic과recover의 올바른 자리 알기- 흔한 안티패턴 피하기
21.1 Go 의 에러 처리 철학
예외가 없다
대부분의 언어는 이런 식이다.
try:
f = open("x.txt")
except FileNotFoundError:
...
함수는 “성공한 결과“만 반환하고, 실패하면 어딘가 위에서 잡혀 줄 거라 기대한다.
Go 에는 이 메커니즘이 없다.
에러는 값(value) 으로 반환된다.
함수 시그니처에 떡하니 적혀 있고, 호출하는 쪽에서 명시적으로 처리한다.
왜 이렇게 했는가
Go 설계자들의 주장은 단순하다.
- 예외는 흐름을 보이지 않게 만든다
- 어떤 함수가 무엇을 던질지 알기 어렵다
- “조용히 위로 던지면 누군가 잡겠지” 가 대부분의 사고로 이어졌다
대신 Go 는 이렇게 강제한다.
“에러가 나는 함수는 에러를 반환해라. 호출자는 그것을 받아서 결정해라.”
명시성의 대가와 보상
물론 단점도 있다.
if err != nil {
return err
}
이 패턴이 코드 곳곳에 깔린다. “왜 이렇게 장황하냐” 는 비판도 있다.
대신 얻는 게 있다.
- 어떤 함수가 실패할 수 있는지 한눈에 보인다
- 처리하지 않은 에러를 컴파일러가 자주 잡아 준다
- 흐름이 위에서 아래로 정직하게 흐른다
panic 이라는 메커니즘도 있긴 하지만, 일반적인 에러 처리에 쓰는 도구가 아니다. 그 얘기는 뒤(21.7)에서 따로 다룬다.
21.2 error 인터페이스
Go 에서 “에러“라는 건 사실 하나의 인터페이스를 만족하는 어떤 값일 뿐이다.
정의
표준 라이브러리에 이렇게 정의되어 있다.
type error interface {
Error() string
}
이게 전부다.
Error() string 메서드 하나만 가지면
무엇이든 error 가 될 수 있다.
16장의 인터페이스가 떠오르는가
16장에서 인터페이스를 배웠다. “이런 메서드를 가진 타입이면 다 받겠다” 는 약속이었다.
error 는 그 약속의 가장 대표적인 사례다.
- 표준 라이브러리의 에러
- 외부 라이브러리의 에러
- 내가 만든 에러 타입
errors.New가 만든 단순한 에러
이 모두가 똑같은 error 인터페이스 뒤로 들어온다.
호출하는 쪽은 종류를 몰라도
err.Error() 로 메시지를 얻거나
if err != nil 로 발생 여부를 검사할 수 있다.
출력해 보면 그냥 메시지
package main
import (
"errors"
"fmt"
)
func main() {
err := errors.New("something broke")
fmt.Println(err) // something broke
fmt.Println(err.Error()) // something broke
}
fmt.Println 은 error 를 보면
알아서 Error() 를 호출해 준다.
21.3 에러 반환 관례
마지막 반환값이 error
Go 의 관례는 단순하다.
에러를 낼 수 있는 함수는 마지막 반환값으로
error를 돌려준다.
표준 라이브러리의 예를 보자.
func Open(name string) (*File, error)
func ReadFile(name string) ([]byte, error)
func Atoi(s string) (int, error)
성공하면 결과를 반환하고 err 는 nil,
실패하면 결과는 의미 없는 값이고 err 가 채워진다.
호출 패턴
result, err := someFunc()
if err != nil {
// 에러 처리
return err
}
// result 사용
이 다섯 줄짜리 모양이 Go 코드의 절반쯤을 채운다. 지겹다 싶을 만큼 자주 본다.
실전 예제
파일을 열어 내용을 출력하는 코드.
package main
import (
"fmt"
"os"
)
func main() {
data, err := os.ReadFile("hello.txt")
if err != nil {
fmt.Println("파일 읽기 실패:", err)
return
}
fmt.Println(string(data))
}
흐름이 보이는 그대로 흐른다.
- 파일 읽기 시도
- 실패하면 메시지 출력 후 종료
- 성공하면 내용 출력
nil 검사를 빼먹으면
err 를 검사하지 않고 결과를 그대로 쓰면
조용히 잘못된 값으로 진행하다가
나중에 엉뚱한 곳에서 터진다.
n, _ := strconv.Atoi("abc")
fmt.Println(n + 10) // 10 출력. 사실 변환은 실패함
_ 로 받아 버리면 컴파일러도 더는 도와줄 수 없다.
새 함수를 부를 때마다 반사적으로
if err != nil을 적는 습관이 든다. Go 개발자들에겐 이게 그냥 호흡이다.
21.4 에러 만들기
errors.New: 가장 간단한 방법
메시지 한 줄짜리 에러를 만들 때 쓴다.
import "errors"
func div(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("0으로 나눌 수 없다")
}
return a / b, nil
}
errors.New 가 돌려주는 값은
Error() 메서드를 가진 작은 객체다.
그 자체로 error 인터페이스를 만족한다.
fmt.Errorf: 포맷팅이 필요할 때
메시지에 변수 값을 끼워 넣고 싶을 땐
fmt.Errorf 를 쓴다.
import "fmt"
func div(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("0으로 나눌 수 없다: a=%d", a)
}
return a / b, nil
}
fmt.Printf 와 같은 포맷 동사를 다 쓸 수 있다.
사용자 정의 에러 타입
메시지뿐 아니라 추가 정보를 함께 담고 싶을 때, 직접 에러 타입을 만든다.
type ValidationError struct {
Field string
Value string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf(
"필드 %q 의 값 %q 가 올바르지 않다",
e.Field, e.Value,
)
}
핵심은 Error() string 메서드를 구현하는 것뿐이다.
이러면 이 타입의 값은 어디든 error 가 들어가는 자리에 쓸 수 있다.
func validateAge(s string) error {
if s == "" {
return &ValidationError{Field: "age", Value: s}
}
return nil
}
쓰는 쪽은 이렇다.
err := validateAge("")
if err != nil {
fmt.Println(err)
}
이후 16장에서 본 타입 단언으로
원본 타입을 꺼내 추가 정보에 접근할 수도 있다.
이 방법은 21.5 에서 더 깔끔한 도구(errors.As)로 다시 다룬다.
포인터 리시버(
*ValidationError)로 정의하는 게 관례다. 이유는 15장에서 다룬 리시버 선택 규칙과 같다.
21.5 에러 래핑 (%w)
컨텍스트가 없으면 추적이 어렵다
DB 호출이 실패했다고 해 보자. 원래 에러 메시지는 이렇다.
connection refused
이걸 그대로 위로 던지면, 호출 트리의 위에선 무슨 일이 났는지 알 길이 없다.
좋은 코드는 단계마다 컨텍스트를 더한다.
사용자 조회 실패: DB 쿼리 실패: connection refused
이런 식으로 차곡차곡 쌓고 싶다.
%w 로 감싸기
fmt.Errorf 의 포맷 동사 중 %w 가 특별하다.
err := doQuery()
if err != nil {
return fmt.Errorf("DB 쿼리 실패: %w", err)
}
%w 는 단순히 메시지에 끼워 넣는 게 아니라,
원래 에러를 새 에러 안에 보존한다.
이걸 에러 래핑(wrapping) 이라 부른다.
errors.Is 와 errors.As
래핑된 에러 안을 들여다보는 도구가 둘 있다.
errors.Is(err, target) // 같은 에러인가 비교
errors.As(err, &target) // 특정 타입으로 꺼내기
errors.Is
특정 값을 찾을 때 쓴다.
if errors.Is(err, os.ErrNotExist) {
fmt.Println("파일이 없네요")
}
err 자체가 os.ErrNotExist 든,
누군가 그걸 한 번 감싼 거든,
열 번 감싼 거든 다 찾아낸다.
errors.As
특정 타입으로 꺼낼 때 쓴다.
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("필드:", ve.Field)
}
래핑된 어디쯤에 *ValidationError 가 있다면
그걸 꺼내서 ve 에 담아 준다.
한눈에 비교
| 도구 | 묻는 것 |
|---|---|
errors.Is(err, X) | “에러 체인에 X 와 같은 값이 있는가?” |
errors.As(err, &v) | “에러 체인에서 이 타입을 꺼낼 수 있는가?” |
21.6 sentinel 에러
미리 정의된 에러 값
특정 상황을 호출자가 비교해서 처리해야 할 때, “이 에러는 이 값입니다” 라고 미리 정해 두는 패턴이 있다.
package store
import "errors"
var ErrNotFound = errors.New("not found")
이 패키지를 쓰는 쪽은 이렇게 한다.
user, err := store.GetUser(id)
if errors.Is(err, store.ErrNotFound) {
// 사용자가 없는 경우만 따로 처리
return
}
if err != nil {
return err
}
이런 미리 정의된 비교용 에러 값을 sentinel(센티넬) 에러 라 부른다.
표준 라이브러리의 sentinel
이미 알게 모르게 자주 쓰고 있었다.
| 에러 값 | 의미 |
|---|---|
io.EOF | 입력 끝에 도달했다 |
os.ErrNotExist | 파일이 없다 |
os.ErrPermission | 권한이 없다 |
sql.ErrNoRows | 조회 결과가 없다 |
for 루프로 파일을 읽다가
if err == io.EOF 로 끝을 검사해 본 경험이 있다면,
sentinel 패턴을 이미 써 본 것이다.
== 비교는 그만, errors.Is 로
옛날 코드에는 이런 비교가 흔하다.
if err == io.EOF { // 옛 스타일
...
}
값이 직접 반환됐다면 잘 동작한다.
하지만 누군가 중간에 %w 로 래핑했다면
이 비교는 실패한다.
요즘 권장 스타일은 항상 이렇다.
if errors.Is(err, io.EOF) { // 권장
...
}
이름 짓기 관례
sentinel 변수는 보통 Err 로 시작한다.
var ErrNotFound = errors.New("not found")
var ErrTimeout = errors.New("timeout")
var ErrInvalidInput = errors.New("invalid input")
여기서 Err 가 대문자로 시작하는 것은
20장에서 본 export 규칙 때문이다.
패키지 바깥에서 비교에 써야 하므로 공개되어 있다.
21.7 panic 과 recover
지금까지 본 건 다 “값으로서의 에러“였다. Go 에는 또 다른 메커니즘이 따로 있다. 바로 panic 이다.
panic 이란
panic 은 프로그램을 즉시 중단시키는 비상 탈출구다.
func main() {
a := []int{1, 2, 3}
fmt.Println(a[10]) // runtime error: index out of range
}
이 코드는 패닉을 일으키며 죽는다.
panic: runtime error: index out of range [10] with length 3
goroutine 1 [running]:
main.main()
/tmp/main.go:5 +0x18
exit status 2
명시적으로 일으킬 수도 있다.
panic("뭔가 단단히 잘못됐다")
언제 panic 을 쓰는가
원칙은 단호하다.
“정말로 비정상적인 상황, 프로그램이 더 진행해 봐야 의미가 없을 때만 쓴다.”
그런 상황의 예:
- 절대로 발생할 수 없다고 가정한 분기에 들어왔을 때
- 프로그램 시작 시점에 필수 설정이 빠져 있을 때
- 슬라이스 범위 초과, nil 역참조 같은 런타임 위반 (이건 우리가 부르지 않아도 Go 가 알아서 일으킴)
일반적인 실패는 panic 으로 처리하지 않는다.
- 파일이 없다 →
error반환 - 입력이 잘못됐다 →
error반환 - 네트워크가 끊겼다 →
error반환
다른 언어의
throw처럼 panic 을 던지면 Go 다운 코드가 아니게 된다.
recover
panic 으로 죽어 가는 프로그램을 살릴 수 있는 도구다.
딱 하나 규칙이 있다.
recover는defer안에서만 동작한다.
defer 는 9장에서 다뤘다.
함수가 끝날 때 실행되는 마법의 호출이다.
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("패닉 발생: %v", r)
}
}()
return a / b, nil
}
b 가 0 이면 a / b 가 패닉을 일으키지만,
defer 안의 recover 가 잡아서
err 에 담아 정상 반환으로 돌린다.
라이브러리 경계에서 변환하는 패턴
라이브러리를 만들 때 자주 보이는 형태다.
안에서 어쩌다 panic 이 일어나더라도 바깥(사용자)에는 정상적인
error로 변환해 보낸다.
func Run() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("내부 패닉: %v", r)
}
}()
doWork()
return nil
}
명명된 반환값(err error) 을 쓰는 이유는,
defer 안에서 err 를 바꿔 줘야 하기 때문이다.
recover 를 만능 안전망으로 쓰지 말 것
try/catch 처럼 모든 함수에 두르는 건 잘못된 사용이다.
recover 는 라이브러리의 가장 바깥 경계나,
HTTP 핸들러 같은 요청 단위 격리 지점에만 쓴다.
21.8 에러 처리 안티패턴
마지막으로, 실전에서 자주 보이는 잘못된 패턴을 모았다.
안티패턴 1: 에러 무시하기
n, _ := strconv.Atoi(s)
_ 로 받아 버리면 변환 실패가 조용히 묻힌다.
n 은 그냥 0 이 되고,
나중에 엉뚱한 곳에서 사고가 난다.
정말로 무시해도 되는 경우엔 주석을 남기자. 대부분은 그런 경우가 아니다.
안티패턴 2: 컨텍스트 없이 그냥 반환
func loadUser(id int) (*User, error) {
data, err := db.Query(id)
if err != nil {
return nil, err // 컨텍스트 없음
}
...
}
이러면 호출 트리 깊은 곳에서 일어난 에러가 원본 메시지만 가지고 위로 올라간다. 어디서 났는지 알 수 없다.
권장 형태:
if err != nil {
return nil, fmt.Errorf("loadUser(%d): %w", id, err)
}
단계마다 한 줄씩 컨텍스트를 더한다.
안티패턴 3: 같은 에러 메시지 중복 래핑
if err != nil {
return fmt.Errorf("loadUser: %w", err)
}
이걸 위쪽에서 또
if err != nil {
return fmt.Errorf("loadUser: %w", err)
}
이러면 메시지가
loadUser: loadUser: loadUser: connection refused
이렇게 된다. 래핑은 새 정보를 더할 때만 한다.
안티패턴 4: panic 남발
file, err := os.Open(path)
if err != nil {
panic(err) // 흔한 실수
}
파일이 없는 건 비정상이 아니라 예상 가능한 실패다. 호출자가 처리할 기회를 빼앗으면 안 된다.
panic 을 쓰기 전에 항상 자문하자.
“이 상황을 호출자가 처리할 가능성이 조금이라도 있나?”
조금이라도 있으면 error 다.
안티패턴 5: error 만 보고 결과도 함께 쓰기
data, err := os.ReadFile(path)
fmt.Println(string(data)) // err 검사 전에 사용
if err != nil {
return err
}
err 가 있을 땐 결과값은 의미 없는 값이 보통이다.
먼저 에러를 검사하고 나서 결과를 쓴다.
21.9 정리
이 장에서 살펴본 내용:
- Go 는 예외 대신 에러를 값으로 다룬다
error는Error() string메서드 하나짜리 인터페이스다- 함수는 마지막 반환값에
error를 두는 게 관례다 errors.New와fmt.Errorf로 에러를 만든다- 사용자 정의 에러 타입으로 추가 정보를 담을 수 있다
%w로 에러를 래핑하고errors.Is,errors.As로 검사한다- sentinel 에러로 미리 정의된 비교용 값을 만든다
panic은 비정상적인 상황만,recover는defer안에서 경계 보호용으로 쓴다- 에러 무시, 컨텍스트 없는 반환, panic 남발은 피한다
if err != nil 이 처음엔 번거롭게 느껴진다.
몇 주 쓰다 보면 그 명시성이 오히려 안심이 된다.
어떤 함수가 실패할 수 있는지가 코드에 그대로 보이고,
어디서 실패를 처리했는지도 그대로 보이기 때문이다.
다음 장부터는 Go 의 또 다른 큰 자랑거리, 동시성을 다룬다. 고루틴과 채널이라는 두 도구로 여러 일을 한꺼번에 다루는 법을 배운다.