LabHub
배우기 러닝패스 코스

Goでサーバを作る

if err != nilがうんざりする理由

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

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

각 층이 자기가 아는 맥락만 한 조각씩 붙이면 이렇게 된다. 대문자로 시작하지 않고 마침표로 끝내지 않는 것이 관례다 — 다른 메시지 안에 끼워 넣어지기 때문이다.