Go API를 위한 견고한 오류 처리 시스템 구축
Wenhao Wang
Dev Intern · Leapcell

소개
네트워크 애플리케이션, 특히 마이크로서비스 및 API의 세계에서 우아한 오류 처리는 신뢰할 수 있고 사용자 친화적인 시스템의 필수 요소일 뿐만 아니라 중요한 구성 요소입니다. 문제가 발생했을 때 불투명하거나 알 수 없는 오류 메시지는 사용자에게 좌절감을 주고, 다운스트림 서비스를 오도하며, 개발자의 디버깅 노력을 복잡하게 만들 수 있습니다. 반대로 잘 정의되고 구조화된 오류 시스템은 문제에 대한 명확한 커뮤니케이션, 운영 통찰력을 위한 일관된 로깅, 다양한 API 엔드포인트 전반에 걸친 예측 가능한 동작을 가능하게 합니다. 이 문서는 API 응답 및 내부 로깅 모두에 대한 오류를 효과적으로 관리하는 방법에 초점을 맞춰 Go에서 이러한 시스템을 설계하는 방법을 자세히 살펴보고, 애플리케이션이 기능적일 뿐만 아니라 탄력적이고 관찰 가능하도록 보장합니다.
구조화된 오류를 위한 핵심 개념
구현에 들어가기 전에 구조화된 오류 처리 접근 방식의 필수적인 몇 가지 핵심 용어를 정의해 보겠습니다.
- 오류 코드(Error Code): 시스템 내의 특정 오류 유형을 나타내는 고유 식별자, 일반적으로 문자열 또는 열거형. 이는 인간이 읽을 수 있는 메시지와 독립적으로 오류를 기계적으로 읽고 분류하는 표준화된 방법을 제공합니다.
- 오류 범주/유형(Error Category/Type): 오류에 대한 더 넓은 분류(예:
유효성 오류(Validation Error)
,인증 오류(Authentication Error)
,내부 서버 오류(Internal Server Error)
). 이는 유사한 오류 코드를 그룹화하는 데 도움이 되며 오류가 처리되거나 표현되는 방식에 영향을 줄 수 있습니다. - 사용자 메시지(User Message): 최종 사용자 또는 클라이언트 애플리케이션을 위한 사람이 읽을 수 있는 메시지로, 이해하기 쉬운 비기술적인 방식으로 무엇이 잘못되었는지 설명합니다. 이 메시지는 로케일에 따라 달라질 수 있습니다.
- 개발자 메시지(Developer Message): 디버깅 중 개발자를 위한 더 자세하고 기술적인 메시지입니다. 여기에는 내부 세부 정보, 컨텍스트 및 잠재적인 수정 단계가 포함될 수 있습니다.
- 오류 컨텍스트(Error Context): 오류에 대한 특정 컨텍스트 정보를 제공하는 추가적인 동적 키-값 쌍입니다. 예를 들어, 유효성 오류의 경우 유효성 검사에 실패한 필드와 잘못된 값이 포함될 수 있습니다. 데이터베이스 오류의 경우 시도된 쿼리가 포함될 수 있습니다.
- HTTP 상태 코드(HTTP Status Code): HTTP 요청의 결과를 나타내는 표준 숫자 코드(예:
200 OK
,400 Bad Request
,500 Internal Server Error
). 오류와 관련이 있지만, 내부 오류 구조가 오류 자체가 아니라 HTTP 상태 코드 선택을 주도합니다.
Go에서 구조화된 오류 시스템 설계
우리의 목표는 이러한 모든 정보를 캡슐화하는 사용자 정의 오류 유형을 Go에서 만드는 것입니다. 이를 통해 애플리케이션 전반에 걸쳐 풍부한 오류 세부 정보를 전파한 다음 API 응답 및 로그 항목에 대해 적절하게 번역할 수 있습니다.
사용자 정의 오류 유형
사용자 정의 오류 구조체를 정의해 보겠습니다.
package apperror import ( "fmt" "net/http" ) // Category는 오류의 광범위한 유형을 정의합니다. type Category string const ( CategoryBadRequest Category = "BAD_REQUEST" CategoryUnauthorized Category = "UNAUTHORIZED" CategoryForbidden Category = "FORBIDDEN" CategoryNotFound Category = "NOT_FOUND" CategoryConflict Category = "CONFLICT" CategoryInternal Category = "INTERNAL_SERVER_ERROR" CategoryServiceUnavailable Category = "SERVICE_UNAVAILABLE" // 필요에 따라 더 많은 카테고리를 추가합니다. ) // Error는 구조화된 애플리케이션 오류를 나타냅니다. type Error struct { Code string `json:"code"` // 오류에 대한 고유 식별자 (예: "USER_NOT_FOUND") Category Category `json:"category"` // 오류의 광범위한 범주 (예: "NOT_FOUND") UserMessage string `json:"user_message"` // 사용자 친화적인 메시지 DevMessage string `json:"dev_message,omitempty"` // 개발자 친화적인 메시지, 선택 사항 Context map[string]interface{} `json:"context,omitempty"` // 컨텍스트를 위한 추가 키-값 쌍 Cause error `json:"-"` // 기본 오류, 직렬화되지 않음 } // Error는 오류 인터페이스를 구현합니다. func (e *Error) Error() string { if e.DevMessage != "" { return fmt.Sprintf("[%s:%s] %s (Dev: %s)", e.Category, e.Code, e.UserMessage, e.DevMessage) } return fmt.Sprintf("[%s:%s] %s", e.Category, e.Code, e.UserMessage) } // Unwrap은 errors.Is 및 errors.As가 사용자 정의 오류 유형과 함께 작동하도록 합니다. func (e *Error) Unwrap() error { return e.Cause } // New는 새로운 구조화된 오류를 생성합니다. func New(category Category, code, userMsg string, opts ...ErrorOption) *Error { err := &Error{ Category: category, Code: code, UserMessage: userMsg, Context: make(map[string]interface{}), // nil 맵 패닉을 피하기 위해 컨텍스트 초기화 } for _, opt := range opts { opt(err) } return err } // ErrorOption은 오류를 사용자 정의하기 위한 함수형 옵션을 정의합니다. type ErrorOption func(*Error) // WithDevMessage는 개발자 메시지를 설정합니다. func WithDevMessage(msg string) ErrorOption { return func(e *Error) { e.DevMessage = msg } } // WithContext는 오류 컨텍스트에 키-값 쌍을 추가합니다. func WithContext(key string, value interface{}) ErrorOption { return func(e *Error) { e.Context[key] = value } } // WithCause는 오류의 기본 원인을 설정합니다. func WithCause(cause error) ErrorOption { return func(e *Error) { e.Cause = cause } } // MapCategoryToHTTPStatus는 오류 범주를 표준 HTTP 상태 코드로 매핑합니다. func MapCategoryToHTTPStatus(cat Category) int { switch cat { case CategoryBadRequest: return http.StatusBadRequest case CategoryUnauthorized: return http.StatusUnauthorized case CategoryForbidden: return http.StatusForbidden case CategoryNotFound: return http.StatusNotFound case CategoryConflict: return http.StatusConflict case CategoryServiceUnavailable: return http.StatusServiceUnavailable case CategoryInternal: return http.StatusInternalServerError default: return http.StatusInternalServerError // 처리되지 않은 범주의 경우 내부 서버 오류로 기본값 설정 } }
이 Error
구조체는 error
인터페이스를 구현하여 표준 Go error
가 예상되는 곳이면 어디든 사용할 수 있습니다. Unwrap
메서드는 Go의 errors
패키지 함수(errors.Is
, errors.As
)와의 호환성을 위해 중요합니다. 또한 오류를 간결하게 구축하기 위한 함수형 옵션도 제공합니다.
애플리케이션 로직에서의 예시 사용법
이제 서비스 계층에서 이를 사용하는 방법을 살펴보겠습니다.
package userservice import ( "errors" "fmt" "your_module/apperror" // apperror 패키지가 위에 정의되었다고 가정 ) // User는 사용자 엔티티를 나타냅니다. type User struct { ID string Name string Email string } // UserRepository는 사용자 데이터 액세스를 위한 인터페이스를 정의합니다. type UserRepository interface { GetUserByID(id string) (*User, error) CreateUser(user *User) error } // Service는 사용자 관련 비즈니스 로직을 제공합니다. type Service struct { repo UserRepository } func NewService(repo UserRepository) *Service { return &Service{repo: repo} } // GetUser는 ID로 사용자를 가져옵니다. func (s *Service) GetUser(id string) (*User, error) { user, err := s.repo.GetUserByID(id) if err != nil { if errors.Is(err, apperror.New(apperror.CategoryNotFound, "USER_NOT_FOUND", "User not found")) { // 이 검사는 단순화입니다. 이상적으로는 리포지토리가 구조화된 오류를 반환해야 합니다. // 데모를 위해 리포지토리가 현재 일반 오류를 반환한다고 가정해 보겠습니다. } // 예: 리포지토리가 찾을 수 없음(not found)을 나타내는 일반 오류를 반환한다고 가정 if err.Error() == "sql: no rows in result set" { // 또는 기본 드라이버의 특정 오류 return nil, apperror.New( apperror.CategoryNotFound, "USER_NOT_FOUND", "요청한 사용자를 찾을 수 없습니다.", apperror.WithDevMessage(fmt.Sprintf("ID %s인 사용자가 데이터베이스에 없습니다.", id)), apperror.WithContext("userID", id), apperror.WithCause(err), // 기본 데이터베이스 오류 래핑 ) } // 다른 예상치 못한 리포지토리 오류의 경우 return nil, apperror.New( apperror.CategoryInternal, "DB_OPERATION_FAILED", "사용자 데이터를 가져오는 동안 예상치 못한 오류가 발생했습니다.", apperror.WithDevMessage(fmt.Sprintf("데이터베이스에서 사용자 ID %s를 검색하지 못했습니다.", id)), apperror.WithContext("operation", "GetUserByID"), apperror.WithCause(err), ) } return user, nil } // CreateUser는 새 사용자를 생성합니다. func (s *Service) CreateUser(user *User) error { if user.Name == "" || user.Email == "" { return apperror.New( apperror.CategoryBadRequest, "INVALID_USER_DATA", "사용자 이름과 이메일은 필수입니다.", apperror.WithContext("input", user), ) } err := s.repo.CreateUser(user) if err != nil { // 예: 데이터베이스의 고유 제약 조건 위반을 가정 if errors.Is(err, apperror.New(apperror.CategoryConflict, "DUPLICATE_EMAIL", "Email already in use")) { // 다시 말하지만, 이 검사는 단순화입니다. 이상적으로는 리포지토리가 구조화된 오류를 반환해야 합니다. } if err.Error() == "pq: duplicate key value violates unique constraint \"users_email_key\"" { return apperror.New( apperror.CategoryConflict, "DUPLICATE_EMAIL", "제공된 이메일이 이미 등록되었습니다.", apperror.WithDevMessage(fmt.Sprintf("이메일 '%s'가 이미 존재합니다.", user.Email)), apperror.WithContext("email", user.Email), apperror.WithCause(err), ) } return apperror.New( apperror.CategoryInternal, "DB_INSERT_FAILED", "사용자를 만드는 동안 예상치 못한 오류가 발생했습니다.", apperror.WithDevMessage(fmt.Sprintf("데이터베이스에 사용자 '%s'를 삽입하지 못했습니다.", user.Email)), apperror.WithCause(err), ) } return nil }
API 응답 처리
HTTP 핸들러는 이러한 구조화된 오류를 받아 적절한 API 응답으로 변환합니다.
package httpapi import ( "encoding/json" "net/http" "your_module/apperror" // apperror 패키지 "your_module/userservice" // userservice 패키지 ) // ErrorResponse는 API 오류 응답의 구조를 정의합니다. type ErrorResponse struct { Code string `json:"code"` Category apperror.Category `json:"category"` Message string `json:"message"` Details map[string]interface{} `json:"details,omitempty"` // 컨텍스트에서 클라이언트 대면용으로 이름 변경 } // UserHandler는 사용자 관련 HTTP 요청을 처리합니다. type UserHandler struct { service *userservice.Service } func NewUserHandler(service *userservice.Service) *UserHandler { return &UserHandler{service: service} } func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { userID := r.URL.Query().Get("id") if userID == "" { h.writeError(w, apperror.New( apperror.CategoryBadRequest, "MISSING_USER_ID", "사용자 ID가 필요합니다.", apperror.WithContext("param", "id"), )) return } user, err := h.service.GetUser(userID) if err != nil { h.writeError(w, err) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(user) } func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var newUser userservice.User if err := json.NewDecoder(r.Body).Decode(&newUser); err != nil { h.writeError(w, apperror.New( apperror.CategoryBadRequest, "INVALID_JSON_BODY", "요청 본문이 유효한 JSON이 아닙니다.", apperror.WithCause(err), )) return } err := h.service.CreateUser(&newUser) if err != nil { h.writeError(w, err) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(newUser) // 또는 성공 메시지 } func (h *UserHandler) writeError(w http.ResponseWriter, err error) { var appErr *apperror.Error if !errors.As(err, &appErr) { // 이것은 예상치 못한, 구조화되지 않은 오류입니다. 철저하게 로깅하십시오. // API 응답의 경우 일반 내부 서버 오류를 반환합니다. appErr = apperror.New( apperror.CategoryInternal, "UNEXPECTED_ERROR", "예상치 못한 내부 오류가 발생했습니다.", apperror.WithDevMessage(err.Error()), // dev용 원본 메시지 캡처 apperror.WithCause(err), ) } logError(appErr) // 중앙 집중식 로깅 함수 httpStatus := apperror.MapCategoryToHTTPStatus(appErr.Category) resp := ErrorResponse{ Code: appErr.Code, Category: appErr.Category, Message: appErr.UserMessage, Details: appErr.Context, // 클라이언트 표현을 위해 컨텍스트를 Details로 사용 } w.Header().Set("Content-Type", "application/json") w.WriteHeader(httpStatus) json.NewEncoder(w).Encode(resp) }
구조화된 로깅
로깅을 위해 apperror.Error
구조체에 내장된 풍부한 컨텍스트를 활용할 수 있습니다.
package httpapi // 또는 전용 로깅 패키지 import ( "log/slog" // Go 1.21+ "your_module/apperror" ) // 우리의 사용자 정의 로거, 아마도 slog 주변에 래핑됨. // 이것은 단순화된 예입니다. 실제 로거는 더 구성 가능할 것입니다. var logger = slog.Default() // logError는 로깅을 위해 구조화된 애플리케이션 오류를 처리합니다. func logError(err error) { var appErr *apperror.Error if !errors.As(err, &appErr) { // 진정한 예상치 못한, 구조화되지 않은 오류를 로깅합니다. logger.Error("처리되지 않은 오류 발생", "error", err) return } logAttrs := []slog.Attr{ slog.String("error_code", appErr.Code), slog.String("error_category", string(appErr.Category)), slog.String("user_message", appErr.UserMessage), } if appErr.DevMessage != "" { logAttrs = append(logAttrs, slog.String("developer_message", appErr.DevMessage)) } // 컨텍스트 필드 추가 for k, v := range appErr.Context { logAttrs = append(logAttrs, slog.Any(k, v)) } // 기본 원인이 있는 경우 로깅 if appErr.Cause != nil { logAttrs = append(logAttrs, slog.Any("cause", appErr.Cause.Error())) // 원인의 메시지 로깅 } // 오류 범주에 따라 로그 수준 결정 logLevel := slog.LevelError if appErr.Category == apperror.CategoryBadRequest || appErr.Category == apperror.CategoryNotFound || appErr.Category == apperror.CategoryConflict { // 클라이언트 측 오류는 정책에 따라 Info 또는 Warn으로 로깅될 수 있습니다. logLevel = slog.LevelWarn } logger.LogAttrs(r.Context(), logLevel, "Application error", logAttrs...) }
이 로깅 함수는 구조화된 오류의 모든 관련 세부 정보가 로그에서 키-값 쌍으로 캡처되도록 보장하여 ELK 스택, Splunk 또는 클라우드 로깅 서비스와 같은 도구를 사용하여 오류를 쉽게 필터링, 검색 및 분석할 수 있도록 합니다. 클라이언트 측 오류는 WARNING
수준으로, 서버 측 오류는 일반적으로 ERROR
수준으로 기록되어 더 명확한 운영 통찰력을 제공합니다.
이 접근 방식의 이점
- 일관성: API 전반의 모든 오류에 대해 균일한 구조를 가지므로 클라이언트 측 오류 구문 분석 및 처리가 단순화됩니다.
- 명확성: 사용자와 개발자를 위한 별도의 메시지는 두 대상 모두에게 적절한 정보를 제공하도록 보장합니다.
- 추적성: 오류 코드와 범주는 빠른 식별을 제공합니다.
Context
는 특정 인스턴스 디버깅을 훨씬 쉽게 만듭니다. - 관찰 가능성: 구조화된 로그는 기계가 구문 분석할 수 있으므로 오류 추세에 대한 모니터링, 경고 및 분석이 향상됩니다.
- 유지보수성: 새로운 오류 유형을 추가하고, 분류하고, 오류 응답을 중앙에서 관리하기 쉽습니다.
- 디커플링: 내부 오류 표현은 외부 HTTP 상태 코드와 독립적이므로 유연한 매핑이 가능합니다.
결론
잘 설계된 오류 처리 시스템은 견고하고 유지보수 가능한 Go API 애플리케이션을 구축하는 데 매우 중요합니다. 사용자 정의의 구조화된 오류 유형으로 오류 세부 정보를 캡슐화함으로써 일관된 API 응답, 상세하고 기계가 읽을 수 있는 로그, 크게 향상된 개발자 경험을 달성할 수 있습니다. 이 접근 방식은 오류 처리를 단순한 필요에서 애플리케이션 신뢰성과 관찰 가능성을 위한 강력한 도구로 전환합니다. 구조화된 오류 시스템은 문제가 발생했을 때 혼란이 아닌 명확성을 얻도록 보장합니다.