Go 에러 핸들링 2026: 패턴, 래핑, 기술 면접 핵심 질문

Go 에러 핸들링 패턴 완벽 가이드. 센티넬 에러, 커스텀 타입, errors.Is와 errors.As, %w를 활용한 에러 래핑, 기술 면접 빈출 질문까지 체계적으로 다룬다.

Go error handling patterns and wrapping

Go의 에러 핸들링은 대부분의 주류 프로그래밍 언어와 근본적으로 다른 접근 방식을 채택하고 있다. 예외 처리와 try/catch 블록에 의존하는 언어들과 달리, Go는 에러를 값으로 취급하며 함수의 반환값으로 명시적으로 반환하는 설계를 고수한다. 이러한 의도적인 설계 결정은 에러 처리를 코드의 전면에 배치하여, 실패 경로의 가시성과 테스트 용이성을 크게 향상시킨다.

예외가 아닌 에러 값

Go에는 예외 메커니즘이 존재하지 않는다. 실패할 수 있는 모든 함수는 마지막 반환값으로 에러를 반환한다. 이 패턴 덕분에 에러를 무시하는 비용이 소스 코드 수준에서 가시화되며, 에러 흐름의 테스트가 매우 간단해진다.

error 인터페이스와 그 중요성

Go 에러 시스템 전체는 표준 라이브러리에 정의된 단일 인터페이스를 기반으로 한다:

builtin.gogo
type error interface {
    Error() string
}

Error() string을 구현하는 모든 타입이 이 인터페이스를 충족한다. 이 단순함이 합성 가능성을 촉진한다. 에러는 구조체, 래퍼, 혹은 문자열 표현을 생성할 수 있는 어떤 것이든 될 수 있다.

기본적인 커스텀 에러 타입으로 이 패턴을 확인할 수 있다:

apperror.gogo
type NotFoundError struct {
    Resource string
    ID       string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with ID %s not found", e.Resource, e.ID)
}

NotFoundError는 구조화된 데이터를 보유한다. 호출자는 문자열을 파싱하는 대신 리소스 이름이나 ID를 프로그래밍 방식으로 추출할 수 있다. 이는 HTTP API나 CLI 도구를 구축할 때 에러를 특정 응답에 매핑하는 데 결정적인 이점이 된다.

센티넬 에러를 활용한 알려진 조건 표현

센티넬 에러는 특정한 잘 알려진 실패 조건을 나타내는 패키지 수준 변수이다. 표준 라이브러리에서는 이 패턴을 광범위하게 사용한다: io.EOF, sql.ErrNoRows, os.ErrNotExist 등이 대표적인 예이다.

errors.gogo
var (
    ErrNotFound     = errors.New("record not found")
    ErrUnauthorized = errors.New("unauthorized access")
    ErrConflict     = errors.New("resource conflict")
)

센티넬 에러는 호출자가 상세한 이유가 아닌 무엇이 실패했는지만 알면 되는 경우에 가장 적합하다. 추가 컨텍스트 없이 조건을 시그널한다. Go 규약에 따라 Err 접두사를 붙이고, Go Code Review Comments 가이드라인에 맞게 메시지를 소문자로 구두점 없이 유지한다.

센티넬 에러가 문제가 되는 경우

센티넬 에러를 익스포트하면 공개 API 계약이 생성된다. 다운스트림 패키지가 errors.Is로 이를 매칭하므로, 센티넬 에러의 이름 변경이나 제거는 브레이킹 체인지가 된다. 호출자가 실제로 분기해야 하는 조건에만 사용하는 것이 권장된다.

fmt.Errorf와 %w를 활용한 에러 래핑

Go 1.13에서 도입된 에러 래핑은 원래 에러를 체인에 보존하면서 컨텍스트를 추가한다. fmt.Errorf%w 동사가 이 체인을 생성한다:

repository.gogo
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
    user, err := r.db.QueryContext(ctx, "SELECT * FROM users WHERE id = $1", id)
    if err != nil {
        return nil, fmt.Errorf("UserRepo.FindByID(%s): %w", id, err)
    }
    return user, nil
}

래핑된 에러는 전체 체인을 유지한다. 세 레이어 위의 호출자도 원래 센티넬을 매칭하거나 원래 타입을 추출할 수 있다. 각 레이어는 무엇이 발생했는지를 숨기지 않으면서 어디에서 에러가 발생했는지에 대한 컨텍스트를 추가한다.

중요한 구분이 있다: %w는 체인을 보존하며 래핑하지만, %v는 에러를 문자열로 포맷하고 체인을 끊는다. 리포지토리가 데이터베이스 드라이버 에러를 래핑하는 경우처럼, 원래 에러가 추상화 경계를 넘어 노출되어서는 안 되는 경우에는 의도적으로 %v를 사용한다.

errors.Is와 errors.As를 활용한 에러 검사

errors 패키지는 직접 비교와 타입 어설션을 대체하는, 래핑된 에러 체인을 검사하기 위한 두 가지 함수를 제공한다.

errors.Is는 체인을 순회하며 특정 에러 값을 찾는다:

handler.gogo
func handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := userService.GetByID(r.Context(), chi.URLParam(r, "id"))
    if errors.Is(err, ErrNotFound) {
        // ErrNotFound가 여러 번 래핑되었어도 매칭됨
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(user)
}

errors.As는 체인에서 특정 에러 타입을 추출한다:

middleware.gogo
func errorMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if r := recover(); r != nil {
                http.Error(w, "Internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func mapErrorToHTTP(err error) int {
    var notFound *NotFoundError
    if errors.As(err, &notFound) {
        // notFound.Resource와 notFound.ID 사용 가능
        return http.StatusNotFound
    }
    var validationErr *ValidationError
    if errors.As(err, &validationErr) {
        return http.StatusBadRequest
    }
    return http.StatusInternalServerError
}

타입 어설션 대비 핵심적인 장점은, 두 함수 모두 래핑된 전체 체인을 순회한다는 것이다. fmt.Errorf%w로 세 번 래핑된 NotFoundError도 문제없이 매칭된다.

레이어드 아키텍처에서의 구조화된 에러 핸들링

프로덕션 Go 애플리케이션은 일반적으로 세 레이어에 걸쳐 에러를 구성한다. 도메인 에러가 비즈니스 수준의 조건을 정의하고, 서비스 에러가 운영 컨텍스트를 추가하며, 핸들러 또는 트랜스포트 에러가 외부 응답에 매핑한다.

domain/errors.gogo
type DomainError struct {
    Code    string // 기계 판독 가능: "USER_NOT_FOUND", "INVALID_INPUT"
    Message string // 사람이 읽을 수 있는 설명
    Err     error  // 원래 원인
}

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

func (e *DomainError) Unwrap() error {
    return e.Err
}

Unwrap 메서드는 errors.Iserrors.As가 체인을 통해 동작하게 하는 핵심이다. 다른 에러를 래핑하는 커스텀 에러 타입은 이 메서드를 반드시 구현해야 한다.

서비스 레이어는 도메인 에러에 운영 컨텍스트를 추가한다:

service/user.gogo
func (s *UserService) Deactivate(ctx context.Context, userID string) error {
    user, err := s.repo.FindByID(ctx, userID)
    if err != nil {
        return fmt.Errorf("deactivating user %s: %w", userID, err)
    }
    if user.Status == StatusInactive {
        return &DomainError{
            Code:    "ALREADY_INACTIVE",
            Message: fmt.Sprintf("user %s is already inactive", userID),
        }
    }
    return s.repo.UpdateStatus(ctx, userID, StatusInactive)
}

Go 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

Go 에러 핸들링 기술 면접 빈출 질문

Go 포지션 기술 면접에서는 에러 핸들링 관련 지식이 빈번하게 테스트된다. 아래 질문들은 스크리닝 라운드에서 정기적으로 출제되며, Go 에러 핸들링 면접 질문 모음에서 심층적으로 다루고 있다.

Go가 예외 대신 에러 값을 사용하는 이유는 무엇인가?

예외는 보이지 않는 제어 흐름을 생성한다. throw하는 함수는 잠재적으로 여러 스택 프레임 너머의 알 수 없는 catch 지점으로 제어를 전달한다. Go의 에러 값은 함수 시그니처에서 모든 실패 경로를 명시적으로 만든다. 호출자는 즉시 에러 처리 방법을 결정한다: 재시도, 래핑, 로깅, 전파 중 하나를 선택한다. 이로 인해 "예상치 못한 throw" 문제가 제거되고, 별도의 도구 지원 없이도 에러 흐름을 읽을 수 있게 된다.

fmt.Errorf에서 %w%v의 차이점은 무엇인가?

%w는 에러를 래핑하여 errors.Iserrors.As를 위한 체인을 보존한다. %v는 에러를 문자열로 포맷하며 원래 에러로의 체인 링크가 없는 새로운 에러를 생성한다. 호출자가 원인을 검사할 수 있어야 하는 경우 %w를 사용하고, 원인이 추상화 경계를 넘어 노출되어서는 안 되는 구현 세부사항인 경우 %v를 사용한다.

함수가 에러를 반환해야 하는 경우와 panic해야 하는 경우의 기준은?

panic은 인덱스 범위 초과, nil 포인터 역참조, 불변 조건 위반과 같은 버그를 나타내는 진정으로 복구 불가능한 상황에만 사용된다. 네트워크 타임아웃, 레코드 누락, 잘못된 입력 등 복구 가능한 실패는 에러를 반환한다. 실용적인 규칙으로, 정상 운영 중에 발생할 수 있는 조건이면 에러를 반환하고, 프로그램에 버그가 있음을 의미하면 panic한다.

Unwrap 메서드는 에러 체인 검사에 어떤 영향을 미치는가?

커스텀 에러 타입이 Unwrap() error를 구현하면, errors.Iserrors.As 함수가 해당 메서드를 따라 체인을 순회한다. Unwrap이 없으면 체인은 해당 에러에서 멈춘다. Go 1.20부터는 Unwrap() []error를 구현하여 여러 래핑된 에러를 반환할 수도 있으며, 검증 에러 집계와 같은 경우에 트리 형태의 에러 체인이 가능해졌다.

피해야 할 에러 핸들링 안티패턴

Go 코드베이스와 코드 리뷰 세션에서 자주 발견되는 안티패턴들이 있다:

에러의 무시. 빈 식별자는 구문적으로 쉽지만 의미적으로 위험하다:

go
// 안티패턴: 에러의 묵시적 무시
result, _ := riskyOperation()

무시되는 모든 에러는 의도적이고 문서화된 선택이어야 하며, 결코 편의를 위한 생략이어서는 안 된다.

로깅과 반환의 동시 수행. 이 패턴은 중복된 로그 항목을 생성하고 에러를 마찬가지로 처리하는 호출자를 혼란스럽게 한다:

go
// 안티패턴: 이중 핸들링
if err != nil {
    log.Printf("operation failed: %v", err) // 여기서 로깅
    return err                               // 그리고 호출자에게 반환 (재로깅 가능성)
}

수정 방법: 에러를 처리하거나(로깅, 기본값 반환, 재시도) 전파하거나 둘 중 하나만 수행한다. 둘 다 하지 않는다.

문자열 매칭을 통한 에러 분류. err.Error() 출력을 strings.Contains로 확인하는 것은 취약하다. 에러 메시지는 API 계약이 아니며, 라이브러리 버전 간에 변경될 수 있다. 센티넬 값에는 errors.Is를, 타입에는 errors.As를 사용한다.

동기화 없이 고루틴 경계를 넘는 에러 래핑. 여러 고루틴이 동시에 에러를 생성하는 경우, golang.org/x/sync 패키지의 errgroup.Group 또는 유사한 동기화 메커니즘을 사용한다. 뮤텍스 없이 공유 슬라이스에 직접 추가하면 데이터 레이스가 발생한다.

errgroup을 활용한 동시 처리 에러 핸들링

golang.org/x/syncerrgroup 패키지는 동시 고루틴에서의 에러 수집에 대한 깔끔한 패턴을 제공한다:

batch.gogo
func (s *OrderService) ProcessBatch(ctx context.Context, orderIDs []string) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(10) // 동시 고루틴 수 제한

    for _, id := range orderIDs {
        g.Go(func() error {
            if err := s.processOrder(ctx, id); err != nil {
                return fmt.Errorf("processing order %s: %w", id, err)
            }
            return nil
        })
    }

    // 첫 번째 nil이 아닌 에러를 반환하고 나머지 작업을 취소
    return g.Wait()
}

errgroup.WithContext는 어떤 고루틴이 에러를 반환하는 시점에 파생된 컨텍스트를 취소하여 다른 고루틴에 중지를 시그널한다. 이를 통해 불필요한 처리가 방지되고 호출자에게 단일 래핑된 에러가 제공된다.

Go 1.24와 에러 핸들링

Go 1.24(2025년 2월)에서는 errors 패키지에 변경사항이 없었다. 에러 핸들링 API는 Go 1.13 이후로 안정적이며, Go 1.20에서 Unwrap() []error를 통한 멀티 에러 언래핑이 추가된 것이 마지막 변경이다. 개선된 에러 핸들링 구문에 대한 현재 제안은 Go issue tracker에서 계속 논의 중이다.

결론

  • 에러를 값으로 취급하고, 숨겨진 예외 메커니즘에 의존하지 않으며 모든 호출 지점에서 명시적으로 반환하고 처리한다
  • 센티넬 에러(var ErrX = errors.New(...))는 호출자가 분기해야 하는 조건에만 사용하고 공개 API로 취급한다
  • fmt.Errorf%w로 컨텍스트를 추가하면서 체인을 보존한다. 추상화 경계에서 의도적으로 체인을 끊을 때는 %v를 사용한다
  • == 대신 errors.Is를, 타입 어설션 대신 errors.As를 우선한다. 두 함수 모두 래핑된 전체 체인을 순회한다
  • 커스텀 에러 타입에 Unwrap() error를 구현하여 체인 검사가 올바르게 동작하도록 한다
  • 에러는 처리하거나 전파하되 둘 다 하지 않는다. 로깅과 반환의 동시 수행은 중복 노이즈를 만든다
  • 수동 고루틴 동기화 대신 errgroup을 사용하여 동시 에러 수집을 수행한다
  • Go 규약에 따라 에러 메시지는 소문자로, 구두점 없이 작성한다

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#go
#error-handling
#best-practices
#interview

공유

관련 기사