Skip to content
ioob.dev
Go back

Go 입문 4편 — 에러 처리

· 4분 읽기

Table of contents

Go는 예외를 던지지 않는다

Java나 Python에서는 뭔가 잘못되면 예외(exception)를 던진다. try-catch로 잡거나 말거나. Go는 이 방식을 선택하지 않았다. 대신 에러를 값으로 반환한다.

package main

import (
    "fmt"
    "os"
)

func main() {
    f, err := os.Open("없는파일.txt")
    if err != nil {
        fmt.Println("파일 열기 실패:", err)
        return
    }
    defer f.Close()
    fmt.Println("파일 열기 성공")
}

os.Open은 파일과 에러를 함께 반환한다. 에러가 nil이 아니면 뭔가 잘못된 것이다. 이 패턴이 반복적으로 느껴질 수 있는데, Go 커뮤니티에서도 이 점은 인정한다. 그럼에도 이 방식을 고수하는 이유는 명확하다. 에러를 무시하기 어렵게 만든다.

예외 기반 언어에서는 catch 블록을 안 쓰면 에러가 조용히 사라지거나 프로그램이 갑자기 죽는다. Go에서는 반환된 에러를 처리하지 않으면 gofmt이나 린터가 경고를 띄운다.

error 인터페이스

Go의 에러는 단순한 인터페이스다.

type error interface {
    Error() string
}

Error() 메서드 하나만 구현하면 어떤 타입이든 에러가 될 수 있다. 이 단순함이 Go 에러 시스템의 핵심이다.

가장 간단하게 에러를 만드는 방법은 errors.New다.

package main

import (
    "errors"
    "fmt"
)

func validate(age int) error {
    if age < 0 {
        return errors.New("나이는 음수일 수 없다")
    }
    if age > 150 {
        return errors.New("현실적인 나이를 입력해라")
    }
    return nil
}

func main() {
    if err := validate(-5); err != nil {
        fmt.Println("검증 실패:", err)
    }
}

에러가 없을 때는 nil을 반환한다. 호출하는 쪽에서 if err != nil로 확인하는 게 Go 코드의 기본 리듬이다.

fmt.Errorf로 맥락 추가하기

에러 메시지에 추가 정보를 넣고 싶을 때는 fmt.Errorf를 쓴다.

package main

import (
    "fmt"
    "os"
)

func readConfig(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("설정 파일 읽기 실패 (%s): %w", path, err)
    }
    return nil
}

func main() {
    err := readConfig("/etc/myapp/config.yaml")
    if err != nil {
        fmt.Println(err)
    }
}

여기서 %w가 중요하다. 일반 %v와 달리 %w는 원본 에러를 감싸서(wrap) 보관한다. 나중에 errors.Iserrors.As로 원본 에러를 꺼내 확인할 수 있게 된다.

에러에 맥락을 추가하면서 체인을 이어가는 이 패턴은 실무에서 필수적이다. 로그에 “파일 열기 실패”만 찍히는 것과 “설정 파일 읽기 실패 (/etc/myapp/config.yaml): open /etc/myapp/config.yaml: no such file or directory”가 찍히는 것은 디버깅 난이도가 완전히 다르다.

errors.Is와 errors.As

에러가 감싸져 있을 때, 특정 에러인지 확인하는 방법이 errors.Is다.

package main

import (
    "errors"
    "fmt"
    "os"
)

func readFile(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("readFile 실패: %w", err)
    }
    return nil
}

func main() {
    err := readFile("없는파일.txt")
    if err != nil {
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("파일이 존재하지 않는다")
        } else {
            fmt.Println("다른 에러:", err)
        }
    }
}

errors.Is는 에러 체인을 따라가면서 일치하는 에러가 있는지 확인한다. ==로 비교하면 감싸진 에러를 찾을 수 없기 때문에, 반드시 errors.Is를 쓰는 습관을 들여야 한다.

특정 타입의 에러를 꺼내고 싶을 때는 errors.As를 쓴다.

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    _, err := os.Open("없는파일.txt")
    if err != nil {
        var pathErr *os.PathError
        if errors.As(err, &pathErr) {
            fmt.Println("경로:", pathErr.Path)
            fmt.Println("동작:", pathErr.Op)
        }
    }
}

errors.As는 에러 체인에서 특정 타입을 찾아 변수에 담아준다. 에러의 추가 정보에 접근해야 할 때 유용하다.

커스텀 에러 타입

error 인터페이스를 직접 구현하면 에러에 원하는 정보를 담을 수 있다.

package main

import "fmt"

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: "0에서 150 사이여야 한다",
        }
    }
    return nil
}

func main() {
    err := validateAge(200)
    if err != nil {
        fmt.Println(err)  // age: 0에서 150 사이여야 한다
    }
}

Error() string 메서드만 있으면 error 인터페이스를 만족한다. 구조체에 필드, 코드, 원인 등 필요한 정보를 자유롭게 담을 수 있어서, API 에러 응답을 만들거나 에러를 분류할 때 이 패턴을 많이 쓴다.

defer

defer는 함수가 끝날 때 실행될 코드를 예약한다. 파일 닫기, 락 해제, 연결 종료 같은 정리 작업에 쓴다.

package main

import (
    "fmt"
    "os"
)

func main() {
    f, err := os.Create("test.txt")
    if err != nil {
        fmt.Println("파일 생성 실패:", err)
        return
    }
    defer f.Close()  // main이 끝날 때 실행됨

    f.WriteString("Hello, Go!")
    fmt.Println("파일 작성 완료")
}

defer의 핵심은 리소스를 열자마자 닫기를 예약한다는 것이다. 열기와 닫기가 가까이 있으니 빠뜨릴 일이 없다. Java의 try-finally나 Python의 with와 비슷한 역할이지만, 훨씬 가볍다.

defer가 여러 개면 LIFO(후입선출) 순서로 실행된다.

package main

import "fmt"

func main() {
    fmt.Println("시작")
    defer fmt.Println("첫 번째 defer")
    defer fmt.Println("두 번째 defer")
    defer fmt.Println("세 번째 defer")
    fmt.Println("")
}
// 시작
// 끝
// 세 번째 defer
// 두 번째 defer
// 첫 번째 defer

스택처럼 쌓여서 역순으로 실행된다. 이건 리소스를 여는 순서의 역순으로 닫아야 할 때 자연스럽게 맞아떨어지는 설계다.

주의할 점 하나. defer에 전달되는 인자는 defer를 선언하는 시점에 평가된다.

package main

import "fmt"

func main() {
    x := 10
    defer fmt.Println("deferred x:", x)  // 10이 캡처됨
    x = 20
    fmt.Println("x:", x)  // x: 20
}
// x: 20
// deferred x: 10

panic과 recover

panic은 프로그램을 즉시 중단시킨다. recoverpanic으로부터 복구한다. 이 둘은 일상적인 에러 처리에 쓰면 안 된다. 정말 복구 불가능한 상황에서만 사용한다.

package main

import "fmt"

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이면 panic 발생
}

func main() {
    result, err := safeDiv(10, 0)
    if err != nil {
        fmt.Println("에러:", err)
        return
    }
    fmt.Println("결과:", result)
}

recover는 반드시 defer 안에서만 동작한다. 일반 코드에서 호출하면 항상 nil을 반환하기 때문이다.

실무에서 panic이 적절한 경우는 극히 제한적이다. 프로그램 시작 시 필수 설정이 없거나, 절대 발생하면 안 되는 프로그래밍 실수가 감지됐을 때 정도다.

func mustParseURL(rawURL string) *url.URL {
    u, err := url.Parse(rawURL)
    if err != nil {
        panic(fmt.Sprintf("잘못된 URL: %s", rawURL))
    }
    return u
}

Go 표준 라이브러리에서 Must로 시작하는 함수(template.Must, regexp.MustCompile 등)가 이 패턴을 따른다. 초기화 시점에 실패하면 프로그램을 계속 돌릴 이유가 없으니, 그냥 panic을 터뜨리는 것이다.

일반적인 에러는 반드시 error 반환값으로 처리하고, panic은 최후의 수단으로만 쓰자.


다음 편에서는 Go의 컬렉션을 다룬다. 배열과 슬라이스의 차이, 슬라이스의 내부 구조, 그리고 맵의 활용법을 살펴보자.

-> 5편: 배열, 슬라이스, 맵


Share this post on:

Comments

Loading comments...


Previous Post
Go 입문 5편 — 배열, 슬라이스, 맵
Next Post
Go 입문 3편 — 함수