Go errors 패키지 심화

Go errors 패키지 심화: Is/As/Unwrap부터 도메인 에러 시스템까지

이 글에서는 Go errors 패키지의 동작 원리를 깊이 파고들고, 실제 프로덕션 서버에서 사용할 수 있는 도메인 에러 시스템을 단계적으로 설계합니다. 단순히 if err != nil을 확인하는 수준을 넘어, 에러 체인 탐색 메커니즘부터 계층별 에러 변환, HTTP 미들웨어 통합, 다중 에러 집계, 구조화된 로깅까지 — 실무에서 마주치는 에러 처리 시나리오를 모두 다룹니다.


1. Go 1.13 이전과 이후: errors 패키지의 진화

Go 1.12까지는 에러를 “감싸는” 공식 수단이 없었습니다. fmt.Errorf로 새 문자열 에러를 만들 수는 있었지만, 원본 에러와의 연결이 끊어지는 문제가 있었습니다.

// Go 1.12 이하 — 원본 에러 정보 소실
err := fmt.Errorf("파일 열기 실패: %v", originalErr)
// errors.Is(err, originalErr) → false (연결 없음)

Go 1.13에서 %w 동사와 errors.Is, errors.As, errors.Unwrap이 표준 라이브러리에 추가되며 상황이 완전히 달라졌습니다.

기능Go 1.12 이하Go 1.13+
에러 래핑불가 (%v는 문자열 변환)fmt.Errorf("%w", err)
원본 에러 비교err.Error() == target.Error() (취약)errors.Is(err, target)
타입 단언직접 type assertionerrors.As(err, &target)
체인 순회없음errors.Unwrap(err)
커스텀 비교없음Is(), As() 메서드 구현

Go 1.20에서는 errors.Join이 추가되어 여러 에러를 하나로 합치는 것도 표준화되었습니다.


2. 에러 래핑 메커니즘: %w와 Unwrap 체인

2-1. fmt.Errorf(“%w”)의 내부 구조

fmt.Errorf("...: %w", err)는 내부적으로 Unwrap() error 메서드를 가진 구조체를 생성합니다.

// fmt 패키지 내부 (개념적 표현)
type wrapError struct {
    msg string
    err error
}

func (e *wrapError) Error() string { return e.msg }
func (e *wrapError) Unwrap() error { return e.err }

이 덕분에 에러를 여러 계층에서 래핑해도 원본 에러에 도달할 수 있는 “체인”이 유지됩니다.

var ErrDBConnection = errors.New("DB 연결 실패")

// 3단계 래핑
err1 := fmt.Errorf("쿼리 실행 오류: %w", ErrDBConnection)
err2 := fmt.Errorf("사용자 조회 실패: %w", err1)
err3 := fmt.Errorf("로그인 처리 오류: %w", err2)

// 체인: err3 → err2 → err1 → ErrDBConnection
fmt.Println(errors.Is(err3, ErrDBConnection)) // true

2-2. errors.Is 탐색 알고리즘

errors.Is(err, target)은 다음 순서로 체인을 탐색합니다.

1. err == target 인가?
2. err.Is(target)가 있고 true를 반환하는가? (커스텀 비교)
3. errors.Unwrap(err)로 다음 에러를 꺼내 1번부터 반복
4. nil에 도달하면 false

이 탐색 특성을 이용하면 커스텀 Is() 메서드로 비교 로직을 직접 정의할 수 있습니다.

type HTTPError struct {
    Code    int
    Message string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message)
}

// 같은 상태 코드면 동일한 에러로 취급
func (e *HTTPError) Is(target error) bool {
    t, ok := target.(*HTTPError)
    if !ok {
        return false
    }
    return e.Code == t.Code
}

var ErrNotFound = &HTTPError{Code: 404, Message: "not found"}

// 사용 예
err := fmt.Errorf("리소스 조회 실패: %w", &HTTPError{Code: 404, Message: "user not found"})
fmt.Println(errors.Is(err, ErrNotFound)) // true — Code 404 일치

🛠️ errors.Is에서 == 비교는 포인터 동등성이므로, 같은 구조체라도 새로 할당된 인스턴스는 false를 반환합니다. 커스텀 Is() 메서드는 이 경우에 특히 유용합니다.


3. errors.As: 타입 체인 탐색

errors.As(err, &target)은 에러 체인에서 target 타입에 해당하는 첫 번째 에러를 찾아 target에 할당합니다.

3-1. 기본 사용법

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("검증 오류 [%s]: %s", e.Field, e.Message)
}

func validateEmail(email string) error {
    if !strings.Contains(email, "@") {
        return &ValidationError{Field: "email", Message: "올바른 이메일 형식이 아닙니다"}
    }
    return nil
}

func processRegistration(email string) error {
    if err := validateEmail(email); err != nil {
        return fmt.Errorf("회원가입 처리 실패: %w", err)
    }
    return nil
}

func main() {
    err := processRegistration("not-an-email")

    var ve *ValidationError
    if errors.As(err, &ve) {
        // ve에 *ValidationError가 할당됨
        fmt.Printf("필드: %s, 이유: %s\n", ve.Field, ve.Message)
        // 필드: email, 이유: 올바른 이메일 형식이 아닙니다
    }
}

3-2. 커스텀 As() 메서드

As() 메서드를 구현하면 타입 변환 로직을 직접 제어할 수 있습니다. 주로 인터페이스로 추출하거나 다른 타입으로 변환할 때 활용합니다.

type WrappedErrors struct {
    Errors []error
}

func (e *WrappedErrors) Error() string {
    msgs := make([]string, len(e.Errors))
    for i, err := range e.Errors {
        msgs[i] = err.Error()
    }
    return strings.Join(msgs, "; ")
}

// errors.As가 WrappedErrors 내부의 에러들도 탐색하도록 확장
func (e *WrappedErrors) As(target any) bool {
    for _, err := range e.Errors {
        if errors.As(err, target) {
            return true
        }
    }
    return false
}

4. Sentinel 에러 vs 타입 에러 vs 구조체 에러

실무에서 에러를 어떤 형태로 정의할지는 용도에 따라 달라집니다.

형태예시장점단점적합한 상황
Sentinel 에러var ErrNotFound = errors.New(...)단순, 패키지 경계로 공개 가능추가 정보 없음특정 조건 감지만 필요할 때
타입 에러 (interface)type NotFoundError interface {...}인터페이스로 느슨한 결합구현 복잡외부 패키지 호환이 중요할 때
구조체 에러type AppError struct {...}풍부한 정보 포함 가능패키지 결합도 높음내부 도메인 에러 시스템
// 1) Sentinel 에러 — 간단한 조건 감지
var (
    ErrNotFound   = errors.New("리소스를 찾을 수 없습니다")
    ErrForbidden  = errors.New("접근 권한이 없습니다")
    ErrConflict   = errors.New("리소스 충돌")
)

// 2) 구조체 에러 — HTTP 응답 코드와 도메인 정보 포함
type AppError struct {
    HTTPStatus int    // HTTP 응답 상태 코드
    Code       string // 클라이언트용 에러 코드 (예: "USER_NOT_FOUND")
    Message    string // 사용자에게 보여줄 메시지
    Internal   error  // 내부 디버깅용 원인 에러 (로그에만 기록)
}

func (e *AppError) Error() string {
    if e.Internal != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

func (e *AppError) Unwrap() error {
    return e.Internal
}

// 생성 헬퍼
func ErrNotFoundApp(resource string) *AppError {
    return &AppError{
        HTTPStatus: http.StatusNotFound,
        Code:       "NOT_FOUND",
        Message:    resource + "을(를) 찾을 수 없습니다",
    }
}

func ErrValidationApp(field, reason string) *AppError {
    return &AppError{
        HTTPStatus: http.StatusBadRequest,
        Code:       "VALIDATION_ERROR",
        Message:    fmt.Sprintf("%s: %s", field, reason),
    }
}

func ErrInternalApp(cause error) *AppError {
    return &AppError{
        HTTPStatus: http.StatusInternalServerError,
        Code:       "INTERNAL_ERROR",
        Message:    "서버 내부 오류가 발생했습니다",
        Internal:   cause,
    }
}

5. 계층별 에러 변환 패턴

실제 프로덕션 서버에서는 에러가 여러 계층을 거쳐 전달됩니다. 각 계층이 자신의 언어로 에러를 번역하는 것이 유지보수의 핵심입니다.

Repository (DB 에러) → Service (도메인 에러) → Handler (HTTP 에러)

5-1. Repository 계층

// internal/repository/user.go

type UserRepository struct {
    db *sql.DB
}

func (r *UserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
    var user User
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name, &user.Email)

    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            // DB 에러를 도메인 Sentinel 에러로 변환
            return nil, fmt.Errorf("FindByID id=%d: %w", id, ErrNotFound)
        }
        return nil, fmt.Errorf("FindByID id=%d: %w", id, err)
    }
    return &user, nil
}

5-2. Service 계층

// internal/service/user.go

type UserService struct {
    repo UserRepositoryInterface
}

func (s *UserService) GetProfile(ctx context.Context, id int64) (*UserProfile, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            // Sentinel 에러를 AppError로 승격
            return nil, ErrNotFoundApp(fmt.Sprintf("사용자(id=%d)", id))
        }
        // 예상치 못한 에러는 Internal로 래핑 (원인 보존)
        return nil, ErrInternalApp(fmt.Errorf("GetProfile: %w", err))
    }

    return &UserProfile{
        ID:    user.ID,
        Name:  user.Name,
        Email: user.Email,
    }, nil
}

5-3. Handler 계층

// internal/handler/user.go

func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
    idStr := r.PathValue("id") // Go 1.22+
    id, err := strconv.ParseInt(idStr, 10, 64)
    if err != nil {
        writeError(w, ErrValidationApp("id", "숫자여야 합니다"))
        return
    }

    profile, err := h.service.GetProfile(r.Context(), id)
    if err != nil {
        writeError(w, err)
        return
    }

    writeJSON(w, http.StatusOK, profile)
}

// writeError: AppError 여부에 따라 응답 분기
func writeError(w http.ResponseWriter, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        writeJSON(w, appErr.HTTPStatus, map[string]string{
            "code":    appErr.Code,
            "message": appErr.Message,
        })
        return
    }
    // AppError가 아닌 경우 500 반환
    writeJSON(w, http.StatusInternalServerError, map[string]string{
        "code":    "INTERNAL_ERROR",
        "message": "서버 내부 오류가 발생했습니다",
    })
}

🛠️ Internal 필드를 가진 AppError는 로그에는 전체 정보를 기록하고, 클라이언트에는 안전한 메시지만 반환합니다. 이 분리가 보안과 디버깅 편의성을 동시에 해결하는 핵심입니다.


6. net/http 에러 미들웨어

에러 처리를 미들웨어에서 중앙화하면 각 핸들러의 코드가 크게 줄어듭니다. 표준 net/http에서는 핸들러가 에러를 직접 반환하지 않으므로, 커스텀 핸들러 타입을 정의하는 패턴을 사용합니다.

// 에러를 반환할 수 있는 핸들러 타입
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error

// http.Handler로 변환하는 어댑터 — 에러 처리 로직 중앙화
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := f(w, r); err != nil {
        // 1) 구조화된 로깅 (slog)
        logError(r.Context(), err)

        // 2) 클라이언트 응답
        writeError(w, err)
    }
}

// 사용 예
mux := http.NewServeMux()
mux.Handle("GET /users/{id}", HandlerFunc(userHandler.GetProfile))
mux.Handle("POST /users", HandlerFunc(userHandler.CreateUser))

이 패턴을 쓰면 핸들러가 훨씬 간결해집니다.

// HandlerFunc 패턴 적용 후 — 에러 처리 로직 없이 비즈니스 로직에 집중
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) error {
    id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    if err != nil {
        return ErrValidationApp("id", "숫자여야 합니다")
    }

    profile, err := h.service.GetProfile(r.Context(), id)
    if err != nil {
        return err // 그대로 반환, 어댑터가 처리
    }

    return writeJSON(w, http.StatusOK, profile)
}

7. 다중 에러 집계: errors.Join과 MultiError

입력 검증처럼 여러 오류를 동시에 수집해야 하는 경우, 에러를 하나씩 반환하면 사용자 경험이 나빠집니다.

7-1. Go 1.20 errors.Join

// errors.Join: 여러 에러를 하나로 합침 (Go 1.20+)
func validateCreateUser(req CreateUserRequest) error {
    var errs []error

    if req.Name == "" {
        errs = append(errs, errors.New("name: 이름은 필수입니다"))
    }
    if len(req.Name) > 50 {
        errs = append(errs, errors.New("name: 50자를 초과할 수 없습니다"))
    }
    if !isValidEmail(req.Email) {
        errs = append(errs, errors.New("email: 올바른 이메일 형식이 아닙니다"))
    }
    if len(req.Password) < 8 {
        errs = append(errs, errors.New("password: 8자 이상이어야 합니다"))
    }

    return errors.Join(errs...) // nil만 있으면 nil 반환
}

errors.Join으로 만든 에러는 Unwrap() []error를 구현하여, errors.Iserrors.As가 각각의 에러를 순회합니다.

7-2. 필드별 에러를 포함한 커스텀 MultiError

API 응답에서 필드별 에러 목록을 구조화해 반환하려면 커스텀 타입이 필요합니다.

type FieldError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

type MultiValidationError struct {
    Errors []FieldError `json:"errors"`
}

func (e *MultiValidationError) Error() string {
    msgs := make([]string, len(e.Errors))
    for i, fe := range e.Errors {
        msgs[i] = fmt.Sprintf("%s: %s", fe.Field, fe.Message)
    }
    return strings.Join(msgs, "; ")
}

func (e *MultiValidationError) Add(field, message string) {
    e.Errors = append(e.Errors, FieldError{Field: field, Message: message})
}

func (e *MultiValidationError) HasErrors() bool {
    return len(e.Errors) > 0
}

// 사용
func validateUser(req CreateUserRequest) error {
    ve := &MultiValidationError{}

    if req.Name == "" {
        ve.Add("name", "필수 입력값입니다")
    }
    if !isValidEmail(req.Email) {
        ve.Add("email", "올바른 이메일 형식이 아닙니다")
    }
    if len(req.Password) < 8 {
        ve.Add("password", "8자 이상이어야 합니다")
    }

    if ve.HasErrors() {
        return ve
    }
    return nil
}

// 핸들러에서의 처리
func handleCreateUser(w http.ResponseWriter, r *http.Request) error {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        return ErrValidationApp("body", "올바른 JSON 형식이 아닙니다")
    }

    if err := validateUser(req); err != nil {
        var mve *MultiValidationError
        if errors.As(err, &mve) {
            // 필드별 에러 목록을 400으로 반환
            writeJSON(w, http.StatusBadRequest, map[string]any{
                "code":   "VALIDATION_ERROR",
                "errors": mve.Errors,
            })
            return nil
        }
        return err
    }
    // ... 나머지 처리
    return nil
}

8. 에러 로깅: log/slog 연동

Go 1.21에 표준 구조화 로깅 패키지 log/slog가 추가되었습니다. 에러를 로깅할 때는 에러 체인에서 내부 원인까지 추출해 기록하는 것이 중요합니다.

// 에러 로그 헬퍼 — Internal 필드까지 구조화 기록
func logError(ctx context.Context, err error) {
    attrs := []slog.Attr{
        slog.String("error", err.Error()),
    }

    // AppError의 Internal 원인도 별도 기록
    var appErr *AppError
    if errors.As(err, &appErr) && appErr.Internal != nil {
        attrs = append(attrs,
            slog.String("internal_error", appErr.Internal.Error()),
            slog.String("error_code", appErr.Code),
            slog.Int("http_status", appErr.HTTPStatus),
        )
    }

    // context에서 요청 ID 추출 (이전 글의 미들웨어 연동)
    if reqID, ok := ctx.Value(requestIDKey{}).(string); ok {
        attrs = append(attrs, slog.String("request_id", reqID))
    }

    slog.LogAttrs(ctx, slog.LevelError, "request error", attrs...)
}

이 함수를 앞서 정의한 HandlerFunc.ServeHTTP 어댑터에서 호출하면, 모든 에러가 자동으로 구조화 로그로 기록됩니다.

🛠️ slog.LevelError는 500 이상 에러에 사용하고, 400번대 클라이언트 에러는 slog.LevelWarn으로 구분하면 알림 설정에 유용합니다. appErr.HTTPStatus >= 500 조건으로 레벨을 분기하세요.


9. Panic을 에러로 변환하는 recover 미들웨어

기본편에서 다룬 panic/recover 패턴을 에러 시스템과 통합하면, 예상치 못한 패닉도 에러 체계 안으로 흡수할 수 있습니다.

func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if v := recover(); v != nil {
                // 스택 트레이스 포함
                buf := make([]byte, 4096)
                n := runtime.Stack(buf, false)
                stackTrace := string(buf[:n])

                internalErr := fmt.Errorf("panic: %v\n%s", v, stackTrace)
                appErr := ErrInternalApp(internalErr)

                logError(r.Context(), appErr)
                writeError(w, appErr)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

10. 에러 처리 안티패턴

안티패턴 1: 에러 삼키기

// ❌ 에러를 무시 — 디버깅 불가
result, _ := someOperation()

// ✅ 처리할 수 없더라도 로그 기록
result, err := someOperation()
if err != nil {
    slog.Warn("someOperation 실패 (계속 진행)", "error", err)
}

안티패턴 2: 에러 메시지만 비교

// ❌ 문자열 비교 — 메시지 변경 시 버그
if err.Error() == "file not found" {
    // ...
}

// ✅ errors.Is 사용
if errors.Is(err, ErrNotFound) {
    // ...
}

안티패턴 3: 같은 에러를 여러 번 래핑

// ❌ 중복 래핑 — 로그에 같은 메시지가 반복됨
func service() error {
    err := repo.Find()
    if err != nil {
        return fmt.Errorf("service.Find 실패: %w", err) // 래핑
    }
    return nil
}

func handler() error {
    err := service()
    if err != nil {
        return fmt.Errorf("handler.service 실패: %w", err) // 또 래핑
    }
    return nil
}
// 최종: "handler.service 실패: service.Find 실패: repo error"

// ✅ 계층 경계에서만 래핑, 나머지는 그대로 반환
func service() error {
    err := repo.Find()
    if err != nil {
        return fmt.Errorf("service.Find: %w", err)
    }
    return nil
}

func handler() error {
    return service() // 추가 래핑 불필요
}

안티패턴 4: Internal 에러를 클라이언트에 노출

// ❌ DB 에러 메시지 노출 — 보안 위협
writeJSON(w, 500, map[string]string{
    "error": err.Error(), // "pq: relation \"users\" does not exist"
})

// ✅ 내부 에러는 로그에만, 클라이언트엔 안전한 메시지만
logError(r.Context(), err)
writeJSON(w, 500, map[string]string{
    "code":    "INTERNAL_ERROR",
    "message": "서버 내부 오류가 발생했습니다",
})

11. 전체 에러 흐름 통합 예시

func main() {
    db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))

    userRepo := &UserRepository{db: db}
    userService := &UserService{repo: userRepo}
    userHandler := &UserHandler{service: userService}

    mux := http.NewServeMux()
    mux.Handle("GET /users/{id}", HandlerFunc(userHandler.GetProfile))
    mux.Handle("POST /users", HandlerFunc(userHandler.CreateUser))

    // 미들웨어 체인: Recovery → RequestID → Logging → Router
    handler := RecoveryMiddleware(
        RequestIDMiddleware(
            LoggingMiddleware(mux),
        ),
    )

    srv := &http.Server{
        Addr:    ":8080",
        Handler: handler,
    }

    slog.Info("서버 시작", "addr", srv.Addr)
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        slog.Error("서버 시작 실패", "error", err)
        os.Exit(1)
    }
}

핵심 요약

다음 글에서는 Go JSON 처리를 다룹니다. encoding/json 패키지의 Marshal/Unmarshal, 커스텀 직렬화, 스트리밍 디코더, 성능 최적화까지 살펴보겠습니다.