본문으로 건너뛰기

Go defer·error·클로저 완전 정리 — LIFO, 에러 래핑, 루프 변수 함정

이 글의 순서10개 절

Go는 예외를 뺐고, 그 자리를 defer와 값으로서의 error로 채웠다. 셋 다 문법은 30초면 배우는데 함정은 몇 년째 나온다. 각각이 정확히 언제 무엇을 붙잡는지를 기준으로 다시 정리했다.

defer: 실행은 미뤄도 인자는 미루지 않는다

defer를 함수가 끝날 때 실행된다고만 외우면 절반이다. 정확히는 이 호출을 함수 끝에 실행해 달라고 지금 예약하는 것이고, 예약하는 그 순간에 인자가 전부 평가되어 박제된다.

① 등록할 때: 인자가 이 순간 평가된다i := 0defer fmt.Println(“A:”, i)↳ 인자 0을 박제defer func() { … i … }()↳ 변수 i를 붙잡음i = 1← 여기서 i가 바뀐다② 함수가 끝날 때: 나중에 등록한 것부터 (LIFO)먼저 실행 · 나중에 등록func() { … i … }B: 1실행 시점의 i를 읽는다나중 실행 · 먼저 등록fmt.Println(“A:”, 0)A: 0등록 시점에 박제된 값출력: B: 1 → A: 0
같은 i를 쓰는데 결과가 다르다. 인자냐 클로저냐의 차이다
인자와 클로저
i := 0
defer fmt.Println("A:", i) // 인자 평가: 0이 박제된다
defer func() { fmt.Println("B:", i) }() // 클로저: 변수 i를 붙잡는다
i = 1
// 출력: B: 1 → A: 0 (LIFO)

잘 안 짚이는 게 하나 더 있다. 메서드 리시버도 인자다. defer mu.Unlock()에서 mu가 무엇을 가리키는지도 등록 시점에 정해진다. 값 리시버라면 그 시점의 복사본이 잡히고, 이건 1편에서 본 락이 복사되면 무력해진다는 이야기와 그대로 이어진다.

그래서 실행 시점의 값이 필요하면 클로저로 감싸고, 등록 시점의 값을 고정하고 싶으면 인자로 넘긴다. 후자가 쓸모 있을 때도 있는데, 루프에서 defer cleanup(id)처럼 그 순간의 id를 붙잡아 두고 싶을 때다.

defer의 진짜 비용: 루프 안에 넣으면 두 번 손해다

defer는 느리다는 말은 이제 반쯤 틀렸다. Go 1.14부터 open-coded defer가 들어가면서, 조건이 맞으면 컴파일러가 defer를 함수 끝에 그냥 펼쳐 넣는다. 런타임 자료구조를 아예 쓰지 않으니 비용이 거의 없다.

문제는 그 조건이 깨질 때다. 대표적인 게 루프 안의 defer인데, 여기서 손해가 두 겹으로 온다.

✓조건이 맞으면: open-coded

  • 컴파일러가 함수 끝에 호출을 그대로 펼쳐 넣는다
  • 런타임 자료구조가 없어서 비용이 사실상 없다
  • 조건: 함수 안 defer가 8개 이하, 루프 안 defer가 없을 것, return 문 수 × defer 수가 15 이하

✕루프 안에 있으면: 힙에 쌓인다

  • 반복마다 defer 레코드를 힙에 만들어 리스트로 잇는다
  • ① 할당 비용이 생기고
  • ② 함수가 끝날 때까지 실행이 쌓인다. 파일 핸들이 그때까지 닫히지 않는다

루프 안 defer를 피하라는 말은 이 컴파일러 동작에서 나왔다. 해결은 알려진 대로 루프 본문을 익명 함수로 감싸는 것이다. 그러면 반복마다 그 익명 함수가 끝나면서 바로 정리되고, defer도 루프 밖으로 나가 open-coding 대상이 된다.

루프 안의 defer
// ✕ 파일이 하나도 닫히지 않은 채 루프가 끝날 때까지 쌓인다
for _, name := range files {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // 함수가 끝나야 실행된다 → 핸들 고갈
use(f)
}
// ✓ 본문을 함수로 떼어 낸다: 반복마다 바로 닫힌다
for _, name := range files {
if err := func() error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // 이 익명 함수가 끝날 때 실행
return use(f)
}(); err != nil {
return err
}
}

defer를 믿으면 안 되는 순간들

defer를 무조건 실행되는 정리 코드로 믿고 쓰다가 데인 적이 있다. 실행되지 않는 경우가 분명히 있다.

상황 defer 실행 여부
정상 return 실행
panic 실행. 스택을 되감으며 역순으로
runtime.Goexit 실행. 이 고루틴의 defer를 전부 돌린 뒤 종료
os.Exit 실행 안 됨. 즉시 프로세스 종료
log.Fatal 계열 실행 안 됨. 안에서 os.Exit(1)을 부른다
fatal error 실행 안 됨. 맵 동시 쓰기 등

아래 세 줄이 중요하다. log.Fatal은 이름은 로깅처럼 생겼지만 실제로는 프로세스를 즉시 끝낸다. 편하다고 에러 처리 자리에 뿌려 두면 defer로 걸어 둔 정리 코드가 통째로 날아간다. 그래서 main 함수 맨 바깥 말고는 쓰지 않는 게 안전하다. 2편에서 본 맵 동시 쓰기 fatal error도 같은 부류다. defer도 recover도 통하지 않는다.

defer f.Close()가 조용히 삼키는 것

이건 실행은 되는데 결과를 버리는 경우다. Close()는 에러를 반환하는데 defer f.Close()는 그걸 그냥 버린다. 읽기 전용이면 대개 상관없지만, 쓰기 파일이라면 얘기가 완전히 달라진다. 버퍼를 실제로 디스크에 내려보내는 게 Close 시점일 수 있고, 여기서 실패하면 데이터가 사라진다. 함수는 nil을 리턴했는데 파일은 깨져 있는 상태다.

Close 에러를 반환값에 합치기
// named return + defer 로 Close 에러를 반환값에 합류시킨다
func save(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr // 본문이 성공했을 때만 Close 에러를 올린다
}
}()
_, err = f.Write(data)
return err
}

여기서 named return이 핵심이다. 반환값에 이름이 있어야 defer 안에서 그 값을 고칠 수 있고, 이 패턴은 바로 다음 절에서 panic을 error로 바꾸는 코드에도 그대로 쓰인다.

panic과 recover: 되감기와 세 가지 조건

panic이 나면 먼저 스택을 되감으면서 각 프레임의 defer를 역순으로 실행한다. 그 도중 어떤 defer 안에서 recover가 호출되면 되감기가 거기서 멈추고, 그 함수는 정상적으로 리턴한 것처럼 처리된다.

parse()panic 발생 · 여기서 되감기가 시작된다handle()defer 안에서 recover() · 되감기가 멈춘다main()handle이 그냥 리턴한 것처럼 보인다되감기되감는 동안 각 프레임의 defer가 역순으로 실행된다
recover는 defer된 함수가 직접 부를 때, 같은 고루틴 안에서만 되감기를 멈춘다

recover가 동작하려면 세 조건이 전부 맞아야 한다.

  1. defer된 함수 안에서
  2. 그 함수가 recover()를 직접 호출하고
  3. panic이 난 것과 같은 고루틴이어야 한다.

두 번째 조건이 특히 함정이다. 한 단계만 건너뛰어도 무효다. 공통 에러 처리를 헬퍼로 빼려다 조용히 망가지는 코드가 여기서 나온다.

세 번째 조건은 더 무섭다. 고루틴 하나가 panic으로 죽으면 프로그램 전체가 죽는다. 아무리 위에서 recover를 걸어 놔도 다른 고루틴의 panic은 잡히지 않는다. 그래서 서버라면 고루틴을 띄우는 자리마다 방어를 넣어야 한다.

panic을 error로, 고루틴 경계마다 방어
// panic을 error로 바꿔 내보내는 표준 패턴: named return + recover
func safeParse(b []byte) (result Node, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse panic: %v", r) // named return을 덮어쓴다
}
}()
return parse(b), nil // parse 안에서 panic이 나도 여기서 error가 된다
}
// 고루틴 경계마다 방어: 안 하면 하나가 죽을 때 프로세스가 같이 죽는다
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("worker panic: %v\n%s", r, debug.Stack())
}
}()
doWork()
}()

그렇다면 panic은 언제 쓰는 게 맞을까. 기준은 호출자가 이걸 처리해서 계속 진행할 수 있느냐다.

✓panic이 맞는 자리

  • 복구할 수 없는 초기화 실패 (MustCompile 계열)
  • 불변 조건이 깨진 프로그래머 실수
  • 재귀가 깊은 파서에서 바닥까지 한 번에 빠져나올 때

✕panic이 틀린 자리

  • 입력 검증 실패, 네트워크·파일 오류, 조회 결과 없음
  • 예상할 수 있는 실패는 전부 error다
  • panic을 제어 흐름으로 쓰면 호출자가 대응할 방법이 사라진다

세 번째 경우만 덧붙이면, 표준 라이브러리도 재귀가 깊은 파서 안에서는 panic으로 한 번에 빠져나온 뒤 패키지 경계에서 반드시 error로 바꿔 내보낸다. 핵심은 panic이 패키지 밖으로 새지 않는다는 것이다. 내부 구현으로 쓰는 건 괜찮지만, 공개 API가 panic을 던지면 안 된다.

🧪 직접 해보기

recover가 조용히 실패하는 경우

직접 호출 조건이 진짜인지 확인하려고, 헬퍼로 뺀 버전과 직접 호출한 버전을 나란히 돌려 봤다.

직접 호출과 한 단계 건너뛴 호출
func cleanup() {
if r := recover(); r != nil {
fmt.Println("잡았다:", r)
}
}
func direct() { defer cleanup(); panic("boom") } // 직접 호출
func indirect() { defer func() { cleanup() }(); panic("boom") } // 한 단계 건너뜀
실행 결과
direct() → 잡았다: boom
indirect() → panic: boom [프로그램 종료]

같은 cleanup 함수인데 defer cleanup()이면 잡히고 defer func() { cleanup() }()이면 안 잡힌다. 한 겹 감싼 것만으로 방어가 사라지는데, 컴파일 에러도 경고도 없다.

덤으로 알게 된 것 하나. Go 1.21부터 panic(nil)은 그대로 통과하지 않고 *runtime.PanicNilError 패닉으로 바뀐다. 예전에는 recover()가 nil을 돌려줘서 패닉 중인데도 패닉이 아니라고 오판하는 함정이 있었는데, 언어 차원에서 막아 둔 셈이다.

error는 값이다: 예외를 뺀 자리

Go에서 error는 메서드 하나짜리 인터페이스다. 1편에서 본 작은 인터페이스 문화의 극단적인 예이기도 하다.

error의 정의
type error interface {
Error() string
}
// 이게 전부다. 언어 차원의 특별 취급은 없고, 그냥 값이다

예외를 뺐다는 건 문법을 뺐다는 게 아니라 보이지 않는 제어 흐름을 뺐다는 뜻이다. 예외가 있는 언어에서는 어떤 함수 호출이든 그 줄에서 실행이 튀어 나갈 수 있고, 그게 코드에 보이지 않는다. Go에서는 실패할 수 있는 모든 지점이 if err != nil로 눈에 드러난다.

예외 (try/catch) 값 (Go)
에러 경로 코드에 보이지 않는다 모든 지점이 보인다
무시하면 위로 자동 전파된다 명시적으로 버려야 한다
조합 언어 문법에 묶인다 그냥 값이라 자유롭게 다룬다
대가 디버깅이 어렵다 장황하다

마지막 줄이 정직한 맞바꿈이다. if err != nil이 지겹다는 비판은 사실이고, Go 팀도 이걸 안다. 다만 지겨운 대신 빠뜨릴 수 없는 쪽을 택한 것이다.

사소해 보이지만 팀에서 자주 어긋나는 규칙 하나. 에러 문자열은 소문자로 시작하고 마침표를 찍지 않는다. 위에서 fmt.Errorf("saving user: %w", err)처럼 앞에 문맥을 계속 붙여 나가는 구조라서, 중간에 대문자나 마침표가 끼면 문장이 어색하게 끊긴다.

에러 체인: %w가 만드는 계약

에러를 위로 올릴 때 문맥을 덧붙이고 싶은데, 원인도 잃고 싶지 않다. 그래서 나온 게 %w다. %v는 원인을 문자열로 녹여 버리지만, %w는 원본 값을 그대로 품은 채 감싼다.

%w · 체인이 이어진다“주문 처리: %w”핸들러가 붙인 문맥“user 42 조회: %w”리포지토리가 붙인 문맥sql.ErrNoRows진짜 원인 · 센티널errors.Is → true%v · 여기서 끊긴다“주문 처리: %v”원인은 문자열로만 남는다✕ 체인이 끊긴다sql.ErrNoRows프로그램이 찾을 수 없다errors.Is → false
사람이 읽는 메시지는 같아 보여도, 프로그램이 다룰 수 있느냐가 갈린다
Is, As, Join
var ErrNotFound = errors.New("not found") // 센티널
func find(id string) error {
return fmt.Errorf("find %s: %w", id, ErrNotFound) // 체인 보존
}
// ① 같은 에러인가: 몇 겹 감싸져 있어도 판별된다
if errors.Is(err, ErrNotFound) { /* 404로 응답 */ }
// ② 이 타입인가 + 구체 값 꺼내기
var pe *fs.PathError
if errors.As(err, &pe) { log.Println(pe.Path) }
// ③ 여러 에러를 한 번에 (Go 1.20+)
joined := errors.Join(errValidate, errNotify) // 둘 다 Is로 판별된다
both := fmt.Errorf("두 곳 실패: %w, %w", errValidate, errNotify) // %w 여러 개도 된다

동작 원리는 단순하다. errors.Is와 errors.As는 Unwrap()을 계속 호출하며 체인을 타고 내려가면서 비교한다. 그래서 내가 만든 에러 타입에 Unwrap()을 붙이면 그 순간 체인에 참여하게 되고, Is(target error) bool 메서드를 직접 구현하면 비교 방식까지 바꿀 수 있다.

그런데 %w는 공짜가 아니다

%w로 감싸는 순간 그 하위 에러는 사실상 공개 API 계약이 된다. 호출자가 errors.Is(err, sql.ErrNoRows)로 판별하기 시작하면, 나중에 내가 DB 드라이버를 바꾸는 순간 남의 코드가 조용히 깨진다.

전략 언제 쓰나
%w로 감싸기 같은 패키지·모듈 안에서. 호출자가 원인을 알아야 대응할 수 있을 때
%v로 끊기 패키지·서비스 경계에서 내부 구현을 숨길 때. 어떤 드라이버를 쓰는지가 밖으로 새면 안 된다
도메인 에러로 번역 경계에서 가장 좋은 선택. sql.ErrNoRows를 내 ErrUserNotFound로 바꿔 올린다

✕내부 구현이 계약으로 샌다

func (r *Repo) GetUser(id string) (*User, error) {
// 호출자가 sql.ErrNoRows에 기대게 된다
return nil, fmt.Errorf("get user %s: %w", id, sql.ErrNoRows)
}

✓경계에서 도메인 에러로 번역

var ErrUserNotFound = errors.New("user not found")
func (r *Repo) GetUser(id string) (*User, error) {
var u User
err := r.db.QueryRow(q, id).Scan(&u.Name)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user %s: %w", id, ErrUserNotFound)
}
if err != nil {
return nil, fmt.Errorf("get user %s: %v", id, err) // 드라이버 에러는 끊는다
}
return &u, nil
}

나는 %w를 기본값처럼 쓰고 있었는데, 이걸 알고 나서 감쌀 때마다 한 번 멈추게 됐다. 이 하위 에러를 앞으로도 계속 보장할 수 있는지 묻고, 보장할 수 있으면 %w, 아니면 경계에서 끊거나 내 도메인 에러로 바꿔 올린다.

클로저: 바깥 변수를 그대로 붙잡는다

클로저는 함수와 그 함수가 붙잡은 환경을 묶은 것인데, 이때 값을 복사해 두지 않고 변수 자체를 붙잡는다. 그래서 바깥에서 그 변수가 바뀌면 클로저가 보는 값도 바뀐다.

함수가 끝났는데 캡처한 변수가 왜 살아 있는지는 escape analysis가 답한다. 컴파일러는 그 값의 수명이 함수 프레임을 넘어가는지를 본다. 아래 counter처럼 클로저가 반환되면 n은 프레임과 함께 사라질 수 없으니 힙으로 올라가고, 클로저 값은 함수 코드 포인터와 캡처한 n의 주소를 들고 다닌다.

클로저의 쓰임
// 상태를 가진 함수: n이 힙으로 올라가 살아남는다
func counter() func() int {
n := 0
return func() int { n++; return n } // n을 참조로 캡처
}
c := counter()
c(); c() // 1, 2: 호출마다 상태가 이어진다
// 미들웨어: 핸들러를 감싸는 클로저 (데코레이터 패턴)
func withLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Println(r.URL.Path, time.Since(start)) // next와 start를 캡처
})
}
// 함수형 옵션 패턴: 클로저 + 가변 인자
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
srv := NewServer(WithTimeout(3*time.Second), WithRetries(2))

비용도 같은 곳에서 나온다. 클로저가 함수 밖으로 새면(반환되거나 고루틴으로 넘어가면) 클로저와 캡처한 변수가 힙에 올라간다. 핫 패스에서 그런 클로저를 남발하면 allocs/op가 올라간다. 반대로 함수 안에서만 쓰고 끝나는 클로저는 스택에 남고, 캡처한 변수가 작고 이후 바뀌지 않으면 컴파일러가 값으로 복사해 넣기도 한다.

더 조용한 문제는 큰 객체를 캡처할 때다. 클로저 안에서 필드 하나만 읽더라도, 그 객체를 가리키는 변수를 붙잡고 있는 한 클로저가 살아 있는 내내 객체 전체가 GC되지 않는다. 2편의 재슬라이싱 누수와 같은 구조다.

루프 변수: 언어가 직접 고친 버그

변수를 캡처한다는 성질이 가장 유명하게 사고를 낸 자리다. Go 1.21까지는 루프 변수가 반복마다 새로 만들어지지 않고 하나를 계속 재사용했다. 그래서 루프 안에서 고루틴을 띄우면 전부 같은 변수를 봤다. 찍히는 건 대개 마지막 값이었다.

Go 1.21 이하 · 변수 하나g1g2g3v = “c”출력: c c c셋이 같은 변수를 본다Go 1.22 이상 · 반복마다 새 변수g1g2g3“a”“b”“c”출력: a b c각자 자기 값 (순서는 제각각)
상자가 하나냐 셋이냐, 이게 Go 1.22가 바꾼 전부다

가장 중요한 건 이 변경이 저절로 적용되지 않는다는 점이다. 툴체인을 1.26으로 올려도 소용없고, go.mod의 go 지시어가 1.22 이상이어야 새 동작이 적용된다. 기존 코드의 의미를 바꾸지 않으려는 하위 호환 장치인데, 거꾸로 말하면 오래된 모듈은 최신 Go로 빌드해도 여전히 옛 동작이라는 뜻이다.

go.mod와 버전에 상관없이 안전한 관용구
// go.mod
module example.com/app
go 1.22 // 이 줄이 1.22 미만이면 최신 Go로 빌드해도 옛 동작이다
// 버전에 상관없이 안전한 관용구: 의도도 분명해서 지금도 자주 쓴다
for _, v := range items {
go func(v Item) { process(v) }(v) // 인자로 넘겨 값을 복사
}

이 버그가 얼마나 무서운지는 실제 사례가 말해 준다. Let’s Encrypt에서 공개된 장애가 대표적인데, 코드를 짠 사람은 문제를 알고 있었다. 루프 변수 k는 이미 복사해 뒀는데, 다른 함수가 또 다른 루프 변수 v를 포인터로 참조하고 있어서 그쪽에서 터졌다. 캡처가 여러 함수에 흩어지면 눈으로 잡는 건 사실상 불가능하다.

테스트가 조용히 통과하는 함정

이 버그는 실패해야 할 테스트를 통과시키기도 한다. 아래 테스트는 1이 홀수니까 실패해야 하는데, 1.21 이하에서는 통과한다.

아무것도 검증하지 않는 테스트
func TestAllEven(t *testing.T) {
for _, v := range []int{1, 2, 4, 6} {
t.Run("sub", func(t *testing.T) {
t.Parallel() // 루프가 끝날 때까지 기다린다
if v%2 != 0 {
t.Fatal("odd v", v)
}
})
}
}
// 1.21 이하: 서브테스트가 돌 때는 v가 이미 6 → 전부 6은 짝수 검사 → 통과
// 1.22 이상: v가 반복마다 따로 → 1에서 제대로 실패한다

t.Parallel()이 서브테스트를 루프가 끝날 때까지 붙잡아 두기 때문에, 실행 시점에는 v가 이미 마지막 값이다. 테스트는 초록불인데 아무것도 검증하지 않고 있는 상태다. Go 팀도 이 변경을 준비하면서 이런 테스트를 대량으로 고쳐야 했다고 한다.

정리

  • defer는 실행만 미루고, 인자와 리시버는 등록할 때 정해진다. os.Exit, log.Fatal, fatal error 앞에서는 아예 실행되지 않는다.
  • 루프 안 defer는 함수가 끝날 때까지 자원을 붙잡고 open-coded 조건도 깨뜨린다.
  • recover는 defer된 함수가 직접 불러야 하고, 다른 고루틴의 panic은 못 잡는다. 그래서 고루틴을 띄우는 자리마다 방어를 넣는다.
  • %w로 감싸면 하위 에러가 공개 API가 된다.
  • 밖으로 샌 클로저는 힙 할당을 만들고, 큰 객체를 붙잡으면 그 객체 전체가 GC되지 않는다. 루프 변수 문제는 1.22가 고쳤지만 go.mod의 go 줄을 올려야 적용된다.

이번 편을 쓰면서 셋을 꿰는 질문이 하나라는 걸 알았다. 이건 정확히 언제, 무엇을 붙잡는가. defer는 등록 시점의 인자를, 클로저는 변수 자체를, %w는 하위 에러를 붙잡는다. 이 글에 적은 함정은 전부 붙잡는 대상을 착각한 데서 나왔다.

참고 자료

GitHub에서 고치기
esc

최근 글

↑↓ 이동↵ 열기검색 페이지에서 보기 ›