에러 처리 전략
이 챕터에서 다루는 것
파트 4에서 에러가 값이라는 것과 %w로 감싸는 법을 배웠다. 이 챕터는 그 도구를
서비스 전체에 걸친 하나의 정책으로 굳히는 작업이다. 어느 계층이 에러의 성격을
정하고, 어디서 감싸고, 어디서 딱 한 번 로깅하고, 무엇을 사용자에게 보여 줄지를
코드의 모양으로 못 박는다.
11-7에서 일부러 남겨 둔 숙제 하나를 여기서 갚는다.
문제 — 정책이 없으면 이렇게 된다
11-7의 HTTP 계층에 이런 코드가 있었다.
func writeServiceErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, task.ErrNotFound):
writeErr(w, http.StatusNotFound, "할 일을 찾을 수 없다")
case errors.Is(err, task.ErrInvalidStatus):
writeErr(w, http.StatusBadRequest, err.Error())
default:
// 검증 실패는 400, 나머지는 500이다. 검증 에러에 센티널을 두면
// 이 분기가 더 깔끔해진다 — 12장의 숙제로 남긴다.
writeErr(w, http.StatusBadRequest, err.Error())
}
}
문제는 두 가지고, 둘 다 심각하다.
첫째, default가 400이다. task.Validate가 만든 검증 에러에 센티널이 없어서,
그것을 400으로 보내려다 보니 나머지 전부가 딸려 들어갔다. DB 커넥션이 끊겨도 400,
디스크가 가득 차도 400이다. 클라이언트는 "내 요청이 잘못됐다"고 판단해 재시도하지
않고, 모니터링은 5xx 비율만 보고 있으니 장애가 그래프에 나타나지도 않는다.
둘째, err.Error()가 그대로 응답에 실린다. 그 문자열은 계층을 지나며 쌓인 개발자용
메시지다. DB 드라이버가 만든 에러였다면 사용자 화면에 이런 것이 뜬다.
{"error":"task: 생성: dial tcp 10.0.3.14:5432: connect: connection refused"}
내부 IP와 포트, 쓰는 DB의 종류가 한 줄에 다 있다.
두 문제의 뿌리는 같다. 에러가 "어떤 종류인지"와 "사용자에게 뭐라고 할지"를 스스로 갖고 있지 않아서, 받는 쪽이 문자열을 보고 추측하고 있다는 것이다.
에러에 종류를 붙인다
센티널 에러(var ErrNotFound = errors.New(...))를 종류마다 하나씩 만들어도 되지만,
개수가 늘면 switch가 길어지고 새 에러를 만들 때마다 매핑 표를 고쳐야 한다.
Go 커뮤니티에서 널리 쓰이는 대안은 에러에 분류 필드를 두는 것이다.
// Kind는 에러의 종류다.
//
// HTTP 상태 코드를 그대로 쓰지 않는 이유는, 도메인 계층이 HTTP를 몰라야 하기
// 때문이다. 같은 서비스를 gRPC나 CLI로도 노출한다면 매핑만 하나 더 만든다.
type Kind int
// 알려진 종류. 0값이 KindInternal인 것이 의도다.
// 종류를 지정하지 않고 만든 에러는 자동으로 500이 된다.
const (
KindInternal Kind = iota
KindInvalid
KindNotFound
KindConflict
KindUnauthorized
KindPermission
KindTimeout
)
KindInternal이 0값이라는 것이 이 설계의 핵심 결정이다. Go에서 구조체를 만들 때
지정하지 않은 필드는 0값이 된다. 그러니 실수로 종류를 안 붙인 에러는 자동으로 500이
된다. 안전한 쪽이 기본값이다. KindInvalid를 0으로 뒀다면 실수가 400을 만들고, 그것이
정확히 11-7에서 일어난 일이다.
:::tip 0값을 어느 쪽에 둘 것인가 Go에서 열거형을 설계할 때 매번 던져야 하는 질문이다. "값을 안 채웠을 때 어떻게 되기를 바라는가"를 먼저 정하고 거기에 0을 준다. 파트 3에서 0값이 곧 유효한 값이 되도록 구조체를 설계하라고 한 것의 연장이다. :::
에러 타입 자체는 평범하다.
// Error는 종류와 공개 메시지를 함께 나르는 에러다.
//
// Op는 어디서 났는지를 나타내는 짧은 이름이다. 스택 트레이스 대신 쓴다.
// 스택 트레이스는 로그를 뒤덮는 데 비해, 계층마다 한 단어씩 쌓인
// Op 사슬은 "store.Get → notes.Rename → api.rename"처럼 읽힌다.
type Error struct {
Kind Kind
Op string
// Msg는 사용자에게 보여 줘도 되는 메시지다. 비어 있으면 종류별 기본
// 문구를 쓴다. 여기에 SQL이나 파일 경로를 넣지 않는다.
Msg string
Err error
}
Msg와 Err이 나뉘어 있는 것이 두 번째 문제의 답이다. Msg는 응답으로 나가고,
Err은 로그에만 남는다. 필드가 둘로 나뉘어 있으면 섞으려면 일부러 섞어야 한다.
Kind를 읽는 쪽
errors.AsType(파트 4-7)이 사슬을 뚫고 첫 *Error를 찾는다.
// KindOf는 에러 사슬에서 첫 번째 Kind를 찾는다.
//
// 못 찾으면 KindInternal이다. 이것이 "모르는 에러는 500" 정책의 구현이다.
func KindOf(err error) Kind {
if e, ok := errors.AsType[*Error](err); ok {
return e.Kind
}
return KindInternal
}
세 줄짜리 함수 하나가 정책 전체를 담고 있다. nil을 넣어도, errors.New로 만든 날것의
에러를 넣어도, 남의 라이브러리가 fmt.Errorf로 감싼 것을 넣어도 결과는 안전한 쪽이다.
공개 메시지 고르기도 같은 방식이다.
// Public은 사용자에게 내보낼 메시지를 고른다.
//
// 사슬을 따라가며 메시지가 있는 첫 *Error를 쓴다. 하나도 없으면 종류별
// 기본 문구다. err.Error()를 그대로 응답에 넣지 않는 것이 핵심이다.
// 그렇게 하면 "dial tcp 10.0.3.14:5432: connect: connection refused"가
// 사용자 화면에 뜬다.
func Public(err error) string {
for e := err; e != nil; e = errors.Unwrap(e) {
if ae, ok := e.(*Error); ok && ae.Msg != "" {
return ae.Msg
}
}
return defaultMsg(KindOf(err))
}
errors.AsType을 쓰지 않고 직접 사슬을 걷는 이유가 있다. AsType은 첫 번째 *Error를
찾고 멈춘다. 그런데 바깥쪽 래퍼는 Msg가 비어 있고 안쪽에 메시지가 있는 경우가 흔하다.
그래서 "메시지가 있는 첫 *Error"라는 다른 조건이 필요하고, 그것은 직접 걸어야 한다.
HTTP를 아는 함수는 딱 하나다.
// HTTPStatus는 종류를 상태 코드로 옮긴다. 이 함수만이 HTTP를 안다.
func HTTPStatus(err error) int {
switch KindOf(err) {
case KindInvalid:
return http.StatusBadRequest
case KindNotFound:
return http.StatusNotFound
case KindConflict:
return http.StatusConflict
case KindUnauthorized:
return http.StatusUnauthorized
case KindPermission:
return http.StatusForbidden
case KindTimeout:
return http.StatusGatewayTimeout
default:
return http.StatusInternalServerError
}
}
도메인 패키지에 http.StatusNotFound를 쓰지 않는 이유가 여기서 분명해진다. 같은 서비스를
gRPC로도 노출한다면 GRPCCode(err)를 하나 더 만들면 되고, 도메인 코드는 한 줄도 바뀌지
않는다.
감싸기는 계층 경계에서 한 번
가장 흔한 실수는 모든 return err 자리에서 감싸는 것이다. 그러면 로그가 이렇게 된다.
handler: service: service: repo: repo: query: sql: no rows in result set
규칙은 단순하다. 함수가 스스로 만든 에러는 감싸지 않고, 남에게서 받은 에러는 자기 이름으로 한 겹만 감싼다.
// Wrap은 이미 종류가 정해진 에러에 Op만 한 겹 얹는다.
//
// 종류와 공개 메시지는 원래 것을 그대로 물려받는다. 계층을 지날 때마다
// 종류가 바뀌면 정책이 흐트러지기 때문이다. 종류를 바꿔야 한다면 그것은
// 감싸기가 아니라 번역이고, E로 새로 만들어야 한다.
func Wrap(op string, err error) error {
if err == nil {
return nil
}
return &Error{Kind: KindOf(err), Op: op, Err: err}
}
Wrap(op, nil)이 nil을 돌려주는 것은 편의가 아니라 안전장치다. 이것이 없으면
return apperr.Wrap("op", err)을 무조건 쓰는 코드에서 "에러가 아닌데 에러가 아닌 것도
아닌" 값이 만들어진다. 파트 4-8에서 다룬, 인터페이스에 담긴 nil 포인터가 nil과 같지 않은
그 함정과 정확히 같은 종류의 사고다.
서비스 계층은 이렇게 생긴다.
// Rename은 제목을 바꾼다.
//
// 감싸기는 이 함수의 경계에서 한 번뿐이다. validateTitle이 만든 에러도,
// store가 만든 에러도 여기서 한 겹씩만 얹혀 나간다. 층마다 두 번 감싸면
// 로그가 "notes.Rename: notes.Rename: store.Get: ..."처럼 된다.
func (s *Service) Rename(ctx context.Context, owner string, id int64, title string) (Note, error) {
if err := validateTitle(title); err != nil {
return Note{}, apperr.Wrap("notes.Rename", err)
}
n, err := s.store.Get(ctx, owner, id)
if err != nil {
return Note{}, apperr.Wrap("notes.Rename", err)
}
n.Title = title
if err := s.store.Save(ctx, n); err != nil {
return Note{}, apperr.Wrap("notes.Rename", err)
}
return n, nil
}
세 번 Wrap이 나오지만 한 번의 호출에서는 한 번만 실행된다. 쌓이는 것은 계층 수만큼이다.
Kind를 정하는 것은 에러를 처음 만드는 곳, 즉 저장소와 검증 함수의 몫이다.
// Get은 소유자의 메모를 찾는다. 없으면 KindNotFound다.
//
// 남의 메모에 KindPermission을 주지 않는다. 그러면 "그 ID는 존재한다"는
// 사실이 새어 나간다. 11-7에서 정한 정책을 그대로 잇는다.
func (s *MemStore) Get(_ context.Context, owner string, id int64) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
n, ok := s.items[id]
if !ok || n.Owner != owner {
return Note{}, apperr.E(apperr.KindNotFound, "store.Get", "메모를 찾을 수 없다", nil)
}
return n, nil
}
로깅은 딱 한 곳에서
에러를 로깅할 자격이 있는 것은 에러를 처리하기로 결정한 계층뿐이다. 위로 돌려보내는 계층은 로깅하지 않는다. 돌려보냈다는 것은 "내가 처리하지 않겠다"는 뜻이고, 위쪽에서 또 찍힐 것이기 때문이다.
HTTP 서버에서 그 자리는 응답을 쓰는 곳이다. 그런데 표준 http.HandlerFunc는 에러를
돌려줄 수 없어서, 핸들러마다 각자 응답을 쓰고 각자 로그를 남기게 된다. 시그니처를 한 칸
바꾸면 그것이 한 곳으로 모인다.
// apiFunc는 에러를 돌려주는 핸들러다.
//
// 표준 http.HandlerFunc는 에러를 돌려줄 수 없어서, 모든 핸들러가 각자
// 응답을 쓰고 각자 로그를 남기게 된다. 시그니처를 한 칸 바꾸면
// 응답 쓰기와 로깅을 한 곳으로 모을 수 있다.
type apiFunc func(w http.ResponseWriter, r *http.Request, svc *notes.Service) error
func handle(svc *notes.Service, logf LogFunc, fn apiFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r, svc); err != nil {
writeError(w, r, logf, err)
}
})
}
// writeError는 에러를 응답과 로그로 나눈다.
//
// 응답에는 apperr.Public(err)만 나간다. 로그에는 err.Error() 전체가 남는다.
// 이 함수가 그 분리를 담당하는 유일한 지점이다.
func writeError(w http.ResponseWriter, r *http.Request, logf LogFunc, err error) {
status := apperr.HTTPStatus(err)
if logf != nil {
logf(r, status, err)
}
writeJSON(w, status, map[string]string{
"error": apperr.Public(err),
"kind": apperr.KindOf(err).String(),
})
}
LogFunc는 12-2에서 *slog.Logger가 들어올 자리다. 지금은 출력이 결정적이도록 필요한
것만 한 줄로 찍는다.
로깅이 정말 한 번인지는 주장이 아니라 테스트로 못 박는다.
func TestErrorLoggedExactlyOnce(t *testing.T) {
// 계층마다 로깅하면 에러 하나가 로그 세 줄이 된다. 로깅 지점이
// writeError 하나뿐이라는 것을 개수로 못 박는다.
svc := notes.NewService(notes.NewMemStore())
count := 0
h := web.Handler(svc, func(*http.Request, int, error) { count++ })
request(t, h, "PATCH", "/notes/42", "sohee", `{"title":"x"}`)
if count != 1 {
t.Fatalf("로그 %d회, 1회를 기대", count)
}
}
패닉 복구 경계
Go에서 패닉은 예외가 아니다. Java나 Python의 예외 처리에 익숙하다면 recover를 catch처럼
쓰고 싶어지지만, 그것은 Go 코드가 아니다. recover는 프로세스 경계를 지키는 도구다.
"요청 하나를 포기하고 나머지 서버는 살린다"는 결정이며, 그 결정을 내리는 지점은 요청당
하나여야 한다.
// Recover는 패닉 복구 경계다.
//
// 경계는 요청 하나마다 하나씩만 둔다. 서비스나 저장소 안에 recover를
// 흩어 놓으면 어디서 무엇이 삼켜졌는지 알 수 없게 된다.
//
// 여기서 복구하는 것은 "이 요청 하나를 포기하고 나머지 서버는 살린다"는
// 뜻이지, 패닉을 정상 흐름으로 쓰라는 뜻이 아니다. 복구된 패닉은 항상
// 500이고 항상 버그다.
func Recover(logf LogFunc) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
// http.ErrAbortHandler는 net/http가 정상 흐름으로 쓰는
// 패닉이다. 삼키면 안 되고 그대로 다시 던진다.
if rec == http.ErrAbortHandler {
panic(rec)
}
:::warning http.ErrAbortHandler를 삼키지 마라
net/http는 클라이언트가 연결을 끊었을 때 등 정상적인 상황에서 http.ErrAbortHandler로
패닉한다. 서버는 이 패닉을 알아보고 조용히 연결만 정리한다. 직접 만든 복구 미들웨어가
이것까지 잡아 500 응답을 쓰려 들면, 이미 끊긴 연결에 쓰기를 시도하면서 로그가 오염된다.
recover 미들웨어를 손으로 쓸 때 가장 자주 빠뜨리는 조건이다.
:::
:::danger 고루틴 안의 패닉은 복구되지 않는다
go func() {
process(item) // 여기서 패닉하면 프로세스 전체가 죽는다
}()
Recover 미들웨어는 핸들러 고루틴의 스택만 지킨다. 핸들러가 띄운 다른 고루틴이 패닉하면
그 고루틴의 스택에는 recover가 없으므로 프로세스가 통째로 종료된다. 파트 7에서
"고루틴을 띄우는 함수를 따로 만들라"고 한 이유 중 하나가 이것이다. 백그라운드 작업을
띄우는 헬퍼 안에 defer recover()를 넣어 두어야 한다. 파트 13의 워커가 이 문제를 정면으로
다룬다.
:::
전체를 돌려 본 결과
go run ./01-error-strategy의 출력이다.
== 정상 저장소 ==
POST /notes owner="sohee" -> 201 {"id":1,"title":"장보기"}
[log] POST /notes status=409 kind=conflict ops=web.create<-notes.Create<-store.Create err="web.create: notes.Create: store.Create: 같은 제목의 메모가 이미 있다"
POST /notes owner="sohee" -> 409 {"error":"같은 제목의 메모가 이미 있다","kind":"conflict"}
[log] POST /notes status=400 kind=invalid ops=web.create<-notes.Create<-notes.validateTitle err="web.create: notes.Create: notes.validateTitle: 제목이 비어 있다"
POST /notes owner="sohee" -> 400 {"error":"제목이 비어 있다","kind":"invalid"}
[log] POST /notes status=401 kind=unauthorized ops=web.owner err="web.owner"
POST /notes owner="" -> 401 {"error":"인증이 필요하다","kind":"unauthorized"}
[log] PATCH /notes/1 status=404 kind=not_found ops=web.rename<-notes.Rename<-store.Get err="web.rename: notes.Rename: store.Get: 메모를 찾을 수 없다"
PATCH /notes/1 owner="minjun" -> 404 {"error":"메모를 찾을 수 없다","kind":"not_found"}
PATCH /notes/1 owner="sohee" -> 200 {"id":1,"title":"장보기(수정)"}
[log] GET /boom status=500 kind=internal ops=web.Recover err="web.Recover: 패닉: assignment to entry in nil map"
GET /boom owner="sohee" -> 500 {"error":"일시적인 오류가 발생했다","kind":"internal"}
== 저장소가 날것의 에러를 돌려줄 때 ==
[log] PATCH /notes/1 status=500 kind=internal ops=web.rename<-notes.Rename err="web.rename: notes.Rename: dial tcp 10.0.3.14:5432: connect: connection refused"
PATCH /notes/1 owner="sohee" -> 500 {"error":"일시적인 오류가 발생했다","kind":"internal"}
== 사슬 확인 ==
err.Error() : notes.Rename: store.Get: 메모를 찾을 수 없다
Public() : 메모를 찾을 수 없다
KindOf() : not_found
HTTPStatus(): 404
Ops() : [notes.Rename store.Get]
읽을 곳이 세 군데다.
날것의 에러가 500이 되었다. Kind가 없는 드라이버 에러가 사슬에 섞였는데도 400이
아니라 500이 나갔다. 11-7에서 못 고쳤던 default 분기가 여기서 해결됐다.
같은 에러가 로그와 응답에서 다르게 보인다. 로그에는 dial tcp 10.0.3.14:5432...가
남았고, 응답에는 "일시적인 오류가 발생했다"만 나갔다. 이것을 테스트로 고정해 둔다.
func TestRawStoreErrorIs500AndLeaksNothing(t *testing.T) {
// 11-7의 writeServiceErr는 default 분기가 400이었고, err.Error()를
// 그대로 응답에 넣었다. 정책을 뒤집었으니 둘 다 확인한다.
secret := "dial tcp 10.0.3.14:5432: connect: connection refused"
svc := notes.NewService(notes.BrokenStore{Cause: errors.New(secret)})
var logged error
h := web.Handler(svc, func(_ *http.Request, _ int, err error) { logged = err })
rec := request(t, h, "PATCH", "/notes/1", "sohee", `{"title":"새 제목"}`)
if rec.Code != 500 {
t.Fatalf("status = %d, 500을 기대", rec.Code)
}
if strings.Contains(rec.Body.String(), "5432") {
t.Fatalf("응답에 내부 정보가 샜다: %s", rec.Body.String())
}
if logged == nil || !strings.Contains(logged.Error(), secret) {
t.Fatalf("로그에는 원인이 남아야 한다: %v", logged)
}
}
패닉이 500이 되고 서버는 살아 있다. nil 맵에 쓰기(파트 3-4에서 본 그 함정)로
일부러 패닉을 냈는데, 응답은 다른 500과 구별되지 않는다. 사용자에게 "패닉했다"고 알릴
이유가 없기 때문이다.
흔한 실수
모든 return err에서 감싼다. 로그가 같은 단어의 반복이 된다. 감싸기는 계층 경계에서
한 번이다.
에러를 로깅하고 다시 돌려준다. log.Println(err); return err — 위 계층에서도 찍히므로
같은 사건이 두 줄, 세 줄이 된다. 로깅하면 처리한 것이고, 돌려주면 처리하지 않은 것이다.
둘 다 할 수는 없다.
err.Error()를 응답에 넣는다. 이 챕터 전체가 그 이야기다.
errors.Is로 문자열을 비교한다. strings.Contains(err.Error(), "not found") 같은 코드는
남의 라이브러리가 메시지를 다듬는 순간 조용히 깨진다. 문자열은 사람이 읽는 것이고,
분기는 타입과 센티널로 한다.
민감한 값을 Msg에 넣는다. Msg는 정의상 사용자에게 나가는 필드다. "사용자 %s의
토큰 %s이 만료됐다" 같은 문구를 여기 쓰면 필드를 나눈 의미가 없다.
recover를 흐름 제어로 쓴다. 라이브러리 안쪽에서 panic으로 빠져나오고 API 경계에서
recover로 잡는 패턴은 encoding/json 같은 표준 라이브러리에 실제로 있지만, 패키지
경계를 넘어가지 않을 때만 성립한다. 애플리케이션 코드에서는 그냥 에러를 돌려주는 편이 낫다.
서로 다른 계층의 에러가 같은 이름을 갖는다. store.Get과 notes.Get이 둘 다 Op: "Get"이면
사슬을 봐도 어디서 났는지 모른다. 패키지.함수 형태로 적는다.
정리
- 에러에 종류(
Kind)를 붙이고, 0값을KindInternal로 둔다. 실수의 결과가 안전한 쪽이 된다. - 사용자용 메시지(
Msg)와 내부 원인(Err)을 다른 필드에 둔다. 섞으려면 일부러 섞어야 한다. - Kind는 에러를 처음 만드는 곳에서 정하고, 위 계층은
Op만 얹는다. - HTTP를 아는 함수는
HTTPStatus하나뿐이다. 도메인은 상태 코드를 모른다. - 감싸기는 계층 경계에서 한 번, 로깅은 처리하는 곳에서 한 번.
- 핸들러가
error를 돌려주게 하면 응답 쓰기와 로깅이 자연히 한 곳으로 모인다. recover는 요청당 하나의 경계에서만.http.ErrAbortHandler는 다시 던지고, 고루틴 안의 패닉은 그 고루틴에서 따로 잡는다.
연습문제
-
apperr.Kind에KindRateLimited를 추가하고 429로 매핑해 보자. 그런데 429 응답에는Retry-After헤더가 필요하다. 헤더까지 실으려면Kind하나로는 부족한데,*Error에 필드를 더할 것인가 아니면 별도의 타입을 만들 것인가? 두 방법의 차이를 적어 보자. -
Wrap이 같은Op를 연달아 두 번 얹는 것을 막아 보자. 힌트:Wrap안에서errors.AsType[*Error](err)로 바로 바깥 계층의Op를 꺼내 비교할 수 있다. 그런데 이것이 좋은 아이디어인가? 재귀 함수에서 같은Op가 정당하게 두 번 쌓이는 경우를 생각해 보자. -
context.DeadlineExceeded를KindTimeout으로 자동 변환해 보자.KindOf를 고칠 것인가,Wrap을 고칠 것인가? 그리고context.Canceled는 어떤 종류여야 하는가 — 클라이언트가 연결을 끊은 것은 서버의 에러가 아니다. 로그 레벨은 무엇이 적당한가? (12-8에서 이 질문으로 다시 돌아온다)