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

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

Page content

Go의 에러 처리는 불평하기 쉽습니다. 모든 Go 개발자는 수백 번 이 코드를 작성해 보셨을 것입니다:

if err != nil {
	return err
}

흥미로운 부분은 바로 그 코드가 아닙니다. 흥미로운 부분은 에러의 의미, 에러를 처리해야 하는 위치, 에러를 감싸야(wrap) 하는 위치, 에러를 변환해야 하는 위치, 에러를 로깅해야 하는 위치, 그리고 호출자에게 노출해야 할 내용입니다. 이것이 바로 아키텍처의 문제입니다.

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

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

이 글은 if err != nil을 위한 초보자용 소개가 아닙니다.

이는 Go 에러 처리 아키텍처에 대한 실용적인 가이드입니다: 래핑(wrapping), 센티널(sentinel), 커스텀 에러 타입, errors.Is, errors.As, 에러 경계, 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)도 없습니다. 실패는 함수 시그니처의 일부입니다.

이는 장점이 있지만, 동시에 에러에도 설계가 필요함을 의미합니다. 모든 패키지가 임의의 메시지를 반환하면 호출자는 신뢰할 수 있는 결정을 내릴 수 없습니다. 모든 레이어가 규율 없이 에러를 감싸면 운영자는 노이즈가 많은 메시지를 받고 개발자는 혼란스러운 체인을 받게 됩니다. 어떤 레이어도 에러를 감싸지 않으면 실패는 컨텍스트를 잃게 됩니다.

목표는 에러 처리를 줄이는 것이 아니라, 에러의 의미를 더 잘 만드는 것입니다.

에러의 세 가지 역할

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

역할 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로 이를 검사할 수 있게 합니다.

역할 3: 경계에서 결정하게 하기

어떤 경계에서 프로그램은 무엇을 할지 결정해야 합니다.

예시:

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

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

현대 Go의 주요 에러 도구들

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

errors.New

단순한 에러 값을 생성하려면 errors.New를 사용하세요:

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

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

fmt.Errorf with %w

에러를 감싸려면 %w와 함께 fmt.Errorf를 사용하세요:

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

감싸기(wrapping)는 컨텍스트를 추가하면서도 검사할 수 있도록 원래 에러를 보존합니다.

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.Iserrors.As로 검사할 수 있습니다.

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

센티널 에러

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

예시:

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")

호출자가 이들을 기준으로 분기하지 않는다면, 이들은 그저 메시지일 뿐일 수 있습니다.

또한 너무 많은 센티널을 내보내는(exporting) 것에 주의하세요. 내보낸 센티널 에러는 패키지 API의 일부가 됩니다.

커스텀 에러 타입

에러가 구조화된 정보를 담고 있을 때 커스텀 에러 타입은 유용합니다.

예시:

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"
}

유용한 데이터가 없다면 센티널이 종종 충분합니다.

에러 래핑(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 읽기
  • 저수준 원인: 권한 거부, 파일 없음 등

둘 다 에러 체인을 통해 사용할 수 있으며, 이것이 %w로 일관되게 래핑하는 것이 의미 있는 이유입니다.

유용한 컨텍스트로 래핑하기

좋은 래핑은 어떤 작업이 실패했는지 말합니다:

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)
}

“실패"라는 단어는 보통 에러가 존재한다는 사실에서 암시됩니다.

번역(Translation) 중이라면 래핑하지 않기

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

예시:

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")

그리고 패키지 경계에서 이를 반환하세요.

에러 경계

Go 에러 처리를 생각하는 가장 유용한 방법은 경계를 통해 생각하는 것입니다.

경계는 에러가 의미나 청중을 바꾸는 곳입니다.

일반적인 경계에는 다음이 포함됩니다:

  • 데이터베이스에서 레포지토리로
  • 레포지토리에서 서비스로
  • 서비스에서 HTTP 핸들러로
  • 서비스에서 CLI 명령어로
  • 내부 에러에서 사용자 facing 메시지로
  • 일시적 실패에서 재시도 결정으로
  • 작업 실패에서 로깅 이벤트로
  • 도메인 에러에서 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를 노출합니다 — 서비스는 스토지가 “찾음"을 어떻게 나타내는지에 대해 아무것도 알 필요가 없는 깔끔한 경계입니다.

서비스 경계

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

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

예시:

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 응답으로 번역합니다.

이는 내부 세부 정보가 사용자 안전 응답이 되어야 하는 경계입니다.

예시:

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
}

생성자:

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 프로젝트 구조: 관행 및 패턴는 이러한 레이어를 깔끔하게 분리하는 internal/, pkg/, 어댑터 관행을 다룹니다.

재시도 가능한 에러

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

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

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

예시:

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"이 포함되어 있는지 확인하는 것보다 훨씬 낫습니다 — 문자열 매칭은 메시지가 변경될 때 조용히 깨지며, 생산자와 소비자 사이에 보이지 않는 결합을 만듭니다.

유효성 검사 에러

유횤성 검사 에러는 종종 구조화된 데이터가 필요합니다.

예시:

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의 좋은 사용 예입니다.

다중 에러

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

예시:

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

모든 에러가 보존되어야 할 때 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.Iserrors.As 둘 다 결합된 에러를 검사할 수 있으므로, 결합된 에러 값은 표준 에러 검사 패턴과 완전히 호환성을 유지합니다.

errors.Join을 사용하지 않을 때

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

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

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

결합된 에러는 유용하지만, 빠르게 노이즈가 될 수 있습니다.

Panic은 에러 처리가 아닙니다

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

나쁜 예:

if err != nil {
	panic(err)
}

programmer 에러 또는 진정한 불가역 상황을 위해 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,
	)
}

이는 전체 에러 체인을 가진 하나의 로그 이벤트를 제공합니다. 프로덕션 준비 구조화된 로깅 설정을 위해, Go에서 slog를 사용한 구조화된 로깅log/slog 레코드, JSON 핸들러, 컨텍스트 상관관계, 삭제를 다룹니다 — 이들은 모두 경계 수준 에러 로깅과 자연스럽게 짝을 이룹니다.

하위 레이어 내부에서 언제 로깅할 것인가

레어가 실제로 에러를 처리하거나 다른 곳에서 보이지 않을 중요한 운영 컨텍스트를 추가할 때만 하위 레이어 내부에서 로깅하세요.

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

하지만 핸들러가 최종 요청 실패를 로깅할 경우, 레포지토리는 모든 쿼리 에러를 로깅해서는 안 됩니다.

사용자 facing 에러 vs 운영자 에러

내부 에러를 사용자에게 직접 보여주지 마세요.

내부 에러:

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

사용자 facing 메시지:

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 백엔드 서비스를 위한 깔끔한 패턴이 있습니다.

레포지토리 레이어

  • 데이터베이스 또는 외부 스토리지와 통신합니다.
  • 스토리지 특화 찾지 못함 에러를 도메인 에러로 변환합니다.
  • 예상치 못한 스토리지 에러를 작업 컨텍스트로 래핑합니다.
  • 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 코드 — 은 종종 버그가 가장 오래 숨는 곳이기 때문에, 에러 동작은 해피 경로만큼 철저히 테스트되어야 합니다. Go 테스트 구조, 목킹, 커버리지 패턴에 대한 전체 가이드는 Go 유닛 테스트: 구조 및 모범 사례를 참조하세요.

센티널 매핑 테스트

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)
	}
}

테스트는 알려진 에러가 각 경계에서 올바른 동작을 생성함을 증명해야 하므로, 스토리지 또는 전송 레이어 리팩토링이 실패 계약을 조용히 변경하지 못하게 합니다.

일반적인 안티패턴

안티패턴 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"와 같이, 진단 가치가 없는 모호한 접두사가 아닌.

안티패attern 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의 일부이며, 이를 추가하는 것은 유지보수에 대한 약속입니다. 외부 호출자가 진정으로 이를 기준으로 분기해야 하지 않는 한 모든 내부 조건을 내보내지 마세요 — 명확한 필요가 있을 때까지 센티널을 내보내지 않도록 유지하는 것을 선호하세요.

안티패턴 8: 예상 실패에 대한 panic 사용

나쁜 예:

panic(err)

정상적인 런타임 실패에 대해. panic은 진정한 불가역 조건 또는 programmer 에러를 위해 예약하고, 누락된 레코드 또는 유효하지 않은 사용자 입력이 아닌 — 이러한 경우 항상 에러를 반환하세요.

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

나쁜 예:

return fmt.Errorf("request failed")

진짜 원인이 context.Canceled였을 때. 컨텍스트 에러를 보존하여 호출자가 진정한 작업 실패와 취소 또는 타임아웃된 요청을 구별하고, 각 상황에 적절히 응답할 수 있게 하세요. 서비스 레이어 전반의 컨텍스트 취소 및 타임아웃 전파가 어떻게 작동하는지에 대한 철저한 처리는 Go context.Context 올바르게 다루기를 참조하세요.

에러 리뷰 체크리스트

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

에러 생성

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

에러 래핑

  • 래핑이 유용한 작업 컨텍스트를 추가하는가?
  • %w가 필요한 곳에서 원인을 보존하는가?
  • 코드가 우연히 구현 세부 정보를 노출하는가?
  • 체인이 너무 노이즈가 있는가?

에러 번역

  • 저수준 에러가 올바른 경계에서 번역되는가?
  • 데이터베이스 특화 동작이 서비스 코드에서 숨겨지는가?
  • 도메인 에러가 HTTP 또는 CLI 관심사와 독립적인가?

에러 처리

  • 호출자가 errors.Is 또는 errors.As로 분기하는가?
  • 컨텍스트 취소 및 데드라인이 올바르게 처리되는가?
  • 재시도 가능한 에러가 명시적으로 식별되는가?
  • 유효성 검사 에러가 구조화된가?

로깅

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

테스트

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

저의 주관적인 규칙

규칙 1: 에러는 의미를 가지고 경계를 넘어야 한다

에러를 단순히 전달하지 마세요. 각 레이어에서 이것이 무엇을 의미하는지 결정하세요.

규칙 2: 장식 대신 컨텍스트를 위해 래핑하라

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

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

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

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

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

규칙 5: 한 번만 로깅하라

에러가 위로 올라갈 때 래핑하세요. 에러가 마침내 처리되는 곳에서 로깅하세요.

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

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

규칙 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 패턴에 대한 더 넓은 관점은 프로덕션에서의 앱 아키텍처를 참조하세요.

출처

구독하기

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