← 목록으로

Go 에러 처리, 패닉은 그만! 우아하게 감싸는 5가지 노하우

2026. 5. 9.

Go 에러 처리, 패닉은 그만! 우아하게 감싸는 5가지 노하우

안녕하세요, 10년 차 IT 실무자이자 테크 블로거입니다. Go 언어로 개발을 시작하면서 많은 분들이 한 번쯤 겪는 흔한 고민이 있죠. 바로 지겹도록 반복되는 에러 처리 코드입니다.

처음 Go 프로그램을 만들고 멋지게 컴파일까지 성공했을 때의 그 짜릿함! 하지만 이내 마주하는 코드를 보면 잠시 멈칫하게 됩니다.

file, err := os.Open("dreams.txt")
if err != nil {
    return err
}
defer file.Close()

data, err := io.ReadAll(file)
if err != nil {
    return err
}

result, err := process(data)
if err != nil {
    return err
}

그리고 머릿속에는 이런 생각이 맴돌죠: "잠깐, 앞으로 남은 평생 if err != nil만 써야 하는 건가?"

네, 맞습니다. 써야 합니다. 하지만 제 이야기를 한번 들어보세요. 일단 이 현실을 받아들이고 나면, 생각보다 아름답게 느껴질 때가 올 겁니다. 솔직히 저도 처음 Go를 접하고 if err != nil을 수없이 타이핑했을 땐 '내가 평생 이 짓을 해야 하나?' 싶은 회의감이 들기도 했죠. 그런데 몇 년간 실무에서 이 패턴을 꾸준히 쓰다 보니, 의외로 에러 발생 지점을 명확하게 인지하고 처리하는 데 엄청난 도움이 되더라고요.

Go가 당신에게 왜 이런 시련을 주는가 (그리고 왜 괜찮은가)

다른 언어들은 에러를 생일 파티에 초대받지 않았는데도 불쑥 나타나 모든 분위기를 망치는 친구처럼 취급합니다. 어디선가 갑자기 튀어나와 모든 걸 엉망으로 만들고, 결국 다른 누군가가 그 친구를 책임져야 하죠.

Go는 이렇게 결정했습니다: 에러는 값이다 (errors are values). 그저 문자열이나 정수 같은 하나의 '값'일 뿐이죠.

이것이 의미하는 바는 다음과 같습니다:

  • ✅ 함수 시그니처에서 에러의 존재를 명확히 알 수 있습니다.
  • ✅ 의도치 않게 에러를 무시할 수 없습니다 (물론 할 수는 있지만, 린터가 당신을 비난할 겁니다).
  • ✅ 14개 스택 프레임을 건너뛰며 흐름을 제어하는 보이지 않는 마법이 없습니다.
  • if err != nil을 약 9,000번 타이핑해야 합니다.

이건 트레이드오프입니다. 언젠가 이 방식의 진가를 깨닫게 될 겁니다. 아니면 Rust로 갈아탈 수도 있고요. 둘 다 유효한 선택입니다.

Image description

패턴 1: 그저 반환하기 (Just Return It)

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }
    
    return &cfg, nil
}

언제 사용하는가: 호출자에게 더 이상 추가할 유용한 정보가 없고, 이미 충분한 문맥(context)을 가지고 있을 때입니다. 단순하게 에러를 위로 전파하는 것이죠.

언제 사용하지 말아야 하는가: 호출자가 unexpected end of JSON input 같은 에러 메시지를 보고도, 앱의 47개 JSON 파일 중 어떤 파일에서 문제가 발생했는지 전혀 알 수 없을 때입니다. 이때는 그냥 반환하는 것만으로는 부족합니다.

패턴 2: 1999년처럼 에러 감싸기 (Wrap It Like It's 1999)

fmt.Errorf%w는 이제 당신의 가장 친한 친구가 될 겁니다. 잘 대해주세요.

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("loadConfig: reading %s: %w", path, err)
    }
    
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("loadConfig: parsing %s: %w", path, err)
    }
    
    return &cfg, nil
}

이제 이 코드가 실패하면 다음과 같은 메시지를 얻게 됩니다:

loadConfig: parsing /etc/app/config.json: unexpected end of JSON input

%w 동사는 에러를 **래핑(wrapping)**합니다. 덕분에 호출자는 errors.Iserrors.As를 사용해서 원래 에러를 계속 검사할 수 있습니다. 에러 체인을 따라가며 근본적인 원인을 파악하는 거죠.

만약 %v를 대신 사용하면, 당신은 에러를 단순히 문자열로 변환해버린 셈입니다. 원본 에러는 사살된 것과 다름없습니다. 당신은 에러를 죽인 살인자입니다. (농담이 아니라, 정말 중요한 차이입니다!)

패턴 3: 센티넬 에러 (명명된 에러)

때로는 호출자가 특정 에러를 확인하기를 원할 때가 있습니다. 이럴 때 센티넬(Sentinel) 에러를 사용합니다.

var (
    ErrNotFound      = errors.New("user: not found")
    ErrUnauthorized  = errors.New("user: unauthorized")
    ErrRateLimited   = errors.New("user: rate limited, chill out")
)

func GetUser(id string) (*User, error) {
    if id == "" {
        return nil, ErrNotFound
    }
    // ... 실제 사용자 조회 로직
    return nil, fmt.Errorf("GetUser failed: %w", ErrNotFound) // 예시로 래핑
}

그리고 호출자는 이렇게 처리할 수 있습니다:

user, err := GetUser(id)
if errors.Is(err, ErrNotFound) {
    return c.JSON(404, "user not found")
}
if err != nil {
    return c.JSON(500, "something exploded")
}

errors.Is는 에러 래핑 체인을 따라 이동하며, 설령 에러가 12번이나 래핑되었다 해도, 센티넬 에러를 식별해낼 수 있습니다. 마치 에러를 위한 DNA 검사와 같죠.

패턴 4: 커스텀 에러 타입 (좀 멋 좀 부리고 싶을 때)

단순한 문자열만으로는 부족할 때가 있습니다. 에러와 함께 특정 데이터를 첨부하고 싶을 때, 구조체(struct)를 사용합니다. 즉, 멋 좀 부리고 싶을 때 쓰는 방법입니다.

type ValidationError struct {
    Field   string
    Message string
}

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

func validateEmail(email string) error {
    if !strings.Contains(email, "@") {
        return &ValidationError{
            Field:   "email",
            Message: "missing @ symbol, are you okay?",
        }
    }
    return nil
}

그리고 호출자는 이렇게 특정 에러 타입을 추출할 수 있습니다:

err := validateEmail(input)
var vErr *ValidationError // 추출할 에러 타입의 포인터를 선언
if errors.As(err, &vErr) { // err가 *ValidationError 타입으로 변환될 수 있는지 확인
    fmt.Printf("이봐, 네 %s 필드가 이상해: %s\n", vErr.Field, vErr.Message)
}

errors.Aserrors.Is의 야망 넘치는 사촌이라고 할 수 있습니다. 단순히 에러를 확인하는 것을 넘어, 특정 타입의 에러 객체를 추출하여 그 안에 담긴 데이터를 활용할 수 있게 해줍니다.

Image description

패턴 5: panicrecover (금지된 기술)

panic이라는 말을 들어본 적이 있을 겁니다. 어쩌면 어떤 라이브러리에서 이를 보고 싸늘한 기운을 느꼈을 수도 있고요.

경험상 황금률: panic을 발생시키고 있다면, 아마도 에러를 반환해야 할 상황일 가능성이 높습니다.

진정한 예외 상황 (극히 드물게 사용):

  • 정말로 복구 불가능한 상황 (예: 프로그램 상태가 심각하게 손상된 경우)
  • 프로그램이 아예 시작조차 할 수 없는 init() 함수 내부
  • 자신이 만든 패키지 내부에서, recover()를 통해 패키지 경계에서 패닉을 에러로 변환할 때 (주로 라이브러리에서 안전한 API를 제공할 때)
func MustCompile(pattern string) *Regexp {
    re, err := Compile(pattern)
    if err != nil {
        panic(err) // 시작 시점에서 치명적인 오류
    }
    return re
}

만약 정상적인 제어 흐름에 panic을 사용하고 있다면, Go 골퍼들이 당신을 찾아낼 겁니다. 그들에겐 방법이 있어요. 정말입니다.

스크롤러를 위한 요약 (TL;DR)

상황사용할 방법
추가 정보 없이 단순히 에러를 위로 전파할 때return err
문맥을 추가하고 싶을 때fmt.Errorf("어떤 작업 중 X: %w", err)
호출자가 특정 에러를 확인해야 할 때센티넬 에러 + errors.Is
호출자가 에러 객체의 데이터가 필요할 때커스텀 타입 + errors.As
세상이 망하고 있을 때 (정말 극히 드물게)panic (아껴서 사용하세요!)

마무리하며

네, if err != nil을 정말 많이 작성하게 될 겁니다.

하지만 핵심은, 이 코드를 더 이상 상투적인 반복문으로 보지 않고 **'결정의 순간'**으로 인식하기 시작하면 달라진다는 겁니다. 이 작은 블록 하나하나가 개발자인 당신이 잠시 멈춰 서서 "여기서 실패는 무엇을 의미하는가? 호출자는 무엇을 알아야 하는가?"라고 고민하는 소중한 시간이 됩니다.

이것은 결코 부담이 아닙니다. 오히려 개발자의 장인정신이라고 할 수 있죠.

이제 가서 당신의 에러들을 우아하게 감싸주세요!

Image description

이 글이 유익했다면 댓글에 🦫 이모티콘을 남겨주세요! 그렇지 않았다면, 속죄의 의미로 if err != nil { return err }를 100번 작성하세요. (웃음)


잠깐, 개발자님의 시간은 소중하니까!

개발자들이 코드를 작성하는 속도는 놀랍도록 빨라졌습니다. 하지만 AI 에이전트들이 생성하는 코드는 가끔 소리 없이 로직을 제거하거나, 의도치 않게 동작을 변경하고, 심지어 버그를 만들어내기도 합니다. 종종 이런 문제들은 프로덕션 환경에 배포된 후에야 발견되곤 하죠.

제가 참여하고 있는 git-lrc는 이런 문제를 해결하기 위해 태어났습니다. Git 커밋에 연동되어 모든 변경 사항(diff)을 배포 전에 리뷰해 줍니다. 60초면 설정 끝! 완전 무료이며, 무제한으로 사용할 수 있습니다.

git-lrc

피드백이나 기여자분들은 언제든 환영합니다! 온라인에서 소스 코드를 확인할 수 있으며, 누구나 사용해 볼 수 있습니다.

GitHub에서 별을 눌러 프로젝트를 응원해주세요! GitHub에서 git-lrc 프로젝트 살펴보기


원문: https://dev.to/lovestaco/error-handling-in-go-stop-panicking-start-wrapping-351d 수집일: 2026-05-09 01:44:18