강력한 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 프로그램을 만들 수 있습니다. 효과적인 에러 처리는 단순히 에러를 잡는 것이 아니라, 에러를 이해하고, 전달하고, 필요할 때 우아하게 복구하거나 실패하는 것임을 기억하십시오.