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

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

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

이 글의 핵심

C++ try-catch 대신 Go의 명시적 에러 핸들링을 배웁니다. 다중 반환값, if err != nil 패턴, defer로 자원 해제, panic/recover의 올바른 사용법까지 실전 예제로 학습합니다.

시리즈 안내

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

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

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


💡 초보자를 위한 한 줄: Go에는 try-catch가 없습니다. 에러는 error 타입으로 반환하고 if err != nil즉시 처리합니다. defer는 “함수 끝날 때 실행”으로 파일 닫기/락 해제에 씁니다. panic은 C++의 abort()처럼 “프로그램 죽이기”이므로 웬만하면 안 씁니다.

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

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

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

편리하지만, 코드만 봐서는 어디서 예외가 발생할지 알기 어렵습니다.

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

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

처음에는 if err != nil이 계속 반복되어 번거로워 보이지만, 코드의 흐름이 명확해지고 “예상치 못한 예외로 프로그램이 죽는 일”이 줄어듭니다. 이 글에서 배울 내용

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

C++ 개발자 관점: C++ 백그라운드에서 Go로 전환하며 겪은 차이점과 함정을 중심으로 설명합니다. 포인터, 동시성, 메모리 관리 등 핵심 개념을 비교하며 정리했습니다.

실무에서의 체감

C++ 위주로 서버를 다루던 환경에서 Go를 도입할 때 흔히 드는 인상은 문법과 툴체인이 단순해 보인다는 점입니다. 프로덕션에서는 그 단순함이 빌드·배포·동시성 코드 가독성으로 이어지는 경우가 많습니다. 자주 언급되는 장점

  • 개발 속도: 팀·도메인에 따라 다르지만, 네트워크·CLI 코드를 빠르게 완성하기 쉽습니다.
  • 안정성: GC가 있어 수동 할당 해제 부담이 줄어듭니다.
  • 배포: 단일 바이너리로 옮기기 쉬운 구조입니다.

1. 에러를 값으로: 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는 예외를 던지지 않습니다
  • 에러는 반환값으로 전달됩니다
  • 호출자는 반드시 에러를 확인해야 합니다

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
}

2. if err != nil 패턴

기본 패턴

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

에러 처리 변형

// 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)
}

3. 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()
}

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

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  (실행 시점의 값)

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. 트랜잭션 롤백
func transaction() error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()
    
    // 작업...
    if err := doWork(tx); err != nil {
        return err  // defer에서 Rollback 실행
    }
    
    return tx.Commit()
}

4. 에러 래핑과 언래핑

에러 래핑 (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
    }
}

errors.Is와 errors.As

// Go: 특정 에러 확인
package main
import (
    "errors"
    "fmt"
    "os"
)
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가 따라가지 못합니다.

5. 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 포함
}

커스텀 에러 타입과 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 }

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

  • 패키지 상단에 var ErrXxx = errors.New(...) 로 선언해 의미를 고정합니다.
  • 호출부는 errors.Is(err, pkg.ErrNotFound)처럼 비교하며, 중간 함수들은 fmt.Errorf(“context: %w”, err)로 맥락만 덧붙입니다.
  • 같은 문장 문자열을 여러 번 errors.New로 만들면 Is가 실패합니다. 반드시 공유된 변수를 쓰세요.

에러 래핑 실전 체크리스트

  1. 감싸기: 실패 지점에서 return fmt.Errorf("작업 X: %w", err).
  2. 판별: 바깥에서는 errors.Is / errors.As만 사용(문자열 Contains 비교는 최후 수단).
  3. 로그: 최종적으로 사용자·운영에 남길 때 한 번만 상세 스택이 필요한지 팀 규칙으로 정합니다.

6. 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")  // 프로그램 종료
    }
    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
}

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/recover는 언제 쓰나

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

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,}$`)

7. 실습 과제

과제 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")
}

과제 2: 커스텀 에러 타입

// Go: 커스텀 에러 타입
package main
import (
    "errors"
    "fmt"
)
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)
    }
}

과제 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")
    }
}

과제 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 100ms)

정리: 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 인터페이스더 간결

다음 단계 예고

Day 8~9에서는 Go의 에러 처리를 배웠습니다. 다음 글에서는 Go의 정말 강점인 고루틴과 채널을 다룹니다. C++ 개발자가 Go에 열광하는 이유를 직접 체험하게 됩니다!

📚 시리즈 네비게이션

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

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

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Go error 처리, if err != nil, defer Go, panic recover, fmt.Errorf, C++ 예외 비교, Golang 에러 핸들링, Go 2주 완성 등으로 검색하시면 이 글이 도움이 됩니다.

실전 팁 (Go)

  • go fmt, go vet, 필요 시 golangci-lint로 기본 품질을 맞춥니다.
  • if err != nil을 습관화합니다. 에러를 삼키면 운영에서 원인 추적이 어렵습니다.
  • go test ./...로 패키지 단위 회귀를 확인합니다. 성능은 go test -bench측정할 수 있을 때만 다룹니다.

실전 체크리스트 (Go)

코드

  • 외부 호출·I/O 실패 시 에러가 처리되거나 로깅되는가?
  • 고루틴·채널 사용 시 블로킹·누수 가능성을 검토했는가?

모듈

  • go.mod / go.sum이 팀과 합의된 범위인가?

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ try-catch 대신 Go의 명시적 에러 핸들링을 배웁니다. 다중 반환값, if err != nil 패턴, defer로 자원 해제, panic/recover의 올바른 사용법까지 실전 예제로 학습합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

관련 글