본문 바로가기
Golang

[Golang] Go의 defer, panic, recover: 예외 없는 오류 제어 메커니즘

by Tru5tC0der 2025. 11. 9.

Go 언어는 try-catchthrow 같은 예외(Exception) 구문을 제공하지 않습니다. 대신 단순하고 예측 가능한 흐름 제어를 위해 defer, panic, recover 세 가지 키워드를 사용합니다. 이는 Go의 핵심 철학 — 단순함(Simplicity), 명확성(Clarity), 가독성(Readability) — 을 그대로 반영한 설계입니다.


목차

  • 예외 처리의 철학: 왜 try-catch가 없는가
  • defer: 지연 실행과 자원 정리
  • panic: 런타임 에러의 명시적 발생
  • recover: 패닉에서의 복구
  • 세 키워드의 실행 순서와 동작 원리
  • 실무 예제: 안전한 파일 처리
  • 다른 언어와의 비교 분석
  • 결론: 단순함이 만드는 안정성

예외 처리의 철학: 왜 try-catch가 없는가

C++, Java, Python 등 대부분의 언어는 try-catch 구문으로 예외를 처리합니다. 하지만 Go는 의도적으로 예외(Exception) 메커니즘을 제거하고 명시적 오류 반환을 선택했습니다.

Go 설계자의 철학

"Errors are values." — Rob Pike

Go에서 오류(Error)는 예외적인 흐름이 아니라 하나의 값(Value)입니다. 즉, 함수는 오류를 반환하고 호출자는 이를 명시적으로 처리해야 합니다.

f, err := os.Open("config.json")
if err != nil {
    log.Fatal(err)
}
defer f.Close()

이 방식은 코드가 다소 길어지더라도, 프로그램의 모든 오류를 명확하게 드러내는 장점이 있습니다.

예외 없는 설계의 이점

1. 예측 가능한 제어 흐름

  • 예외로 인한 예상치 못한 점프가 없음
  • 모든 오류 경로가 명시적으로 표현됨

2. 성능 최적화

  • 예외 처리 오버헤드 제거
  • 스택 언와인딩 비용 없음

3. 코드 가독성

  • 오류 처리 로직이 명확히 보임
  • 함수의 실패 가능성이 시그니처에 드러남

defer: 지연 실행과 자원 정리

기본 개념

defer 키워드는 "이 함수가 종료될 때 실행하라"는 의미입니다. defer로 지정된 문장은 현재 함수가 return되기 직전에 실행됩니다.

package main

import "os"

func main() {
    f, err := os.Open("example.txt")
    if err != nil {
        panic(err)
    }

    // main 함수 마지막에 파일 close 실행
    defer f.Close()

    // 파일 읽기
    data := make([]byte, 1024)
    f.Read(data)
    println(string(data))
}

위 예제에서 defer f.Close()main 함수가 끝날 때 실행됩니다. 따라서 이후 코드에서 에러가 발생하더라도 항상 파일이 닫히는 것을 보장합니다.

defer의 특징

특성 설명 예시
실행 시점 함수 종료 직전 (return 직전) 리소스 정리
실행 순서 후입선출(LIFO) 마지막 defer가 먼저 실행
주 사용처 파일 닫기, 락 해제, 리소스 정리 defer f.Close()
유사 개념 C#/Java의 finally 블록 정리 작업 보장

defer 실행 순서

func multipleDefers() {
    defer println("first")
    defer println("second") 
    defer println("third")
}
// 출력:
// third
// second
// first

defer의 고급 사용법

1. 함수 파라미터 캡처

func deferExample() {
    x := 1
    defer fmt.Println(x) // x=1이 캡처됨

    x = 2
    defer fmt.Println(x) // x=2가 캡처됨
}
// 출력: 2, 1

2. 명명된 반환값 수정

func namedReturn() (result int) {
    defer func() {
        result++
    }()
    return 1
}
// 반환값: 2 (defer에서 1 증가)

3. 뮤텍스 자동 해제

var mu sync.Mutex

func safeOperation() {
    mu.Lock()
    defer mu.Unlock() // 함수 종료 시 반드시 해제

    // 크리티컬 섹션
    criticalWork()
}

panic: 런타임 에러의 명시적 발생

panic의 핵심 개념

Go의 내장 함수 panic()은 프로그램을 즉시 중단시키는 함수입니다. 현재 함수의 실행을 멈추고, 등록된 defer들을 모두 실행한 뒤 상위 함수로 전파됩니다.

package main

import "os"

func main() {
    // 잘못된 파일명을 사용
    openFile("invalid-file.txt")

    // openFile() 안에서 panic이 실행되면
    // 아래 println 문장은 실행되지 않음
    println("Done")
}

func openFile(filename string) {
    f, err := os.Open(filename)
    if err != nil {
        panic(err)
    }

    defer f.Close()
    // 파일 처리 로직...
}

panic 사용 시점

적절한 사용

  • 프로그램이 계속 실행될 수 없는 치명적 오류
  • 라이브러리 내부의 불변성 위반
  • 개발 중 디버깅 목적

부적절한 사용

  • 일반적인 오류 상황
  • 사용자 입력 검증 실패
  • 네트워크 오류 등 예상 가능한 실패

panic의 전파 과정

func level1() {
    defer fmt.Println("level1 defer")
    level2()
    fmt.Println("level1 after level2") // 실행되지 않음
}

func level2() {
    defer fmt.Println("level2 defer")
    level3()
    fmt.Println("level2 after level3") // 실행되지 않음
}

func level3() {
    defer fmt.Println("level3 defer")
    panic("something went wrong")
    fmt.Println("level3 after panic") // 실행되지 않음
}

// 출력:
// level3 defer
// level2 defer  
// level1 defer
// panic: something went wrong

recover: 패닉에서의 복구

recover의 핵심 개념

recover()panic으로 인해 발생한 패닉 상태를 복원하는 내장 함수입니다. 단, recover는 반드시 defer 함수 안에서 호출해야 합니다.

package main

import (
    "fmt"
    "os"
)

func main() {
    // 잘못된 파일명을 사용
    openFile("invalid-file.txt")

    // recover에 의해 이 문장이 실행됨
    println("Program continues...")
}

func openFile(filename string) {
    // defer 함수: panic 발생 시 실행됨
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("OPEN ERROR:", r)
        }
    }()

    f, err := os.Open(filename)
    if err != nil {
        panic(err)
    }

    defer f.Close()
    // 파일 처리 로직...
}

위 예제에서는 os.Open()이 실패하면 panic이 발생하지만, recover를 통해 프로그램이 비정상 종료되지 않고 정상 흐름으로 복귀합니다.

recover 사용 패턴

1. 기본 패턴

func safeOperation() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Recovered from: %v\n", r)
        }
    }()

    // 위험한 작업 수행
    dangerousOperation()
}

2. 에러로 변환하는 패턴

func safeCall() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic occurred: %v", r)
        }
    }()

    riskyOperation()
    return nil
}

3. 웹 서버 미들웨어 패턴

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Handler panic: %v", err)
                http.Error(w, "Internal Server Error", 500)
            }
        }()

        next.ServeHTTP(w, r)
    })
}

세 키워드의 실행 순서와 동작 원리

실행 순서 예제

func executionOrder() {
    defer println("defer 1")
    defer println("defer 2")

    fmt.Println("Before panic")
    panic("Something went wrong")

    defer println("defer 3") // 실행되지 않음
    fmt.Println("After panic") // 실행되지 않음
}

// 출력:
// Before panic
// defer 2
// defer 1  
// panic: Something went wrong

동작 원리 요약

단계 동작 설명
defer 등록 함수 실행 중 defer 문 만남 스택에 등록, 즉시 실행 안 함
panic 발생 panic() 함수 호출 현재 함수 실행 즉시 중단
defer 실행 등록된 defer들을 LIFO 순서로 실행 자원 정리 작업 수행
recover 체크 defer 내부에서 recover() 호출 시 panic 상태를 포착하고 복구
전파 또는 복구 recover 없으면 상위로 전파 프로그램 종료 또는 정상 복귀

panic과 recover의 상호작용

func panicRecoverFlow() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("Recovered: %v\n", r)
            // 여기서 다시 panic을 발생시킬 수도 있음
            // panic(fmt.Sprintf("re-panic: %v", r))
        }
    }()

    defer fmt.Println("Second defer")
    defer fmt.Println("First defer")

    panic("Original panic")

    fmt.Println("This will not be printed")
}

// 출력:
// First defer
// Second defer
// Recovered: Original panic

실무 예제: 안전한 파일 처리

예제 1: 기본적인 파일 처리

func processFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return fmt.Errorf("failed to open file: %w", err)
    }
    defer file.Close()

    data := make([]byte, 1024)
    n, err := file.Read(data)
    if err != nil {
        return fmt.Errorf("failed to read file: %w", err)
    }

    fmt.Printf("Read %d bytes: %s\n", n, string(data[:n]))
    return nil
}

예제 2: panic과 recover를 활용한 안전한 처리

func safeFileProcessor(filename string) {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("파일 처리 중 오류 발생: %v\n", r)
        }
    }()

    file, err := os.Open(filename)
    if err != nil {
        panic(fmt.Sprintf("파일 열기 실패: %v", err))
    }
    defer file.Close()

    data := make([]byte, 1024)
    _, err = file.Read(data)
    if err != nil {
        panic(fmt.Sprintf("파일 읽기 실패: %v", err))
    }

    // JSON 파싱 등 추가 처리...
    processData(data)

    fmt.Println("파일 처리 완료")
}

func processData(data []byte) {
    // 데이터 처리 중 panic 발생 가능
    if len(data) == 0 {
        panic("빈 데이터입니다")
    }

    // 실제 처리 로직...
}

예제 3: 데이터베이스 트랜잭션 처리

func executeTransaction(db *sql.DB, queries []string) (err error) {
    tx, err := db.Begin()
    if err != nil {
        return fmt.Errorf("트랜잭션 시작 실패: %w", err)
    }

    defer func() {
        if r := recover(); r != nil {
            tx.Rollback()
            err = fmt.Errorf("트랜잭션 중 panic 발생: %v", r)
        } else if err != nil {
            tx.Rollback()
        } else {
            err = tx.Commit()
        }
    }()

    for _, query := range queries {
        _, err := tx.Exec(query)
        if err != nil {
            return fmt.Errorf("쿼리 실행 실패: %w", err)
        }
    }

    return nil
}

예제 4: 웹 서버에서의 안전한 핸들러

func safeHandler(handler http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Handler panic: %v\n%s", err, debug.Stack())
                http.Error(w, "Internal Server Error", 500)
            }
        }()

        handler(w, r)
    }
}

// 사용 예
func main() {
    http.HandleFunc("/api/data", safeHandler(dataHandler))
    http.ListenAndServe(":8080", nil)
}

func dataHandler(w http.ResponseWriter, r *http.Request) {
    // 위험한 작업 수행
    data := processRequest(r)
    w.Write(data)
}

다른 언어와의 비교 분석

언어별 예외 처리 메커니즘

언어 예외 구조 복구 방식 설계 철학
Java try-catch-finally 예외 객체 기반 캡슐화된 예외 처리
Python try-except-finally 동적 예외 처리 표현력 중심
C# try-catch-finally 강타입 예외 안전성과 생산성
Rust Result<T, E> 타입 기반 메모리 안전성
Go defer-panic-recover 값 기반 복구 단순함 + 명시성

Java vs Go 비교

Java의 예외 처리

// Java
public String readFile(String filename) throws IOException {
    FileReader file = null;
    try {
        file = new FileReader(filename);
        // 파일 읽기 로직
        return content;
    } catch (IOException e) {
        logger.error("File read error", e);
        throw e;
    } finally {
        if (file != null) {
            try {
                file.close();
            } catch (IOException e) {
                logger.error("File close error", e);
            }
        }
    }
}

Go의 명시적 오류 처리

// Go
func readFile(filename string) (string, error) {
    file, err := os.Open(filename)
    if err != nil {
        return "", fmt.Errorf("파일 열기 실패: %w", err)
    }
    defer func() {
        if closeErr := file.Close(); closeErr != nil {
            log.Printf("파일 닫기 실패: %v", closeErr)
        }
    }()

    data, err := io.ReadAll(file)
    if err != nil {
        return "", fmt.Errorf("파일 읽기 실패: %w", err)
    }

    return string(data), nil
}

Python vs Go 비교

Python의 예외 처리

# Python
def process_data(data):
    try:
        result = risky_operation(data)
        return result
    except ValueError as e:
        print(f"값 오류: {e}")
        return None
    except Exception as e:
        print(f"예상치 못한 오류: {e}")
        return None
    finally:
        cleanup_resources()

Go의 함수형 오류 처리

// Go
func processData(data []byte) (interface{}, error) {
    defer cleanupResources()

    result, err := riskyOperation(data)
    if err != nil {
        // 구체적인 오류 타입 확인
        if errors.Is(err, ErrInvalidValue) {
            return nil, fmt.Errorf("값 오류: %w", err)
        }
        return nil, fmt.Errorf("예상치 못한 오류: %w", err)
    }

    return result, nil
}

Rust vs Go 비교

Rust의 Result 타입

// Rust
use std::fs::File;
use std::io::Read;

fn read_file(filename: &str) -> Result<String, std::io::Error> {
    let mut file = File::open(filename)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

Go의 간단한 오류 처리

// Go
func readFile(filename string) (string, error) {
    file, err := os.Open(filename)
    if err != nil {
        return "", err
    }
    defer file.Close()

    contents, err := io.ReadAll(file)
    if err != nil {
        return "", err
    }

    return string(contents), nil
}

결론: 단순함이 만드는 안정성

Go의 오류 제어는 다음 세 가지 원칙을 지향합니다:

1. 명시적 오류 처리 (Explicit over Implicit)

// 모든 오류가 명시적으로 처리됨
func processFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return err // 오류를 명시적으로 반환
    }
    defer file.Close()

    // 추가 처리 로직...
    return nil
}

2. 자원 정리의 보장

// defer를 통한 확실한 자원 정리
func databaseOperation() error {
    db, err := sql.Open("mysql", connectionString)
    if err != nil {
        return err
    }
    defer db.Close() // 함수 종료 시 반드시 실행

    // 데이터베이스 작업...
    return nil
}

3. 제어 가능한 복구

// 필요한 경우에만 panic/recover 사용
func criticalOperation() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("치명적 오류 발생: %v", r)
        }
    }()

    // 위험한 작업 수행
    performRiskyTask()
    return nil
}

Go의 오류 처리가 주는 이점

1. 예측 가능성

  • 모든 오류 경로가 명시적으로 표현됨
  • 예상치 못한 제어 흐름 점프가 없음

2. 성능

  • 예외 처리 오버헤드 제거
  • 빠른 실행 속도

3. 유지보수성

  • 간단하고 이해하기 쉬운 코드
  • 오류 처리 로직이 명확히 보임

4. 안정성

  • 자원 누수 방지
  • 예상 가능한 프로그램 동작

모범 사례 요약

defer 사용 시:

  1. 자원을 획득한 직후에 defer로 해제 코드를 작성
  2. 여러 defer가 있을 때 실행 순서(LIFO)를 고려
  3. defer 내에서 에러를 처리할 때는 로깅을 고려

panic 사용 시:

  1. 정말 복구 불가능한 상황에만 사용
  2. 라이브러리 코드에서는 가급적 panic 대신 error 반환
  3. main 함수에서는 panic 보다는 log.Fatal 사용 고려

recover 사용 시:

  1. 반드시 defer 함수 내에서만 호출
  2. recover 후에는 적절한 정리 작업 수행
  3. 필요시 panic을 error로 변환하여 반환

Go는 복잡한 try-catch 대신 예측 가능한 단순함으로 안정성을 확보합니다. 이는 "적은 것이 많은 것"이라는 Go의 철학을 잘 보여주는 사례입니다.


참고 자료: