Go 에러 처리 아키텍처: 경계와 패턴

적절한 경계에서 에러를 처리하세요.

Page content

Go의 에러 처리는 쉽게 불만을 제기할 수 있는 주제입니다. 모든 Go 개발자가 수백 번 이 코드를 작성해 봤을 것입니다:

if err != nil {
	return err
}

그것은 흥미로운 부분이 아닙니다. 흥미로운 부분은 에러가 무엇을 의미하는지, 어디서 처리되어야 하는지, 어디서 감싸야(wrap) 하는지, 어디서 번역(translate)되어야 하는지, 어디서 로그를 남겨야 하는지, 그리고 콜러(caller)에게 무엇을 노출해야 하는지입니다 — 이것이 바로 아키텍처의 문제입니다.

Go는 에러를 값(value)으로 취급합니다. 이것은 실패를 명시적으로 만듭니다. 동시에, 코드베이스에 명확한 에러 처리 설계가 필요하다는 뜻이기도 합니다. 이러한 설계가 없다면 에러는 무작위 문자열이 되고, HTTP 핸들러는 데이터베이스 세부 정보를 누출시키며, 로그는 동일한 실패를 다섯 번 중복 기록하고, 잘못된 이유로 재시도가 발생하며, 콜러는 동작 대신 텍스트를 조사하게 됩니다.

Go 에러 처리 아키텍처: 레이어 사이로 흐르는 에러

이 글은 if err != nil에 대한 초보자의 입문서가 아닙니다.

이것은 Go 에러 처리 아키텍처에 대한 실용적인 가이드입니다: 감싸기(wrapping), 센티널(sentinel), 커스텀 에러 타입, errors.Is, errors.As, 에러 바운더리(boundary), API 매핑, 로깅, 재시도, 보안, 그리고 프로덕션 패턴.

약간의 의견이 담긴 버전으로 말하자면, Go의 에러를 사라지게 하려고 하지 마세요. 올바른 바운더리에서 의미를 부여하세요.

Go의 에러란 무엇인가

Go에서 에러는 이 인터페이스를 구현하는 단순한 값입니다:

type error interface {
	Error() string
}

이 작은 인터페이스가 Go의 에러 처리가 그렇게 직접적으로 느껴지는 이유입니다.

함수는 명시적으로 에러를 반환합니다:

func LoadUser(id string) (*User, error) {
	// ...
}

콜러가 무엇을 할지 결정합니다:

user, err := LoadUser(id)
if err != nil {
	return nil, err
}

예외(exception)나 숨겨진 스택 언와인딩(stack unwinding)은 없습니다. 실패는 함수 시그니처의 일부입니다.

좋은 점이지만, 에러에도 설계가 필요하다는 뜻이기도 합니다. 모든 패키지가 임의의 메시지를 반환하면, 콜러는 신뢰할 수 있는 결정을 내릴 수 없습니다. 모든 레이어가 규율 없이 모든 에러를 감싼다면, 운영자는 소음 많은 메시지를 받고 개발자는 혼란스러운 체인(chain)에 직면합니다. 아무 레이어도 에러를 감싸지 않으면, 실패는 컨텍스트를 잃습니다.

목표는 에러 처리를 줄이는 것이 아니라, 에러의 의미를 향상시키는 것입니다.

에러의 세 가지 역할

유용한 에러는 보통 하나 이상의 역할을 가집니다.

역할 1: 무엇이 실패했는지 설명하기

사람들을 위해, 에러는 어떤 작업이 실패했는지 설명해야 합니다.

예시:

return fmt.Errorf("load user %s: %w", id, err)

이것은 컨텍스트를 제공합니다. 실패가 사용자 로드 중에 발생했다는 것을 나타냅니다.

역할 2: 원인을 보존하기

코드를 위해, 해당 원인이 중요할 때 기본 원인을 보존해야 합니다.

예시:

return fmt.Errorf("load user %s: %w", id, err)

%w는 원래 에러를 감싸서 콜러가 errors.Is 또는 errors.As로 이를 조사할 수 있게 합니다. 감싸기(Wrapping)에는 Go 1.27에서 하나의 함정이 생겼습니다. go test 뒤의 vet 체크가 이제 p가 자체적으로 error를 구현하는 타입의 포인터인 경우 fmt.Errorf("...: %w", p)에서 경고를 발생시킵니다. 왜냐하면 추가된 참조(indirection)가 타입 어설션(type assertions)과 errors.Is 매칭을 깨뜨리기 때문입니다. Go 1.27 업그레이드 가이드는 이를 포함하여 툴체인 업그레이드 후 드러나는 다른 vet 발견 사항을 다룹니다.

역할 3: 바운더리가 결정을 내릴 수 있게 하기

어떤 바운더리에서든, 프로그램은 무엇을 할지 결정해야 합니다.

예시:

  • HTTP 404 반환
  • HTTP 409 반환
  • 작업 재시도
  • 경고 레벨로 로깅
  • 사용자 안전한 메시지 표시
  • 트랜잭션 중단
  • 모니터링으로 에러 전송
  • 취소(cancellation) 무시

이 결정은 보통 문자열 매칭이 아닌 에러의 동일성(identity)이나 타입에 기반해야 합니다.

현대 Go의 주요 에러 도구

현대 Go는 작지만 강력한 도구 세트를 제공합니다.

errors.New

errors.New를 사용하여 간단한 에러 값을 생성합니다:

var ErrNotFound = errors.New("not found")

이것은 센티널 에러에 유용합니다.

fmt.Errorf with %w

fmt.Errorf와 %w를 사용하여 에러를 감쌉니다:

return fmt.Errorf("query user: %w", err)

감싸기는 컨텍스트를 추가하면서 조사용으로 원래 에러를 보존합니다.

errors.Is

errors.Is를 사용하여 에러 체인의 어딘가에 특정 대상이 일치하는지 확인합니다:

if errors.Is(err, ErrNotFound) {
	// handle not found
}

이것은 센티널 에러와 알려진 조건에 사용하세요.

errors.As

errors.As를 사용하여 체인에서 특정 에러 타입을 추출합니다:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	// use validationErr.Field or validationErr.Reason
}

이것은 에러가 구조화된 데이터를 포함할 때 사용하세요.

errors.Join

여러 에러가 발생했고 모두 보존되어야 할 때 errors.Join를 사용합니다:

return errors.Join(closeErr, flushErr)

結合된 에러는 여전히 errors.Is와 errors.As로 조사할 수 있습니다.

이를 신중하게 사용하세요. 結合된 에러는 여러 실패가 하나의 결과의 일부임을 의미합니다.

센티널 에러 (Sentinel Errors)

센티널 에러는 알려진 조건을 나타내는 패키지 수준의 에러 값입니다.

예시:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

콜러가 실패의 범주만 알면 될 때 센티널 에러는 유용합니다.

예시:

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.queryUser(ctx, id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}
		return nil, fmt.Errorf("query user: %w", err)
	}

	return user, nil
}

그런 다음 서비스나 핸들러가 다음과 같이 검사할 수 있습니다:

if errors.Is(err, ErrUserNotFound) {
	// return 404
}

센티널 에러를 사용할 때

다음과 같은 경우 센티널 에러를 사용하세요:

  • 조건이 안정적일 때.
  • 콜러가 이를 기반으로 분기해야 할 때.
  • 추가적인 구조화 데이터가 필요하지 않을 때.
  • 에러가 자신의 패키지나 도메인에 속할 때.

좋은 예시:

var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")

센티널 에러를 사용하지 말아야 할 때

가능한 모든 실패마다 센티널을 생성하지 마세요.

나쁜 예시:

var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")

콜러가 이 값들에 대해 분기하지 않는다면, 그것들은 그냥 메시지일 수 있습니다.

또한 너무 많은 센티널을 내보내지 않도록 주의하세요. 내보내기된(Exported) 센티널 에러는 패키지 API의 일부가 됩니다.

커스텀 에러 타입 (Custom Error Types)

에러가 구조화된 정보를 포함할 때 커스텀 에러 타입은 유용합니다.

예시:

type ValidationError struct {
	Field  string
	Reason string
}

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

콜러:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println(validationErr.Field)
}

이것은 에러 문자열을 파싱하는 것보다 낫습니다.

커스텀 에러 타입을 사용할 때

다음과 같은 경우 커스텀 에러 타입을 사용하세요:

  • 콜러가 구조화된 데이터를 필요로 할 때.
  • 에러에 의미 있는 필드가 있을 때.
  • 타입이 자신의 패키지 계약의 일부일 때.
  • 콜러가 여러 값을 다르게 처리해야 할 수 있을 때.

예시:

  • 필드 이름이 있는 검증 에러
  • 재시도 시간이 있는 레이트 리밋 에러
  • 상태 코드가 있는 HTTP 에러
  • 행과 컬럼이 있는 파싱 에러
  • 리소스 ID가 있는 도메인 에러

커스텀 에러 타입을 사용하지 말아야 할 때

errors.New를 피하기 위해 커스텀 타입을 생성하지 마세요.

이것은 불필요합니다:

type NotFoundError struct{}

func (e NotFoundError) Error() string {
	return "not found"
}

유용한 데이터가 없다면, 센티널만으로도 충분할 수 있습니다.

에러 감싸기 (Error Wrapping)

감싸기는 원래 에러를 보존하면서 컨텍스트를 추가합니다.

예시:

func LoadConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read config %s: %w", path, err)
	}

	if err := parseConfig(data); err != nil {
		return fmt.Errorf("parse config %s: %w", path, err)
	}

	return nil
}

os.ReadFile가 실패하면, 콜러는 다음 두 가지를 모두 얻습니다:

  • 고급 작업: config 읽기
  • 저수준 원인: permission denied, file not found 등.

둘 다 에러 체인을 통해 사용 가능하며, 이것이 %w로 일관되게 감싸는 것이 worthwhile한 이유입니다.

유용한 컨텍스트로 감싸기

좋은 감싸기는 어떤 작업이 실패했는지 말합니다:

return fmt.Errorf("create invoice %s: %w", invoiceID, err)

나쁜 감싸기는 노이즈를 추가합니다:

return fmt.Errorf("error: %w", err)

이것은 콜러에게 아무것도 알리지 않습니다.

또한 모든 레이어에서 동일한 명사를 반복하는 것도 피하세요:

return fmt.Errorf("user service: get user: user repository: query user: %w", err)

이러한 종류의 체인은 기술적으로 정확하지만 실용적으로 성가십니다.

컨텍스트가 의미를 바꾸는 곳에서 감싸세요. 어떤 작업이 실패했는지 한 문장으로 설명할 수 없다면, 아마도 너무 공격적으로 감싸고 있거나 충분히 감싸지 않고 있을 것입니다.

언제 감싸고 언제 감싸지 말아야 하는가

이것은 가장 중요한 아키텍처 결정 중 하나입니다.

의미 있는 바운더리를 넘을 때 감싸기

에러가 한 작업에서 더 높은 수준의 작업으로 이동할 때 감싸세요.

예시:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

리포지토리의 에러가 이제 서비스 작업의 일부가 되었고, 그 추가된 컨텍스트는 운영자가 로그를 통해 실패를 추적할 때 유용합니다.

단순히 “실패했다"고 말하기 위해 감싸지 않기

나쁜 예시:

if err != nil {
	return fmt.Errorf("failed: %w", err)
}

“failed"라는 단어는 보통 에러가 존재한다는 사실에 의해 이미 암시됩니다.

번역하고 있을 때는 감싸지 않기

때로는 하나의 에러를 다른 도메인 에러로 번역해야 합니다.

예시:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

이것은 의도적으로 데이터베이스 세부 정보를 숨기고 도메인 조건을 노출합니다.

유용하다면 여전히 원인을 보존할 수 있지만, 의도적으로 하세요.

실수 없이 구현 세부 정보를 노출하지 않기

%w로 저수준 에러를 감싸면, 콜러가 이를 조사할 수 있습니다.

이것은 보통 자신의 애플리케이션 안에서 좋은 일입니다.

하지만 공개 패키지 API에서, 감싸기는 구현 세부 정보를 자신의 계약의 일부로 노출할 수 있습니다.

예를 들어, 자신의 패키지가 sql.ErrNoRows를 감싼다면, 콜러가 이에 의존하기 시작할 수 있습니다:

if errors.Is(err, sql.ErrNoRows) {
	// caller now knows you use database/sql
}

나중에 스토리지를 변경할 수 있다면, 도메인 센티널을 선호하세요:

var ErrUserNotFound = errors.New("user not found")

그런 다음 패키지 바운더리에서 이를 반환하세요.

에러 바운더리 (Error Boundaries)

Go의 에러 처리를 생각하는 가장 유용한 방법은 바운더리를 통해입니다.

바운더리는 에러가 의미나 대상(audience)을 바꾸는 곳입니다.

일반적인 바운더리에는 다음이 포함됩니다:

  • 데이터베이스에서 리포지토리로
  • 리포지토리에서 서비스로
  • 서비스에서 HTTP 핸들러로
  • 서비스에서 CLI 명령으로
  • 내부 에러에서 사용자 대상 메시지로
  • 일시적 실패에서 재시도 결정으로
  • 작업 실패에서 로그 이벤트로
  • 도메인 에러에서 API 응답으로

에러 아키텍처는 대부분 바운더리 설계입니다. 각 바운더리는 에러가 컨텍스트를 얻거나, 구현 세부 정보를 잃거나, 다음 레이어가 조치할 수 있는 형태로 번역되는 결정 지점입니다.

리포지토리 바운더리

리포지토리는 스토리지와 대화합니다.

보통 데이터베이스 특정 에러를 도메인 에러로 번역해야 합니다.

예시:

var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

리포지토리는 sql.ErrNoRows를 숨기고 ErrUserNotFound를 노출합니다 — “not found"가 스토리지에 의해 어떻게 표현되는지 서비스에 알릴 필요가 없는 깨끗한 바운더리입니다.

서비스 바운더리

서비스는 비즈니스 의미를 소유합니다.

보통 작업 컨텍스트를 추가하고 도메인 에러를 보존해야 합니다.

예시:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

이것은 도메인 조건을 보존하면서 의도치 않은 에러에 대한 컨텍스트를 추가합니다.

더 복잡한 비즈니스 규칙의 경우, 서비스는 직접 도메인 에러를 생성할 수 있습니다:

var ErrAccountDisabled = errors.New("account disabled")

func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		return nil, fmt.Errorf("get user by email: %w", err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	// ...
	return session, nil
}

서비스는 비즈니스 레벨 에러를 위한 적합한 장소입니다 — 인프라 조건에서 번역된 것이 아니라 도메인 로직에서 직접 생성됩니다.

HTTP 핸들러 바운더리

HTTP 핸들러는 애플리케이션 에러를 HTTP 응답으로 변환합니다.

이것은 내부 세부 정보가 사용자 안전한 응답이 되어야 하는 바운더리입니다.

웹 프레임워크는 이 변환을 구조화하는 방식이 다릅니다. Echo는 핸들러 에러를 단일 HTTPErrorHandler를 통해 라우팅하는 반면, Gin 핸들러는 요청 컨텍스트에서 직접 에러를 렌더링합니다 — 두 스타일 사이의 트레이드오프는 2026년 Go Web Frameworks: net/http, chi, Gin, Echo, Fiber 비교에서 비교됩니다.

예시:

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		user, err := svc.GetUser(r.Context(), r.PathValue("id"))
		if err != nil {
			writeHTTPError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

에러 매핑:

func writeHTTPError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, ErrUserNotFound):
		http.Error(w, "user not found", http.StatusNotFound)

	case errors.Is(err, ErrAccountDisabled):
		http.Error(w, "account disabled", http.StatusForbidden)

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
	}
}

핸들러는 생데이터 데이터베이스나 내부 에러 세부 정보를 노출하기보다는 도메인 에러를 HTTP 의미론으로 매핑합니다. 이것이 많은 Go 애플리케이션이 실수를 하는 곳입니다 — 너무 많은 내부 세부 정보를 노출하거나 모든 에러를 HTTP 500으로 압축합니다. Go API의 핸들러 패턴과 미들웨어에 대한 전체적인 그림을 위해, Go에서 REST API 빌드하기는 표준 라이브러리, Gin, Echo, Fiber에 걸쳐 인증, 라우팅, 에러 처리를 다룹니다.

CLI 바운더리

CLI는 HTTP API와 다른 바운더리를 가집니다.

CLI에서, 에러는 명령을 실행하는 사람에게 유용해야 합니다.

예시:

func RunImport(ctx context.Context, args []string) error {
	if len(args) == 0 {
		return ErrMissingInputFile
	}

	if err := importFile(ctx, args[0]); err != nil {
		return fmt.Errorf("import %s: %w", args[0], err)
	}

	return nil
}

명령 바운더리에서:

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, formatCLIError(err))
		os.Exit(exitCode(err))
	}
}

알려진 에러를 종료 코드로 매핑:

func exitCode(err error) int {
	switch {
	case errors.Is(err, ErrMissingInputFile):
		return 2
	case errors.Is(err, ErrValidation):
		return 3
	default:
		return 1
	}
}

CLI는 공개 API보다 더 많은 세부 정보를 보여줄 수 있지만, 여전히 시크릿을 누출하는 것은 피해야 합니다.

API 에러 타입 패턴

HTTP API를 위해, 작은 애플리케이션 레벨 에러 타입이 유용할 수 있습니다.

예시:

type APIError struct {
	Status  int
	Code    string
	Message string
	Err     error
}

func (e *APIError) Error() string {
	if e.Err == nil {
		return e.Message
	}

	return e.Message + ": " + e.Err.Error()
}

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

생성자(Constructor):

func NewAPIError(status int, code string, message string, err error) *APIError {
	return &APIError{
		Status:  status,
		Code:    code,
		Message: message,
		Err:     err,
	}
}

사용 예시:

return NewAPIError(
	http.StatusConflict,
	"duplicate_email",
	"email is already registered",
	ErrDuplicateEmail,
)

핸들러:

func writeAPIError(w http.ResponseWriter, err error) {
	var apiErr *APIError
	if errors.As(err, &apiErr) {
		writeJSON(w, apiErr.Status, map[string]string{
			"code":    apiErr.Code,
			"message": apiErr.Message,
		})
		return
	}

	writeJSON(w, http.StatusInternalServerError, map[string]string{
		"code":    "internal_error",
		"message": "internal server error",
	})
}

이 패턴은 안정적인 코드를 가진 구조화된 API 에러를 원할 때 유용합니다.

이를 API 바운더리에서 사용하세요. 모든 내부 패키지에 API 특정 에러를 반환하도록 강제하지 마세요.

도메인 에러 vs 트랜스포트 에러

도메인 에러를 트랜스포트 에러와 분리하세요.

도메인 에러:

var ErrInsufficientBalance = errors.New("insufficient balance")

트랜스포트 매핑:

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "insufficient balance", http.StatusConflict)
	return
}

도메인 레이어에서 HTTP 상태 코드를 반환하도록 하지 마세요:

return &APIError{Status: http.StatusConflict}

이것은 비즈니스 로직을 HTTP에 결합시키고, 서비스 레이어가 HTTP, CLI, 워커, 테스트, 그리고 미래의 gRPC 어댑터 사이에서 깨끗하게 작동하는 것을 방해합니다. 트랜스포트 매핑은 도메인 코드가 아닌 트랜스포트 바운더리에 속합니다. 프로젝트 레이아웃 내에서 도메인 에러, 센티널, 트랜스포트 어댑터를 어디에 정의해야 하는지에 대한 지침을 위해, Go Project Structure: Practices & Patterns는 internal/, pkg/, 그리고 어댑터 컨벤션을 다루며, 이를 통해 레이어가 깨끗하게 분리되도록 합니다.

재시도 가능한 에러 (Retryable Errors)

일부 에러는 재시도를 트리거해야 합니다. 일부는 그렇지 않아야 합니다.

이것을 문자열 매칭으로 결정하지 마세요.

마커 인터페이스 또는 명시적인 함수를 사용하세요.

예시:

type RetryableError struct {
	Err error
}

func (e *RetryableError) Error() string {
	return e.Err.Error()
}

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

헬퍼:

func Retryable(err error) error {
	if err == nil {
		return nil
	}

	return &RetryableError{Err: err}
}

func IsRetryable(err error) bool {
	var retryable *RetryableError
	return errors.As(err, &retryable)
}

사용 예시:

if err := callRemoteAPI(ctx); err != nil {
	if isTemporaryNetworkError(err) {
		return Retryable(fmt.Errorf("call remote api: %w", err))
	}

	return fmt.Errorf("call remote api: %w", err)
}

재시도 루프:

err := doWork(ctx)
if err != nil {
	if IsRetryable(err) {
		// retry with backoff
	}
	return err
}

이것은 에러 문자열이 “timeout"을 포함하는지 검사하는 것보다 훨씬 낫습니다 — 문자열 매칭은 메시지가 변경될 때 조용히 실패하며, 생산자와 소비자 사이의 보이지 않는 결합을 생성합니다.

검증 에러 (Validation Errors)

검증 에러는 종종 구조화된 데이터를 필요로 합니다.

예시:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

func (e *ValidationError) Error() string {
	return "validation failed"
}

사용 예시:

func ValidateCreateUser(req CreateUserRequest) error {
	var fields []FieldError

	if req.Email == "" {
		fields = append(fields, FieldError{
			Field:   "email",
			Message: "email is required",
		})
	}

	if len(fields) > 0 {
		return &ValidationError{Fields: fields}
	}

	return nil
}

핸들러:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	writeJSON(w, http.StatusBadRequest, validationErr)
	return
}

콜러가 불투명한 에러 문자열이 아닌 구조화된 정보 — 필드 이름과 검증 메시지 — 가 필요하기 때문에 errors.As의 좋은 사용 사례입니다.

다중 에러 (Multiple Errors)

때로는 여러 가지가 실패합니다.

예시:

  • 여러 리소스 닫기
  • 여러 필드 검증
  • 여러 워커 종료
  • 독립적인 검사 실행
  • 출력 플러시 및 닫기

모든 에러가 보존되어야 할 때 errors.Join를 사용하세요.

예시:

func CloseAll(closers ...io.Closer) error {
	var errs []error

	for _, closer := range closers {
		if err := closer.Close(); err != nil {
			errs = append(errs, err)
		}
	}

	return errors.Join(errs...)
}

콜러:

if err := CloseAll(a, b, c); err != nil {
	return fmt.Errorf("close resources: %w", err)
}

errors.Is와 errors.As 모두 結合된 에러를 조사할 수 있으므로, 結合된 에러 값은 표준 에러 검사 패턴과 완전히 호환됩니다.

errors.Join을 사용하지 말아야 할 때

주요 에러가 하나이고 로깅 컨텍스트가 일부인 경우 errors.Join를 사용하지 마세요.

어떤 에러가 중요한지 결정하는 것을 피하기 위해 사용하지 마세요.

사용자에게 거대한 結合된 에러를 반환하지 마세요.

結合된 에러는 유용하지만, 빨리 소음 많아질 수 있습니다.

Panic은 에러 처리가 아닙니다

일반적인 애플리케이션 코드에서, 기대되는 에러에 대해 panic을 사용하지 마세요.

나쁜 예시:

if err != nil {
	panic(err)
}

프로그래머 에러나 진정한 복구 불가능한 상황에 panic을 사용하세요.

예시:

  • 불가능한 내부 불변 조건 위반
  • 잘못된 패키지 초기화
  • 제한된 경우의 t.Fatal 또는 panic과 함께 테스트 헬퍼 실패
  • 스타일에 따라 달라지는 복구 불가능한 시작 시 설정 에러

데이터베이스 쿼리가 실패했거나 사용자가 잘못된 입력을 제출했다고 해서 panic하지 마세요.

그것들은 정상적인 에러입니다.

에러 로깅

Go의 일반적인 실수는 모든 레이어에서 동일한 에러를 로깅하는 것입니다.

나쁜 예시:

func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.query(ctx, id)
	if err != nil {
		log.Printf("query failed: %v", err)
		return nil, err
	}
	return user, nil
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		log.Printf("service failed: %v", err)
		return nil, err
	}
	return user, nil
}

이것은 하나의 실패에 대해 중복 로그를 생성합니다.

더 나은 방법:

  • 에러가 위로 이동함에 따라 감싸기
  • 에러가 처리되는 바운더리에서 한 번만 로깅
  • 로그에 구조화된 컨텍스트 포함

예시:

func (s *Server) handleError(r *http.Request, err error) {
	s.logger.ErrorContext(
		r.Context(),
		"request failed",
		"method", r.Method,
		"path", r.URL.Path,
		"err", err,
	)
}

이것은 전체 에러 체인을 가진 하나의 로그 이벤트를 제공합니다. 프로덕션 리디 구조화된 로깅 설정을 위해, slog을 사용한 Go의 구조화된 로깅는 log/slog 레코드, JSON 핸들러, 컨텍스트 상관관계, 편집을 다루며, 이들은 모두 바운더리 레벨 에러 로깅과 자연스럽게 짝을 이룹니다.

더 낮은 레이어 안에서 로깅할 때

해당 레이어가 실제로 에러를 처리하거나, 다른 곳에서 보이지 않을 중요한 운영 컨텍스트를 추가할 때만 더 낮은 레이어 안에서 로깅하세요.

예를 들어, 재시도 루프는 각 재시도 시도를 디버그 또는 경고 레벨로 로깅할 수 있습니다.

하지만 핸들러가 최종 요청 실패를 로깅할 예정이라면, 리포지토리는 각 쿼리 에러를 로깅해야 하지 않습니다.

사용자 대상 에러 vs 운영자 대상 에러

내부 에러를 사용자에게 직접 표시하지 마세요.

내부 에러:

query user by id: dial tcp 10.0.4.12:5432: connection refused

사용자 대상 메시지:

internal server error

운영자 로그:

request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"

이것들은 다른 대상이며, 좋은 에러 아키텍처는 그것들을 분리시킵니다:

  • 내부 진단 에러
  • 사용자 안전한 응답
  • 안정적인 API 에러 코드
  • 운영자 로그 컨텍스트

하나의 에러 문자열로 이 모든 대상을 서비스하려 하면 노출 위험이나 디버깅 지옥 중 하나가 발생합니다. 서로 다른 소비자를 위해 서로 다른 값을 가진 에러 아키텍처를 설계하세요.

안전한 에러 처리

에러는 민감한 정보를 누출할 수 있습니다.

노출을 피하세요:

  • 데이터베이스 연결 문자열
  • 시크릿이 있는 SQL 쿼리
  • 내부 호스트명
  • 파일 경로
  • 액세스 토큰
  • API 키
  • 스택 트레이스
  • 사적인 고객 데이터
  • 인증 정책 세부 정보

이것은 특히 HTTP API에서 중요합니다.

나쁜 예시:

http.Error(w, err.Error(), http.StatusInternalServerError)

좋은 예시:

http.Error(w, "internal server error", http.StatusInternalServerError)

내부 에러는 운영자를 위해 안전하게 로깅하세요. 사용자에게는 안전한 메시지를 반환하세요.

에러 코드

공개 API를 위해, 메시지만에 의존하는 것보다 안정적인 에러 코드가 종종 더 낫습니다.

응답 예시:

{
  "code": "user_not_found",
  "message": "user not found"
}

메시지는 변경될 수 있습니다. 코드는 안정적이어야 합니다.

에러 코드를 다음에 사용하세요:

  • 클라이언트 동작
  • 문서
  • SDK
  • 현지화(로케일)
  • 지원 진단

클라이언트가 영어 에러 메시지를 파싱하게 하지 마세요.

실용적인 레이어 기반 에러 설계

많은 Go 백엔드 서비스에 대한 깨끗한 패턴을 here에 제시합니다.

리포지토리 레이어

  • 데이터베이스 또는 외부 스토리지와 대화.
  • 스토리지 특정 not-found 에러를 도메인 에러로 변환.
  • 의도치 않은 스토리지 에러를 작업 컨텍스트로 감싸기.
  • HTTP 에러를 반환하지 않음.
  • 보통 로깅하지 않음.

예시:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

return nil, fmt.Errorf("query user by id: %w", err)

서비스 레이어

  • 비즈니스 규칙 소유.
  • 도메인 에러 생성.
  • 알려진 도메인 에러 보존.
  • 의도치 않은 더 낮은 레벨 에러 감싸기.
  • HTTP 상태 코드를 반환하지 않음.
  • 보통 로깅하지 않음.

예시:

if user.Disabled {
	return nil, ErrAccountDisabled
}

트랜스포트 레이어

  • 도메인 에러를 HTTP, gRPC, 또는 CLI 응답으로 매핑.
  • 처리되지 않거나 의도치 않은 에러 로깅.
  • 사용자로부터 내부 세부 정보 숨기기.
  • 상태 코드 및 API 에러 코드 설정.

예시:

switch {
case errors.Is(err, ErrUserNotFound):
	writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
	writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}

이러한 분리는 에러 처리를 이해하기 쉽게 유지하고 각 레이어가 독립적으로 발전할 수 있게 합니다 — 서비스 로직이나 트랜스포트 매핑을 건드리지 않고 스토리지 기술을 변경할 수 있습니다. 레이어 기반 설계는 의존성이 하드코딩된 것이 아니라 주입되는 경우 가장 잘 작동합니다; Go의 의존성 주입: 패턴 & 베스트 프랙티스는 각 바운더리를 격리 상태에서 테스트하기 쉽게 만드는 생성자와 인터페이스 패턴을 다룹니다.

완전한 예시

여기 작은 엔드투엔드 예시를 제공합니다.

도메인 에러:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("user not found")
	ErrDuplicateEmail = errors.New("duplicate email")
	ErrAccountDisabled = errors.New("account disabled")
)

리포지토리:

package users

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

type Repository struct {
	db *sql.DB
}

func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name, disabled
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
		&user.Disabled,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

서비스:

package users

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

type Service struct {
	repo *Repository
}

func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get profile for user %s: %w", id, err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

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

HTTP 핸들러:

package httpapi

import (
	"context"
	"errors"
	"net/http"

	"example.com/app/users"
)

type Handler struct {
	users *users.Service
}

func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
	profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
	if err != nil {
		h.writeError(w, err)
		return
	}

	writeJSON(w, http.StatusOK, profile)
}

func (h *Handler) writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, users.ErrUserNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{
			"code":    "user_not_found",
			"message": "user not found",
		})

	case errors.Is(err, users.ErrAccountDisabled):
		writeJSON(w, http.StatusForbidden, map[string]string{
			"code":    "account_disabled",
			"message": "account is disabled",
		})

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		writeJSON(w, http.StatusGatewayTimeout, map[string]string{
			"code":    "request_timeout",
			"message": "request timed out",
		})

	default:
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"code":    "internal_error",
			"message": "internal server error",
		})
	}
}

이 구조는 다음을 제공합니다:

  • 도메인 에러
  • 스토리지 번역
  • 서비스 컨텍스트
  • 안전한 HTTP 매핑
  • 조사 가능한 에러 체인
  • 문자열 매칭 없음
  • 도메인 코드로의 트랜스포트 누출 없음

이것은 확장 가능한 종류의 에러 아키텍처입니다 — 새로운 기여자가 이해하기에 충분히 직관적이고, 도메인 로직이 트랜스포트 응답으로 누출되지 않도록 충분히 구조화되어 있습니다.

에러 동작 테스트

바운더리 결정 — 센티널 매핑, 타입 추출, HTTP 코드 — 은 버그가 가장 오래 숨어 있는 곳이기 때문에, 에러 동작은 해피 패스(happy path)만큼이나 철저하게 테스트되어야 합니다. Go 테스트 구조, 목킹, 커버리지 패턴에 대한 전체적인 가이드를 위해, Go Unit Testing: Structure & Best Practices를 참조하세요.

센티널 매핑 테스트

func TestGetByIDNotFound(t *testing.T) {
	repo := newTestRepository(t)

	_, err := repo.GetByID(t.Context(), "missing")
	if !errors.Is(err, users.ErrUserNotFound) {
		t.Fatalf("got %v, want ErrUserNotFound", err)
	}
}

커스텀 에러 추출 테스트

func TestValidationError(t *testing.T) {
	err := ValidateCreateUser(CreateUserRequest{})

	var validationErr *ValidationError
	if !errors.As(err, &validationErr) {
		t.Fatalf("got %T, want ValidationError", err)
	}

	if len(validationErr.Fields) == 0 {
		t.Fatal("expected validation fields")
	}
}

HTTP 매핑 테스트

func TestWriteErrorNotFound(t *testing.T) {
	rec := httptest.NewRecorder()

	writeHTTPError(rec, users.ErrUserNotFound)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
	}
}

테스트는 알려진 에러가 각 바운더리에서 올바른 동작을 생성함을 증명해야 하므로, 스토리지나 트랜스포트 레이어를 리팩토링해도 실패 계약이 조용히 변경되지 않도록 보장해야 합니다.

일반적인 안티 패턴 (Anti-patterns)

안티 패턴 1: 문자열 매칭

나쁜 예시:

if strings.Contains(err.Error(), "not found") {
	// ...
}

대신 errors.Is 또는 errors.As를 사용하세요 — 둘 다 감싸인 에러 체인을 자동으로 처리하며 메시지가 재형식화되거나 현지화되어도 깨지지 않습니다.

안티 패턴 2: 원인 상실

나쁜 예시:

return errors.New("query failed")

더 나은 방법:

return fmt.Errorf("query user: %w", err)

안티 패턴 3: 의미 없는 감싸기

나쁜 예시:

return fmt.Errorf("error happened: %w", err)

모호한 접두사 대신 "create invoice %s: %w"와 같이 무엇이 시도되고 있었는지 설명하는 작업 컨텍스트로 감싸세요.

안티 패턴 4: 모든 레이어에서 로깅

나쁜 예시:

log.Println(err)
return err

모든 레벨에서. 에러가 최종적으로 처리되는 곳에서 한 번만 로깅하고, 단순히 위쪽으로 전달하는 중간 레이어에서는 로깅하지 마세요.

안티 패턴 5: 도메인 코드에서 HTTP 에러 반환

나쁜 예시:

return &APIError{Status: http.StatusNotFound}

도메인 서비스에서. 도메인 에러를 핸들러 바운더리에서 HTTP 상태 코드와 응답 본문으로 매핑하고, 서비스 레이어가 트랜스포트 관심사와 독립적이도록 유지하세요.

안티 패턴 6: 내부 에러를 사용자에게 노출

나쁜 예시:

http.Error(w, err.Error(), http.StatusInternalServerError)

사용자에게 안전한 일반적인 메시지를 반환하고, 운영자를 위해 전체 내부 에러를 구조화된 컨텍스트와 함께 로깅하세요. 데이터베이스 연결 문자열, 파일 경로, 원시 스택 트레이스를 API 응답에 절대 노출하지 마세요.

안티 패턴 7: 너무 많은 내보내기된 센티널

내보내기된 에러는 패키지 API의 일부이며, 그것들을 추가하면 유지보수에 대한 약속을 하게 됩니다. 외부 콜러가 실제로 분기해야 하는 경우에만 모든 내부 조건을 내보내지 마세요 — 명확한 필요가 있을 때까지 센티널을 내보내지 않는(unexported) 것을 선호하세요.

안티 패턴 8: 기대되는 실패에 panic 사용

나쁜 예시:

panic(err)

정상적인 런타임 실패에 대해. 누락된 레코드나 잘못된 사용자 입력이 아닌, 진정한 복구 불가능한 조건이나 프로그래머 에러에만 panic을 예약하세요 — 그러한 경우 항상 에러를 반환하세요.

안티 패턴 9: 컨텍스트 에러 무시

나쁜 예시:

return fmt.Errorf("request failed")

진정한 원인이 context.Canceled인 경우. 컨텍스트 에러를 보존하여 콜러가 진정한 작업 실패와 취소되거나 타임아웃된 요청을 구별할 수 있도록 하고, 각각에 적절하게 대응하게 하세요. 서비스 레이어 전반에 걸쳐 컨텍스트 취소와 타임아웃 전파가 어떻게 작동하는지에 대한 철저한 처리를 위해, Go context.Context Done Right를 참조하세요.

에러 리뷰 체크리스트

코드 리뷰에서 이 체크리스트를 사용하세요.

에러 생성

  • 이것이 알려진 조건인가?
  • 센티널이어야 하는가?
  • 구조화된 데이터가 필요한가?
  • 커스텀 타입이어야 하는가?
  • 에러 메시지가 명확한가?

에러 감싸기

  • 그 감싸기가 유용한 작업 컨텍스트를 추가하는가?
  • %w가 필요한 곳에서 원인을 보존하는가?
  • 코드가 실수로 구현 세부 정보를 노출하는가?
  • 체인이 너무 소음 많은가?

에러 번역

  • 저수준 에러가 올바른 바운더리에서 번역되었는가?
  • 데이터베이스 특정 동작이 서비스 코드로부터 숨겨졌는가?
  • 도메인 에러가 HTTP 또는 CLI 관심사와 독립적인가?

에러 처리

  • 콜러가 errors.Is 또는 errors.As로 분기하는가?
  • 컨텍스트 취소와 마감시한이 올바르게 처리되었는가?
  • 재시도 가능한 에러가 명시적으로 식별되었는가?
  • 검증 에러가 구조화되었는가?

로깅

  • 에러가 처리 바운더리에서 한 번만 로깅되었는가?
  • 로그가 구조화된가?
  • 민감한 세부 정보가 사용자 응답에서 제외되었는가?
  • 운영자를 위한 충분한 컨텍스트가 있는가?

테스트

  • 알려진 에러 케이스가 테스트되었는가?
  • HTTP 또는 CLI 매핑이 테스트되었는가?
  • 검증 세부 정보가 테스트되었는가?
  • 재시도 결정이 테스트되었는가?

저의 의견이 담긴 규칙

규칙 1: 에러는 의미를 가지고 바운더리를 넘어야 합니다

그냥 에러를 넘겨주지 마세요. 각 레이어에서 그들이 무엇을 의미하는지 결정하세요.

규칙 2: 컨텍스트를 위해 감싸고, 장식은 위해 감싸지 마세요

감싸기가 어떤 작업이 실패했는지에 대한 유용한 정보를 추가하지 않는다면, 감싸지 마세요. 의미 없는 추가 컨텍스트 레이어는 에러 체인을 읽기 어렵게 만들고 진단 가치를 추가하지 않습니다.

규칙 3: 구현 에러를 도메인 에러로 번역하세요

sql.ErrNoRows가 비즈니스 로직의 일부가 되도록 하지 마세요. 스토리지 바운더리에서 구현 에러를 도메인 에러로 번역하여, 애플리케이션의 나머지 부분이 아래에 어떤 데이터베이스나 ORM이 있는지 알 필요가 없게 하세요.

규칙 4: 에러 문자열을 파싱하지 마세요

코드가 실패 타입에 대해 분기해야 하면, 센티널, 커스텀 타입, errors.Is, 또는 errors.As를 사용하세요. 문자열 검사로 인해 에러 메시지가 변경될 때 조용히 깨지는 보이지 않는 결합이 생성됩니다.

규칙 5: 한 번만 로깅하세요

에러가 위로 이동함에 따라 감싸세요. 에러가 최종적으로 처리되는 곳에서 로깅하세요.

규칙 6: 사용자 메시지를 안전하게 유지하세요

내부 진단 에러는 로그를 위한 것입니다. 사용자 대상 메시지는 사용자를 위한 것입니다.

규칙 7: 트랜스포트 에러를 트랜스포트 바운더리에 유지하세요

HTTP 상태 코드는 도메인 서비스가 아닌 핸들러 또는 API 어댑터에 속합니다. 도메인 코드는 트랜스포트 전반에서 재사용 가능해야 합니다 — 오늘 HTTP, 내일 CLI, gRPC, 또는 이벤트 기반 워커.

최종 생각

Go의 에러 처리는 영원히 if err != nil를 작성하는 것에 관한 것이 아닙니다 — 그것은 모든 바운더리에서 실패를 명시적이고 이해 가능하게 만드는 것에 관한 것입니다.

메커니즘은 단순합니다:

return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter

아키텍처는 더 어려운 부분입니다:

translate at boundaries
preserve causes
hide internals from users
log once
test known failures

이것은 잘 수행된 Go 에러 처리입니다 — 교양하지 않고, 마법도 아니지만, 다음 개발자, 운영자, API 클라이언트, 그리고 미래의 내가 무엇에 실패했고 다음에 무엇을 해야 하는지 이해할 수 있을 만큼 충분히 명확합니다. 통합, 테스트, 데이터 액세스 전반의 프로덕션 Go 패턴에 대한 더 넓은 관점을 위해, App Architecture in Production를 참조하세요.

출처

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.