Go에서 강력한 오류 처리를 위한 사용자 정의 오류 타입 만들기
Olivia Novak
Dev Intern · Leapcell

오류 처리는 강력하고 안정적인 소프트웨어를 작성하는 데 기본적인 측면입니다. Go는 특유의 error
인터페이스와 다중 값 반환을 통해 오류를 관리하는 독특한 접근 방식을 제공합니다. 단일 Error() string
메서드를 가진 인터페이스인 내장 error
타입은 많은 시나리오에서 충분하지만, 특정 복잡성의 애플리케이션은 사용자 정의 오류 타입으로부터 이점을 얻는 경우가 많습니다. 이러한 사용자 정의 타입은 개발자가 더 많은 컨텍스트를 첨부하고, 오류를 분류하고, 단순히 문자열 비교를 넘어선 보다 정확한 오류 처리 로직을 활성화할 수 있도록 합니다.
사용자 정의 오류 타입이 필요한 이유?
핵심적으로 Go의 error
인터페이스는 단순성을 위해 설계되었습니다. Error() string
을 구현하는 모든 타입은 오류가 될 수 있습니다. 이러한 유연성은 강력하지만, 주의해서 관리하지 않으면 장황한 조건부 검사로 이어질 수 있습니다. 사용자 정의 오류 타입은 여러 가지 문제를 해결합니다.
- 컨텍스트 정보 추가: 단순한 문자열 메시지만으로는 문제를 진단하기에 충분하지 않을 수 있습니다. 사용자 정의 오류는 타임스탬프, 오류 코드, 실패한 특정 인수 또는 스택 추적과 같은 추가 필드를 포함할 수 있습니다.
- 타입 안전한 오류 식별: 오류 메시지에서 오류의 특성을 추론하기 위해
strings.Contains()
에 의존하는 대신, 사용자 정의 오류 타입을 사용하면 타입 단언(err, ok := someErr.(*MyCustomError)
) 또는 타입 스위치를 사용할 수 있습니다. 이는 오류 메시지가 변경될 경우 더 강력하고 중단의 가능성이 적습니다. - 분류 및 그룹화: 오류는 (예:
DatabaseError
,NetworkError
,ValidationError
) 출처별 또는 (예:NotFoundError
,AlreadyExistsError
,PermissionDeniedError
) 의미별로 그룹화할 수 있습니다. 이를 통해 오류 클래스에 대한 일반적인 처리가 가능합니다. - 특정 처리 로직 활성화: 다른 오류 유형은 다른 복구 메커니즘, 로깅 전략 또는 사용자 피드백을 트리거할 수 있습니다. 타입 기반 식별은 이러한 정확성을 제공합니다.
기본 구성 요소: error
인터페이스 구현
가장 간단한 사용자 정의 오류 타입은 Error() string
메서드를 구현하는 struct
입니다.
package main import ( "fmt" ) // PermissionDeniedError는 권한 부족으로 인해 작업이 거부된 오류를 나타냅니다. type PermissionDeniedError struct { User string Action string Details string } // Error는 PermissionDeniedError에 대한 error 인터페이스를 구현합니다. func (e *PermissionDeniedError) Error() string { return fmt.Sprintf("permission denied for user '%s' to '%s': %s", e.User, e.Action, e.Details) } func checkPermission(user, action string) error { if user == "guest" { return &PermissionDeniedError{ User: user, Action: action, Details: "Guests are not allowed to perform this action.", } } return nil } func main() { if err := checkPermission("guest", "write_file"); err != nil { fmt.Println("Error:", err) // Output: Error: permission denied for user 'guest' to 'write_file': Guests are not allowed to perform this action. // 타입 단언을 사용하여 PermissionDeniedError인지 확인 if pdErr, ok := err.(*PermissionDeniedError); ok { fmt.Printf("Denied user: %s, action: %s, details: %s\n", pdErr.User, pdErr.Action, pdErr.Details) } } }
이 예제에서 PermissionDeniedError
는 권한 거부에 대한 특정 세부 정보를 보유하는 구체적인 타입입니다. 호출 지점에서 타입 단언을 사용하여 이러한 세부 정보를 추출하고 처리할 수 있습니다.
사용자 정의 오류 설계 모범 사례
-
오류 값에 포인터 사용: 항상 사용자 정의 오류 구조체에 대한 포인터(예:
*MyError
)를 반환하십시오. 이는 다음 때문입니다.- 구조체 복사를 방지하여, 구조체가 클 경우 비효율적일 수 있습니다.
- 포인터 수신기를 가진 구조체 메서드(
(e *MyError)
)가 올바르게 작동합니다. 값 타입을 반환하면 포인터 수신기 메서드가 호출되지 않거나, 메서드가 값 수신기를 사용하면 메서드 내부의 변경 사항이 원본 오류 객체에 반영되지 않습니다. - nil 검사(
if err == nil
)가 예상대로 작동합니다. nil인 구체적인 포인터를 보유하는 nil이 아닌 인터페이스 값은 여전히 nil이 아닙니다. 이는 흔한 함정입니다.nil
을 직접 반환하는 것이 오류가 없음을 나타내는 올바른 방법입니다.
// 이것은 문제입니다: 값 타입을 반환 // func (e MyError) Error() string { ... } // MyError는 구조체이지 *MyError가 아님 // return MyError{ ... } // 복사본을 반환합니다. 인터페이스는 값을 보유합니다.
-
필드 적절히 노출: 오류 구조체를 프로그래밍 방식으로 처리에 유용한 필드를 노출하도록 설계하되, 사람이 읽을 수 있는 출력은
Error()
메서드에 맡기십시오. -
**
errors.Is
및errors.As
수용 (Go 1.13+)Go 1.13은 강력한 오류 처리를 위한 게임 체인저인
errors.Is
및errors.As
를 도입했으며, 특히 래핑된 오류의 경우 더욱 그렇습니다.errors.Is(err, target error)
:err
또는 해당 체인의 오류가target
과 "같은"지 확인합니다. 이는 오류를 센티넬 오류 또는 특정 사용자 정의 오류 타입과 비교하는 데 이상적입니다.errors.As(err, target interface{})
: 체인에서target
의 타입과 일치하는 첫 번째 오류를 찾아target
에 할당합니다. 이는 특정 사용자 정의 오류 타입을 해당 세부 정보와 함께 추출하는 타입 안전한 방법으로, 타입 단언과 유사하지만 오류 체인을 탐색합니다.
errors.Is
및errors.As
를 활용하려면 사용자 정의 오류가 종종Unwrap()
메서드를 구현하거나 특정 인터페이스 패턴을 따릅니다.Unwrap()
을 사용한 체인 가능한 오류종종 낮은 계층의 오류가 높은 계층의 오류를 유발합니다. 래핑은 컨텍스트를 추가하면서 원본 오류를 보존할 수 있도록 합니다.
package main import ( "database/sql" "errors" "fmt" ) // OpError는 잠재적으로 기본 오류를 래핑하는 작업 중 오류를 나타냅니다. type OpError struct { Op string // 실패한 작업 Code int // 내부 오류 코드 Description string // 실패에 대한 설명 Err error // 기본 오류 } // Error는 인터페이스를 구현합니다. func (e *OpError) Error() string { if e.Err != nil { return fmt.Sprintf("operation %s failed (code %d): %s: %v", e.Op, e.Code, e.Description, e.Err) } return fmt.Sprintf("operation %s failed (code %d): %s", e.Op, e.Code, e.Description) } // Unwrap은 기본 오류를 반환하여 errors.Is 및 errors.As가 체인을 탐색할 수 있도록 합니다. func (e *OpError) Unwrap() error { return e.Err } func getUserFromDB(userID string) error { // DB 오류 시뮬레이션 if userID == "123" { // 특정 데이터베이스 오류 시뮬레이션, 예: 행 없음 return sql.ErrNoRows // 표준 라이브러리 센티넬 오류 } // 다른 ID에 대한 일반 데이터베이스 연결 오류 시뮬레이션 return errors.New("database connection failed") } func GetUserProfile(userID string) error { err := getUserFromDB(userID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return &OpError{ Op: "GetUserProfile", Code: 404, // 찾을 수 없음 Description: "User not found in database", Err: err, // 원본 오류 래핑 } } return &OpError{ Op: "GetUserProfile", Code: 500, // 내부 서버 오류 Description: "Failed to retrieve user profile", Err: err, // 원본 오류 래핑 } } return nil } func main() { // 사례 1: 사용자를 찾을 수 없음 err1 := GetUserProfile("123") if err1 != nil { fmt.Println("Error 1:", err1) // operation GetUserProfile failed (code 404): User not found in database: sql: no rows in result set if opErr := new(OpError); errors.As(err1, &opErr) { fmt.Printf("Is OpError (Code %d): %s\n", opErr.Code, opErr.Description) // Is OpError (Code 404): User not found in database } if errors.Is(err1, sql.ErrNoRows) { fmt.Println("Underlying error is sql.ErrNoRows") // Underlying error is sql.ErrNoRows } } fmt.Println("---") // 사례 2: 데이터베이스 연결 실패 err2 := GetUserProfile("abc") if err2 != nil { fmt.Println("Error 2:", err2) // operation GetUserProfile failed (code 500): Failed to retrieve user profile: database connection failed if opErr := new(OpError); errors.As(err2, &opErr) { fmt.Printf("Is OpError (Code %d): %s\n", opErr.Code, opErr.Description) // Is OpError (Code 500): Failed to retrieve user profile } // 체인에 sql.ErrNoRows가 없으므로 true가 되지 않음 if errors.Is(err2, sql.ErrNoRows) { fmt.Println("Underlying error is sql.ErrNoRows") } } }
OpError
는 기본 오류를 래핑합니다.Unwrap()
을 구현함으로써errors.Is
및errors.As
는 이제OpError
를 "통해" 근본 원인을 찾아 오류 분류를 훨씬 더 강력하게 만듭니다.
센티넬 오류 대 사용자 정의 오류 타입
-
센티넬 오류: 미리 정의된 오류 변수(종종
errors.New
로 생성된 상수error
값)입니다. 추가 컨텍스트가 필요 없는 간단하고 일반적인 오류 조건에 좋습니다(예:io.EOF
,os.ErrPermission
).errors.Is
를 사용하여 확인합니다.var ErrNotFound = errors.New("item not found") func getItem(id string) error { if id == "nonexistent" { return ErrNotFound } return nil } func main() { if err := getItem("nonexistent"); errors.Is(err, ErrNotFound) { fmt.Println("Item was not found.") } }
-
사용자 정의 오류 타입:
error
를 구현하는struct
입니다. 추가 컨텍스트 또는 식별 이상의 특정 처리 로직이 필요한 오류에 사용됩니다.errors.As
를 사용하여 확인합니다.
단순히 오류의 종류를 알아야 할 때는 센티넬 오류를 선택하고, 오류가 왜 발생했는지 알고 특정 세부 정보를 추출해야 할 때는 사용자 정의 타입을 선택하십시오.
고급 주제 및 고려 사항
-
오류 코드: 정수 오류 코드(예:
OpError.Code
와 같은)를 포함하는 것은 로깅, 모니터링 및 국제화에 매우 유용할 수 있습니다. 이러한 코드를 사전 정의된 세트에 매핑하면 클라이언트가 문자열 메시지를 구문 분석하지 않고도 프로그래밍 방식으로 오류에 대응할 수 있습니다. -
스택 추적: 디버깅을 위해 오류가 생성된 지점에서 스택 추적을 캡처하는 것이 매우 중요할 수 있습니다.
pkg/errors
라이브러리(net/errors
및 표준 라이브러리 개선 사항에 의해 폐기되었지만) 또는 사용자 정의 구현은 이를 포함할 수 있습니다. -
오류 로깅: 오류를 로깅할 때는 구조적 로깅을 우선하십시오. 단순히
log.Print(err)
대신 사용자 정의 오류 필드를 키-값 쌍으로 로깅합니다(예:log.Println("user_id", pdErr.User, "action", pdErr.Action, "error", pdErr.Error())
). -
공개 대 내부 오류: API를 설계하여 호출 클라이언트에 고수준의 안정적인 오류 타입을 반환하십시오. 내부적으로는 더 세분화된 오류 타입을 사용하여 공용 타입으로 반환되기 전에 "래핑"하거나 변환할 수 있습니다. 이는 API 안정성을 유지하고 구현 세부 정보를 노출하지 않습니다.
// api/errors.go - 공개 오류 package api import "fmt" type ServerError struct { Reason string } func (e *ServerError) Error() string { return fmt.Sprintf("server error: %s", e.Reason) } // internal/db/errors.go - 내부 오류 package db import "fmt" type QueryError struct { Query string Err error // 기본 DB 오류 } func (e *QueryError) Error() string { return fmt.Sprintf("db query failed: %s: %v", e.Query, e.Err) } func (e *QueryError) Unwrap() error { return e.Err } // 서비스 계층에서 func getUser(id string) error { _, err := db.RunQuery(fmt.Sprintf("SELECT * FROM users WHERE id = '%s'", id)) if err != nil { var qErr *db.QueryError if errors.As(err, &qErr) { // 내부 DB 오류를 공개 API 오류로 변환 return &api.ServerError{Reason: "failed to retrieve user data"} } return &api.ServerError{Reason: "unknown internal error"} } return nil }
결론
사용자 정의 오류 타입은 Go에서 강력하고 유지 관리가 용이하며 디버깅 가능한 애플리케이션을 구축하는 데 필수적인 도구입니다. 컨텍스트를 포함하고, 타입 안전한 식별을 활성화하고, errors.Is
및 errors.As
와 함께 Unwrap()
메서드를 활용함으로써 개발자는 정밀하고 유연하며 변경에 탄력적인 오류 처리 로직을 작성할 수 있습니다. 단순한 errors.New
에 비해 약간의 장황함이 추가되지만, 명확성, 진단 기능 및 유지 관리성 측면에서 장기적인 이점은 사소하지 않은 애플리케이션의 비용을 훨씬 상회합니다. 오류 타입을 신중하게 설계하고, 오류를 처리하는 시점에 어떤 정보가 필요한지 항상 고려하십시오.