Go의 비밀스러운 삶: 에러 처리

발행: (2025년 12월 29일 오전 11:11 GMT+9)
13 min read
원문: Dev.to

Source: Dev.to

월요일 아침, 무거운 회색 안개가 도시에 뒤덮이며 찾아왔다. 아카이브 안은 완전한 침묵이었고, 오직 엘리노어의 뼈 폴더가 고대 지도에 있는 주름을 펴는 리드미컬한 스크래프‑스크래프‑스크래프 소리만이 깨뜨렸다.

이든이 들어왔고, 우산에서 물이 뚝뚝 떨어졌다. 그는 작은 베이커리 상자를 책상 위에 놓았다.

“배와 아몬드 타르트. 그리고 플랫 화이트, 거품 추가해 주세요.”

엘리노어는 작업을 멈추고 타르트를 살펴보았다.

“고전적인 조합이네요. 배의 달콤함이 아몬드의 쌉쌀함을 균형 잡아 줍니다. 잘 고르셨네요.”

이든은 자리에 앉아 노트북을 열었고, 약간은 큰 한숨을 내쉬었다.

“그 소리,” 엘리노어가 고개를 들지 않고 말했다, “그건 컴파일러와 싸우는 프로그래머의 한숨이야.”

“컴파일러가 아니라,” 이든이 정정했다, “보일러플레이트야. 나는 이 파일 파서를 만들고 있는데, 내 코드의 절반이 오류를 체크하는 데 쓰여. 너무… 원시적인 느낌이야. 예외가 그리워. 모든 것을 거품처럼 감싸고 마지막에 문제를 처리할 수 있는 try‑catch 블록이 그리워.”

그는 화면을 그녀에게 향했다.

func ParseFile(filename string) {
    f, err := os.Open(filename)
    if err != nil {
        log.Fatal(err)
    }
    // ... logic continues
}

“생각해 보니,” 이든이 말했다, “문제가 생기면 그냥 panic하고 최상위에서 recover하면 어떨까? 전역 예외 핸들러처럼 말이야?”

엘리노어는 뼈 폴더를 내려놓고 표정을 굳혔다.

“이든, 책을 읽고 있다고 상상해 보세요. 현재 50쪽에 있는데, 갑자기 경고도 없이 200쪽으로 순간이동합니다. 어떻게 그곳에 도착했는지, 그 사이에 무슨 일이 있었는지 전혀 모릅니다. 그게 바로 예외입니다.”

그녀는 플랫 화이트를 한 모금 마셨다.

“예외는 보이지 않는 제어 흐름입니다. 함수가 스택을 뛰어넘어 반환문과 정리 로직을 우회하도록 합니다. Go에서는 편리함보다 가시성을 중시합니다. 우리는 오류를 으로 취급합니다.”

Errors Are Just Values

She opened a new file. “An error in Go is not a magical event. It is a value, just like an integer or a string. It is a simple interface:”

type error interface {
    Error() string
}

“It is just anything that can describe itself as a string. Because it is a value, you can pass it, store it, wrap it, or ignore it—though you certainly shouldn’t.”

She typed a new example. “You said you wanted to panic. Let me show you why treating errors as values is better.”

package main

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

// Define a "Sentinel Error" – a constant value we can check against
var ErrEmptyConfig = errors.New("config file is empty")

func loadConfig(path string) (string, error) {
    if path == "" {
        // We create the error value right here
        return "", errors.New("path cannot be empty")
    }

    file, err := os.Open(path)
    if err != nil {
        // We return the error value up the stack
        return "", err
    }
    defer file.Close()

    stat, err := file.Stat()
    if err != nil {
        return "", err
    }

    if stat.Size() == 0 {
        // We return our specific sentinel error
        return "", ErrEmptyConfig
    }

    return "config_data", nil
}

func main() {
    _, err := loadConfig("missing.txt")
    if err != nil {
        fmt.Println("Error occurred:", err)
    }
}

“Look at the flow,” Eleanor pointed. “There are no hidden jumps. The error travels up the stack through return values. You can see exactly where it exits. This is explicit control flow.”

“But it’s so verbose,” Ethan grumbled. “if err != nil is everywhere.”

“It is repetitive,” Eleanor conceded, “but consider the alternative. If file.Stat() threw an exception, would you remember to close the file? In Go, the defer file.Close() runs no matter what errors happen later. The explicit checks force you to decide: ‘What do I do if this fails right now?’”

Decorating the Error

“하지만,” 엘리노어가 덧붙였다, “그냥 err를 반환하는 것은 종종 게으른 방법이야. loadConfig가 ‘파일을 찾을 수 없음’이라는 오류를 반환하면, 호출자는 어떤 파일인지 알 수 없어. 컨텍스트를 추가해야 해.”

그녀는 코드를 수정했다:

func loadConfig(path string) (string, error) {
    file, err := os.Open(path)
    if err != nil {
        // Wrap the error with context using %w
        // We only wrap when we are adding useful information.
        return "", fmt.Errorf("failed to open config at %s: %w", path, err)
    }
    // ...
}

%w 동사는 ‘wrap(감싸다)’을 의미한다. 원래 오류를 새로운 오류 안에 넣는다. 이렇게 하면 오류 체인이 만들어진다.”

이선은 찡그렸다. “하지만 감싸면 원래 오류가 무엇인지 어떻게 확인하지? ErrEmptyConfig를 확인하고 싶다면 어떻게 해?”

“훌륭한 질문이야,” 엘리노어가 대답했다. “Go 1.13 이전에는 이게 어려웠어. 이제는 errors.Iserrors.As가 있다.”

미스터리 풀기

그녀는 견고한 오류‑처리 예제를 입력했습니다:

package main

import (
    "errors"
    "fmt"
    "io/fs"
)

func main() {
    _, err := loadConfig("config.json")
    if err != nil {
        // 1. Check if it matches a specific value (sentinel)
        if errors.Is(err, ErrEmptyConfig) {
            fmt.Println("Please provide a non‑empty config file.")
            return
        }

        // 2. Check if it matches a specific type (like PathError)
        var pathErr *fs.PathError
        if errors.As(err, &pathErr) {
            fmt.Println("File system error on path:", pathErr.Path)
            return
        }

        // 3. Fallback
        fmt.Println("Unknown error:", err)
    }
}

errors.Is는 동등성을 확인하는 것과 같습니다. 래핑된 여러 층을 살펴보면서 ErrEmptyConfig가 어딘가에 포함되어 있는지 확인합니다. errors.As는 타입 어설션과 비슷합니다. 오류 체인에 *fs.PathError 타입의 오류가 있는지 검사합니다.”

“그렇다면 오류를 열 번 감싸면서 각 레이어에 컨텍스트를 추가해도 errors.Is는 원래 원인을 찾아낼 수 있나요?”
“정확합니다. 실패한 과정을 서술적으로 추가하면서도 근본 원인을 보존할 수 있습니다.”

당황하지 마세요

언제 panic을 사용할 수 있나요?” Ethan이 물었다.
“거의 절대 안 돼,” Eleanor가 엄격히 대답했다. “panic은 프로그램이 근본적으로 깨져서 더 이상 진행할 수 없을 때 사용합니다—예를 들어 메모리가 부족하거나 내부 상태를 무효화하는 개발자 실수 같은 경우. 파일을 찾을 수 없음이나 네트워크 타임아웃과 같은 경우에 사용하면 안 됩니다.”

그녀는 타르트를 집어 들었다. “내가 가져다 쓰는 라이브러리에서 panic이 발생하면, 내 전체 애플리케이션이 크래시돼. 그건 무례한 행동이야. 오류를 반환해. 내가 크래시할지 말지를 결정하게 해줘.”

Ethan은 창에 빗물이 흐르는 모습을 바라보았다. “그렇게 하면 먼저 불행한 경로를 처리하도록 강제되지.”

“맞아. 코드가 왼쪽으로 정렬돼,” Eleanor가 손으로 공중에 코드 모양을 그리며 말했다. “행복한 경로—성공적인 로직—는 화면 왼쪽에 최소한으로 들여쓰기 된다. 오류 처리는 오른쪽으로 중첩되고 일찍 반환한다. 이렇게 하면 로직을 쉽게 스캔할 수 있어.”

Eleanor는 타르트를 한 입 물었다. “오류는 예외가 아니야, Ethan. 예외는 *‘예상치 못한 일이 발생했다’*는 의미이고, 오류는 *‘이 함수의 가능한 결과다’*는 의미야. 그리고 소프트웨어에서는 실패가 언제나 가능한 결과야.”

그녀는 입술에 묻은 부스러기를 닦았다. “오류를 존중해. 컨텍스트를 제공해. 구체적으로 확인해. 그리고 절대, 절대 오류가 발생하지 않을 거라고 가정하지 마.”

Ethan은 노트북 뚜껑을 닫았다. “try‑catch는 이제 그만.”

try‑catch는 이제 그만,” Eleanor가 동의했다. “그냥 값이야. 손에서 손으로 전달되다가 해결될 때까지.”

12장 주요 개념

오류는 값이다

Go에서 오류는 특수한 제어 흐름 메커니즘이 아니다. error 인터페이스를 구현하는 값이다.

error 인터페이스

type error interface {
    Error() string
}

센티넬 오류

특정 오류에 대한 미리 정의된 전역 변수(io.EOF, errors.New("some error") 등). 간단한 동등성 검사에 유용하다.

오류 래핑

fmt.Errorf("... %w ...", err)를 사용해 오류를 래핑하고, 원본 오류를 보존하면서 컨텍스트를 추가한다.

errors.Is

래핑 체인 어디에서든 특정 오류 값이 존재하는지 확인한다. 래핑된 오류를 처리할 때는 == 대신 이것을 사용한다.

if errors.Is(err, io.EOF) {
    // handle EOF
}

errors.As

체인 어디에서든 특정 타입의 오류가 존재하는지 확인하고, 변수에 할당한다.

var pathErr *fs.PathError // must be a pointer to the error type
if errors.As(err, &pathErr) {
    fmt.Println(pathErr.Path)
}

Panic vs. 오류

오류패닉
언제예상되는 실패 상황(I/O, 검증, 네트워크)예상치 못한 복구 불가능한 상태(널 포인터, 범위 초과 인덱스)
처리값으로 반환되어 호출자가 확인함프로그램이 크래시(복구되지 않으면 거의 발생하지 않음)
가이드라인라이브러리에서 오류 반환라이브러리에서 패닉을 하지 말고 호출자가 결정하도록 함

Happy Path 정렬

오류를 조기에 처리하고 반환하도록 코드를 구조화한다. 성공적인 로직은 최소한으로 들여쓰기한다.


다음 장: Testing—Eleanor가 Ethan에게 테스트 작성이 번거로운 일이 아니라 시스템이 실제로 동작함을 증명하는 유일한 방법임을 보여준다.

Aaron Rose는 tech-reader.blog의 소프트웨어 엔지니어이자 기술 작가이며, Think Like a Genius의 저자이다.

Back to Blog

관련 글

더 보기 »

C++가 “We have try at home”라고 말한다

다른 언어들의 finally 예외를 지원하는 많은 언어¹는 finally 절도 가지고 있어, 따라서 다음과 같이 쓸 수 있습니다. ```cpp try { // stuff } finally { always; } ``` 간단한 c...

Go의 비밀스러운 삶: 패키지와 구조

Chapter 11: The Architect's Blueprint 금요일 오후의 햇살이 아카이브 창문을 통해 낮게 비스듬히 들어와, 공중에서 춤추는 먼지 입자를 비추었다. 이든은…