Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

21장. 에러 처리

프로그램은 자주 실패한다.

  • 열려고 한 파일이 없거나
  • 네트워크가 끊겼거나
  • 사용자 입력이 이상하거나
  • DB 가 잠깐 응답을 안 한다거나

이런 상황을 어떻게 표현하고 어떻게 처리할지가 언어마다 다르다. Java, Python, JavaScript 는 예외(exception) 를 쓴다. Go 는 그 길을 택하지 않았다.

이 장의 목표는 다음과 같다.

  • Go 의 에러 처리 철학을 이해하기
  • error 인터페이스의 정체 알기
  • 에러 반환 / 검사 / 래핑 패턴 익히기
  • sentinel 에러와 사용자 정의 에러 만들기
  • panicrecover 의 올바른 자리 알기
  • 흔한 안티패턴 피하기

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.Printlnerror 를 보면 알아서 Error() 를 호출해 준다.


21.3 에러 반환 관례

마지막 반환값이 error

Go 의 관례는 단순하다.

에러를 낼 수 있는 함수는 마지막 반환값으로 error 를 돌려준다.

표준 라이브러리의 예를 보자.

func Open(name string) (*File, error)
func ReadFile(name string) ([]byte, error)
func Atoi(s string) (int, error)

성공하면 결과를 반환하고 errnil, 실패하면 결과는 의미 없는 값이고 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))
}

흐름이 보이는 그대로 흐른다.

  1. 파일 읽기 시도
  2. 실패하면 메시지 출력 후 종료
  3. 성공하면 내용 출력

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 으로 죽어 가는 프로그램을 살릴 수 있는 도구다. 딱 하나 규칙이 있다.

recoverdefer 안에서만 동작한다.

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 는 예외 대신 에러를 값으로 다룬다
  • errorError() string 메서드 하나짜리 인터페이스다
  • 함수는 마지막 반환값에 error 를 두는 게 관례다
  • errors.Newfmt.Errorf 로 에러를 만든다
  • 사용자 정의 에러 타입으로 추가 정보를 담을 수 있다
  • %w 로 에러를 래핑하고 errors.Is, errors.As 로 검사한다
  • sentinel 에러로 미리 정의된 비교용 값을 만든다
  • panic 은 비정상적인 상황만, recoverdefer 안에서 경계 보호용으로 쓴다
  • 에러 무시, 컨텍스트 없는 반환, panic 남발은 피한다

if err != nil 이 처음엔 번거롭게 느껴진다. 몇 주 쓰다 보면 그 명시성이 오히려 안심이 된다. 어떤 함수가 실패할 수 있는지가 코드에 그대로 보이고, 어디서 실패를 처리했는지도 그대로 보이기 때문이다.

다음 장부터는 Go 의 또 다른 큰 자랑거리, 동시성을 다룬다. 고루틴과 채널이라는 두 도구로 여러 일을 한꺼번에 다루는 법을 배운다.