강력한 Go: 에러 처리를 위한 모범 사례
Wenhao Wang
Dev Intern · Leapcell

Go의 에러 처리는 종종 격렬한 토론과 다양한 접근 방식의 주제가 됩니다. 예외에 크게 의존하는 많은 다른 언어와 달리, Go는 보다 명시적이고 반환 값 기반의 에러 전파 모델을 채택합니다. 처음에는 장황해 보일 수 있지만, 이 접근 방식은 개발자가 모든 단계에서 에러를 고려하고 처리하도록 장려하여 더 강력하고 예측 가능한 애플리케이션을 만들도록 합니다. 이 문서는 Go에서의 에러 처리 모범 사례를 탐색하고, 견고한 시스템 구축에 대한 구체적인 예제와 통찰력을 제공합니다.
Go 방식: 명시적 에러 반환
그 핵심에서 Go의 에러 처리는 error 인터페이스를 중심으로 이루어집니다.
type error interface { Error() string }
실패할 가능성이 있는 함수는 일반적으로 두 개의 값을 반환합니다: 결과와 error. 에러가 발생하면 결과는 일반적으로 해당 타입의 제로 값이고, error 값은 nil이 아닙니다.
func OpenFile(path string) (*os.File, error) { f, err := os.Open(path) if err != nil { return nil, err // 명시적으로 에러 반환 } return f, nil }
가장 기본적인 모범 사례는 항상 에러를 확인하고 즉시 처리하는 것입니다. 에러를 무시하는 것은 재앙을 초래하는 행위이며, 예기치 않은 실패는 예측할 수 없는 동작과 데이터 손상을 유발할 수 있습니다.
func main() { file, err := OpenFile("non_existent_file.txt") if err != nil { // 에러 처리: 로그 기록, 반환 또는 시정 조치 fmt.Printf("Error opening file: %v\n", err) return } defer file.Close() // ... 파일 사용 }
컨텍스트를 위한 에러 래핑
Go의 명시적 에러 처리에 대한 한 가지 일반적인 비판은 에러가 호출 스택을 위로 전파될 때 컨텍스트 손실 가능성입니다. 단순히 err을 위로 반환하는 것은 문제의 원래 출처를 모호하게 할 수 있습니다. Go 1.13은 fmt.Errorf와 %w verb, 그리고 errors.As, errors.Is, errors.Unwrap 함수를 통해 에러 래핑을 도입하여 이를 해결합니다:
package repository import ( "database/sql" "fmt" ) // ErrUserNotFound는 사용자를 찾을 수 없음을 나타냅니다. var ErrUserNotFound = fmt.Errorf("user not found") type User struct { ID int Name string } type UserRepository struct { db *sql.DB } func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } func (r *UserRepository) GetUserByID(id int) (*User, error) { stmt, err := r.db.Prepare("SELECT id, name FROM users WHERE id = ?") if err != nil { return nil, fmt.Errorf("prepare statement failed: %w", err) // 데이터베이스 에러 래핑 } defer stmt.Close() var user User row := stmt.QueryRow(id) if err := row.Scan(&user.ID, &user.Name); err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("get user by ID %d: %w", id, ErrUserNotFound) // 사용자 정의 에러 래핑 } return nil, fmt.Errorf("scan user row: %w", err) // 다른 데이터베이스 에러 래핑 } return &user, nil }
호출 코드에서 래핑된 에러를 검사할 수 있습니다:
package service import ( "errors" "fmt" "log" "your_module/repository" // 실제 모듈 경로로 교체하세요. ) type UserService struct { repo *repository.UserRepository } func NewUserService(repo *repository.UserRepository) *UserService { return &UserService{repo: repo} } func (s *UserService) FetchAndProcessUser(userID int) error { user, err := s.repo.GetUserByID(userID) if err != nil { // errors.Is를 사용하여 특정 에러 타입 확인 if errors.Is(err, repository.ErrUserNotFound) { log.Printf("User with ID %d not found: %v", userID, err) return fmt.Errorf("operation failed: user not found") } // errors.As를 사용하여 특정 에러 타입을 언래핑하고 캐스팅 var dbErr error // 이것은 sql.Error 또는 사용자 정의 DB 에러 타입일 수 있습니다. if errors.As(err, &dbErr) { // 이 예제는 사용자 정의 타입 없이 특정 sql.Error를 직접 잡지 못할 수 있습니다. // 실제 시나리오에서는 사용자 정의 DB 에러 타입을 정의하고 // 여기서 확인하여 구분할 수 있습니다. log.Printf("A database-related error occurred for user ID %d: %v", userID, err) return fmt.Errorf("operation failed due to database issue: %w", err) } // 기타 예상치 못한 에러의 경우, 로그 기록 및 반환 log.Printf("An unexpected error occurred while fetching user ID %d: %v", userID, err) return fmt.Errorf("internal server error during user fetch: %w", err) } fmt.Printf("Successfully fetched user: %+v\n", user) // 추가 처리... return nil }
래핑을 위한 핵심 사항:
- 경계에서 래핑: API 경계 또는 서로 다른 계층(예: 리포지토리에서 서비스로) 간에 에러를 전달할 때 에러를 래핑하세요.
- 과도한 래핑 금지: 모든 에러를 래핑하면 불필요한 장황함과 오버헤드가 추가될 수 있습니다. 귀중한 컨텍스트를 추가하거나 상위 계층의 에러 타입을 변경하려는 경우에만 래핑하세요.
- 타입 확인을 위해 errors.Is사용:errors.Is를 사용하여 에러 체인에서 에러가 특정 센티넬 에러(예:repository.ErrUserNotFound)와 일치하는지 확인하세요.
- 특정 에러 타입 추출을 위해 errors.As사용:errors.As를 사용하여 해당 에러 체인에서 에러가 특정 타입인지 확인하고 더 자세한 검사를 위해 구체적인 값을 추출하세요(예: 사용자 ID를 포함하는 사용자 정의UserNotFoundError구조체).
사용자 정의 에러 타입
센티넬 에러(예: io.EOF 또는 repository.ErrUserNotFound)는 간단하고 잘 정의된 에러 조건을 처리하는 데 좋습니다. 더 복잡한 시나리오의 경우, **사용자 정의 에러 타입( error 인터페이스를 구현하는 구조체)**이 더 강력합니다. 이를 통해 에러에 추가 컨텍스트와 메타데이터를 연결할 수 있습니다.
package auth import "fmt" // InvalidCredentialsError는 잘못된 자격 증명으로 인한 인증 실패를 나타냅니다. type InvalidCredentialsError struct { Username string Reason string } func (e *InvalidCredentialsError) Error() string { return fmt.Sprintf("invalid credentials for user '%s': %s", e.Username, e.Reason) } // Is는 타입 확인을 위해 errors.Is 인터페이스를 구현합니다. // 이를 통해 `errors.Is(err, &InvalidCredentialsError{})`가 작동합니다. func (e *InvalidCredentialsError) Is(target error) bool { _, ok := target.(*InvalidCredentialsError) return ok } // UserAuthenticator는 인증 서비스를 제공합니다. type UserAuthenticator struct{} func NewUserAuthenticator() *UserAuthenticator { return &UserAuthenticator{} } // Authenticate는 사용자 인증을 시뮬레이션합니다. func (a *UserAuthenticator) Authenticate(username, password string) error { // 인증 로직 시뮬레이션 if username != "admin" || password != "password123" { return &InvalidCredentialsError{ Username: username, Reason: "username or password incorrect", } } fmt.Printf("User '%s' authenticated successfully.\n", username) return nil }
사용 방법:
package main import ( "errors" "fmt" "your_module/auth" // 실제 모듈 경로로 교체하세요. ) func main() { authenticator := auth.NewUserAuthenticator() // 성공적인 인증 if err := authenticator.Authenticate("admin", "password123"); err != nil { fmt.Printf("Authentication failed: %v\n", err) } // 실패한 인증 err := authenticator.Authenticate("john.doe", "wrongpass") if err != nil { // 사용자 정의 에러 타입으로 errors.Is 사용 var invalidCredsErr *auth.InvalidCredentialsError if errors.As(err, &invalidCredsErr) { // 언래핑 및 캐스팅을 위해 errors.As 사용 fmt.Printf("Authentication error for user: %s (Reason: %s)\n", invalidCredsErr.Username, invalidCredsErr.Reason) } else { fmt.Printf("An unexpected error occurred during authentication: %v\n", err) } } // 래핑 예제 wrappedErr := fmt.Errorf("failed to process login: %w", authenticator.Authenticate("guest", "pass")) var invalidCredsErr *auth.InvalidCredentialsError if errors.As(wrappedErr, &invalidCredsErr) { fmt.Printf("Caught wrapped InvalidCredentialsError for user: %s\n", invalidCredsErr.Username) } }
사용자 정의 에러 타입의 이점:
- 세분화: 에러 조건을 정확하게 구분할 수 있습니다.
- 컨텍스트: 에러와 관련된 추가 데이터를 담아 디버깅 및 복구를 지원합니다.
- API 명확성: 함수가 반환할 수 있는 특정 에러 타입을 정의하여 함수의 계약을 더 명확하게 만듭니다.
- 프로그래밍 방식 처리: errors.As또는 타입 단언을 통해 에러 처리 로직을 단순화합니다.
처리되지 않은 에러에 대한 구조화된 로깅
명시적 에러 처리가 중요하지만, 모든 에러를 발생 지점에서 우아하게 처리할 수는 없습니다. 구조화된 로깅은 에러를 승격하고 검토해야 할 때 매우 중요합니다. 단순히 fmt.Println(err) 대신, 컨텍스트를 포함하여 에러를 기록하기 위해 로깅 라이브러리(Zap, Logrus 또는 Go 1.21+의 log/slog와 같은 표준 log 패키지)를 사용하세요.
package main import ( "errors" "fmt" "log/slog" "os" "time" ) // 에러를 반환하는 함수의 시뮬레이션 func doRiskyOperation(id string) error { if id == "fail" { return errors.New("something went terribly wrong in doRiskyOperation") } return nil } // 에러를 래핑하는 함수의 시뮬레이션 func processRequest(requestID string) error { err := doRiskyOperation(requestID) if err != nil { return fmt.Errorf("failed to process request %s: %w", requestID, err) } return nil } func main() { // 구조화된 로거 초기화 (예: 기계 파싱을 위한 JSON 출력) logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) // 사례 1: 성공적인 작업 if err := processRequest("success-123"); err != nil { slog.Error("Request processing failed", "request_id", "success-123", "error", err) } else { slog.Info("Request processed successfully", "request_id", "success-123") } fmt.Println("---") // 사례 2: 실패하는 작업 err := processRequest("fail") if err != nil { // 관련 속성을 포함하여 에러 로깅 slog.Error( "Critical failure during request processing", slog.String("request_id", "fail"), slog.String("component", "processor"), slog.String("function", "processRequest"), slog.Any("error", err), // slog.Any는 래핑된 에러를 포함하여 에러를 잘 처리합니다 slog.Time("timestamp", time.Now()), ) // 선택적으로, 애플리케이션 요구 사항에 따라 일반적인 에러를 전파하거나 // 웹 서비스에서 HTTP 500 상태를 반환합니다. } }
에러 로깅 모범 사례:
- 소스에서 로깅: 에러가 발생하는 지점에 최대한 가깝게 로깅하되, 컨텍스트를 추가할 때는 종종 더 높은 계층에서 로깅합니다. 각 계층에서 고유하고 중요한 컨텍스트를 추가하지 않는 한 동일한 에러를 호출 스택에 따라 여러 번 로깅하지 마세요.
- 컨텍스트 포함: 항상 관련 컨텍스트(예: 요청 ID, 사용자 ID, 매개변수)를 로그 항목에 연결하세요.
- 구조화된 형식: 로그 집계 시스템에서 쉽게 파싱하고 분석할 수 있도록 JSON 또는 다른 구조화된 형식을 사용하세요.
- 에러 레벨: 적절한 로깅 레벨(예: Error,Warn)을 사용하세요. 치명적인 에러는 애플리케이션을 종료시킬 수 있습니다.
if err != nil을 넘어서는 에러 처리 전략
1. 빠르게 실패 (Fail Fast)
많은 경우, 에러가 특정 작업에 대해 복구할 수 없는 상태를 의미한다면, 잘못된 상태나 추가 에러로 진행하는 대신 빠르게 실패하는 것이 더 좋습니다. 이는 잘못된 데이터나 추가 에러를 전파하는 것을 방지합니다.
func SaveUser(user *User) error { if user == nil || user.Name == "" { return errors.New("user is nil or name is empty") // 잘못된 입력 시 빠르게 실패 } // ... 저장 진행 return nil }
2. golang.org/x/sync/errgroup으로 에러 그룹화
동시 작업 처리 시, errgroup은 고루틴 간의 에러를 관리하는 강력한 패턴입니다. 여러 고루틴을 실행하고 발생하는 첫 번째 에러를 수집하며 나머지를 취소할 수 있습니다.
package main import ( "errors" "fmt" "log" "net/http" "time" "golang.org/x/sync/errgroup" ) func fetchURL(url string) error { log.Printf("Fetching %s...", url) resp, err := http.Get(url) if err != nil { return fmt.Errorf("failed to fetch %s: %w", url, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch %s, status code: %d", url, resp.StatusCode) } log.Printf("Successfully fetched %s", url) return nil } func main() { urls := []string{ "http://google.com", "http://nonexistent-domain-xyz123.com", // 에러 발생 "http://example.com", "http://httpbin.org/status/404", // 200이 아닌 상태 코드 발생 } // errgroup.Group와 백그라운드 컨텍스트에서 파생된 컨텍스트 생성 // 컨텍스트는 첫 번째 에러가 발생하거나 모든 고루틴이 완료되면 취소됩니다. group, ctx := errgroup.WithContext(context.Background()) for _, url := range urls { url := url // 클로저를 위한 로컬 복사본 생성 group.Go(func() error { select { case <-ctx.Done(): // ctx가 완료되었다면, 다른 고루틴이 실패했다는 의미입니다. // 이 고루틴을 우아하게 종료합니다. log.Printf("Context cancelled for %s, skipping fetch.", url) return nil default: time.Sleep(time.Duration(len(url)) * 50 * time.Millisecond) // 작업 시뮬레이션 return fetchURL(url) } }) } // 모든 고루틴이 완료될 때까지 기다립니다. 어떤 고루틴이라도 nil이 아닌 에러를 반환하면, // Wait는 첫 번째 nil이 아닌 에러를 반환합니다. if err := group.Wait(); err != nil { fmt.Printf("\nOne or more operations failed: %v\n", err) // 필요한 경우 에러 타입을 검사할 수 있습니다. var httpErr *url.Error // net/url에서 특정 에러 타입 확인 예제 if errors.As(err, &httpErr) { if httpErr.Timeout() { fmt.Println("A timeout error occurred.") } else if httpErr.Temporary() { // 임시 네트워크 에러 처리 fmt.Println("A temporary network error occurred.") } } else if errors.Is(err, context.Canceled) { fmt.Println("Context was cancelled (due to another error).") } else { fmt.Printf("Error type: %T\n", errors.Unwrap(err)) } } else { fmt.Println("\nAll operations completed successfully.") } }
3. 멱등성 및 재시도
외부 시스템(API, 데이터베이스)과 상호 작용하는 작업의 경우, 재시도를 통해 일시적인 에러(네트워크 문제, 일시적인 서비스 미사용 가능성)에 대한 복원력을 향상시킬 수 있습니다. 그러나 재시도는 상태를 수정하는 작업의 경우 멱등성과 결합되어야 합니다. 이를 통해 반복적인 시도가 중복 생성이나 의도하지 않은 부작용으로 이어지지 않도록 보장할 수 있습니다.
github.com/cenkalti/backoff와 같은 라이브러리는 재시도에 대한 지수 백오프 전략을 제공합니다.
package main import ( "fmt" "log" "math/rand" "time" "github.com/cenkalti/backoff/v4" ) // 비동기 RPC 호출 시뮬레이션 func makeRPC(attempt int) error { log.Printf("Attempting RPC call (attempt %d)...", attempt) r := rand.Float64() if r < 0.7 { // 처음 몇 번의 시도에 대해 70%의 실패 확률 return fmt.Errorf("RPC failed due to transient error (random value: %.2f)", r) } log.Println("RPC call succeeded!") return nil } func main() { rand.Seed(time.Now().UnixNano()) // 지수 백오프 정책 생성 b := backoff.NewExponentialBackOff() b.InitialInterval = 500 * time.Millisecond // 0.5초 지연부터 시작 b.MaxElapsedTime = 5 * time.Second // 5초 후 중단 b.Multiplier = 2 // 지연 시간 매번 두 배로 증가 operation := func() error { // 실제 시나리오에서는 여기에 컨텍스트를 전달하고 ctx.Done()을 확인합니다. return makeRPC(int(b.Get){ /* 시도 횟수는 여기서 직접 접근할 수 없습니다. */ } + 1) } err := backoff.Retry(operation, b) if err != nil { fmt.Printf("Operation failed after retries: %v\n", err) } else { fmt.Println("Operation succeeded after retries.") } }
피해야 할 안티 패턴
- 에러 무시 (_ = ...,if err != nil { return nil }): 이것은 가장 흔하고 위험한 안티 패턴입니다. 항상 에러를 처리하십시오.
- 복구 가능한 에러에 대한 패닉: panic은 진정으로 복구할 수 없는 상황(예: 프로그래밍 버그, 절대 발생해서는 안 되는 초기화되지 않은 상태)을 위한 것입니다. 예상되는 런타임 에러에 사용하면 애플리케이션이 깨지기 쉬워집니다.
- 에러를 출력하고 계속 진행: fmt.Println(err)또는log.Println(err)를 반환하거나 시정 조치를 취하지 않고 사용하는 것은 종종 문제를 숨깁니다. 에러는 여전히 존재하며 프로그램은 잘못된 상태일 수 있습니다.
- 일반적인 에러 반환: errors.New("something went wrong")는 간단하지만 컨텍스트를 제공하지 않습니다. 원래 에러를 래핑하거나 사용자 정의 에러 타입을 사용하세요.
- 과도한 에러 래핑: 새로운 의미 있는 컨텍스트를 추가하지 않고 에러를 지속적으로 래핑하면 장황하고 읽기 어려운 에러 체인이 생성됩니다.
- 에러 문자열 값 확인: if err.Error() == "record not found"는 불안정합니다. 센티넬 에러 또는 사용자 정의 에러 타입과 함께errors.Is또는errors.As를 사용하여 견고한 에러 확인을 수행하세요.
결론
Go의 명시적 에러 처리와 에러 래핑 및 사용자 정의 에러 타입과 같은 최신 기능은 강력한 애플리케이션을 구축하기 위한 강력하고 유연한 메커니즘을 제공합니다. 이러한 모범 사례—즉시 에러 확인, 래핑 및 사용자 정의 타입을 통한 컨텍스트 추가, 구조화된 로깅 활용, errgroup 또는 재시도와 같은 전략 사용 시기 이해—를 통해 개발자는 성능뿐만 아니라 불가피한 실패에 직면했을 때 복원력 있고 유지 관리하기 쉬운 Go 프로그램을 만들 수 있습니다. 효과적인 에러 처리는 단순히 에러를 잡는 것이 아니라, 에러를 이해하고, 전달하고, 필요할 때 우아하게 복구하거나 실패하는 것임을 기억하십시오.