에러 처리 심화와 패닉·복구
Go가 예외 대신 error 값을 택한 이유
Go의 에러 처리는 처음 보면 반복이 많아 보인다. 함수가 (값, error)를 반환하고, 호출자는 거의 즉시 if err != nil을 확인한다. 하지만 이 반복은 단순한 문법 취향이 아니라 운영 코드를 읽는 방식과 연결되어 있다. 실패가 발생할 수 있는 지점이 숨겨지지 않고, 성공 경로와 실패 경로가 같은 함수 안에서 바로 보인다.
f, err := os.Open("config.yaml")
if err != nil {
return fmt.Errorf("open config: %w", err)
}
defer f.Close()여기서 중요한 점은 error가 특별한 런타임 예외 객체가 아니라 평범한 인터페이스라는 것이다.
type error interface {
Error() string
}즉, 어떤 타입이 Error() string 메서드를 가지면 에러가 될 수 있다. 문자열 하나만 담은 단순한 에러도 가능하고, 파일 경로·오프셋·재시도 가능 여부 같은 구조화된 정보를 담은 에러 타입도 가능하다. Go는 실패를 제어 흐름의 예외로 밀어 올리기보다, 함수의 반환값으로 명시해 호출자가 판단하게 만든다.
운영 관점에서는 이 방식이 장점이 된다. 네트워크 타임아웃, 파일 없음, 권한 부족, 데이터 검증 실패처럼 예상 가능한 실패를 함수 시그니처에서 드러내고, 각 계층이 자기 책임만큼 컨텍스트를 추가할 수 있기 때문이다.
성공 경로를 왼쪽에 두는 에러 체크
Go 코드에서는 실패를 먼저 검사하고 바로 반환하는 패턴이 흔하다.
func loadUser(id string) (*User, error) {
row, err := db.QueryRowContext(ctx, query, id)
if err != nil {
return nil, fmt.Errorf("query user %s: %w", id, err)
}
user, err := scanUser(row)
if err != nil {
return nil, fmt.Errorf("scan user %s: %w", id, err)
}
return user, nil
}if err != nil { return ... }가 반복되더라도, 정상 흐름은 들여쓰기 깊이가 늘어나지 않고 위에서 아래로 읽힌다. else를 덧붙여 성공 경로를 감싸기 시작하면 오히려 핵심 로직이 오른쪽으로 밀리고, 실패 처리와 성공 처리의 경계가 흐려진다.
좋은 에러 메시지는 “무엇이 실패했는가”와 “어떤 대상에서 실패했는가”를 함께 남긴다. 단순히 permission denied만 반환하는 것보다 open /etc/passwd: permission denied가 훨씬 유용하다. 다만 컨텍스트를 추가할 때 원래 에러 값을 잃으면 안 된다.
%w, errors.Is, errors.As
Go 1.13 이후의 핵심은 에러를 문자열로만 이어 붙이지 말고 감싸는(wrap) 것이다.
if err != nil {
return fmt.Errorf("read profile %s: %w", userID, err)
}%w는 출력상으로는 %v처럼 보이지만, 반환된 에러가 내부 원인을 Unwrap()할 수 있게 만든다. 그래서 호출자는 문자열 파싱 없이 원인을 검사할 수 있다.
if errors.Is(err, fs.ErrNotExist) {
// 파일 없음: 기본 설정으로 시작할 수 있음
}errors.Is는 직접 비교보다 안전하다. 아래처럼 여러 계층에서 컨텍스트를 붙였더라도 에러 체인을 따라가며 대상 에러를 찾는다.
var ErrRateLimited = errors.New("rate limited")
func callAPI() error {
return fmt.Errorf("GET /v1/items: %w", ErrRateLimited)
}
func syncOnce() error {
if err := callAPI(); err != nil {
return fmt.Errorf("sync once: %w", err)
}
return nil
}
func main() {
err := syncOnce()
if errors.Is(err, ErrRateLimited) {
fmt.Println("backoff and retry")
}
}특정 타입의 구조화된 정보를 꺼내야 할 때는 errors.As를 쓴다.
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
log.Printf("op=%s path=%s cause=%v", pathErr.Op, pathErr.Path, pathErr.Err)
}직접 타입 단언(err.(*fs.PathError))은 에러가 감싸진 순간 실패할 수 있다. 반면 errors.As는 체인을 따라가며 해당 타입을 찾아준다.
Sentinel error, custom error type, wrapping의 선택 기준
에러 설계에서 자주 헷갈리는 부분은 “어떤 에러를 공개 API로 약속할 것인가”다. 모든 실패를 sentinel error로 만들 필요는 없고, 모든 에러에 커스텀 타입이 필요한 것도 아니다.
| 방식 | 예시 | 적합한 경우 | 주의점 |
|---|---|---|---|
| 단순 메시지 | errors.New("invalid config") | 내부에서만 쓰는 단순 실패 | 호출자가 안정적으로 분기하기 어렵다 |
| Sentinel error | var ErrNotFound = errors.New("not found") | 호출자가 errors.Is로 분기해야 하는 공개 조건 | 공개 API가 되므로 나중에 바꾸기 어렵다 |
| Custom error type | type ParseError struct { Line int; Err error } | 호출자에게 구조화된 정보가 필요 | 타입 필드와 의미를 오래 유지해야 한다 |
| Wrapping | fmt.Errorf("load config: %w", err) | 계층별 컨텍스트를 붙이며 원인을 보존 | %v로 감싸지 않으면 원인 검사가 끊긴다 |
실무에서는 대개 아래 순서로 판단하면 무난하다.
- 호출자가 이 실패를 프로그램적으로 구분해야 하는가?
- 구분해야 한다면 값 비교로 충분한가, 아니면 구조화된 정보가 필요한가?
- 이 에러 조건을 외부 패키지 사용자에게 장기적으로 약속해도 되는가?
- 컨텍스트를 추가하면서 원인 검사가 가능해야 하는가?
예를 들어 저장소 계층에서 “없음”은 자주 공개해야 하는 조건이다.
var ErrUserNotFound = errors.New("user not found")
func (r *Repository) FindUser(ctx context.Context, id string) (*User, error) {
user, err := r.query(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("find user %s: %w", id, ErrUserNotFound)
}
if err != nil {
return nil, fmt.Errorf("find user %s: %w", id, err)
}
return user, nil
}상위 계층은 DB 구현을 몰라도 된다.
user, err := repo.FindUser(ctx, id)
if errors.Is(err, ErrUserNotFound) {
http.NotFound(w, r)
return
}errors.Join과 여러 에러
정리 작업이나 병렬 작업에서는 하나의 작업에서 여러 에러가 나올 수 있다. Go의 errors.Join은 nil이 아닌 에러들을 하나의 에러 값으로 묶는다.
func closeAll(closers ...io.Closer) error {
var errs []error
for _, c := range closers {
if err := c.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}errors.Is와 errors.As는 단일 체인뿐 아니라 여러 하위 에러로 이어지는 에러 트리도 검사한다. 따라서 “여러 작업 중 하나라도 특정 원인으로 실패했는가”를 확인할 수 있다.
if errors.Is(err, context.DeadlineExceeded) {
log.Println("at least one close timed out")
}주의할 점은 errors.Join이 모든 에러를 평평하게 펴서 사람이 읽기 좋은 리포트를 만들어주는 만능 도구는 아니라는 것이다. 사용자에게 보여줄 메시지가 중요하면 별도의 요약 메시지를 만들고, 프로그램적 원인 검사용으로는 에러 트리를 보존하는 식으로 나누는 편이 안전하다.
defer: 실패 경로에서도 자원을 닫기
에러 처리와 함께 꼭 이해해야 하는 것이 defer다. defer는 현재 함수가 반환될 때 실행할 함수를 예약한다. 파일을 열거나 락을 잡은 직후에 해제 코드를 가까이 두는 데 유용하다.
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
mu.Lock()
defer mu.Unlock()defer에는 세 가지 중요한 규칙이 있다.
- deferred call의 인자는
defer를 만나는 순간 평가된다. - 여러 deferred call은 LIFO, 즉 나중에 등록한 것부터 실행된다.
- deferred function은 named return value를 읽거나 수정할 수 있다.
세 번째 규칙은 에러 반환값에 컨텍스트를 덧붙일 때 가끔 쓰인다.
func writeFile(name string, data []byte) (err error) {
f, err := os.Create(name)
if err != nil {
return err
}
defer func() {
if closeErr := f.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("close %s: %w", name, closeErr)
}
}()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
return nil
}이 패턴은 조심해서 써야 한다. named return value를 과하게 수정하면 함수의 반환 흐름이 숨겨진다. 하지만 “쓰기에는 성공했는데 닫기에서 실패한 경우”처럼 마지막 정리 단계의 에러를 놓치면 안 되는 코드에서는 유용하다.
panic은 일반적인 에러 처리가 아니다
panic은 일반적인 실패를 표현하는 도구가 아니다. panic이 호출되면 현재 함수의 정상 실행이 멈추고, 등록된 defer들이 실행된 뒤 호출 스택을 거슬러 올라간다. 중간에서 recover하지 않으면 프로그램은 크래시한다.
func mustLoadConfig(path string) Config {
cfg, err := loadConfig(path)
if err != nil {
panic(err)
}
return cfg
}이런 코드는 애플리케이션 시작 단계에서 “설정 없이는 프로세스를 띄울 의미가 없다”는 경우에는 받아들일 수 있다. 하지만 요청 처리 중 파일이 없거나 외부 API가 실패한 상황에 panic을 쓰면, 한 요청의 실패가 프로세스 전체 안정성을 해칠 수 있다.
panic을 고려할 만한 경우는 제한적이다.
- 프로그래머 버그: 절대 호출되면 안 되는 내부 불변식이 깨진 경우
- 초기화 실패: 프로세스를 계속 실행하는 것이 더 위험한 경우
- 패키지 내부 구현 단순화: 내부에서는 panic을 쓰되 공개 API 경계에서 error로 변환하는 경우
반대로 아래 상황에서는 보통 error가 맞다.
- 사용자 입력이 잘못됨
- 파일, 네트워크, DB처럼 외부 환경이 실패함
- 권한, 인증, 검증 실패처럼 호출자가 대응할 수 있음
- 재시도, 대체 경로, 4xx/5xx 응답 등 정상적인 실패 처리 정책이 있음
recover: 패닉을 잡는 위치가 중요하다
recover는 패닉 중인 고루틴의 제어권을 되찾는다. 단, 아무 곳에서나 동작하지 않는다. recover는 deferred function 안에서 직접 호출될 때 의미가 있다. 정상 실행 중에는 nil을 반환한다.
func safeRun(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
fn()
return nil
}중요한 제약은 고루틴 경계다. 한 고루틴의 recover가 다른 고루틴에서 발생한 panic을 잡아주지는 않는다. 서버에서 작업을 새 고루틴으로 띄운다면 그 고루틴 내부에 별도의 recover 경계를 두어야 한다.
func GoSafe(log *slog.Logger, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Error("goroutine panic", "panic", r)
}
}()
fn()
}()
}다만 recover를 남용하면 버그가 조용히 묻힌다. 복구했다면 최소한 로그, 메트릭, 알림처럼 운영자가 알 수 있는 흔적을 남겨야 한다. 또한 panic 값을 무조건 문자열로만 바꾸면 스택 정보가 사라질 수 있으므로, 필요한 경우 runtime/debug.Stack() 같은 정보를 함께 기록한다.
HTTP 서버에서의 경계 설계
웹 서버에서는 각 요청이 독립적인 실패 단위다. 핸들러 내부에서 예상 가능한 실패는 error로 반환하고, 예기치 못한 panic은 미들웨어 경계에서 잡아 500 응답과 로그로 바꾸는 구조가 흔하다.
type appHandler func(http.ResponseWriter, *http.Request) error
func withErrorBoundary(next appHandler, log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Error("panic in request", "panic", rec, "path", r.URL.Path)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}()
if err := next(w, r); err != nil {
log.Error("request failed", "error", err, "path", r.URL.Path)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
}이 구조의 판단 기준은 명확하다.
- 도메인에서 예상한 실패: 핸들러가
error로 반환한다. - 에러 종류에 따라 응답 코드가 달라져야 함:
errors.Is또는errors.As로 분기한다. - 프로그래머 버그나 예상 밖 panic: 요청 경계에서 복구하고 관측성을 남긴다.
이렇게 하면 panic을 비즈니스 로직의 분기 수단으로 쓰지 않으면서도, 프로세스가 불필요하게 죽는 상황을 줄일 수 있다.
운영 코드에서의 체크리스트
Go 에러 처리는 문법보다 정책이 중요하다. 다음 질문을 코드 리뷰에서 확인하면 많은 문제가 줄어든다.
- 에러 메시지에 작업 이름과 대상이 들어 있는가?
- 원인을 보존해야 하는 위치에서
%w를 썼는가? - 호출자가 분기해야 하는 에러는
errors.Is또는errors.As로 검사 가능한가? - 공개 sentinel error나 custom error type을 너무 쉽게 약속하지 않았는가?
panic이 사용자 입력, 네트워크, DB 실패 같은 정상적 실패에 쓰이지 않았는가?recover가 panic을 삼키기만 하고 로그·메트릭을 남기지 않는가?- 새 고루틴의 panic 경계가 필요한 곳에 있는가?
defer가 자원 획득 직후에 배치되어 실패 경로에서도 정리되는가?
Go다운 에러 처리는 “짧은 코드”보다 “실패가 보이는 코드”에 가깝다. 실패를 숨기지 않고, 필요한 곳에서 컨텍스트를 붙이고, 복구 가능한 실패와 프로그래머 버그를 구분하는 것이 핵심이다.
References
- Go Blog, Error handling and Go
- Go Blog, Working with Errors in Go 1.13
- Go Blog, Defer, Panic, and Recover
- Go Documentation, Effective Go: Errors
- Go Specification, Handling panics
- Go Packages,
errorspackage