2025-12-23
Go 언어에서 에러(Error)는 예외(Exception)가 아닙니다. Python의 try/except, Java의 try/catch에 익숙하다면 처음에는 어색하게 느껴질 수 있습니다. 하지만 Go의 에러 처리 방식을 깊이 이해하고 나면, 이 방식이 얼마나 명확하고 예측 가능하며 안전한지를 체감하게 됩니다.
Go 설계자 Rob Pike의 말처럼, “에러는 값이다(Errors are values).” 이 한 문장이 Go의 에러 처리 철학 전체를 담고 있습니다.
이 글에서는 error 인터페이스의 내부 구조부터 커스텀 에러 타입 설계, 에러 래핑(Wrapping)과 체인, errors.Is와 errors.As의 동작 원리, 그리고 실무 에러 처리 패턴까지 심층적으로 다룹니다.
🛠️ 실무에서 에러 처리의 중요성
필자가 운영하는 API 서버에서 에러 처리를 소홀히 했을 때, 가장 고통스러운 것은 “어디서 에러가 발생했는지 도무지 모르겠다”는 상황이었습니다. Go의 에러 래핑 패턴을 제대로 적용한 이후, 에러 메시지를 보는 것만으로도 실행 경로를 역추적할 수 있게 되었고 디버깅 시간이 크게 줄었습니다.
Go의 error는 내장 인터페이스입니다. 정의는 놀랍도록 단순합니다.
type error interface {
Error() string
}
Error() 메서드 하나만 구현하면 어떤 타입이든 error로 사용할 수 있습니다. 이것이 덕 타이핑의 힘입니다.
Go에서 에러 처리의 가장 기본적인 패턴입니다.
file, err := os.Open("config.json")
if err != nil {
// 에러 처리
return fmt.Errorf("설정 파일 열기 실패: %w", err)
}
defer file.Close()
함수는 성공 시 nil을, 실패 시 error를 반환합니다. err != nil이 Go 코드에서 가장 많이 보이는 패턴인 이유입니다.
errors.New: 단순한 에러 메시지import "errors"
var ErrNotFound = errors.New("데이터를 찾을 수 없습니다")
func findUser(id int) (*User, error) {
// 데이터가 없으면
return nil, ErrNotFound
}
추가 컨텍스트가 필요 없는 단순한 에러에 사용합니다.
fmt.Errorf: 컨텍스트를 포함한 에러import "fmt"
func getConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("설정 파일 읽기 실패 (경로: %s): %w", path, err)
}
// ...
}
%w 동사는 Go 1.13에서 도입되었으며, 에러를 래핑(Wrapping)합니다. 래핑된 에러는 errors.Is와 errors.As로 추출할 수 있습니다.
type ValidationError struct {
Field string
Message string
Value interface{}
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("유효성 검사 실패: 필드 '%s' - %s (입력값: %v)",
e.Field, e.Message, e.Value)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{
Field: "age",
Message: "나이는 0에서 150 사이여야 합니다",
Value: age,
}
}
return nil
}
| 방법 | 사용 시점 |
|---|---|
errors.New | 단순한 정적 에러 메시지 |
fmt.Errorf | 동적 컨텍스트 포함, 에러 래핑 |
| 커스텀 타입 | 구조화된 정보, 타입 기반 분기 처리 |
패키지 레벨에서 미리 정의된 에러 변수를 센티넬 에러라고 합니다. 표준 라이브러리에서도 광범위하게 사용됩니다.
// 표준 라이브러리 예시
var (
io.EOF = errors.New("EOF")
sql.ErrNoRows = errors.New("sql: no rows in result set")
os.ErrNotExist = errors.New("file does not exist")
)
직접 센티넬 에러를 정의하는 패턴입니다.
package user
import "errors"
// 패키지 레벨 센티넬 에러 정의
var (
ErrNotFound = errors.New("사용자를 찾을 수 없습니다")
ErrAlreadyExists = errors.New("이미 존재하는 사용자입니다")
ErrInvalidEmail = errors.New("유효하지 않은 이메일 형식입니다")
)
🛠️ 네이밍 관례
센티넬 에러 변수명은
Err로 시작하는 것이 Go 관례입니다. (ErrNotFound,ErrTimeout등)
에러가 여러 계층을 거쳐 전달될 때, 각 계층에서 컨텍스트를 추가하면 디버깅이 훨씬 쉬워집니다.
func readUserFromDB(id int) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
return nil, fmt.Errorf("DB에서 사용자 조회 실패 (id=%d): %w", id, err)
}
return &user, nil
}
func getUserProfile(id int) (*Profile, error) {
user, err := readUserFromDB(id)
if err != nil {
return nil, fmt.Errorf("사용자 프로필 조회 실패: %w", err)
}
// ...
}
func handleGetProfile(w http.ResponseWriter, r *http.Request) {
profile, err := getUserProfile(123)
if err != nil {
// 에러 체인: "사용자 프로필 조회 실패: DB에서 사용자 조회 실패 (id=123): sql: no rows in result set"
log.Println(err)
}
}
에러 메시지가 실행 경로를 역추적하는 스택 트레이스 역할을 합니다.
errors.Unwrap: 래핑 해제wrapped := fmt.Errorf("레이어 2: %w", fmt.Errorf("레이어 1: %w", originalErr))
// 한 단계 언래핑
inner := errors.Unwrap(wrapped) // "레이어 1: ..." 에러 반환
errors.Is: 에러 동일성 검사errors.Is는 에러 체인 전체를 탐색하여 특정 센티넬 에러와 일치하는지 확인합니다.
var ErrNotFound = errors.New("not found")
// 에러가 래핑되어 있어도 탐색 가능
err := fmt.Errorf("조회 실패: %w", ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound)) // 출력: true
_, err := os.Open("nonexistent.txt")
if errors.Is(err, os.ErrNotExist) {
fmt.Println("파일이 존재하지 않습니다")
} else if errors.Is(err, os.ErrPermission) {
fmt.Println("파일 접근 권한이 없습니다")
}
⚠️ 주의:
==대신errors.Is사용
err == ErrNotFound는 래핑된 에러를 탐색하지 않습니다. 래핑된 에러 체인에서 특정 에러를 찾으려면 반드시errors.Is를 사용하세요.
errors.As: 에러 타입 추출errors.As는 에러 체인에서 특정 타입의 에러를 추출합니다. 타입 단언(Type Assertion)의 에러 특화 버전입니다.
type DatabaseError struct {
Code int
Message string
Query string
}
func (e *DatabaseError) Error() string {
return fmt.Sprintf("[DB 에러 %d] %s (쿼리: %s)", e.Code, e.Message, e.Query)
}
func main() {
err := fmt.Errorf("서비스 실패: %w", &DatabaseError{
Code: 5403,
Message: "연결 타임아웃",
Query: "SELECT * FROM users",
})
var dbErr *DatabaseError
if errors.As(err, &dbErr) {
fmt.Printf("DB 에러 코드: %d\n", dbErr.Code)
fmt.Printf("실패한 쿼리: %s\n", dbErr.Query)
// DB 에러 코드: 5403
// 실패한 쿼리: SELECT * FROM users
}
}
errors.Is vs errors.As 비교| 함수 | 목적 | 사용 시점 |
|---|---|---|
errors.Is(err, target) | 특정 에러 값 동일성 확인 | 센티넬 에러와 비교할 때 |
errors.As(err, &target) | 특정 타입 에러 추출 | 에러의 필드에 접근할 때 |
errors.Is와 errors.As의 탐색 동작을 커스텀 타입에서 직접 제어할 수 있습니다.
type AppError struct {
Code int
Message string
Err error // 내부 에러 저장
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
// errors.Unwrap이 이 메서드를 호출하여 내부 에러에 접근
func (e *AppError) Unwrap() error {
return e.Err
}
// errors.Is가 이 메서드를 호출
func (e *AppError) Is(target error) bool {
t, ok := target.(*AppError)
if !ok {
return false
}
return e.Code == t.Code // 에러 코드가 같으면 동일한 에러로 판단
}
// Repository 계층: 기술적 에러를 도메인 에러로 변환
func (r *UserRepo) FindByEmail(email string) (*User, error) {
var user User
err := r.db.Where("email = ?", email).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound // 도메인 에러로 변환
}
if err != nil {
return nil, fmt.Errorf("이메일로 사용자 조회: %w", err)
}
return &user, nil
}
// Service 계층: 비즈니스 로직 에러 처리
func (s *UserService) Login(email, password string) (*Token, error) {
user, err := s.repo.FindByEmail(email)
if errors.Is(err, ErrUserNotFound) {
return nil, ErrInvalidCredentials // 보안상 이유로 구체적 에러 숨김
}
if err != nil {
return nil, fmt.Errorf("로그인 처리: %w", err)
}
// ...
}
// Handler 계층: 클라이언트 응답으로 변환
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
token, err := h.service.Login(email, password)
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
http.Error(w, appErr.Message, appErr.HTTPStatus)
return
}
log.Printf("예상치 못한 에러: %v", err) // 내부 에러는 로그만
http.Error(w, "서버 내부 오류", http.StatusInternalServerError)
return
}
// 성공 응답...
}
defer를 활용한 에러 컨텍스트 추가func processFile(path string) (err error) {
// Named return을 사용하여 모든 에러에 컨텍스트 추가
defer func() {
if err != nil {
err = fmt.Errorf("파일 처리 실패 (%s): %w", path, err)
}
}()
f, err := os.Open(path)
if err != nil {
return err // defer가 컨텍스트 추가
}
defer f.Close()
// ... 처리 로직
return nil
}
// ❌ 절대 하면 안 되는 패턴
result, _ := someFunction() // 에러 무시
// ✅ 에러는 반드시 처리 또는 명시적으로 무시 이유 기록
result, err := someFunction()
if err != nil {
// 처리하거나
log.Printf("someFunction 실패 (무시됨): %v", err)
// 또는 상위로 전달
}
| 항목 | Go (에러 값) | Java/Python (예외) |
|---|---|---|
| 처리 강제성 | 컴파일러가 경고, 명시적 처리 유도 | 런타임에서 예외 전파 |
| 성능 | 함수 호출 오버헤드 없음 | 스택 트레이스 생성 비용 |
| 가독성 | 에러 흐름이 코드에 명시적 | 에러 흐름이 숨겨짐 |
| 예측 가능성 | 함수 시그니처로 에러 가능성 파악 | 런타임까지 알 수 없음 |
🛠️ 필자의 경험
처음에는
if err != nil반복이 불편하게 느껴졌습니다. 하지만 대규모 팀에서 협업할수록 이 방식의 가치를 실감했습니다. 코드 리뷰 시 에러 처리 누락이 즉시 보이고, 함수 시그니처만 봐도 어떤 에러가 발생할 수 있는지 파악할 수 있었습니다. 코드 라인이 늘어나는 단점보다 안정성과 협업 효율이 훨씬 크다고 느꼈습니다.
Go에서 에러 처리는 단순한 “문법 요소”가 아니라 프로그램 설계의 핵심 부분입니다. 어떤 에러를 외부에 공개할지, 어떤 에러를 내부에서 처리할지, 에러에 어떤 컨텍스트를 담을지를 의도적으로 설계해야 합니다.
오늘 배운 핵심을 요약하면:
error는 Error() string 메서드 하나를 가진 인터페이스이며, 어떤 타입도 에러가 될 수 있다.errors.New는 단순 에러, fmt.Errorf는 컨텍스트 추가, 커스텀 타입은 구조화된 에러에 사용한다.%w로 래핑된 에러는 체인을 형성하며, 실행 경로를 역추적하는 로그가 된다.errors.Is는 값 동일성, errors.As는 타입 추출로, 래핑된 에러 체인 전체를 탐색한다.다음 글에서는 Go 언어의 패닉(panic)과 복구(recover)를 다루며, 예외를 쓰지 않는 Go에서 프로그램이 회복 불가능한 상태에 빠졌을 때 어떻게 처리하는지, 그리고 panic을 안전하게 다루는 실무 패턴을 알아보겠습니다.