Go 에러 핸들링 철학
Go는 예외(exception) 대신 에러 값을 반환하는 명시적 에러 처리를 채택했습니다. Go 1.13부터 에러 래핑(wrapping)이 표준에 추가되어, 에러에 컨텍스트를 더하면서 원본 에러를 보존할 수 있게 되었습니다. 이를 통해 에러 체인을 구성하고 errors.Is/As로 특정 에러를 검사할 수 있습니다.
Sentinel Error 패턴
package repository
import "errors"
// Sentinel Error 선언 — 패키지 수준의 고정 에러 값
var (
ErrNotFound = errors.New("record not found")
ErrDuplicate = errors.New("duplicate entry")
ErrUnauthorized = errors.New("unauthorized access")
ErrInvalidInput = errors.New("invalid input")
)
// 사용 예
func (r *UserRepo) FindByID(id int64) (*User, error) {
user, err := r.db.QueryRow(ctx, query, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("finding user %d: %w", id, err)
}
return user, nil
}
// 호출 측에서 Sentinel Error 검사
user, err := repo.FindByID(42)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// 404 응답
return c.JSON(404, map[string]string{"error": "user not found"})
}
// 500 응답
return c.JSON(500, map[string]string{"error": "internal error"})
}
에러 래핑 (Error Wrapping)
package service
import "fmt"
// fmt.Errorf의 %w 동사로 에러 래핑
func (s *UserService) GetProfile(userID int64) (*Profile, error) {
user, err := s.repo.FindByID(userID)
if err != nil {
// 컨텍스트 추가 + 원본 에러 보존
return nil, fmt.Errorf("getting profile for user %d: %w", userID, err)
}
settings, err := s.settingsRepo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("loading settings for user %d: %w", userID, err)
}
return &Profile{User: user, Settings: settings}, nil
}
// 에러 체인 예시:
// "getting profile for user 42: finding user 42: record not found"
// errors.Is(err, ErrNotFound) -> true (체인 전체를 검사)
커스텀 에러 타입
// 구조화된 에러 타입
type AppError struct {
Code string // "NOT_FOUND", "VALIDATION", "INTERNAL"
Message string
Field string // 유효성 검사용
Err error // 래핑된 원본 에러
}
func (e *AppError) 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)
}
// Unwrap 구현으로 errors.Is/As 지원
func (e *AppError) Unwrap() error {
return e.Err
}
// 생성자 함수
func NewNotFoundError(resource string, err error) *AppError {
return &AppError{
Code: "NOT_FOUND",
Message: fmt.Sprintf("%s not found", resource),
Err: err,
}
}
func NewValidationError(field, message string) *AppError {
return &AppError{
Code: "VALIDATION",
Message: message,
Field: field,
}
}
errors.Is와 errors.As 사용법
// errors.Is: 에러 체인에서 특정 값(Sentinel) 검사
if errors.Is(err, repository.ErrNotFound) {
// ErrNotFound가 체인 어딘가에 존재
}
// errors.As: 에러 체인에서 특정 타입 검사 및 추출
var appErr *AppError
if errors.As(err, &appErr) {
// appErr로 상세 정보 접근 가능
switch appErr.Code {
case "NOT_FOUND":
return c.JSON(404, appErr)
case "VALIDATION":
return c.JSON(400, appErr)
default:
return c.JSON(500, appErr)
}
}
// HTTP 핸들러에서 통합 에러 처리
func errorMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic recovered", "error", err)
http.Error(w, "Internal Server Error", 500)
}
}()
next.ServeHTTP(w, r)
})
}
에러 처리 모범 사례
| 원칙 | 좋은 예 | 나쁜 예 |
|---|---|---|
| 컨텍스트 추가 | fmt.Errorf("parsing config: %w", err) | return err |
| 에러 비교 | errors.Is(err, ErrNotFound) | err.Error() == "not found" |
| 로깅 위치 | 최상위 핸들러에서 1회 | 매 레이어에서 중복 로깅 |
| 에러 무시 | _ = file.Close() // 명시적 | file.Close() // 암묵적 |
// Go 1.20+: 다중 에러 래핑
func validateUser(u *User) error {
var errs []error
if u.Name == "" {
errs = append(errs, NewValidationError("name", "이름은 필수입니다"))
}
if u.Email == "" {
errs = append(errs, NewValidationError("email", "이메일은 필수입니다"))
}
if len(u.Password) < 8 {
errs = append(errs, NewValidationError("password", "비밀번호는 8자 이상"))
}
return errors.Join(errs...) // nil if no errors
}
- Sentinel Error는 패키지의 공개 API에서만 선언하고, 내부에서는 에러 래핑을 사용하세요
- 에러 문자열로 비교하지 말고 항상 errors.Is/As를 사용하세요
- 에러 로깅은 에러를 최종적으로 처리하는 곳에서 한 번만 하세요
댓글 0