[Go 2주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라

이 글의 핵심

if err != nil이 반복되는 코드는 처음엔 번거로워 보이지만 에러가 어디서 처리되는지 흐름이 드러난다는 장점이 있습니다. 이 글은 시리즈 다섯 번째 편으로 에러를 감싸 원인을 보존하는 방법, 커스텀 에러 타입을 errors.As로 꺼내는 법, defer 인자가 언제 평가되는지, panic을 써도 되는 경우와 안 되는 경우를 정리합니다.

시리즈 안내

📚 Go 2주 완성 시리즈 #05 | 전체 목차 보기

이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 8~9 내용입니다.

이전: #04 인터페이스 ← | → 다음: #06 고루틴·채널


💡 초보자를 위한 한 줄: Go에는 try-catch가 없습니다. 에러는 error 타입으로 반환하고 if err != nil로 즉시 처리합니다. defer는 “함수 끝날 때 실행”으로 파일 닫기/락 해제에 씁니다. panic은 스택을 풀며 defer를 실행한 뒤, 아무도 recover하지 않으면 프로그램을 종료시키는 장치라서 C++의 “잡히지 않은 예외”에 가깝고, 일반적인 실패 처리에는 쓰지 않습니다.

들어가며: “try-catch 없이 어떻게 에러 처리를 하죠?”

C++에서는 try-catch로 에러를 한 곳에서 몰아 처리했습니다.

try {
    openFile();
    processData();
    saveResult();
} catch (const std::exception& e) {
    // 어디서 에러가 났는지는 스택 추적으로 확인
}

편리하지만, 코드만 봐서는 어디서 예외가 발생할지 알기 어렵습니다. 세 함수 중 어느 것이 던지는지, 던진다면 어떤 타입을 던지는지는 시그니처에 드러나지 않습니다. noexcept가 붙지 않은 함수는 원칙적으로 무엇이든 던질 수 있다고 가정해야 하므로, 예외 안전성(강한 보장·기본 보장)을 모든 코드 경로에서 따져야 하는 부담이 생깁니다.

Go는 완전히 다릅니다. 예외가 없습니다. 대신 에러를 값으로 반환하며, 호출한 곳에서 즉시 처리합니다.

file, err := os.Open("data.txt")
if err != nil {
    return fmt.Errorf("파일 열기 실패: %w", err)
}
defer file.Close()

처음에는 if err != nil이 계속 반복되어 번거로워 보이지만, 실패할 수 있는 호출이 전부 눈에 보인다는 점이 핵심입니다. 함수 시그니처의 마지막 반환값이 error라면 그 호출은 실패할 수 있고, 그렇지 않다면 (panic을 제외하면) 실패하지 않습니다. 코드 리뷰에서 “이 줄이 실패하면 어떻게 되나”를 따로 추적할 필요 없이 바로 아래 if 블록을 보면 됩니다.

대가도 분명합니다. 에러를 확인하지 않고 _로 버리는 것도 문법적으로는 허용되기 때문에, 강제력은 컴파일러가 아니라 습관과 린터(errcheck, go vet)에서 나옵니다. 또 예외처럼 “자동으로” 위로 올라가지 않으므로, 각 계층에서 맥락을 덧붙여 올려 보내는 일을 사람이 직접 해야 합니다. 이 글의 대부분은 그 작업을 어떻게 잘하느냐에 관한 내용입니다.

이 글에서 배울 내용:

  • 다중 반환값으로 에러 전달
  • if err != nil 패턴
  • defer로 자원 해제 보장
  • errors.New·fmt.Errorf·%w, Sentinel, errors.Is/As
  • panic과 recover의 올바른 사용

에러를 값으로: error 타입

C++ vs Go: 에러 처리 방식

// C++: 예외로 에러 처리
#include <iostream>
#include <fstream>
#include <stdexcept>
void readFile(const std::string& path) {
    std::ifstream file(path);
    if (!file) {
        throw std::runtime_error("Failed to open file");
    }
    
    std::string line;
    while (std::getline(file, line)) {
        if (line.empty()) {
            throw std::invalid_argument("Empty line found");
        }
        std::cout << line << "\n";
    }
}
int main() {
    try {
        readFile("data.txt");
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
        return 1;
    }
    return 0;
}
// Go: error 반환으로 에러 처리
package main
import (
    "bufio"
    "fmt"
    "os"
)
func readFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("failed to open file: %w", err)
    }
    defer file.Close()
    
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := scanner.Text()
        if line == "" {
            return fmt.Errorf("empty line found")
        }
        fmt.Println(line)
    }
    
    if err := scanner.Err(); err != nil {
        return fmt.Errorf("scan error: %w", err)
    }
    
    return nil
}
func main() {
    if err := readFile("data.txt"); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

핵심 차이점:

  • Go는 예외를 던지지 않습니다
  • 에러는 반환값으로 전달됩니다
  • 호출자는 에러를 확인할 책임을 집니다(컴파일러가 강제하지는 않으므로 go vet·errcheck 같은 도구로 보완합니다)

Go 쪽 코드에서 눈여겨볼 곳은 scanner.Err() 확인입니다. bufio.Scanner는 읽기 도중 I/O 에러가 나거나 한 줄이 기본 버퍼 한도(64KB)를 넘으면 Scan()이 false를 반환하고 루프가 그냥 끝납니다. 루프 뒤에서 scanner.Err()를 보지 않으면 “파일을 끝까지 읽었다”와 “중간에 실패했다”를 구분할 수 없습니다. C++의 std::getline 루프도 스트림 상태(bad())를 따로 확인해야 하는 점은 비슷하지만, 예외 모드를 켜지 않는 한 조용히 끝나는 것은 마찬가지입니다.

또 하나, Go 에러 메시지는 관례상 소문자로 시작하고 마침표를 붙이지 않습니다. 위 코드처럼 "failed to open file: %w" 형태로 여러 겹 이어 붙이기 때문에, 중간에 대문자나 마침표가 섞이면 최종 메시지가 어색해집니다. staticcheck의 ST1005 규칙이 이것을 검사합니다.

error 인터페이스

// Go: error는 인터페이스
type error interface {
    Error() string
}
// 커스텀 에러 타입
type FileError struct {
    Path string
    Op   string
    Err  error
}
func (e *FileError) Error() string {
    return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err)
}
// 사용
func openFile(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return &FileError{
            Path: path,
            Op:   "open",
            Err:  err,
        }
    }
    return nil
}

error는 메서드 하나짜리 인터페이스이므로, Error() string만 구현하면 어떤 타입이든 에러가 됩니다. 표준 라이브러리의 *os.PathError도 위 FileError와 거의 같은 구조(Op, Path, Err)입니다. 메서드를 포인터 리시버로 정의했기 때문에 error를 만족하는 것은 FileError가 아니라 *FileError이고, 그래서 &FileError{...}로 반환합니다.

가장 흔한 함정: nil인데 nil이 아닌 에러

C++ 개발자가 Go 에러에서 처음 크게 당황하는 지점이 여기입니다. 인터페이스 값은 내부적으로 (동적 타입, 동적 값) 쌍이고, 둘 다 비어 있어야만 == nil이 참입니다.

func validate(s string) error {
    var e *FileError // nil 포인터
    if s == "" {
        e = &FileError{Op: "validate", Path: s}
    }
    return e // s가 비어 있지 않아도 (타입=*FileError, 값=nil)인 error가 반환됨
}

func main() {
    if err := validate("ok"); err != nil {
        fmt.Println("에러?!", err) // 실행됨
    }
}

validate("ok")는 논리적으로 성공했는데도 err != nil이 참이 됩니다. 구체 포인터 타입 변수를 error로 그대로 반환하면 타입 정보가 채워지기 때문입니다. 저도 헬퍼 함수 안에서 “에러 변수를 먼저 선언해 두고 조건에 따라 채운 뒤 반환”하는 C++식 습관 그대로 코드를 짰다가, 모든 요청이 실패로 기록되는 증상을 겪는 경우를 흔히 봅니다. 메시지를 출력하면 <nil> 비슷한 이상한 문자열이 나오거나, Error() 메서드 안에서 필드에 접근하다 nil 역참조 panic이 납니다. 해법은 단순합니다. 성공 경로에서는 항상 리터럴 nil을 반환하고, 함수의 반환 타입은 구체 타입이 아니라 error로 둡니다.


if err != nil 패턴

기본 패턴

// Go: 가장 흔한 패턴
func doSomething() error {
    result, err := someOperation()
    if err != nil {
        return err  // 에러 전파
    }
    
    // result 사용
    return nil
}

관례상 에러가 nil이 아니면 다른 반환값(result)은 의미 없는 값으로 취급합니다. io.Reader.Read처럼 “읽은 바이트 수 n과 에러를 함께 돌려주고, n > 0이면 에러가 있어도 데이터를 먼저 처리하라”고 문서화된 예외적인 API도 있으니, 표준 라이브러리 함수는 문서의 반환값 설명을 한 번은 읽어 보는 것이 좋습니다.

에러 처리 변형

// Go: 다양한 에러 처리 패턴
package main
import (
    "fmt"
    "os"
)
// 1. 즉시 반환
func pattern1() error {
    f, err := os.Open("file.txt")
    if err != nil {
        return err
    }
    defer f.Close()
    // ...
    return nil
}
// 2. 로깅 후 반환
func pattern2() error {
    f, err := os.Open("file.txt")
    if err != nil {
        fmt.Println("Failed to open file:", err)
        return err
    }
    defer f.Close()
    // ...
    return nil
}
// 3. 기본값 사용
func pattern3() int {
    data, err := fetchData()
    if err != nil {
        return 0  // 기본값
    }
    return data
}
// 4. 재시도
func pattern4() error {
    maxRetries := 3
    for i := 0; i < maxRetries; i++ {
        err := tryOperation()
        if err == nil {
            return nil
        }
        fmt.Printf("Attempt %d failed: %v\n", i+1, err)
    }
    return fmt.Errorf("failed after %d retries", maxRetries)
}

네 패턴은 “누가 이 에러의 최종 책임자인가”로 고르면 됩니다.

  • 즉시 반환은 이 함수가 에러를 해결할 방법이 없을 때의 기본값입니다. 다만 맨 return err보다는 뒤에서 설명할 %w 래핑으로 “무엇을 하다가” 실패했는지 덧붙이는 편이 나중에 로그를 읽기 훨씬 쉽습니다.
  • 로깅 후 반환은 실무에서 가장 조심해야 하는 패턴입니다. 모든 계층이 로그를 찍고 다시 반환하면, 하나의 실패가 로그에 네다섯 줄로 중복 기록되고 어느 것이 원인인지 오히려 찾기 어려워집니다. “처리하거나(handle) 반환하거나(return), 둘 중 하나만” 하는 규칙을 두고, 로그는 요청 핸들러나 main 같은 최상위에서 한 번만 남기는 편이 좋습니다.
  • 기본값 사용은 에러를 삼키는 것이므로, 설정값 누락처럼 기본값이 정말 의미 있는 경우에만 씁니다. 네트워크 실패를 0으로 바꿔 버리면 “데이터가 없다”와 “조회에 실패했다”가 구분되지 않습니다.
  • 재시도는 예제처럼 즉시 반복하면 장애 중인 서버를 더 두드리게 됩니다. 실제로는 시도 사이에 지수 백오프를 두고, 영구적인 에러(인증 실패, 400 응답)는 재시도하지 않도록 에러 종류를 먼저 판별해야 합니다. 마지막 에러를 %w로 감싸 반환하면 호출자가 원인을 알 수 있습니다.

defer: RAII의 대체재

C++ RAII vs Go defer

// C++: RAII로 자동 정리
void processFile() {
    std::ifstream file("data.txt");
    std::lock_guard<std::mutex> lock(mtx);
    
    // 작업...
    
    // 스코프 종료 시 자동으로:
    // 1. lock 해제 (lock_guard 소멸자)
    // 2. file 닫기 (ifstream 소멸자)
}
// Go: defer로 명시적 정리
func processFile() error {
    file, err := os.Open("data.txt")
    if err != nil {
        return err
    }
    defer file.Close()  // 함수 종료 시 실행
    
    mu.Lock()
    defer mu.Unlock()   // 함수 종료 시 실행
    
    // 작업...
    
    return nil
    // 여기서 defer들이 역순으로 실행:
    // 1. mu.Unlock()
    // 2. file.Close()
}

RAII와 defer의 가장 큰 차이는 단위입니다. C++ 소멸자는 블록 스코프({})가 끝날 때 실행되지만, Go의 defer는 함수가 반환할 때 실행됩니다. 그래서 for 루프 안에서 파일을 열고 defer f.Close()를 쓰면, 루프가 천 번 돌 동안 파일이 하나도 닫히지 않고 함수가 끝날 때 한꺼번에 닫힙니다. 파일이 많은 디렉터리를 처리하다 too many open files 에러가 나는 전형적인 원인이 이것입니다. 루프 본문을 별도 함수로 빼서 그 함수 안에서 defer하거나, 루프 안에서는 명시적으로 Close()를 호출해야 합니다.

두 번째 차이는 정리 코드가 자동이 아니라는 점입니다. RAII는 객체를 만든 순간 정리가 약속되지만, Go에서는 defer 한 줄을 빠뜨리면 그만입니다. 그래서 Go 코드에서는 “자원을 얻은 바로 다음 줄에 defer를 쓴다”는 습관이 사실상 규칙처럼 쓰입니다. 에러 확인 다음에 defer를 두는 것도 중요합니다. os.Open이 실패하면 file은 nil이므로, 에러 확인 전에 defer file.Close()를 쓰면 nil에 대한 Close가 호출됩니다.

defer의 실행 순서 (LIFO)

// Go: defer는 LIFO (Last In First Out)
package main
import "fmt"
func example() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
    
    fmt.Println("함수 본문")
}
func main() {
    example()
}
// 출력:
// 함수 본문
// 3
// 2
// 1

역순 실행은 C++에서 지역 객체가 생성의 역순으로 소멸하는 것과 같은 이유입니다. 나중에 얻은 자원이 먼저 얻은 자원에 의존하는 경우(파일을 연 뒤 그 위에 버퍼드 라이터를 만든 경우 등)가 많으므로, 나중 것부터 정리해야 안전합니다.

defer의 평가 시점

// Go: defer의 인자는 즉시 평가됨
package main
import "fmt"
func example() {
    x := 1
    defer fmt.Println("Deferred:", x)  // x=1이 즉시 평가됨
    
    x = 2
    fmt.Println("Current:", x)
}
func main() {
    example()
}
// 출력:
// Current: 2
// Deferred: 1  (defer 등록 시점의 값)

함수 호출로 지연 평가:

// Go: 함수로 감싸서 지연 평가
func example() {
    x := 1
    defer func() {
        fmt.Println("Deferred:", x)  // 실행 시점의 x 사용
    }()
    
    x = 2
    fmt.Println("Current:", x)
}
// 출력:
// Current: 2
// Deferred: 2  (실행 시점의 값)

클로저는 x를 값이 아니라 변수 자체로 캡처합니다(C++ 람다의 [&]와 비슷합니다). 그래서 실행 시점의 값을 봅니다. 이 차이는 아래 트랜잭션 롤백 패턴에서 결정적으로 중요해집니다.

defer 활용 패턴

// Go: defer 실전 패턴
package main
import (
    "fmt"
    "time"
)
// 1. 실행 시간 측정
func measureTime(name string) func() {
    start := time.Now()
    return func() {
        fmt.Printf("%s took %v\n", name, time.Since(start))
    }
}
func slowFunction() {
    defer measureTime("slowFunction")()  // 함수 반환값(함수)를 defer
    
    time.Sleep(100 * time.Millisecond)
}
// 2. 락 보호
func criticalSection() {
    mu.Lock()
    defer mu.Unlock()
    
    // 여러 return 경로가 있어도 unlock 보장
    if condition1 {
        return
    }
    if condition2 {
        return
    }
    // ...
}
// 3. 트랜잭션 롤백 (이름 있는 반환값 err를 defer가 본다)
func transaction() (err error) {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()
    
    // 작업... (:=가 아니라 =로 대입해야 바깥 err에 반영됨)
    if err = doWork(tx); err != nil {
        return err  // defer에서 Rollback 실행
    }
    
    return tx.Commit()
}

measureTime("slowFunction")()의 끝에 붙은 ()를 빠뜨리는 실수가 흔합니다. defer measureTime("slowFunction")이라고 쓰면 시작 시각을 기록하는 바깥 함수가 함수가 끝날 때 호출되고 반환된 클로저는 버려지므로, 측정값이 출력되지 않습니다. 바깥 호출은 defer 등록 시점에 즉시 평가되어야 하므로 ()() 두 쌍이 필요합니다.

트랜잭션 코드에서 흔히 보는 if err := doWork(tx); err != nil 형태는 함정입니다. 이렇게 하면 if 문 안에 새 err 변수가 선언되어 바깥 err을 가립니다(shadowing). defer 클로저가 보는 것은 바깥 err이므로 여전히 nil이고, 롤백이 실행되지 않은 채 트랜잭션이 열려 있게 됩니다. 컴파일도 되고 테스트의 정상 경로도 통과하기 때문에 발견이 늦은 버그입니다. 위 코드처럼 반환값에 이름을 붙이고((err error)), 본문에서는 =로 대입하면 return 문이 반환값 변수에 값을 넣은 뒤에 defer가 실행되므로 실제 에러를 볼 수 있습니다. go vet -vettool에 shadow 분석기를 붙이면 이런 가림을 경고로 받아 볼 수 있습니다. 한편 Commit()이 성공한 뒤의 Rollback()은 sql.ErrTxDone을 반환할 뿐 해가 없으므로, 조건 없이 defer tx.Rollback()을 걸어 두는 더 단순한 방식도 널리 쓰입니다.


에러 래핑과 언래핑

에러 래핑 (Error Wrapping)

// Go: 에러 래핑으로 컨텍스트 추가
package main
import (
    "fmt"
    "os"
)
func readConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        // %w로 원본 에러 래핑
        return fmt.Errorf("read config from %s: %w", path, err)
    }
    
    if len(data) == 0 {
        return fmt.Errorf("config file %s is empty", path)
    }
    
    return nil
}
func loadSettings() error {
    if err := readConfig("config.json"); err != nil {
        return fmt.Errorf("load settings: %w", err)
    }
    return nil
}
func main() {
    if err := loadSettings(); err != nil {
        fmt.Println("Error:", err)
        // 출력: Error: load settings: read config from config.json: open config.json: no such file or directory
    }
}

출력 메시지가 바깥에서 안쪽으로 “load settings → read config → open”처럼 읽히는 것이 래핑의 목적입니다. Go 에러에는 기본적으로 스택 트레이스가 없기 때문에, 이 문맥 문자열이 C++ 예외의 스택 추적 역할을 대신합니다. 그래서 문맥은 “failed to”나 “error:” 같은 빈 단어 대신 무엇을 하려다 실패했는지(read config from %s)를 적는 것이 좋습니다. 경로나 ID처럼 재현에 필요한 값도 여기서 넣습니다. 단, 표준 라이브러리 에러(*os.PathError)가 이미 경로를 포함하고 있으면 같은 경로가 메시지에 두 번 나오니, 한 계층에서만 넣습니다.

errors.Is와 errors.As

// Go: 특정 에러 확인
package main
import (
    "errors"
    "fmt"
)
var (
    ErrNotFound    = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
)
func findUser(id int) error {
    if id < 0 {
        return ErrNotFound
    }
    return nil
}
func main() {
    err := findUser(-1)
    
    // errors.Is로 에러 확인
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found")
    }
}
// Go: errors.As로 타입 확인
package main
import (
    "errors"
    "fmt"
    "os"
)
func openFile(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("open file: %w", err)
    }
    return nil
}
func main() {
    err := openFile("nonexistent.txt")
    
    // errors.As로 특정 타입 추출
    var pathErr *os.PathError
    if errors.As(err, &pathErr) {
        fmt.Println("Path error:")
        fmt.Println("  Op:", pathErr.Op)
        fmt.Println("  Path:", pathErr.Path)
        fmt.Println("  Err:", pathErr.Err)
    }
}

fmt.Errorf로 %w를 쓰면 에러 체인이 만들어져, 여러 겹 감싼 뒤에도 errors.Is(err, os.ErrNotExist)처럼 근본 원인을 찾을 수 있습니다. 반면 %v만 쓰면 문자열로만 붙고 Is/As가 따라가지 못합니다.

errors.As의 두 번째 인자는 찾을 타입의 변수에 대한 포인터여야 합니다. *os.PathError를 찾으려면 var pathErr *os.PathError를 선언하고 &pathErr(즉 **os.PathError)를 넘깁니다. pathErr를 그대로 넘기면 errors.As가 panic을 일으키고, go vet의 errorsas 검사도 이를 잡아 줍니다. C++의 dynamic_cast와 비슷하지만 체인을 따라 한 겹씩 Unwrap()하며 검사한다는 점이 다릅니다.

%w를 무조건 쓰는 것이 정답은 아닙니다. 래핑한 에러는 공개 API의 일부가 됩니다. 저장소 계층이 sql.ErrNoRows를 %w로 감싸 올리면, 서비스 계층 호출자가 errors.Is(err, sql.ErrNoRows)에 의존하기 시작하고, 나중에 DB를 바꾸면 그 코드가 조용히 깨집니다. 계층 경계에서는 sql.ErrNoRows를 자기 패키지의 ErrNotFound로 번역하고, 내부 구현 세부를 노출하고 싶지 않다면 %v로 문자열만 남기는 선택도 의도적으로 할 수 있습니다.


errors.New vs fmt.Errorf, Sentinel, 실전 래핑

errors.New vs fmt.Errorf

API쓰는 때메모
errors.New("msg")문맥 없는 고정 메시지의 단순 에러, Sentinel로 패키지 전역 변수에 두고 errors.Is로 비교포맷 불가 — 문자열 하나
fmt.Errorf("format", args...)동적 값을 문자열에 넣을 때포맷만 하고 끝내면 래핑 아님
fmt.Errorf("...: %w", err)원인 에러를 체인에 남길 때(Go 1.13+)Unwrap 가능 → errors.Is/As가 안쪽까지 따라감
package example
import (
    "errors"
    "fmt"
)
var ErrNotFound = errors.New("not found") // Sentinel — 같은 변수로 Is 비교
func badUser(id int) error {
    return fmt.Errorf("user %d not found", id) // OK이지만 Is(ErrNotFound)와 연결하려면 %w 필요
}
func goodUser(id int) error {
    return fmt.Errorf("user %d: %w", id, ErrNotFound) // 체인에 ErrNotFound 포함
}

Go 1.20부터는 fmt.Errorf에 %w를 여러 개 쓸 수 있고, errors.Join(err1, err2)로 여러 에러를 하나로 묶을 수도 있습니다. 검증 로직에서 필드 에러를 모두 모아 한 번에 돌려주거나, 정리 작업 중 발생한 여러 Close() 에러를 버리지 않고 합칠 때 유용합니다. errors.Is와 errors.As는 묶인 에러 전부를 탐색합니다.

커스텀 에러 타입과 errors.As

  • errors.Is는 값이 같은지(Sentinel 또는 Unwrap 체인) 볼 때 쓰며,
  • errors.As는 구체 타입으로 필드(예: HTTP 상태 코드, 에러 코드)를 꺼낼 때 씁니다.
  • 커스텀 타입은 Error() string만 구현해도 되고, 필요하면 Unwrap() error를 추가해 한 겹 더 감쌀 수 있습니다.
package example
import "fmt"
type AppError struct {
    Code    int
    Message string
    Err     error
}
func (e *AppError) Error() string {
    return fmt.Sprintf("code=%d: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error { return e.Err }

Unwrap()을 빼먹으면 AppError가 체인의 끝이 되어, 그 안에 들어 있는 os.ErrNotExist 같은 원인을 바깥에서 errors.Is로 찾을 수 없습니다. 반대로 일부러 원인을 숨기고 싶을 때는 Unwrap을 정의하지 않는 것이 하나의 방법입니다. 위 Error() 구현이 e.Err를 메시지에 포함하지 않는다는 점도 의식하고 결정해야 합니다. HTTP 응답에 내보낼 메시지와 로그에 남길 상세 원인을 분리하려는 의도라면 괜찮지만, 로그에서도 Error()만 찍는다면 원인이 사라집니다.

Sentinel 에러(전역 비교용) 실무 패턴

  • 패키지 상단에 var ErrXxx = errors.New(...) 로 선언해 의미를 고정합니다.
  • 호출부는 errors.Is(err, pkg.ErrNotFound)처럼 비교하며, 중간 함수들은 fmt.Errorf(“context: %w”, err)로 맥락만 덧붙입니다.
  • 같은 문장 문자열을 여러 번 errors.New로 만들면 Is가 실패합니다. errors.New는 호출할 때마다 서로 다른 포인터를 만들기 때문에 메시지가 같아도 다른 값입니다. 반드시 공유된 변수를 쓰세요.
  • err == ErrNotFound 같은 직접 비교는 래핑이 한 겹만 들어가도 거짓이 됩니다. Go 1.13 이전 코드를 옮길 때 이 비교를 errors.Is로 바꾸지 않으면 래핑을 도입하는 순간 분기 로직이 깨집니다.

문자열 strings.Contains(err.Error(), "not found") 비교는 최후 수단입니다. 메시지는 사람이 읽으라고 있는 것이어서 라이브러리 버전이 바뀌면 언제든 문구가 달라집니다. 로그는 최종적으로 사용자·운영에 남기는 한 곳에서만 상세하게 남기도록 팀 규칙으로 정해 두면 앞서 말한 중복 로깅 문제도 함께 줄어듭니다.


panic과 recover

panic: 복구 불가능한 오류

// C++: 예외 던지기
void divide(int a, int b) {
    if (b == 0) {
        throw std::invalid_argument("division by zero");
    }
    std::cout << a / b << "\n";
}
// Go: panic (일반적으로 지양)
func divide(a, b int) int {
    if b == 0 {
        panic("division by zero")  // recover하지 않으면 프로그램 종료
    }
    return a / b
}
// ✅ 권장: error 반환
func divideWithError(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

panic이 호출되면 현재 고루틴의 스택을 거슬러 올라가며 각 함수의 defer를 실행하고, 어디에서도 recover되지 않으면 스택 트레이스를 출력하고 종료 코드 2로 프로그램 전체를 끝냅니다. 중요한 점은 고루틴 단위라는 것입니다. main에서 recover를 걸어 두어도, 다른 고루틴에서 난 panic은 잡을 수 없고 프로세스 전체가 죽습니다. 서버에서 go func() { ... }()로 백그라운드 작업을 띄울 때 그 안에서 nil 맵 쓰기 같은 panic이 나면 서비스 전체가 내려가는 이유가 이것입니다.

recover: panic 복구

// Go: recover로 panic 복구
package main
import "fmt"
func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()
    
    result = a / b  // b=0이면 panic
    return result, nil
}
func main() {
    result, err := safeDivide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
    } else {
        fmt.Println("Result:", result)
    }
}

이 예제가 동작하는 이유도 이름 있는 반환값입니다. panic으로 함수가 중단되면 return result, nil은 실행되지 않지만, defer 안에서 err에 값을 대입하면 그것이 호출자에게 전달됩니다. 반환값에 이름이 없다면 defer에서 반환값을 바꿀 방법이 없습니다. 참고로 정수를 0으로 나누는 것은 런타임 panic(runtime error: integer divide by zero)이지만, 실수 나눗셈 10.0 / 0.0은 panic 없이 +Inf가 됩니다.

panic/recover는 언제 쓰나

상황권장
일반적인 실패(파일 없음, 네트워크 타임아웃, 잘못된 입력)error 반환
프로그래밍 오류(불변식 위반, nil 역참조, 도달하면 안 되는 분기)패닉 가능 — 가능하면 테스트로 걸러냄
초기화 시 반드시 성공해야 하는 값(정규식 MustCompile, template.Must)panic 허용되는 관용구
HTTP 서버 등에서 한 요청만 격리recover는 고루틴 경계에서만 의미 있음 — 미들웨어·핸들러 상단 패턴(#06 이후와 연결)

recover는 지연 함수(defer) 안에서 직접 호출될 때만 동작합니다. defer된 함수가 다시 호출한 헬퍼 함수 안에서 recover()를 부르면 nil이 반환됩니다. “조용히 삼키기”보다는 로그 남기고 error로 변환하거나 요청 단위로 500을 반환하는 식이 일반적입니다. 표준 net/http 서버는 이미 핸들러마다 panic을 recover하고 로그를 남긴 뒤 연결을 끊기 때문에, 핸들러 panic 하나로 서버가 죽지는 않습니다. 다만 그 핸들러가 띄운 고루틴은 보호받지 못합니다.

panic 사용 가이드

// ❌ 나쁜 예: 일반 에러에 panic 사용
func fetchUser(id int) *User {
    user, err := db.Query(id)
    if err != nil {
        panic(err)  // 나쁨!
    }
    return user
}
// ✅ 좋은 예: error 반환
func fetchUser(id int) (*User, error) {
    user, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("fetch user %d: %w", id, err)
    }
    return user, nil
}
// ✅ panic이 적절한 경우: 프로그래밍 오류
func mustCompile(pattern string) *regexp.Regexp {
    re, err := regexp.Compile(pattern)
    if err != nil {
        panic(fmt.Sprintf("invalid regex pattern: %s", pattern))
    }
    return re
}
// 초기화 시 사용 (프로그램 시작 시 실패해야 함)
var emailRegex = mustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)

Must 접두어 함수는 “입력이 소스 코드에 박힌 상수라서, 실패한다면 프로그래머의 실수”인 경우를 위한 관용구입니다. 정규식 패턴을 사용자 입력에서 받는다면 MustCompile이 아니라 Compile을 쓰고 에러를 반환해야 합니다. C++에서 예외를 흐름 제어처럼 쓰던 습관으로 라이브러리 내부에서 panic을 던지고 공개 함수 입구에서 recover해 error로 바꾸는 방식도 가능하고, 표준 라이브러리의 encoding/json이 내부적으로 그렇게 합니다. 하지만 이 panic이 패키지 경계를 넘어 새어 나가면 호출자에게는 예고 없는 크래시가 되므로, 쓰더라도 패키지 내부에 가둬야 합니다.


실습 과제

과제 1: 파일 복사 with defer

// Go: defer로 안전한 파일 복사
package main
import (
    "fmt"
    "io"
    "os"
)
func copyFile(src, dst string) error {
    // 원본 파일 열기
    srcFile, err := os.Open(src)
    if err != nil {
        return fmt.Errorf("open source: %w", err)
    }
    defer srcFile.Close()  // 반드시 닫힘
    
    // 대상 파일 생성
    dstFile, err := os.Create(dst)
    if err != nil {
        return fmt.Errorf("create destination: %w", err)
    }
    defer dstFile.Close()  // 반드시 닫힘
    
    // 복사
    _, err = io.Copy(dstFile, srcFile)
    if err != nil {
        return fmt.Errorf("copy: %w", err)
    }
    
    return nil
}
func main() {
    if err := copyFile("source.txt", "dest.txt"); err != nil {
        fmt.Fprintf(os.Stderr, "Copy failed: %v\n", err)
        os.Exit(1)
    }
    fmt.Println("Copy successful")
}

이 과제에는 일부러 남겨 둔 개선점이 하나 있습니다. 쓰기용 파일의 Close() 에러를 무시하고 있다는 점입니다. 읽기 전용 파일은 Close 에러를 무시해도 대개 문제가 없지만, 쓰기 파일은 운영체제나 네트워크 파일 시스템이 버퍼를 비우는 시점에 디스크 부족 같은 에러를 Close에서야 알려 주는 경우가 있습니다. 이때 copyFile은 성공을 반환했는데 파일이 잘려 있게 됩니다. 이름 있는 반환값을 써서 defer func() { if cerr := dstFile.Close(); cerr != nil && err == nil { err = cerr } }()처럼 Close 에러를 반영하거나, io.Copy 뒤에 dstFile.Sync()와 dstFile.Close()를 명시적으로 호출해 에러를 확인해 보세요.

과제 2: 커스텀 에러 타입

// Go: 커스텀 에러 타입
package main
import (
    "errors"
    "fmt"
    "strings"
)
type ValidationError struct {
    Field   string
    Value   string
    Message string
}
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error [%s=%s]: %s", 
        e.Field, e.Value, e.Message)
}
func validateEmail(email string) error {
    if email == "" {
        return &ValidationError{
            Field:   "email",
            Value:   email,
            Message: "email is required",
        }
    }
    
    if !strings.Contains(email, "@") {
        return &ValidationError{
            Field:   "email",
            Value:   email,
            Message: "invalid email format",
        }
    }
    
    return nil
}
func registerUser(email string) error {
    if err := validateEmail(email); err != nil {
        return fmt.Errorf("register user: %w", err)
    }
    
    // 등록 로직...
    return nil
}
func main() {
    err := registerUser("invalid")
    
    // 타입 확인
    var validationErr *ValidationError
    if errors.As(err, &validationErr) {
        fmt.Println("Validation failed:")
        fmt.Println("  Field:", validationErr.Field)
        fmt.Println("  Value:", validationErr.Value)
        fmt.Println("  Message:", validationErr.Message)
    }
}

validateEmail이 성공 경로에서 리터럴 nil을 반환하는 점을 확인하세요. 앞서 본 “nil인데 nil이 아닌 에러” 함정을 피하는 형태입니다. 여러 필드를 검증해야 한다면 첫 에러에서 멈추지 말고 errors.Join으로 모두 모아 반환하도록 바꿔 보는 것도 좋은 연습입니다.

과제 3: 에러 체인

// Go: 에러 체인 추적
package main
import (
    "errors"
    "fmt"
)
var (
    ErrDatabase   = errors.New("database error")
    ErrConnection = errors.New("connection error")
)
func connectDB() error {
    return ErrConnection
}
func queryUser(id int) error {
    if err := connectDB(); err != nil {
        return fmt.Errorf("query user %d: %w", id, err)
    }
    return nil
}
func getProfile(id int) error {
    if err := queryUser(id); err != nil {
        return fmt.Errorf("get profile: %w", err)
    }
    return nil
}
func main() {
    err := getProfile(123)
    
    fmt.Println("Error:", err)
    // 출력: Error: get profile: query user 123: connection error
    
    // 원본 에러 확인
    if errors.Is(err, ErrConnection) {
        fmt.Println("Root cause: connection error")
    }
}

queryUser의 %w를 %v로 바꿔 실행해 보면, 출력 문자열은 똑같은데 errors.Is 분기만 실행되지 않는 것을 확인할 수 있습니다. 문자열만 보고는 래핑이 끊긴 것을 알 수 없다는 점이 이 과제의 요점입니다.

과제 4: defer로 실행 시간 측정

// Go: defer로 함수 실행 시간 측정
package main
import (
    "fmt"
    "time"
)
func trace(name string) func() {
    start := time.Now()
    fmt.Printf("Entering %s\n", name)
    
    return func() {
        fmt.Printf("Exiting %s (took %v)\n", name, time.Since(start))
    }
}
func processData() {
    defer trace("processData")()
    
    fmt.Println("Processing...")
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Done")
}
func main() {
    processData()
}
// 출력(시간은 실행마다 조금씩 다름):
// Entering processData
// Processing...
// Done
// Exiting processData (took 100.2ms)

정리: Day 8~9 학습 체크리스트

완료해야 할 항목

  • error 타입과 if err != nil 패턴 숙지
  • 다중 반환값으로 에러 전달
  • defer로 자원 해제 보장 (RAII 대체)
  • defer의 LIFO 실행 순서 이해
  • fmt.Errorf와 %w로 에러 래핑
  • errors.New(Sentinel) vs fmt.Errorf·%w 구분
  • errors.Is, errors.As로 에러 검사
  • panic/recover는 제한적으로만 사용
  • 실습 과제 4개 완료

C++에서 Go로 전환 포인트

C++Go비고
throwreturn error명시적
try-catchif err != nil매 호출마다
RAIIdefer블록이 아니라 함수 단위
예외 전파에러 반환 체인명확한 흐름, 문맥은 직접 덧붙임
std::exceptionerror 인터페이스더 간결
스택 트레이스%w 래핑 메시지기본 에러에는 스택 정보 없음

다음 단계 예고

Day 8~9에서는 Go의 에러 처리를 배웠습니다. 다음 글에서는 고루틴과 채널을 다룹니다. 이번 글에서 본 “panic은 고루틴 단위로만 recover된다”는 성질이 동시성 코드에서 왜 중요한지도 이어서 확인합니다.


📚 시리즈 네비게이션

이전 글목차다음 글
← #04 인터페이스📑 전체 목차#06 고루틴·채널 →

Go 2주 완성 시리즈: 커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료


Go는 예외 대신 명시적 에러 반환으로 코드 흐름을 명확하게 하며, defer로 자원 해제를 보장합니다.

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. defer에 넘긴 변수 값이 나중에 바뀌면 어떤 값이 쓰이나요?

A. defer fmt.Println(x)처럼 함수 호출을 직접 defer하면 인자는 defer를 등록하는 순간 평가되므로, 이후에 x를 바꿔도 등록 시점의 값이 출력됩니다. 실행 시점의 값을 쓰고 싶다면 defer func() { fmt.Println(x) }()처럼 클로저로 감싸야 합니다. 이 차이는 defer 안에서 에러 변수를 로깅하거나 실행 시간을 측정할 때 자주 헷갈리는 부분입니다.