양날의 검: 에러 래핑이 드러내는 것보다 더 많이 숨길 때
Ethan Miller
Product Engineer · Leapcell

현대 소프트웨어 개발 환경에서 강력한 에러 처리는 매우 중요합니다. Go는 단순성과 명확성에 대한 독특한 접근 방식으로 버전 1.13에 에러 래핑을 도입했습니다. 이 기능은 에러의 출처에 대한 더 많은 컨텍스트 정보를 제공하는 능력을 크게 향상시켰습니다. 하나의 에러가 다른 에러를 래핑하도록 함으로써, 개발자는 추적 가능한 실패 체인을 구축하여 디버깅을 훨씬 용이하게 할 수 있습니다. 하지만 모든 강력한 도구와 마찬가지로, 에러 래핑은 오용되거나 잘못 이해될 때 역설적으로 혼란과 복잡성의 원천이 될 수 있습니다. 이는 우리가 "양날의 검" 또는 더 직접적으로 "잘못된 래핑 및 언래핑"이라고 부를 수 있는 현상입니다.
래핑의 약속: fmt.Errorf
와 errors.Is
/errors.As
문제점으로 뛰어들기 전에 핵심 메커니즘을 간략히 요약해 보겠습니다. Go의 에러 래핑은 %w
verb를 사용하는 fmt.Errorf
함수와 검사를 위한 errors.Is
및 errors.As
함수를 활용합니다.
간단한 시나리오를 생각해 봅시다: readConfig
함수는 설정 파일을 읽어야 합니다. 파일이 존재하지 않으면 표준 os.ErrNotExist
에러를 래핑할 수 있습니다.
package main import ( "errors" "fmt" "os" ) // ErrConfigRead는 설정 읽기 중 일반적인 에러를 나타냅니다. var ErrConfigRead = errors.New("failed to read configuration") func readConfig(filename string) ([]byte, error) { data, err := os.ReadFile(filename) if err != nil { // 여기에서 더 많은 컨텍스트를 사용하여 기본 에러를 래핑합니다. // os.ErrNotExist는 특정하고 인식 가능한 에러이기 때문에 래핑됩니다. if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("%w: config file '%s' not found", err, filename) } // 다른 에러의 경우, 래핑할 수 있지만 메시지를 일반화할 수 있습니다. return nil, fmt.Errorf("%w: Failed to read config file '%s'", err, filename) } return data, nil } func main() { _, err := readConfig("non_existent_config.json") if err != nil { fmt.Println("Error:", err) if errors.Is(err, os.ErrNotExist) { fmt.Println(" --> It's a 'file not found' error!") } var pathErr *os.PathError if errors.As(err, &pathErr) { fmt.Printf(" --> It's an os.PathError! Op: %s, Path: %s\n", pathErr.Op, pathErr.Path) } // 내부 에러를 검사를 위해 언래핑하는 예제 (애플리케이션 로직에서 직접적으로 거의 필요하지 않음) unwrappedErr := errors.Unwrap(err) fmt.Println(" --> Unwrapped error:", unwrappedErr) } fmt.Println("\n--- Simulating another error ---") // os.ErrNotExist가 아닌 에러 시뮬레이션, 예: 권한 오류 // (참고: os.ReadFile이 모든 경우에 권한 거부에 대해 특정 에러 타입을 반환하지 않을 수 있습니다. // 데모 목적으로 모의 에러를 강제로 발생시킬 수 있습니다) const mockPermissionDenied = "permission denied" // 시뮬레이션하려면 모의 파일 시스템이 필요하지만, 데모를 위해 복합 오류를 생성해 보겠습니다. mockError := fmt.Errorf("%w: failed to open file", errors.New(mockPermissionDenied)) // os.ReadFile에서 나온 것처럼 모의 에러를 다시 래핑합니다. _, err = readConfigWithSimulatedError("protected_config.json", mockError) if err != nil { fmt.Println("Error:", err) if errors.Is(err, os.ErrNotExist) { fmt.Println(" --> This should not happen for a permission error.") } if errors.Is(err, errors.New(mockPermissionDenied)) { // 이것은 직접적으로 작동하지 않습니다. fmt.Println(" --> This check won't pass without careful implementation or using a named error constant.") } // 특정 문자열을 에러 메시지에서 찾는 더 강력한 검사, 하지만 이상적이지는 않음 if err.Error() == fmt.Errorf("%w: failed to open file", errors.New(mockPermissionDenied)).Error() || errors.Is(errors.Unwrap(err), errors.New(mockPermissionDenied)) { fmt.Println(" --> This is a wrapped permission error (demonstrative check).") } } } // 특정 에러로 readConfig의 동작을 시뮬레이션하기 위한 헬퍼 func readConfigWithSimulatedError(filename string, simErr error) ([]byte, error) { return nil, fmt.Errorf("%w: Failed to read config file '%s'", simErr, filename) }
이 예제는 errors.Is
를 사용하여 에러 체인의 어느 곳에서든 특정 에러 타입을 확인하는 방법과 errors.As
를 사용하여 특정 타입의 에러를 추출하는 방법을 보여줍니다. 이를 통해 타입별 처리가 가능합니다. 이것이 원본 에러의 신원을 잃지 않고 컨텍스트를 추가하는 "올바른" 방법입니다.
문제점: 래핑이 잘못되었을 때
fmt.Errorf("%w", err)
의 힘에는 책임이 따릅니다. 무분별하게 사용되거나 그 의미를 명확히 이해하지 못하고 사용될 때 문제가 발생할 수 있습니다.
1. 과도한 래핑: "마트료시카 인형" 효과
흔한 안티 패턴은 내부 에러가 호출자에게 고유하거나 실행 가능한 정보를 거의 제공하지 않거나, 외부 계층이 내부 계층으로부터 새로운 컨텍스트를 파생하지 않는 경우에도 호출 스택의 모든 계층에서 에러를 래핑하는 것입니다.
package main import ( "errors" "fmt" ) // 서비스 에러 var ( ErrDatabaseOpFailed = errors.New("database operation failed") ErrInvalidInput = errors.New("invalid input received") ) // --- 저수준 데이터베이스 액세스 --- func queryDatabase(sql string) error { // 데이터베이스 에러 시뮬레이션 if sql == "bad query" { return fmt.Errorf("%w: syntax error in SQL", errors.New("sql.ErrSyntax")) // DB 특정 에러 시뮬레이션 } return nil } // --- 리포지토리 계층 --- func getUser(id string) error { err := queryDatabase("bad query") // 저수준 함수 호출 if err != nil { // 문제: 호출자에게 최소한의 가치를 제공하는 저수준 에러 래핑 // 호출자는 DB 작업이 실패했는지 여부에만 관심이 있을 수 있으며, 특정 SQL 구문 에러에는 관심이 없을 수 있습니다. return fmt.Errorf("%w: failed to fetch user from DB", err) // 과도한 래핑! } return nil } // --- 서비스 계층 --- func processUserRequest(userID string) error { if userID == "" { return ErrInvalidInput } err := getUser(userID) // 리포지토리 호출 if err != nil { // 문제: 여기서 또 다른 래핑 계층이 발생하며, 아마도 ErrDatabaseOpFailed만으로도 충분할 수 있습니다. // 원본 `sql.ErrSyntax`는 이제 깊이 중첩되었습니다. return fmt.Errorf("%w: processing request for user %s failed", err, userID) // 더 많은 과도한 래핑! } return nil } func main() { err := processUserRequest("123") if err != nil { fmt.Println("Final Error:", err) // 디버깅은 에러 메시지가 장황해지면서 어려워지고, // 실제 근본 원인은 몇 번의 `errors.Unwrap` 호출 뒤에 있을 수 있습니다. // 몇 번 언래핑 해봅시다 currentErr := err for i := 0; currentErr != nil; i++ { fmt.Printf("Layer %d: %v\n", i, currentErr) currentErr = errors.Unwrap(currentErr) } // 데이터베이스 에러인지 여부만 알고 싶다면? if errors.Is(err, ErrDatabaseOpFailed) { fmt.Println(" --> Confirmed: Database operation failed!") } else { fmt.Println(" --> Not specifically ErrDatabaseOpFailed, but wrapped within.") } // 외부 시스템이 매우 특정한 에러 타입(예: sql.ErrSyntax)을 기대한다면? // 여전히 존재하지만 깊이 숨겨져 있습니다. var syntaxErr string // 플레이스홀더, 문자열 에러를 사용했습니다. isSyntaxErr := errors.As(err, &syntaxErr) // `errors.New("sql.ErrSyntax")`에는 작동하지 않습니다. if errors.Is(err, errors.New("sql.ErrSyntax")) { // 이것은 *이름 있는* 에러를 확인하는 방법이며, 임의의 문자열은 아닙니다. fmt.Println(" --> Found SQL syntax error!") } else { fmt.Println(" --> SQL Syntax error not directly detected yet, need to check its representation.") } } }
과도한 래핑의 문제는 에러 메시지가 복잡한 문자열이 된다는 것이며, errors.Is
/errors.As
검사는 덜 효율적이거나 개발자가 압도적인 컨텍스트 때문에 의도했던 특정 검사를 놓칠 수 있다는 것입니다. 또한 이는 불분명한 에러 경계를 나타냅니다. 상위 계층이 실제로 DB 작업 실패 여부만 신경 쓴다면, 하위 계층은 더 일반화된 ErrDatabaseOpFailed
를 직접 반환해야 하거나, 즉각적인 컨텍스트로 한 번 래핑해야 하며, 내부 오류를 무차별적으로 전달해서는 안 됩니다.
해결책: 호출 계층이 의미 있는 컨텍스트를 추가하거나, 상위 계층에서 정확히 래핑된 에러를 검사해야 하는 경우(예: os.ErrNotExist
)를 제외하고는, 에러를 래핑하지 마십시오. 그렇지 않으면 로그/디버깅에 필요한 원본 정보를 유지하면서 해당 계층에 대한 새롭고 맥락에 맞는 에러를 생성합니다.
// --- 수정된 리포지토리 계층 --- func getUserRevised(id string) error { err := queryDatabase("bad query") if err != nil { // 여기에서 저수준 에러를 도메인별 에러로 변환합니다. // 디버깅에 필요한 세부 정보가 필요한 경우, 실제로 원래 에러를 래핑할 수 있지만, // *반환되는* 에러는 `ErrDatabaseOpFailed`입니다. return fmt.Errorf("%w: failed to fetch user (internal error: %s)", ErrDatabaseOpFailed, err.Error()) // 또는 `errors.Is(..., ErrDatabaseOpFailed)`가 실제로 참이 되도록 하려면: // return fmt.Errorf("failed to fetch user: %w", ErrDatabaseOpFailed) // 잘못됨, 이것은 ErrDatabaseOpFailed를 래핑합니다. // 올바른 방법: 원래 에러를 로깅/디버깅용으로 보존하면서 ErrDatabaseOpFailed를 "반환"하는 것: // logger.Error("Failed to query database for user", "error", err) // 원본 로깅 // return ErrDatabaseOpFailed // 더 간단하고 도메인별 에러 반환 } return nil }
이것은 신중한 논쟁을 필요로 합니다. ErrDatabaseOpFailed
가 래핑될 때 errors.Is(err, ErrDatabaseOpFailed)
가 참이어야 할까요? Go 표준 라이브러리는 종종 래핑하지만, 애플리케이션별 에러의 경우 새 에러를 도입하는 것과 계속 래핑하는 것 중 언제를 선택할지가 어려울 수 있습니다.
2. 특정 검사를 위해 일반 에러 래핑: "왜 errors.Is
가 작동하지 않는가?!"
일반적인 오해는 errors.Is
가 일반 에러 문자열 뒤의 의도를 마법처럼 이해한다는 것입니다. errors.New("permission denied")
를 래핑한 다음, errors.Is(err, errors.New("permission denied"))
를 나중에 확인하려고 하면 실패합니다. 그 이유는 errors.New
가 매번 새로운 에러 인스턴스를 생성하기 때문입니다.
package main import ( "errors" "fmt" ) // --- 내부 연산을 시뮬레이션하는 헬퍼 --- func readFileContent() error { // 특정 내부 에러를 시뮬레이션하지만, 이름 있는 // 패키지 레벨의 상수로 대체합니다. return errors.New("file system: permissions denied") } // --- 이를 래핑하는 상위 레벨 함수 --- func processFile() error { err := readFileContent() if err != nil { return fmt.Errorf("could not process file: %w", err) } return nil } func main() { err := processFile() if err != nil { // 이 검사는 실패할 것입니다. 왜냐하면 errors.New("file system: permissions denied") // *새로운* 에러 인스턴스를 생성하기 때문이며, 래핑된 에러와 동일하지 않습니다. if errors.Is(err, errors.New("file system: permissions denied")) { fmt.Println("ERROR: Detected generic permission denied error!") } else { fmt.Println("INFO: Generic permission denied error NOT detected directly via errors.Is.") fmt.Printf("Full error: %v\n", err) fmt.Printf("Unwrapped error: %v\n", errors.Unwrap(err)) } // 올바른 방법: 이름 있는 에러 상수 또는 특정 타입에 대항하여 검사합니다. // 예를 들어 readFileContent가 os.ErrPermission을 반환한다면. if errors.Is(err, errors.ErrUnsupported) { // 데모용, readFileContent가 이것을 반환할 수 있다고 가정 fmt.Println("This is an unsupported operation error.") } } }
결과는 다음과 같습니다: INFO: Generic permission denied error NOT detected directly via errors.Is.
해결책: errors.Is
및 errors.As
검사에 대해 errors.New
를 사용하거나 사용자 정의 에러 타입을 사용하여 패키지 레벨의 내보낸 변수로 특정 에러를 항상 정의하십시오. 이러한 이름 있는 에러는 안정적인 ID를 제공합니다.
package main import ( "errors" "fmt" ) // 비교를 위해 이름 있는 에러 상수를 정의합니다. var ErrPermissionDenied = errors.New("permission denied") // --- 내부 연산을 시뮬레이션하는 헬퍼 --- func readFileContentGood() error { return ErrPermissionDenied // 이름 있는 에러 반환 } // --- 이를 래핑하는 상위 레벨 함수 --- func processFileGood() error { err := readFileContentGood() if err != nil { return fmt.Errorf("could not process file: %w", err) } return nil } func main() { err := processFileGood() if err != nil { // 이제 이 검사는 성공할 것입니다! if errors.Is(err, ErrPermissionDenied) { fmt.Println("CORRECT: Detected named permission denied error!") } else { fmt.Println("ERROR: Should have detected permission denied error.") } } }
3. 오해의 소지가 있는 에러 메시지: 근본 원인 은폐
래핑이 컨텍스트를 추가하더라도, 잘못 구성된 래핑 메시지는 오해를 불러일으키거나 원본 문제를 은폐할 수 있습니다. 래핑 메시지가 단순히 래핑된 에러를 재구성하거나, 더 나쁘게는 부정확한 컨텍스트를 제공하는 경우, 목적을 달성하지 못합니다.
package main import ( "errors" "fmt" "strconv" ) func parseInt(s string) (int, error) { val, err := strconv.Atoi(s) if err != nil { // 오해의 소지가 있는 래핑: 이것은 파싱 에러가 아니라 네트워크 문제를 암시합니다. return 0, fmt.Errorf("network error failed to parse string: %w", err) } return val, nil } func main() { _, err := parseInt("abc") if err != nil { fmt.Println("Error:", err) // "network error"를 보는 디버거는 실제 파싱 로직 대신 네트워크 코드를 먼저 살펴보게 될 것입니다. var numErr *strconv.NumError if errors.As(err, &numErr) { fmt.Printf(" --> Actually a NumError: %v (Func: %s, Num: %q, Err: %v)\n", numErr, numErr.Func, numErr.Num, numErr.Err) } } }
해결책: 래핑 메시지가 현재 계층의 작업과 관련하여 정확한 추가 컨텍스트를 제공하고, 기본 에러를 부정하거나 모호하게 만들지 않도록 하십시오.
4. 불필요한 언래핑: 성능 및 가독성 저하
errors.Is
및 errors.As
는 에러 체인을 스마트하게 탐색하지만, 직접적인 errors.Unwrap
호출은 애플리케이션 로직에서 거의 사용되지 않아야 하며, 주로 로깅이나 매우 특수한 에러 처리를 위해 예약되어 있습니다. 조건부 검사를 위해 애플리케이션 로직에서 errors.Unwrap
을 반복적으로 호출하는 것은 errors.Is
또는 errors.As
가 더 적합하다는 것을 나타내거나, 에러 타입이 잘 정의되지 않았음을 의미할 수 있습니다.
// 애플리케이션 로직에서 직접적인 언래핑의 문제 예 fundID, err := getFundID(req) if err != nil { // 에러가 정확히 우리의 FundNotFoundError가 아닌 경우, 언래핑 시도. // 이것은 errors.Is보다 덜 관용적입니다. if !errors.Is(err, domain.ErrFundNotFound) { if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { if !errors.Is(unwrappedErr, domain.ErrFundNotFound) { // ... 어쩌면 다시 언래핑? 이것은 빠르게 번거롭고 오류 발생 가능성이 높아집니다. // 또한 에러 구조가 너무 복잡하거나 쉽게 검사하도록 설계되지 않았음을 나타냅니다. } } } return nil, err }
해결책: 체인에서 에러가 특정 에러 인스턴스와 일치하는지 확인하려면 errors.Is
를 사용하고, 체인에서 특정 에러 타입의 필드나 메서드에 접근하려면 errors.As
를 사용하십시오.
에러 래핑 및 언래핑을 위한 모범 사례
- 이름 있는 에러 정의:
errors.Is
를 사용하여 검사하려는 에러에 대해var ErrSomething = errors.New("something went wrong")
를 사용합니다. 구조화된 데이터가 필요한 에러의 경우,error
인터페이스를 구현하는 사용자 정의 에러 타입을 정의합니다. - 의미 있게 래핑: 현재 계층이 후속 계층이 유용하게 사용할 수 있는 가치 있는 컨텍스트를 추가할 수 있을 때만 에러를 래핑하십시오. 래퍼는 이 특정 계층에서 무엇이 실패했는지를 설명해야 하며,
%w
는 왜 (기본 원인)를 제공합니다. - 신원 확인에는
errors.Is
사용:err
(또는 래핑된 에러)가 특정 에러 인스턴스(예:os.ErrNotExist
,ErrAuthFailed
)인지 여부에 신경 쓰인다면errors.Is
를 사용하십시오. - 타입별 처리를 위해
errors.As
사용: 체인에 있는 특정 에러 타입(예:*MyCustomError
,*os.PathError
)의 필드나 메서드에 접근해야 하는 경우errors.As
를 사용하십시오. - 과도하게 래핑하지 마십시오: 사소한 새로운 컨텍스트로 동일한 에러를 단순히 다시 래핑하는 과도하게 깊은 에러 체인을 만들지 마십시오. 때로는 (디버깅을 위해 원본을 로깅하면서) 새로운 상위 레벨 에러를 반환하는 것이 더 명확할 수 있습니다.
- 컨트롤 흐름을 위해 (대부분) 사용하지 말고 디버깅/로깅을 위해 언래핑하십시오:
errors.Unwrap
은 주로 로깅 또는 추적 목적으로 내부 에러를 검사하는 데 유용합니다. 조건부 검사를 위해 직접적으로 사용하는 것은 일반적으로errors.Is
또는errors.As
가 더 적절하다는 신호입니다. - 에러 경계 고려: 에러의 "소유권"이 바뀌는 곳을 생각해 봅시다. 낮은 레벨의
io.EOF
는 리포지토리 계층에서는repository.ErrNoRecordsFound
가 될 수 있고, 서비스 계층에서는service.ErrUserNotFound
가 될 수 있습니다. 특정io.EOF
는 리포지토리 계층 이상에서는 관련이 없을 수 있습니다. 종종 끝없이 래핑하는 대신 논리 계층 간에 에러를 변환합니다.
결론
Go의 에러 래핑 메커니즘은 에러 디버깅과 통찰력을 부인할 수 없을 정도로 향상시키는 강력한 추가 기능입니다. 그러나 그 효과는 사려 깊고 규율 있는 적용에 달려 있습니다. "잘못된 래핑" - 과도한 래핑, 잘못된 에러 이름 지정, 오해의 소지가 있는 컨텍스트 등 - 은 이 강력한 기능을 부담으로 전환하여 복잡한 에러 메시지, 취약한 검사, 좌절스러운 디버깅 경험으로 이어질 수 있습니다. fmt.Errorf
, errors.Is
, errors.As
의 뉘앙스를 이해하고 모범 사례를 따르면 개발자는 이 도구를 사용하여 더 강력하고 유지 관리 가능하며 관찰 가능한 Go 애플리케이션을 구축할 수 있습니다. 목표는 항상 에러의 여정을 명확히 하는 것이어야 하며, 그것을 모호하게 하는 것이 아닙니다.