2026-01-02
이 글에서는 Go errors 패키지의 동작 원리를 깊이 파고들고, 실제 프로덕션 서버에서 사용할 수 있는 도메인 에러 시스템을 단계적으로 설계합니다. 단순히 if err != nil을 확인하는 수준을 넘어, 에러 체인 탐색 메커니즘부터 계층별 에러 변환, HTTP 미들웨어 통합, 다중 에러 집계, 구조화된 로깅까지 — 실무에서 마주치는 에러 처리 시나리오를 모두 다룹니다.
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 assertion | errors.As(err, &target) |
| 체인 순회 | 없음 | errors.Unwrap(err) |
| 커스텀 비교 | 없음 | Is(), As() 메서드 구현 |
Go 1.20에서는 errors.Join이 추가되어 여러 에러를 하나로 합치는 것도 표준화되었습니다.
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
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()메서드는 이 경우에 특히 유용합니다.
errors.As(err, &target)은 에러 체인에서 target 타입에 해당하는 첫 번째 에러를 찾아 target에 할당합니다.
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, 이유: 올바른 이메일 형식이 아닙니다
}
}
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
}
실무에서 에러를 어떤 형태로 정의할지는 용도에 따라 달라집니다.
| 형태 | 예시 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|---|
| 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,
}
}
실제 프로덕션 서버에서는 에러가 여러 계층을 거쳐 전달됩니다. 각 계층이 자신의 언어로 에러를 번역하는 것이 유지보수의 핵심입니다.
Repository (DB 에러) → Service (도메인 에러) → Handler (HTTP 에러)
// 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
}
// 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
}
// 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는 로그에는 전체 정보를 기록하고, 클라이언트에는 안전한 메시지만 반환합니다. 이 분리가 보안과 디버깅 편의성을 동시에 해결하는 핵심입니다.
에러 처리를 미들웨어에서 중앙화하면 각 핸들러의 코드가 크게 줄어듭니다. 표준 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)
}
입력 검증처럼 여러 오류를 동시에 수집해야 하는 경우, 에러를 하나씩 반환하면 사용자 경험이 나빠집니다.
// 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.Is와 errors.As가 각각의 에러를 순회합니다.
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
}
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조건으로 레벨을 분기하세요.
기본편에서 다룬 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)
})
}
// ❌ 에러를 무시 — 디버깅 불가
result, _ := someOperation()
// ✅ 처리할 수 없더라도 로그 기록
result, err := someOperation()
if err != nil {
slog.Warn("someOperation 실패 (계속 진행)", "error", err)
}
// ❌ 문자열 비교 — 메시지 변경 시 버그
if err.Error() == "file not found" {
// ...
}
// ✅ errors.Is 사용
if errors.Is(err, ErrNotFound) {
// ...
}
// ❌ 중복 래핑 — 로그에 같은 메시지가 반복됨
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() // 추가 래핑 불필요
}
// ❌ 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": "서버 내부 오류가 발생했습니다",
})
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)
}
}
fmt.Errorf("%w"): 에러를 래핑하여 체인을 구성. %v는 체인 불가errors.Is: 체인을 순회하며 값 동등성 비교. 커스텀 Is() 메서드로 확장 가능errors.As: 체인에서 특정 타입을 추출. 커스텀 As() 메서드로 확장 가능errors.Unwrap: 체인의 다음 에러를 반환. Unwrap() error 또는 Unwrap() []error 구현AppError: HTTPStatus + Code + Message + Internal 구조로 계층별 에러 변환의 핵심errors.Join (Go 1.20): 여러 에러를 합쳐 errors.Is/As가 순회 가능한 구조 생성error를 반환하는 핸들러 타입으로 에러 처리 중앙화log/slog: 구조화 로그에 에러 코드, 요청 ID, 내부 원인까지 기록다음 글에서는 Go JSON 처리를 다룹니다. encoding/json 패키지의 Marshal/Unmarshal, 커스텀 직렬화, 스트리밍 디코더, 성능 최적화까지 살펴보겠습니다.