Quick Comparison
err := errors.Join(
closeFile(),
flushMetrics(),
shutdownServer(ctx),
)
if err != nil {
return err
}| 패턴 | 용도 |
|---|---|
fmt.Errorf("...: %w", err) | 단일 원인에 문맥 추가 |
errors.Join(a, b, c) | 독립 실패 여러 개를 함께 반환 |
errors.Is | error tree 안의 sentinel 검사 |
errors.As | error tree 안의 구체 타입 추출 |
에러 묶음
errors.Join은 여러 error를 하나의 error로 만든다
여러 cleanup, 여러 goroutine 결과, 여러 검증 오류처럼 독립된 실패가 동시에 나올 수 있습니다. 이때 첫 번째 error만 반환하면 나머지 실패가 사라집니다. errors.Join은 nil이 아닌 error들을 묶어 하나의 error로 반환합니다.
func shutdown(ctx context.Context) error {
return errors.Join(
stopHTTP(ctx),
closeDB(),
flushLogs(),
)
}모든 인자가 nil이면 결과도 nil입니다. nil이 아닌 결과는 Unwrap() []error를 구현하므로, 호출자는 일반 error 하나로 받되 내부에서는 여러 자식 error를 가진 tree로 검사됩니다. Error() 문자열은 각 자식 메시지를 줄바꿈으로 이으므로 사용자 메시지나 프로토콜 값으로 그대로 노출하지 않습니다.
errors.Is와 errors.As는 joined error도 탐색한다
Go의 error 검사는 단순 문자열 비교가 아니라 error tree 탐색입니다. errors.Is는 Unwrap() error뿐 아니라 Unwrap() []error로 묶인 여러 error도 따라가며 target과 맞는지 확인합니다.
err := errors.Join(
fmt.Errorf("cache: %w", ErrUnavailable),
fmt.Errorf("db: %w", context.DeadlineExceeded),
)
if errors.Is(err, context.DeadlineExceeded) {
return retryLater(err)
}구체 error 타입이 필요하면 errors.As를 씁니다. joined error 안에 해당 타입이 있으면 target에 대입됩니다. 여러 자식이 같은 타입이면 As는 tree를 depth-first로 탐색하며 처음 만난 값을 대입하므로, 모든 field 오류를 수집해야 하는 API에는 typed error list가 더 적합합니다.
var pathErr *os.PathError
if errors.As(err, &pathErr) {
log.Println(pathErr.Op, pathErr.Path)
}Join은 실패를 보존하는 cleanup 보고에 맞는다
errors.Join의 문자열 출력은 사람이 읽는 로그에는 유용하지만, 프로그램 분기 기준으로 쓰면 취약합니다. 검사 기준은 errors.Is, errors.As, 별도 typed error가 되어야 합니다.
if strings.Contains(err.Error(), "deadline") {
// 취약한 분기
}
if errors.Is(err, context.DeadlineExceeded) {
// 안정적인 분기
}작업 순서가 중요하고 "첫 실패에서 중단"해야 하는 흐름이면 Join보다 즉시 return이 더 명확합니다. Join은 가능한 작업을 모두 시도한 뒤 실패를 모아서 보고해야 할 때 적합합니다. 호출 순서로 error message가 나열되더라도 프로그램 분기나 사용자 노출 순서를 Error() 문자열에 의존하지 않습니다.
선택 기준
| 상황 | 적합한 선택 |
|---|---|
| 하나의 원인에 문맥 추가 | %w wrapping |
| 여러 cleanup 실패를 모두 보고 | errors.Join |
| validation 오류 여러 개 반환 | typed error list 또는 errors.Join |
| 첫 실패에서 바로 중단해야 함 | 즉시 return err |
| 호출자가 특정 원인을 검사해야 함 | errors.Is / errors.As 유지 |
multi-error를 공개 API로 반환할 때는 호출자가 무엇을 할 수 있어야 하는지 먼저 정해야 합니다. 단순 로그용이면 Join으로 충분하지만, 필드별 검증 오류처럼 구조가 중요하면 별도 타입을 제공하는 편이 낫습니다.
wrapping은 호출자에게 검사 권한을 준다
errors.Is와 errors.As가 자식을 탐색한다는 뜻은 %w와 errors.Join으로 감싼 원인이 호출자의 분기 표면이 된다는 뜻입니다. database driver나 내부 transport error까지 호출자가 검사해도 되는지 결정한 뒤 원인을 보존합니다. 숨겨야 하는 내부 오류는 문맥만 남긴 새 sentinel/typed error로 바꾸고, %w로 그대로 노출하지 않습니다.
errors.Unwrap(err)는 Unwrap() error만 한 단계 풉니다. joined error처럼 Unwrap() []error를 가진 값을 펼치지 않습니다. 정말 자식 목록을 검사해야 하는 내부 도구라면 아래처럼 타입 assertion을 쓰되, 일반 분기는 errors.Is와 errors.As로 끝내는 편이 안전합니다.
type multiUnwrapper interface {
Unwrap() []error
}
if joined, ok := err.(multiUnwrapper); ok {
for _, child := range joined.Unwrap() {
log.Println(child)
}
}주의할 점
joined error의 Error() 문자열은 분기 계약이 아닙니다.
여러 줄 문자열이나 포맷은 바뀔 수 있으므로, 코드 흐름은 errors.Is와 errors.As로 결정해야 합니다.
err := errors.Join(ErrConfig, ErrNetwork)
switch {
case errors.Is(err, ErrConfig):
return "config"
case errors.Is(err, ErrNetwork):
return "network"
}errors.Join은 중복 error를 자동으로 의미 있게 병합하지 않습니다. 같은 원인이 여러 번 발생하면 여러 자식으로 들어갈 수 있으므로, 사용자에게 보여 줄 메시지는 필요하면 별도 정규화 단계를 두는 편이 좋습니다.
참고 링크
1 sources