오류는 값이다
실패할 수 있는 Go 함수는 마지막 결과로 error를 반환합니다. 호출자는 즉시 확인합니다.
출력:
parsed: 42
could not parse: strconv.Atoi: parsing "forty-two": invalid syntax
이것이 메커니즘의 전부입니다. 예외도, try나 catch도, 숨겨진 제어 흐름도 없습니다. 오류는 코드가 넘겨주는 곳으로만 이동합니다. 대가는 눈에 보이는 if err != nil의 반복입니다. 이점은 모든 실패 지점이 페이지에 보이고, 각 지점에서 무슨 일이 일어날지 직접 정한다는 것입니다.
error 타입
error는 메서드 하나를 가진 내장 인터페이스입니다:
type error interface {
Error() string
}
Error() string 메서드가 있는 타입은 모두 오류입니다. nil 오류는 성공을 뜻합니다. fmt.Println(err)나 %v로 오류를 출력하면 Error()가 호출됩니다.
오류 만들기
대부분의 경우는 함수 두 개로 해결됩니다.
errors.New는 고정된 텍스트로 오류를 만듭니다. fmt.Errorf는 Printf와 같은 동사로 오류를 형식화합니다. 오류 문자열은 보통 더 긴 메시지 안에 들어가기 때문에(load config: open app.yaml: no such file or directory) 관례상 소문자로 시작하고 끝에 문장 부호를 붙이지 않습니다.
if err != nil 패턴
관용적인 모양은 호출, 확인, 이른 반환입니다. 성공 경로는 왼쪽 여백에 머물고, 각 실패는 일어나는 즉시 빠져나갑니다.
func loadUser(id int) (*User, error) {
row, err := db.Query(id)
if err != nil {
return nil, err
}
u, err := parseUser(row)
if err != nil {
return nil, err
}
if err := u.Validate(); err != nil {
return nil, err
}
return u, nil
}
주목할 관례 두 가지:
- 오류가 났을 때는 나머지 결과로 제로 값(
nil,0,"")을 반환하세요. 호출자는err != nil일 때 그 값을 쓰면 안 됩니다. - 함수가 오류만 반환할 때
if err := f(); err != nil은err의 스코프를if로 한정해서 바깥 스코프를 깨끗하게 유지합니다.
오류 반환 뒤의 else는 피하세요. if err != nil { return err } else { ... }는 이유 없이 정상 경로를 들여쓰기만 합니다.
오류를 반환할 때 맥락 더하기
그대로 위로 넘긴 오류는 어디서 왔는지에 대한 이야기를 잃습니다. open config.yaml: no such file or directory만으로는 시작 과정의 어느 단계에서 실패했는지 알 수 없습니다. fmt.Errorf와 %w 동사로 맥락을 더하세요:
출력:
start server: read config: open /etc/myapp/config.yaml: no such file or directory
true
각 계층이 자신이 하던 일을 덧붙이므로, 최종 메시지는 호출의 맨 위에서 원인까지 이어지는 흔적처럼 읽힙니다. 좋은 맥락은 작업과 입력을 밝힙니다: parse line 12, fetch user 42. 모든 단계에 "error"나 "failed"를 붙이지 마세요. 메시지는 이미 오류입니다.
%w는 감쌉니다. 원래 오류를 새 오류 안에 유지하므로 errors.Is와 errors.As가 여전히 찾을 수 있습니다. %v는 텍스트만 복사합니다. 예를 들어 호출자가 데이터베이스 드라이버의 오류 타입에 의존하지 못하게 하는 것처럼, 구현 세부 사항을 일부러 숨기고 싶을 때 %v를 쓰세요.
특정 오류 확인하기: errors.Is와 errors.As
호출자가 한 종류의 실패에 반응해야 할 때가 있습니다. 파일이 없으면 "기본값 사용", 타임아웃이면 "재시도"처럼요. 두 함수가 이에 답하며, 둘 다 감싼 모든 계층을 살펴봅니다.
경험 법칙:
- 미리 정의된 오류 값(
io.EOF,os.ErrNotExist,sql.ErrNoRows같은 센티널)과는==가 아니라errors.Is로 비교하세요.==는 오류가 감싸지는 순간 실패합니다. - 같은 이유로 타입 있는 오류는 타입 단언이 아니라
errors.As로 꺼내세요.errors.As는 대상 타입 변수의 포인터를 받습니다. err.Error()텍스트로 매칭하지 마세요. 메시지는 버전마다 바뀌고, 바뀌면 텍스트 매칭은 조용히 깨집니다.
자신만의 센티널 오류와 오류 타입을 정의하는 방법과 errors.Join으로 여러 오류를 합치는 방법은 커스텀 오류 페이지에서 다룹니다.
오류는 한 번만 처리하라
오류는 정확히 한 번 처리되어야 합니다. 처리란 다음 중 하나입니다. (보통 감싸서) 반환하기, 로그로 남기고 계속하기, 재시도하기, 사용자를 위한 응답으로 바꾸기. 이 중 두 가지를 하는 것이 Go 코드에서 가장 흔한 오류 버그입니다.
// Wrong: logged here, and returned, so it is logged again by every caller.
if err != nil {
log.Printf("could not fetch user: %v", err)
return err
}
// Right: add context and return. The top of the program logs once.
if err != nil {
return fmt.Errorf("fetch user %d: %w", id, err)
}
로그로 남기고 반환하면 같은 실패가 로그에 여러 번, 그것도 최종 메시지보다 적은 맥락으로 남습니다. 무엇을 할지 결정할 수 있는 곳(HTTP 핸들러, main, 워커 반복문)까지 오류가 흘러가게 하고, 거기서 로그를 남기세요.
오류가 도착하는 곳
프로그램의 맨 위에서는 무언가가 오류에 대해 행동해야 합니다. main이라면 보통 오류를 출력하고 0이 아닌 상태 코드로 종료한다는 뜻입니다:
인자 없이 실행하면 stderr에 error: usage: app <name>을 출력하고 상태 코드 1로 종료합니다(다른 경로를 보려면 Args 패널에 이름을 입력하세요). main을 이런 모양으로 두고 실제 작업은 run에 두면 프로그램을 테스트할 수 있고, os.Exit는 지연된 호출을 건너뛰므로 run 안의 defer 문도 여전히 실행됩니다.
HTTP 서버에서는 핸들러가 맨 위입니다. 오류를 상태 코드와 클라이언트에게 보여 줘도 안전한 메시지로 바꾸고, 자세한 메시지는 여러분을 위해 로그로 남깁니다.
무시해도 되는 오류와 그러면 안 되는 오류
오류를 무시하는 것이 옳을 때도 있지만, 결정이었다는 것을 읽는 사람이 알 수 있도록 _로 명시하세요:
_ = conn.SetDeadline(t) // best effort
어떤 호출은 실제로 실패할 수 없습니다(strings.Builder.WriteString, bytes.Buffer.Write). 다른 호출은 무해해 보여도 그렇지 않습니다. 쓰기를 마친 파일의 Close는 데이터가 디스크에 도달하지 못했다고 보고할 수 있고, json.Marshal은 채널과 함수에서 실패합니다. 확실하지 않다면 확인하세요.
errcheck 린터(golangci-lint에 포함)는 확인하지 않은 오류를 보고합니다. go vet 자체는 이를 지적하지 않습니다.
오류와 패닉
Go에도 panic이 있지만 예외 시스템은 아닙니다. 잘못된 입력, 없는 파일, 네트워크 실패처럼 정상 동작 중에 잘못될 수 있는 모든 것에는 오류를 쓰세요. 패닉은 버그(불가능한 상태, 깨진 불변식)와 계속 진행해도 의미가 없는 시작 실패를 위해 남겨 두세요. 라이브러리가 API 너머로 패닉을 일으키는 일은 거의 없어야 합니다. panic과 recover 페이지를 참고하세요.
반복 줄이기
if err != nil은 장황하고, 이를 위한 새 문법을 추가하자는 제안은 여러 번 거절되었습니다. Go 팀은 2025년에 오류 처리를 위한 문법 변경을 더 이상 추진하지 않겠다고 발표했습니다. 언어 안에서 잡음을 줄이는 패턴이 몇 가지 있습니다:
- 일찍 반환하고 함수를 작게 유지하세요. 반복의 대부분은 여러 단계를 처리하는 긴 함수에서 나옵니다.
- 끈적한 오류(sticky error). 연속된 쓰기라면 첫 번째 오류를 구조체 필드에 보관하고, 설정된 뒤에는 이후 호출이 아무것도 하지 않게 하세요.
bufio.Writer가 이렇게 동작하므로Flush뒤에 오류를 한 번만 확인하면 됩니다. - 함수마다 한 번 감싸기. 이름 있는 결과에 대한 지연된 클로저로 함수가 반환하는 모든 오류에 같은 맥락을 더할 수 있습니다(defer 참고).
흔한 실수
- err가 nil이 아닌데 값을 사용함. 먼저 확인하고 그다음 쓰세요.
- 로그로 남기고 반환함. 하나만 고르세요.
- 감싼 뒤
==로 비교함.errors.Is를 쓰세요. %v로 원인을 잃음. 숨기는 것이 목적이 아니라면%w를 쓰세요.- 타입 있는 nil 포인터를
error로 반환함.var e *MyErr; return e는 호출자에게 nil이 아닙니다. 리터럴nil을 반환하세요. - 대문자나 문장 부호가 붙은 메시지.
errors.New("Failed to connect.")는 감싸고 나면 어색하게 읽힙니다.connect to db: ...라고 쓰세요.
자주 묻는 질문
Go에서 에러 처리는 어떻게 동작하나요?
실패할 수 있는 함수는 마지막 결과로 error를 반환합니다. 호출자는 바로 확인합니다: v, err := f(); if err != nil { return err }. error는 메서드 Error() string 하나를 가진 평범한 인터페이스 값이며, nil은 성공을 뜻합니다. 예외는 없습니다.
Go에 try/catch가 있나요?
없습니다. Go에는 예외도 try/catch도 없습니다. 예상된 실패는 error 값으로 반환되고 if err != nil로 확인합니다. panic과 recover가 있지만, 일반적인 오류 흐름이 아니라 프로그래밍 버그와 복구할 수 없는 상태를 위한 것입니다.
Go에서 오류는 어떻게 반환하나요?
마지막 결과를 error로 선언하고 성공하면 nil을 반환합니다. 고정된 텍스트라면 errors.New("message")로, 받은 오류에 맥락을 더하려면 fmt.Errorf("reading %s: %w", name, err)로 오류를 만듭니다. 실패하면 나머지 결과로는 제로 값을 반환하세요.
fmt.Errorf에서 %w와 %v의 차이는 무엇인가요?
둘 다 원래 오류의 메시지를 새 오류에 넣습니다. %w는 오류를 감싸기도 하므로 errors.Is와 errors.As가 여전히 원래 오류를 찾을 수 있습니다. %v는 텍스트만 담은 새 오류를 만듭니다. 호출자가 원인을 확인해야 할 수 있다면 %w를, 숨기고 싶다면 %v를 쓰세요.
Go에서 어떤 오류가 반환되었는지 어떻게 확인하나요?
io.EOF나 os.ErrNotExist 같은 센티널 오류와 비교할 때는 errors.Is(err, target)를, *fs.PathError 같은 특정 오류 타입을 꺼낼 때는 errors.As(err, &target)를 씁니다. 둘 다 감싼 오류를 따라갑니다. err.Error() 문자열 비교는 피하세요.