if err != nilがうんざりする理由
한국어 원문으로 표시합니다.
한 줄 요약
Go 는 에러를 값으로 돌려준다. 그래서 에러 처리를 건너뛸 수 없고, 대신 코드가 길어진다. 그 길이는 비용이 아니라 명시성의 가격이다.
왜 이게 필요했나
예외가 있는 언어에서는 어느 줄이 던질 수 있는지 코드만 봐서는 모른다. 그래서 "여기서 죽을 수 있나" 를 확인하려면 호출 그래프를 따라가야 한다.
Go 는 그걸 시그니처에 적는다.
func ReadConfig(path string) (*Config, error)
이 함수가 실패할 수 있다는 것이 타입에 있다. 호출하는 쪽은 그것을 무시할 수 없다 — 무시하려면 _ 라고 적어야 하고, 그건 의도가 드러난다.
감싸기
에러를 그대로 올려보내면 "어디서 났는지" 를 잃는다. %w 로 감싼다.
if err != nil {
return fmt.Errorf("설정 읽기(%s): %w", path, err)
}
%w 는 원본을 안에 품는다. 그래서 위쪽에서 errors.Is / errors.As 로 원인을 물어볼 수 있다.
if errors.Is(err, os.ErrNotExist) { ... }
var perr *os.PathError
if errors.As(err, &perr) { fmt.Println(perr.Path) }
%v 로 감싸면 문자열만 남고 이 질문이 불가능해진다. 감쌀 때는 항상 %w.
언제 panic 하나
거의 안 한다. panic 은 "프로그램이 계속 돌 이유가 없다" 일 때만이다 — 초기화 실패, 불변식 위반. 요청 처리 중의 panic 은 그 요청만 죽여야 하므로 HTTP 서버는 기본적으로 잡아 준다.
흔한 착각
"에러를 로그로 남기고 또 반환한다" — 그러면 같은 사고가 로그에 여러 번 찍힌다. 처리하거나 올려보내거나 하나만 한다. 로그는 최종적으로 처리하는 곳에서 한 번.
"nil 에러면 결과가 유효하다" — Go 관례상 맞지만 강제는 아니다. 라이브러리에 따라 둘 다 유효하거나 둘 다 nil 일 수 있다. 문서를 본다.
센티널 에러와 커스텀 타입 중 무엇을 쓰나
호출자가 무엇을 알아야 하는지로 정합니다.
"이 경우인가" 만 알면 된다면 센티널. 패키지 수준에 값 하나를 선언하고
errors.Is 로 비교합니다.
var ErrNotFound = errors.New("not found")
if errors.Is(err, ErrNotFound) { ... }
추가 정보가 필요하면 타입. 어느 필드가 왜 틀렸는지 같은 것은 값에 담아야 합니다.
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Reason)
}
var verr *ValidationError
if errors.As(err, &verr) {
log.Printf("필드 %s 가 문제: %s", verr.Field, verr.Reason)
}
주의할 함정이 하나 있습니다. nil 인 커스텀 에러를 error 인터페이스에
담으면 err != nil 이 참이 됩니다.
func do() error {
var e *ValidationError // nil
...
return e // ❌ 인터페이스는 (타입, nil) 이라 nil 이 아니다
}
if do() != nil { /* 오류가 없는데 여기로 들어온다 */ }
구체 타입 변수를 그대로 반환하지 말고, 오류가 없으면 return nil 을 명시합니다.
Go 에서 가장 오래된 함정이고 지금도 나옵니다.
여러 오류를 한 번에
Go 1.20 부터 errors.Join 으로 여러 오류를 묶습니다. 검증처럼 "다 모아서 한 번에
알려 줘야" 하는 자리에 맞습니다.
var errs error
for _, f := range fields {
if err := f.Validate(); err != nil {
errs = errors.Join(errs, err)
}
}
return errs // 하나도 없으면 nil 이다
fmt.Errorf 도 %w 를 여러 개 받습니다. errors.Is 는 묶인 것 전부를 훑습니다.
계층마다 무엇을 붙이나
에러를 감쌀 때 자기가 아는 것만 붙입니다. 아래 계층이 이미 말한 것을 반복하면 메시지가 길어지기만 합니다.
| 계층 | 붙일 것 | 붙이지 말 것 |
|---|---|---|
| 저장소 | 테이블·키 | SQL 전문(로그에만) |
| 서비스 | 무엇을 하려 했는지 | 저장소 세부 |
| 핸들러 | 요청 식별자 | 내부 구조 |
그리고 사용자에게 보내는 메시지와 로그에 남기는 메시지를 분리 합니다. 내부 경로가 그대로 HTTP 응답에 나가면 그것이 정보 노출입니다.
if err != nil {
log.Printf("주문 조회 실패: %v", err) // 전체 사슬
http.Error(w, "일시적인 오류입니다", 500) // 사용자에게는 이것만
}
실무에서 진짜 중요한 것
에러 메시지는 위에서 아래로 읽었을 때 경로가 되도록 쓴다.
사용자 조회(id=42): 설정 읽기(/etc/app.yaml): open /etc/app.yaml: no such file
각 층이 자기가 아는 맥락만 한 조각씩 붙이면 이렇게 된다. 대문자로 시작하지 않고 마침표로 끝내지 않는 것이 관례다 — 다른 메시지 안에 끼워 넣어지기 때문이다.