At a Glance
user, err := repo.Find(id)
if errors.Is(err, os.ErrNotExist) {
return ErrMissingUser
}
if err != nil {
return fmt.Errorf("load user %s: %w", id, err)
}Go의 에러 처리는 "예외를 던진다"보다 반환값으로 실패를 드러내고, 문맥은 wrapping으로 붙인다로 읽는 편이 맞습니다.
기본 흐름
기본 계약은 (T, error)다
func FindUser(id string) (User, error) {
// ...
}호출자는 거의 항상 아래 흐름으로 읽습니다.
user, err := FindUser("u-1")
if err != nil {
return err
}즉 정상값과 실패 경로를 함께 돌려주는 것이 Go의 기본 계약입니다.
wrapping은 원인을 버리지 않고 문맥을 붙인다
fmt.Errorf("...: %w", err)를 쓰면 에러 원인을 유지한 채 바깥 문맥을 덧붙일 수 있습니다.
if err != nil {
return fmt.Errorf("open config: %w", err)
}이렇게 해야 위쪽 호출자가 "어디서 실패했는지"와 "실제 원인이 무엇인지"를 둘 다 볼 수 있습니다.
%w는 단순한 로그 장식이 아니라 호출자가 errors.Is와 errors.As로 내부 원인을 검사할 수 있게 만드는 API 계약입니다. 외부 호출자가 os.ErrNotExist나 driver 오류에 따라 분기해야 하면 %w가 맞습니다. 구현 세부사항을 노출하고 싶지 않다면 %v로 문맥만 남기거나, package가 소유한 sentinel error나 타입으로 변환합니다.
var ErrMissingUser = errors.New("user not found")
func FindPublicUser(id string) (User, error) {
user, err := repo.Find(id)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrMissingUser
}
if err != nil {
return User{}, fmt.Errorf("find user: %v", err)
}
return user, nil
}이렇게 바꾸면 호출자는 ErrMissingUser만 계약으로 보고, DB 구현 오류에는 의존하지 않습니다.
비교는 문자열보다 errors.Is, errors.As를 먼저 본다
if errors.Is(err, os.ErrNotExist) {
// 파일 없음 처리
}구체 타입이 필요할 때는 errors.As를 씁니다.
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// pathErr.Path, pathErr.Op 사용
}에러 메시지 문자열 비교는 로그에는 쓸 수 있어도 제어 흐름 기준으로는 약합니다.
errors.As의 두 번째 인수는 원하는 error 타입의 포인터입니다. 타입을 찾았다는 사실만으로 복구 가능한 것은 아니므로, 추출한 필드가 현재 계층에서 처리할 책임인지 먼저 정합니다.
언제 panic을 쓸까
대부분의 일반 실패는 error 반환으로 충분합니다. panic은 보통 복구 불가능한 프로그래머 실수나 초기화 불변식 붕괴처럼 범위를 더 좁게 봅니다.
- 파일 없음, 입력 오류, 외부 API 실패:
error - 정말 계속 진행하면 안 되는 내부 붕괴: 제한적으로
panic
선택 기준
| 상황 | 먼저 떠올릴 선택 |
|---|---|
| 일반 실패 보고 | error 반환 |
| 상위 호출자에 문맥 추가 | %w wrapping |
| sentinel error 비교 | errors.Is |
| 구체 에러 타입 추출 | errors.As |
| 복구 불가능한 내부 붕괴 | 제한적 panic |
주의할 점
Go에서 흔한 실수는 에러를 문자열로만 덮어써 원인을 잃는 것입니다. 문맥을 붙여야 할 때는 %w로 감싸고, 비교는 errors.Is/errors.As로 읽는 흐름을 기본값으로 두는 편이 안전합니다.
참고 링크
3 sources