Go에서 깔끔한 에러 처리 패턴

English version

TECH INSIGHT / EXPLAINED

좋은 에러는 실패한 작업을 설명하면서 호출자가 검사할 원인을 보존합니다. 설정 파일을 열지 못하는 상황을 통해 감싸기·분류·타입 추출이 함께 동작하는 방식을 실행 가능한 프로그램으로 살펴봅니다.

동료 세 명이 에러 원인 보존과 분류 주제를 그림으로 설명하는 모습
이해를 돕기 위한 AI 생성 설명 일러스트. 실제 운영 화면이나 실적 자료가 아닙니다.
package main

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

func loadConfig(path string) ([]byte, error) {
    b, err := os.ReadFile(path)
    if err != nil { return nil, fmt.Errorf("load config: %w", err) }
    return b, nil
}

func main() {
    _, err := loadConfig("missing-directory/settings.json")
    if errors.Is(err, fs.ErrNotExist) {
        fmt.Println("missing: stop startup")
    }
    var pe *fs.PathError
    if errors.As(err, &pe) {
        fmt.Println("operation:", pe.Op)
    }
}

한 에러에 두 가지 질문

errors.Is는 에러 트리에 특정 조건이 있는지 묻습니다. errors.As는 원하는 타입으로 할당 가능한 에러를 찾아 필드를 읽게 합니다. &pe를 넘기는 이유는 As가 포인터 변수에 결과를 써야 하기 때문입니다. 없는 디렉터리에서는 missing: stop startup과 operation: open이 출력됩니다. 에러 문자열은 사람이 읽는 설명으로 쓰고, 문자열 비교로 복구 경로를 결정하지 않습니다.

기본값 사용은 별도의 정책이다

화면 색상 파일이 없으면 기본값이 괜찮을 수 있습니다. 하지만 자동매매의 위험 한도 설정이 없는데 임의의 기본값으로 시작하면 노출이 달라집니다. 중단·재시도·명시적인 기본값 중 무엇을 쓸지는 애플리케이션이 결정해야 합니다. 권한 오류, 잘못된 JSON, 취소를 모두 파일 없음으로 처리하지 마세요.

감싼 원인도 API의 일부가 된다

호출자가 파일 시스템 에러에 의존하면, 나중에 저장소를 데이터베이스로 바꾸는 일이 계약 변경이 됩니다. 이 경계가 중요하면 안정적인 도메인 에러를 반환하고 자세한 원인은 제한된 로그에 남기는 편이 낫습니다. 복구 책임을 가진 계층에서 한 번 기록하세요. 모든 계층에서 기록하고 반환하면 같은 장애 알림이 중복됩니다. 토큰이나 전체 설정 내용은 에러 메시지에 넣지 않습니다.

반환값 계약부터 실패 표를 만든다

함수마다 성공과 실패 때 반환 데이터의 의미를 먼저 적어 두면 호출부가 짧아집니다. 예를 들어 설정 로더는 완전히 검증된 설정만 성공으로 반환하고, 일부 필드만 읽힌 객체는 외부에 공개하지 않도록 정할 수 있습니다. 반면 스트림 읽기는 데이터와 에러가 함께 올 수 있으므로 같은 규칙을 모든 함수에 적용하면 안 됩니다. 읽기 실패, 구문 오류, 필수 항목 누락을 분리하고 각 경우에 기존 상태가 유지되는지도 계약에 포함합니다.

검증은 에러 문장을 그대로 비교하기보다 관측할 행동을 확인합니다. 존재하지 않는 경로에서는 설정을 적용하지 않는지, 잘못된 값에서는 기본값으로 슬쩍 시작하지 않는지, 성공하면 정확히 한 번만 새 설정을 공개하는지 검사하세요. 테스트용 입력은 임시 파일이나 주입한 읽기 함수로 만들면 운영 설정을 건드리지 않고 재현할 수 있습니다.

여러 원인이 있으면 복구 우선순위도 필요하다

작업 실패 후 정리까지 실패했다면 마지막 에러로 앞의 원인을 덮지 않습니다. Go 1.20부터 제공되는 errors.Join으로 둘 다 보존할 수 있습니다. 다만 여러 원인을 보존하는 것과 복구 결정을 여러 번 실행하는 것은 다릅니다. 취소와 일시 장애가 함께 있으면 호출자의 취소를 먼저 존중하는 식으로 애플리케이션의 우선순위를 정해야 합니다.

아래 교육용 프로그램은 파일이나 네트워크를 건드리지 않습니다. 출력은 순서대로 true false, true, true입니다. Join이 만든 가지를 검사할 때 단일 원인용 Unwrap을 반복하는 도구는 충분하지 않다는 점도 확인합니다.

package main

import (
    "context"
    "errors"
    "fmt"
)

type validationError struct{}
func (*validationError) Error() string { return "invalid input" }

func main() {
    var pointer *validationError
    var boxed error = pointer
    fmt.Println(pointer == nil, boxed == nil)

    err := errors.Join(context.Canceled, errors.New("cleanup failed"))
    fmt.Println(errors.Is(err, context.Canceled))
    fmt.Println(errors.Unwrap(err) == nil)
}

nil 포인터가 성공을 뜻하지 않는 경우

첫 출력에서 포인터는 nil이지만 error 인터페이스에는 구체적 타입이 들어 있으므로 nil이 아닙니다. 성공 경로에서 특정 에러 포인터 변수를 그대로 반환하면 호출자가 실패로 분기할 수 있습니다. 성공은 명시적으로 return nil로 표현하고, 에러를 인터페이스에 넣기 전에 생성 여부를 확인하세요. 이 동작은 Go FAQ의 nil 에러 설명에 나오는 언어 규칙입니다.

호출부마다 리플렉션으로 typed nil을 지우는 처방보다 반환 함수의 계약을 고치는 편이 낫습니다. 외부 라이브러리 경계에서 방어 처리가 필요하다면 그 어댑터에 한정하고, 잘못된 성공 응답을 실제로 재현하는 회귀 사례를 남깁니다.

재시도는 에러 이름만 보고 결정하지 않는다

동일한 타임아웃이라도 읽기와 쓰기의 의미가 다릅니다. 설정 조회는 전체 기한 안에서 다시 읽을 수 있지만, 외부 상태를 바꾸는 요청은 처리 결과 확인이 먼저입니다. context 문서의 취소 통지는 원격 작업을 되돌리는 명령이 아닙니다. 재시도를 승인하는 계층에는 남은 시간, 시도 한도, 작업의 중복 실행 가능성을 함께 전달하세요.

실패 표에는 최소한 성공, 감싼 취소, 서로 다른 원인의 결합, typed nil을 넣습니다. 재시도 횟수뿐 아니라 취소 뒤 추가 호출이 없고, 실패 뒤 부분 데이터가 적용되지 않으며, 정리 실패가 최초 원인을 지우지 않는지 확인하면 에러 처리가 실제 동작의 계약이 됩니다.

공식 문서와 이어 읽기

내용·예제 검토: 2026-10-01

댓글