error의 정체
이 챕터에서 다루는 것
Go를 처음 보는 사람이 가장 자주 불평하는 것이 if err != nil이다. 그 세 줄이
왜 거기 있는지, 그리고 그것이 인터페이스 하나로 어떻게 만들어졌는지를 다룬다.
문법은 짧다. 대부분은 습관과 판단이다.
error는 인터페이스다
Go에 내장된 인터페이스는 딱 하나다.
type error interface {
Error() string
}
이게 전부다. 에러는 언어 기능이 아니라 값이다. 그래서 다음이 전부 가능하다.
- 변수에 담는다.
- 함수 인자로 넘긴다.
- 슬라이스에 모은다.
- 맵의 값으로 쓴다.
- 구조체 필드로 둔다.
- 새 타입을 만들어 필드를 붙인다.
예외(exception)를 가진 언어에서는 이 중 어느 것도 자연스럽지 않다.
문제 — 왜 예외를 빼기로 했나
Java나 Python에 익숙하다면 이 코드가 답답할 것이다.
f, err := os.Open(path)
if err != nil {
return err
}
data, err := io.ReadAll(f)
if err != nil {
return err
}
같은 일을 파이썬으로 쓰면 세 줄이다.
with open(path) as f:
data = f.read()
Go가 예외를 뺀 이유는 제어 흐름의 가시성이다.
1. 어디서 함수가 빠져나가는지 코드에 적혀 있다. 예외가 있는 언어에서는 어떤
호출이든 예외를 던질 수 있다. 그 함수의 문서를 읽지 않고서는 어느 줄이 탈출 지점인지
알 수 없다. Go에서는 return이 적힌 자리에서만 나간다.
2. 처리하지 않으면 컴파일이 안 된다 — 완전히는 아니지만, 미사용 변수가 컴파일
에러인 규칙(2-1)이 상당 부분을
잡는다. err을 받아 놓고 안 쓰면 컴파일 에러다. 버리려면 _라고 명시적으로 적어야
하고, 그 _은 코드 리뷰에서 눈에 띈다.
3. 에러 처리와 정상 흐름이 같은 층위에 있다. try 블록 안에서 어느 줄이 실패했는지
찾는 대신, 실패 지점 바로 아래에 처리가 붙는다.
4. 계약이 시그니처에 있다. func Open(name string) (*File, error)를 보면
실패할 수 있다는 것을 안다. Java의 checked exception이 노렸던 것인데, Go는 그것을
반환값으로 했다.
대가는 분명하다. 코드가 길어지고, 반복적이고, 에러 처리를 빼먹으면
(_로 버리면) 아무도 안 막아 준다. Go 팀도 이것을 알고 여러 차례 개선안
(try 내장 함수, check/handle)을 검토했지만 전부 철회했다. 현재 결론은
"지금 방식을 유지한다" 이다.
:::info 패닉은 에러가 아니다
2-7에서 본 panic은 예외가 아니다.
프로그램 논리가 깨졌을 때 쓰는 것이고, 예상 가능한 실패(파일 없음, 잘못된 입력,
네트워크 끊김)에는 쓰지 않는다. 라이브러리가 패닉으로 실패를 알리는 것은
거의 항상 잘못된 설계다.
:::
에러를 만들고 반환하기
package main
import (
"errors"
"fmt"
"strconv"
)
// error는 언어에 내장된 인터페이스다. 선언은 이것뿐이다.
//
// type error interface {
// Error() string
// }
// rangeError는 Error() 하나만 있으면 error가 된다.
type rangeError struct {
Field string
Min, Max int
Got int
}
func (e rangeError) Error() string {
return fmt.Sprintf("%s는 %d~%d여야 하는데 %d다", e.Field, e.Min, e.Max, e.Got)
}
// 센티널 에러 — 패키지 수준 변수로 두고 호출자가 식별에 쓴다.
var errEmptyInput = errors.New("입력이 비어 있다")
// parseAge는 Go의 반환 관례를 따른다.
// error가 마지막 반환값이고, 실패하면 나머지는 제로값이다.
func parseAge(s string) (int, error) {
if s == "" {
return 0, errEmptyInput
}
n, err := strconv.Atoi(s)
if err != nil {
// 받은 에러에 맥락을 붙여 올린다. %w는 4-7에서 다룬다.
return 0, fmt.Errorf("나이 파싱 %q: %w", s, err)
}
if n < 0 || n > 150 {
return 0, rangeError{Field: "나이", Min: 0, Max: 150, Got: n}
}
return n, nil
}
func main() {
for _, in := range []string{"42", "", "abc", "999"} {
age, err := parseAge(in)
if err != nil {
fmt.Printf("%-6q -> 에러: %v\n", in, err)
continue
}
fmt.Printf("%-6q -> %d살\n", in, age)
}
fmt.Println("---")
// errors.New는 부를 때마다 다른 값을 만든다. 문자열이 같아도 같은 에러가 아니다.
a := errors.New("같은 문구")
b := errors.New("같은 문구")
fmt.Println("a == b:", a == b)
fmt.Println("a.Error() == b.Error():", a.Error() == b.Error())
// 그래서 센티널은 변수 하나를 공유해야 한다.
_, err := parseAge("")
fmt.Println("err == errEmptyInput:", err == errEmptyInput)
// error도 인터페이스이므로 동적 타입을 가진다.
_, err = parseAge("999")
fmt.Printf("동적 타입: %T\n", err)
// 커스텀 타입이면 필드를 꺼내 쓸 수 있다.
// 감싸지 않은 에러라서 타입 단언으로 충분하다. 감싼 경우는 4-7에서 다룬다.
if re, ok := err.(rangeError); ok {
fmt.Println("허용 최대값:", re.Max, "/ 받은 값:", re.Got)
}
// fmt.Errorf는 그냥 문자열을 만드는 에러다.
simple := fmt.Errorf("포트 %d는 이미 사용 중이다", 8080)
fmt.Println(simple)
fmt.Printf("%T\n", simple)
}
go run ./06-error-basics
"42" -> 42살
"" -> 에러: 입력이 비어 있다
"abc" -> 에러: 나이 파싱 "abc": strconv.Atoi: parsing "abc": invalid syntax
"999" -> 에러: 나이는 0~150여야 하는데 999다
---
a == b: false
a.Error() == b.Error(): true
err == errEmptyInput: true
동적 타입: main.rangeError
허용 최대값: 150 / 받은 값: 999
포트 8080는 이미 사용 중이다
*errors.errorString
에러를 만드는 세 가지 방법
| 방법 | 언제 |
|---|---|
errors.New("...") | 고정 문자열. 주로 센티널로 패키지 변수에 담아 둔다 |
fmt.Errorf("... %v ...", x) | 값을 끼워 넣어야 할 때 |
| 커스텀 타입 | 호출자가 필드를 꺼내 쓰거나 종류로 분기해야 할 때 |
errors.New는 매번 다른 값이다
a == b: false
a.Error() == b.Error(): true
errors.New는 내부적으로 &errorString{s}를 돌려준다. 포인터라서 문자열이 같아도
==가 false다.
이 성질이 센티널 에러(sentinel error) 를 가능하게 한다. io.EOF,
os.ErrNotExist, sql.ErrNoRows가 전부 패키지 수준 변수 하나다. 호출자는
그 변수와 비교해서 종류를 안다.
var (
ErrNotFound = errors.New("찾을 수 없음")
ErrConflict = errors.New("충돌")
)
이름 규칙: 공개 센티널은 Err 접두사, 비공개는 err. 타입이라면 Error 접미사
(rangeError, os.PathError).
:::warning 에러 문자열로 분기하지 않는다
if strings.Contains(err.Error(), "not found") { ... } // 절대 하지 않는다
에러 문구는 언제든 바뀔 수 있는 사람이 읽을 텍스트다. 종류로 분기하려면 센티널이나 타입을 쓴다(4-7). :::
반환 관례
error가 마지막 반환값이다. 예외 없이.- 실패하면 다른 반환값은 제로값이다. 호출자가 그것을 쓰지 않을 것을 전제한다.
예외적으로 부분 결과가 의미 있으면 문서에 적는다(
io.Reader의n이 그렇다). - 성공하면
nil을 돌려준다. 구체 타입 변수를 거치지 않는다 (4-4의 함정).
에러 문자열 작성 규칙
Go 커뮤니티의 관례는 영어 기준으로 이렇다.
- 소문자로 시작하고 마침표로 끝내지 않는다. 에러가 다른 에러에 감싸져
"a: b: c"처럼 이어지기 때문이다. - "failed to", "error:" 같은 접두사를 붙이지 않는다. 이미 에러라는 것을 안다.
- 맥락을 앞에, 원인을 뒤에.
fmt.Errorf("나이 파싱 %q: %w", s, err).
위 출력의 이 줄이 그 결과다.
나이 파싱 "abc": strconv.Atoi: parsing "abc": invalid syntax
세 계층의 맥락이 콜론으로 이어져 있다. 각 계층이 자기 몫만 붙였다.
한국어로 쓸 때는 대소문자 규칙이 무의미하지만, 마침표를 붙이지 않고 맥락을 앞에 두는 것은 그대로 지키는 편이 좋다.
에러는 받은 즉시 확인한다
3-5에서 예고한 이야기다.
package main
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
)
// delayedCheck는 에러를 나중에 확인한다. f는 nil인데 먼저 쓰였다.
func delayedCheck() (msg string) {
defer func() {
if rec := recover(); rec != nil {
msg = fmt.Sprintf("패닉: %v", rec)
}
}()
f, err := os.Open("존재하지_않는_파일")
name := f.Name() // f는 nil이다 — 여기서 패닉
if err != nil {
return "에러를 확인했다: " + err.Error()
}
defer func() {
if cerr := f.Close(); cerr != nil {
fmt.Println("close 실패:", cerr)
}
}()
return "열린 파일: " + name
}
// immediateCheck는 받은 즉시 확인한다.
func immediateCheck() string {
f, err := os.Open("존재하지_않는_파일")
if err != nil {
return "에러를 확인했다: " + err.Error()
}
defer func() {
if cerr := f.Close(); cerr != nil {
fmt.Println("close 실패:", cerr)
}
}()
return "열린 파일: " + f.Name()
}
// writeReport는 Close의 에러까지 돌려준다.
// bufio가 버퍼에 들고 있는 데이터는 Flush 시점에 쓰이므로, 여기서 에러가 날 수 있다.
func writeReport(path string, lines []string) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("파일 생성 %s: %w", path, err)
}
defer func() {
// 이미 에러가 있으면 덮어쓰지 않는다.
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("파일 닫기 %s: %w", path, cerr)
}
}()
w := bufio.NewWriter(f)
for i, line := range lines {
if _, werr := fmt.Fprintf(w, "%d\t%s\n", i+1, line); werr != nil {
return fmt.Errorf("쓰기 %s: %w", path, werr)
}
}
if ferr := w.Flush(); ferr != nil {
return fmt.Errorf("flush %s: %w", path, ferr)
}
return nil
}
func main() {
fmt.Println("지연 확인:", delayedCheck())
fmt.Println("즉시 확인:", immediateCheck())
// 에러를 _로 버리면 잘못된 값으로 계속 간다.
n, _ := strconv.Atoi("열두개")
fmt.Println("버린 결과로 계산:", n*1000)
n2, err := strconv.Atoi("열두개")
if err != nil {
fmt.Println("확인했더라면:", err)
} else {
fmt.Println("계산:", n2*1000)
}
// 에러 확인이 붙은 정상 경로
path := filepath.Join(os.TempDir(), "go-lecture-report.txt")
if err := writeReport(path, []string{"슬라이스", "맵", "인터페이스"}); err != nil {
fmt.Println("리포트 실패:", err)
return
}
data, err := os.ReadFile(path)
if err != nil {
fmt.Println("읽기 실패:", err)
return
}
fmt.Printf("작성된 내용 %d바이트\n", len(data))
if err := os.Remove(path); err != nil {
fmt.Println("정리 실패:", err)
}
// 없는 파일을 지우면 에러다. 종류를 구분할 수 있다.
err = os.Remove(path)
fmt.Println("두 번째 삭제:", errors.Is(err, os.ErrNotExist))
}
go run ./06-check-immediately
지연 확인: 패닉: runtime error: invalid memory address or nil pointer dereference
즉시 확인: 에러를 확인했다: open 존재하지_않는_파일: no such file or directory
버린 결과로 계산: 0
확인했더라면: strconv.Atoi: parsing "열두개": invalid syntax
작성된 내용 39바이트
두 번째 삭제: true
delayedCheck가 패닉을 내는 것이 옳다
os.Open이 실패하면 f는 nil이다. f.Name()은 nil 포인터를 역참조하므로
패닉이 나야 한다. 그런데 Go 1.21부터 1.24까지는 이 패닉이 지연되는 컴파일러 버그가
있었다. 최적화 과정에서 nil 검사가 뒤로 밀려, 운이 나쁘면 잘못된 값으로 계속
실행됐다.
Go 1.25에서 고쳐졌다. 이제 이 코드는 확실히 그 자리에서 죽는다.
버그가 고쳐졌다는 사실보다 중요한 것은 왜 이 코드가 애초에 잘못됐는가다.
err을 확인하기 전에 f를 쓰는 순간, 그 코드는 "실패하지 않았다"고 가정한 것이다.
가정이 틀리면 패닉이거나(운이 좋으면) 조용한 오작동이다(운이 나쁘면).
규칙: err을 받은 다음 줄이 if err != nil이다.
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("설정 파일 열기: %w", err)
}
defer f.Close() // 이 줄은 확인 뒤에 온다
defer f.Close()도 확인 뒤에 와야 한다. f가 nil이면 Close도 패닉이다.
_로 버리지 않는다
버린 결과로 계산: 0
strconv.Atoi("열두개")가 실패해서 n이 0인데, 그것으로 곱셈을 했다. 0이 정답인 것처럼
계속 흘러간다. 에러를 버리면 제로값이 정상값으로 위장한다. 이것이 _가 위험한
이유다.
_가 정당한 자리는 좁다.
- 실패가 불가능함을 증명할 수 있을 때.
strings.Builder의Write는 절대 실패하지 않는다고 문서에 적혀 있다. - 에러를 처리할 방법이 정말 없을 때. 정리 경로에서의
Close정도인데, 이것도 로그는 남기는 편이 낫다.
버릴 때는 왜 버리는지 주석으로 적는다.
// strings.Builder.Write는 항상 nil을 돌려준다 (문서에 명시).
_, _ = sb.WriteString(line)
:::tip errcheck
golangci-lint의 errcheck 린터가 버려진 에러를 전부 잡아 준다. 새 프로젝트라면
처음부터 켜 두는 편이 좋다. 표준 go vet은 fmt.Fprintf 같은 일부 경우만 본다.
:::
Close의 에러를 어떻게 다루는가
writeReport가 그 형태다. 세 가지가 겹쳐 있다.
1. 쓰기 함수에서 Close의 에러는 진짜 중요하다. 버퍼에 남아 있던 데이터가
Close(또는 Flush) 시점에 쓰이기 때문이다. 여기서 디스크가 꽉 차면
Close만 알려 준다. defer f.Close()로 버리면 데이터가 잘렸는데 성공으로 보고한다.
2. 명명된 반환값으로 받는다. func writeReport(...) (err error).
defer 안에서 err에 대입할 수 있는 것은 반환값에 이름이 있기 때문이다
(2-7).
3. 기존 에러를 덮어쓰지 않는다. cerr != nil && err == nil 조건이 그것이다.
쓰기가 이미 실패했다면 그쪽이 더 중요한 원인이다.
읽기만 하는 경우에는 defer f.Close()로 버려도 실무상 문제가 없다. 그래도
습관을 위해 이 강의의 예제는 확인하는 쪽을 택했다.
흔히 하는 실수
1. 에러를 확인하고 로그만 남긴 뒤 계속 진행한다
if err != nil {
log.Println(err) // 그리고 아무 일도 없었다는 듯 계속
}
로그는 처리가 아니다. 돌려보내거나, 대체 경로로 가거나, 멈추거나 셋 중 하나다.
2. 같은 에러를 여러 계층에서 로깅한다
각 계층이 log.Println(err) 하고 위로 올리면, 에러 하나에 로그가 다섯 줄 남는다.
로깅은 한 곳에서 — 보통 최상위(핸들러, main)다. 나머지 계층은 맥락만 붙여
올린다. 4-7에서 자세히 본다.
3. 에러를 문자열로 비교한다
err.Error() == "not found". 문구가 바뀌면 조용히 깨진다.
4. err을 확인하기 전에 다른 반환값을 쓴다
delayedCheck가 그것이다. Go 1.25 이후로는 대체로 즉시 패닉이 나지만, 애초에
쓰지 않는 것이 규칙이다.
5. panic으로 실패를 알린다
func MustParse(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
return n
}
Must 접두사가 붙은 함수는 관례상 패닉을 낸다(regexp.MustCompile,
template.Must). 초기화 시점에 실패하면 프로그램이 시작하면 안 되는 경우에만
정당하다. 요청 처리 중에는 절대 쓰지 않는다.
6. 에러를 _로 버리고 잊는다
가장 흔하고 가장 조용하다. errcheck를 켠다.
정리
error는Error() string하나짜리 인터페이스다. 에러는 값이다.- Go가 예외를 뺀 이유는 제어 흐름의 가시성과 계약이 시그니처에 있는 것이다.
- 만드는 법 셋:
errors.New(센티널),fmt.Errorf(맥락), 커스텀 타입(필드). errors.New는 매번 다른 값이다. 그래서 센티널은 패키지 변수 하나여야 한다.- 반환 관례:
error가 마지막, 실패하면 나머지는 제로값, 성공하면nil. - 에러 문자열은 소문자로 시작하고 마침표 없이, 맥락을 앞에 원인을 뒤에.
- 에러는 받은 즉시 확인한다. Go 1.25에서 nil 검사 지연 버그가 고쳐졌지만, 애초에 확인 전에 다른 반환값을 쓰지 않는 것이 규칙이다.
_로 버리지 않는다. 제로값이 정상값으로 위장한다. 버린다면 이유를 주석으로.- 쓰기 경로에서
Close의 에러는 진짜 중요하다. 명명된 반환값과defer로 받는다.
연습문제
-
parseAge가rangeError를 포인터로 반환하도록 바꿔 보자 (return 0, &rangeError{...}). 그다음Error()메서드의 리시버를 값에서 포인터로, 또 반대로 바꿔 가며 컴파일이 되는 조합과 안 되는 조합을 확인해 보자. 4-1의 메서드 집합이 답이다. -
writeReport에서defer블록의err == nil조건을 지워 보자. 쓰기가 실패하는 상황을 어떻게 만들 수 있는가? 힌트: 쓰기 권한이 없는 경로나/dev/full(리눅스)을 써 볼 수 있다. 조건이 없으면 어떤 정보를 잃는가? -
여러 에러를 모으는
validateUser(u User) error를 써 보자. 이름·이메일·나이를 전부 검사하고, 실패한 것을 모두 보고한다. 지금 지식으로는 문자열을 이어 붙이는 방법밖에 없을 텐데, 그 결과를 호출자가 종류별로 구분할 수 있는가? 힌트: 다음 챕터의errors.Join이 이 문제를 위한 것이다.