GO · 심층 가이드
Go 에러처리 완전 정리
센티널 에러와 커스텀 타입, %w 래핑, errors.Join, 패닉 복구, 에러 미들웨어까지 Go 에러 처리 13가지를 실제 서비스 코드 관점에서 정리했습니다.
Go의 에러는 값입니다. 이 한 문장이 실무에서 뜻하는 바는, 에러를 설계할 수 있다는 것입니다. 던지고 잡는 대신 반환하고 검사하기 때문에 에러 타입도 도메인 모델의 일부가 됩니다. 문제는 대부분의 코드가 if err != nil { return err }만 반복하며 맥락을 흘려버린다는 점입니다. 그렇게 다섯 계층을 지나면 로그에는 원인만 남고 경로는 사라집니다. 감싸느냐 그대로 올리느냐의 판단이 곧 에러 처리 실력입니다.
에러 래핑 패턴과 fmt.Errorf %w 심화가 짝이고, 그 결과를 판별하는 쪽이 센티널 에러와 커스텀 에러 타입과 인터페이스, 에러 인터페이스 활용입니다. 여러 갈래 실패를 한꺼번에 다뤄야 할 때 errors.Join (다중 에러)이 들어오고, 사용자 입력처럼 실패가 여러 건 나오는 상황은 검증 에러 패턴이 받습니다. 마지막으로 에러 미들웨어와 구조화된 로깅 전략이 이 값들을 응답 코드와 로그 필드로 변환하는 경계 지점입니다.
래핑에서 흔한 실수는 동사 지시자를 잘못 쓰는 것입니다. %v로 감싸면 메시지는 남지만 체인이 끊겨 errors.Is가 원본을 찾지 못합니다. 판별이 필요한 에러는 반드시 %w여야 합니다. 반대로 외부에 노출되는 API 경계에서는 내부 에러를 그대로 감싸 올리면 구현 세부가 새어 나가니, 경계에서 한 번 번역하는 편이 낫습니다. 패닉도 마찬가지입니다. recover는 같은 고루틴 안에서만 동작하므로, 새로 띄운 고루틴에서 패닉이 나면 상위의 복구 코드는 아무 도움이 되지 않습니다.
01에러 처리 패턴 & errors 패키지
Go의 에러 처리 철학: 에러는 값입니다. 예외 대신 명시적 반환으로 처리합니다.
Go code
package main
import (
"errors"
"fmt"
)
// Sentinel error — 비교 가능한 에러 값
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
)
// 커스텀 에러 타입
type NotFoundError struct {
Resource string
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s #%d를 찾을 수 없습니다", e.Resource, e.ID)
}
// 에러 래핑 (Go 1.13+)
func findUser(id int) (string, error) {
if id <= 0 {
return "", fmt.Errorf("잘못된 ID %d: %w", id, ErrNotFound)
}
if id > 100 {
return "", &NotFoundError{"User", id}
}
return fmt.Sprintf("User#%d", id), nil
}
func main() {
// 기본 에러 처리
user, err := findUser(0)
if err != nil {
// errors.Is — Sentinel 에러 체인 검사
if errors.Is(err, ErrNotFound) {
fmt.Println("없음:", err)
}
} else {
fmt.Println(user)
}
// errors.As — 특정 타입으로 언래핑
_, err = findUser(999)
var nfe *NotFoundError
if errors.As(err, &nfe) {
fmt.Printf("리소스: %s, ID: %d
", nfe.Resource, nfe.ID)
}
// 에러 무시 안 하기
if _, err := findUser(1); err != nil {
fmt.Println("예상치 못한 에러:", err)
} else {
fmt.Println("성공!")
}
// fmt.Errorf + %w로 컨텍스트 추가
err = fmt.Errorf("서비스 레이어: %w",
fmt.Errorf("저장소 레이어: %w", ErrNotFound))
fmt.Println(err)
fmt.Println(errors.Is(err, ErrNotFound)) // true (체인 탐색)
}errors.Is()는 에러 체인을 재귀적으로 탐색합니다. %w로 래핑된 에러도 감지합니다. 단순 == 비교보다 항상 errors.Is()를 사용하세요.
if err != nil { return err }를 반복하는 것은 Go의 정상적인 패턴입니다. 에러를 무시하거나 _로 버리지 마세요.
02errgroup — 고루틴 에러 처리
여러 고루틴의 에러를 하나로 모아 처리하는 errgroup 패키지.
Go code
// go get golang.org/x/sync/errgroup
package main
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
"time"
)
func fetchURL(ctx context.Context, url string) (string, error) {
// 컨텍스트 취소 확인
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(100 * time.Millisecond):
}
if url == "bad-url" {
return "", fmt.Errorf("fetch 실패: %s", url)
}
return "data:" + url, nil
}
func main() {
// 기본 errgroup
g := new(errgroup.Group)
results := make([]string, 3)
urls := []string{"url1", "url2", "url3"}
for i, url := range urls {
i, url := i, url // 캡처
g.Go(func() error {
data, err := fetchURL(context.Background(), url)
if err != nil { return err }
results[i] = data
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("에러:", err)
} else {
fmt.Println("결과:", results)
}
// context와 함께 — 하나 실패 시 나머지 취소
g2, ctx := errgroup.WithContext(context.Background())
badURLs := []string{"url1", "bad-url", "url3"}
for _, url := range badURLs {
url := url
g2.Go(func() error {
_, err := fetchURL(ctx, url)
return err
})
}
if err := g2.Wait(); err != nil {
fmt.Println("그룹 에러:", err)
}
}errgroup.WithContext()는 첫 번째 에러 발생 시 컨텍스트를 자동으로 취소합니다. 나머지 고루틴이 ctx.Done()을 감지해 일찍 종료할 수 있습니다.
g.Wait()는 첫 번째 에러만 반환합니다. 모든 에러를 수집하려면 채널이나 뮤텍스로 직접 수집해야 합니다.
03net/http ServeMux 개선 (1.22+)
메서드 패턴, 와일드카드 라우팅 지원
Go code
<span class="cm">// net/http ServeMux 개선 (1.22+) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("net/http ServeMux 개선 (1.22+)") }GO 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
04에러 래핑 패턴
에러에 컨텍스트를 추가하며 래핑하는 패턴. 호출 체인을 따라 에러 원인을 추적할 수 있습니다.
Go code
package main
import (
"errors"
"fmt"
)
var ErrDB = errors.New("데이터베이스 오류")
func queryDB() error {
return ErrDB
}
func getUser(id int) error {
if err := queryDB(); err != nil {
return fmt.Errorf("getUser(%d): %w", id, err)
}
return nil
}
func handleRequest() error {
if err := getUser(42); err != nil {
return fmt.Errorf("handleRequest: %w", err)
}
return nil
}
func main() {
err := handleRequest()
fmt.Println(err)
// handleRequest: getUser(42): 데이터베이스 오류
// 체인을 따라 원인 추적
fmt.Println(errors.Is(err, ErrDB)) // true
// Unwrap 체인
for err != nil {
fmt.Println("→", err)
err = errors.Unwrap(err)
}
}에러 메시지에 함수명과 핵심 인자를 포함하면 로그만으로 문제를 추적할 수 있습니다.
에러 래핑 시 민감한 정보(비밀번호, 토큰 등)를 포함하지 마세요. 로그에 노출될 수 있습니다.
05errors.Join (다중 에러)
Go 1.20의 errors.Join으로 여러 에러를 하나로 결합합니다. 병렬 작업의 에러 수집에 유용합니다.
Go code
package main
import (
"errors"
"fmt"
)
func validateForm(name, email string, age int) error {
var errs []error
if name == "" {
errs = append(errs, fmt.Errorf("이름은 필수입니다"))
}
if email == "" {
errs = append(errs, fmt.Errorf("이메일은 필수입니다"))
}
if age < 0 {
errs = append(errs, fmt.Errorf("나이는 0 이상이어야 합니다"))
}
return errors.Join(errs...)
}
var ErrRequired = errors.New("필수 필드 누락")
func main() {
err := validateForm("", "", -1)
if err != nil {
fmt.Println("검증 에러:")
fmt.Println(err)
// 이름은 필수입니다
// 이메일은 필수입니다
// 나이는 0 이상이어야 합니다
}
// nil 에러는 무시됨
err2 := errors.Join(nil, nil)
fmt.Println(err2 == nil) // true
}errors.Join은 nil 에러를 자동으로 건너뜁니다. 모든 에러가 nil이면 nil을 반환합니다.
errors.Join으로 결합된 에러는 errors.Is로 개별 에러를 검사할 수 있지만, errors.Unwrap은 단일 에러만 반환합니다.
06센티널 에러
패키지 수준의 센티널 에러로 에러 종류를 식별합니다. errors.Is로 비교합니다.
Go code
package main
import (
"errors"
"fmt"
)
// 센티널 에러 정의
var (
ErrNotFound = errors.New("리소스를 찾을 수 없음")
ErrUnauthorized = errors.New("인증 실패")
ErrForbidden = errors.New("권한 없음")
ErrConflict = errors.New("리소스 충돌")
)
func findItem(id string) error {
if id == "" {
return fmt.Errorf("findItem: %w", ErrNotFound)
}
return nil
}
func handleError(err error) {
switch {
case errors.Is(err, ErrNotFound):
fmt.Println("404: 찾을 수 없음")
case errors.Is(err, ErrUnauthorized):
fmt.Println("401: 인증 필요")
case errors.Is(err, ErrForbidden):
fmt.Println("403: 접근 거부")
default:
fmt.Println("500: 내부 서버 오류")
}
}
func main() {
err := findItem("")
handleError(err) // 404: 찾을 수 없음
}센티널 에러는 패키지의 공개 API로 노출합니다. var ErrNotFound = errors.New(...) 형태가 관례입니다.
센티널 에러를 ==로 비교하면 래핑된 에러를 놓칩니다. 항상 errors.Is를 사용하세요.
07커스텀 에러 타입과 인터페이스
error 인터페이스를 구현하는 구조체로 풍부한 에러 정보를 전달합니다.
Go code
package main
import (
"errors"
"fmt"
"time"
)
type AppError struct {
Code int
Message string
Op string // 실패한 작업
Time time.Time
Err error // 원인 에러
}
func (e *AppError) Error() string {
return fmt.Sprintf("[%d] %s: %s", e.Code, e.Op, e.Message)
}
func (e *AppError) Unwrap() error { return e.Err }
func newAppError(code int, op, msg string, err error) *AppError {
return &AppError{
Code: code, Message: msg,
Op: op, Time: time.Now(), Err: err,
}
}
func fetchData() error {
return newAppError(503, "fetchData", "서비스 불가", nil)
}
func main() {
err := fetchData()
fmt.Println(err)
var appErr *AppError
if errors.As(err, &appErr) {
fmt.Printf("코드: %d, 작업: %s, 시각: %s\n",
appErr.Code, appErr.Op, appErr.Time.Format("15:04:05"))
}
}Unwrap() error 메서드를 구현하면 errors.Is와 errors.As가 에러 체인을 탐색합니다.
커스텀 에러를 값 리시버로 Error()를 구현하면 errors.As에서 포인터와 값 모두 매칭되어 혼란을 줄 수 있습니다.
08패닉과 복구
panic은 치명적 에러, recover는 패닉을 잡아 정상 흐름으로 복귀합니다.
Go code
package main
import "fmt"
// recover는 반드시 defer에서 호출
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("패닉 복구: %v", r)
}
}()
return a / b, nil // b==0이면 패닉
}
// 미들웨어에서 패닉 복구
func withRecover(fn func()) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("복구됨: %v\n", r)
}
}()
fn()
}
func main() {
result, err := safeDiv(10, 0)
if err != nil {
fmt.Println(err) // 패닉 복구: runtime error: integer divide by zero
} else {
fmt.Println(result)
}
withRecover(func() {
panic("의도적 패닉!")
})
fmt.Println("프로그램 계속 실행")
}HTTP 서버의 미들웨어에서 recover를 사용하면 하나의 요청 패닉이 전체 서버를 중단시키지 않습니다.
panic은 정상적인 에러 처리에 사용하면 안 됩니다. 프로그래머 에러(불가능한 상태)에만 사용하세요.
09구조화된 로깅 전략
구조화된 로그로 에러를 기록합니다. Go 1.21의 log/slog을 사용합니다.
Go code
package main
import (
"errors"
"log/slog"
"os"
)
func main() {
// 구조화된 로거 생성
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
// 레벨별 로깅
logger.Debug("디버그 메시지", "module", "auth")
logger.Info("사용자 로그인", "user_id", 42, "ip", "192.168.1.1")
logger.Warn("느린 쿼리", "duration_ms", 500, "query", "SELECT...")
// 에러 로깅
err := errors.New("연결 실패")
logger.Error("DB 에러", "error", err, "retry", 3)
// 그룹 로깅
logger.WithGroup("request").Info("요청 처리",
"method", "POST",
"path", "/api/users",
"status", 201,
)
// 기본 로거 교체
slog.SetDefault(logger)
slog.Info("기본 로거 사용", "app", "myapp")
}slog는 Go 1.21 표준 라이브러리입니다. 키-값 쌍으로 구조화된 로그를 JSON이나 텍스트로 출력합니다.
fmt.Println으로 에러를 출력하면 구조화되지 않아 로그 분석이 어렵습니다. 항상 slog를 사용하세요.
10에러 미들웨어
HTTP 핸들러의 에러를 통합 처리하는 미들웨어. 에러 타입에 따라 적절한 HTTP 응답을 반환합니다.
Go code
package main
import (
"errors"
"fmt"
"log/slog"
)
type HTTPError struct {
Code int
Message string
Err error
}
func (e *HTTPError) Error() string { return e.Message }
func (e *HTTPError) Unwrap() error { return e.Err }
var (
ErrNotFound = &HTTPError{Code: 404, Message: "리소스 없음"}
ErrBadReq = &HTTPError{Code: 400, Message: "잘못된 요청"}
)
// 에러를 반환하는 핸들러 타입
type AppHandler func(req string) (string, error)
// 에러 미들웨어
func errorMiddleware(h AppHandler) func(string) string {
return func(req string) string {
result, err := h(req)
if err != nil {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
slog.Error("HTTP 에러", "code", httpErr.Code, "msg", httpErr.Message)
return fmt.Sprintf("%d: %s", httpErr.Code, httpErr.Message)
}
slog.Error("내부 에러", "error", err)
return "500: 내부 서버 오류"
}
return result
}
}
func main() {
handler := func(req string) (string, error) {
if req == "" { return "", fmt.Errorf("빈 요청: %w", ErrBadReq) }
return "OK: " + req, nil
}
wrapped := errorMiddleware(handler)
fmt.Println(wrapped("hello"))
fmt.Println(wrapped(""))
}에러 미들웨어에서 센티널 에러와 커스텀 에러 타입을 분류하면 일관된 HTTP 응답과 로깅을 보장할 수 있습니다.
에러 미들웨어에서 내부 에러 메시지를 그대로 클라이언트에 노출하면 보안 취약점이 됩니다. 일반적인 메시지로 변환하세요.
11검증 에러 패턴
입력 검증 에러를 구조화하여 여러 필드의 에러를 한 번에 수집하고 반환합니다.
Go code
package main
import (
"fmt"
"strings"
)
type FieldError struct {
Field string
Message string
}
type ValidationErrors []FieldError
func (ve ValidationErrors) Error() string {
msgs := make([]string, len(ve))
for i, e := range ve {
msgs[i] = fmt.Sprintf("%s: %s", e.Field, e.Message)
}
return strings.Join(msgs, "; ")
}
func (ve ValidationErrors) HasErrors() bool { return len(ve) > 0 }
type UserInput struct {
Name string
Email string
Age int
}
func (u UserInput) Validate() error {
var errs ValidationErrors
if u.Name == "" {
errs = append(errs, FieldError{"name", "필수 항목입니다"})
}
if !strings.Contains(u.Email, "@") {
errs = append(errs, FieldError{"email", "유효한 이메일이 아닙니다"})
}
if u.Age < 0 || u.Age > 150 {
errs = append(errs, FieldError{"age", "0~150 범위여야 합니다"})
}
if errs.HasErrors() { return errs }
return nil
}
func main() {
input := UserInput{Name: "", Email: "invalid", Age: -1}
if err := input.Validate(); err != nil {
fmt.Println(err)
if ve, ok := err.(ValidationErrors); ok {
for _, e := range ve {
fmt.Printf(" 필드 '%s': %s\n", e.Field, e.Message)
}
}
}
}검증 에러를 슬라이스로 수집하면 API 응답에서 모든 에러를 한 번에 보여줄 수 있어 UX가 좋습니다.
첫 번째 에러에서 바로 반환하면 사용자가 에러를 하나씩 수정해야 합니다. 가능하면 모든 에러를 한 번에 수집하세요.
12에러 인터페이스 활용
에러가 특정 인터페이스를 구현하는지 확인하여 동작을 결정합니다. 에러 타입에 따른 유연한 처리가 가능합니다.
Go code
package main
import (
"fmt"
"net"
)
// 재시도 가능한 에러 인터페이스
type Retryable interface {
error
IsRetryable() bool
}
type TempError struct{ Msg string }
func (e *TempError) Error() string { return e.Msg }
func (e *TempError) IsRetryable() bool { return true }
type FatalError struct{ Msg string }
func (e *FatalError) Error() string { return e.Msg }
func (e *FatalError) IsRetryable() bool { return false }
func handleWithRetry(err error, maxRetries int) {
if r, ok := err.(Retryable); ok && r.IsRetryable() {
fmt.Printf("재시도 가능: %s (최대 %d회)\n", err, maxRetries)
return
}
fmt.Printf("치명적 에러: %s\n", err)
}
func main() {
handleWithRetry(&TempError{"일시적 네트워크 오류"}, 3)
handleWithRetry(&FatalError{"설정 파일 없음"}, 3)
// 표준 라이브러리의 예: net.Error
var netErr net.Error
_ = netErr // Timeout() bool, Temporary() bool 메서드 보유
}에러 인터페이스로 행동을 확인하면 구체적 타입에 의존하지 않아도 됩니다. "재시도 가능한가?"같은 질문에 적합합니다.
에러 인터페이스를 너무 많이 만들면 복잡해집니다. Retryable, Timeout 등 핵심적인 것만 정의하세요.
13fmt.Errorf %w 심화
%w 동사의 동작 원리와 Go 1.20+ 다중 래핑을 이해합니다.
Go code
package main
import (
"errors"
"fmt"
)
var (
ErrAuth = errors.New("인증 실패")
ErrPerm = errors.New("권한 없음")
)
func main() {
// 단일 래핑
err1 := fmt.Errorf("로그인: %w", ErrAuth)
fmt.Println(errors.Is(err1, ErrAuth)) // true
// Go 1.20+: 다중 래핑
err2 := fmt.Errorf("접근 거부: %w, %w", ErrAuth, ErrPerm)
fmt.Println(errors.Is(err2, ErrAuth)) // true
fmt.Println(errors.Is(err2, ErrPerm)) // true
// %v vs %w
err3 := fmt.Errorf("에러: %v", ErrAuth) // 래핑 안 됨
fmt.Println(errors.Is(err3, ErrAuth)) // false
err4 := fmt.Errorf("에러: %w", ErrAuth) // 래핑됨
fmt.Println(errors.Is(err4, ErrAuth)) // true
// 중첩 래핑
inner := fmt.Errorf("DB: %w", ErrAuth)
outer := fmt.Errorf("서비스: %w", inner)
fmt.Println(errors.Is(outer, ErrAuth)) // true (체인 탐색)
}Go 1.20부터 fmt.Errorf에 %w를 여러 번 사용하여 다중 에러를 래핑할 수 있습니다.
%v는 에러를 문자열로만 변환하므로 errors.Is/errors.As로 추적할 수 없습니다. 체인이 필요하면 %w를 사용하세요.
정리하며
- 판별이 필요한 에러는 %v가 아니라 %w로 감싸야 errors.Is/As가 동작합니다
- recover는 같은 고루틴에서만 유효하므로 새 고루틴마다 방어를 넣습니다
- 센티널 에러는 비교 대상, 커스텀 타입은 부가 정보 전달용으로 구분해 씁니다
- API 경계에서는 내부 에러를 그대로 노출하지 말고 한 번 번역합니다
더 깊이 들어가고 싶다면 Go 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.