1. 왜 defer가 필요한가
처음 시작할 때는 “함수 호출을 나중으로 미룬다”는 생각이 꽤 수상하게 들릴 수 있습니다. 마치 “왜 끝에서 cleanup()을 쓰고 그냥 편하게 살면 안 되지?” 같은 느낌이죠. 문제는 “끝”이 하나의 지점이 아니라는 데 있습니다. 실제 코드에서는 return이 여러 개일 때가 많습니다. 어디선가 입력을 검사하고, 어디선가 오류를 받고, 어디선가 조건이 맞지 않아 일찍 종료합니다. defer는 바로 이 문제를 해결합니다. “반드시 해야 하는 작업”을 그것이 필요해지는 지점 바로 옆에 붙여 둘 수 있게 해 줍니다.
머릿속에 넣기 가장 쉬운 기본 모델은 이렇습니다: defer는 “이 함수에서 나가기 전에 이것을 하라고 나에게 상기시켜 줘”라는 뜻이다. 여기서 “나가기 전에”는 현재 함수에서 나가기 전에를 의미합니다. main 함수가 아니라, 프로그램 전체도 아니고, “언젠가 나중에”도 아니라, 함수가 일을 마치는 바로 그 순간입니다.
defer의 공식적인 동작 설명은 매우 단순합니다. defer는 지연 호출을 쌓아 두었다가, 함수가 바깥으로 돌아가기로 결정한 뒤에 그것들을 실행합니다. 여기서 오늘 핵심인 두 규칙이 정해집니다: 인자는 즉시 계산되고, 호출은 LIFO 순서로 실행됩니다.
2. defer는 언제 동작하는가
defer에서 가장 흔한 사고방식의 오류는 이것입니다. 사람은 defer를 보자마자 무의식적으로 “현재 줄이 끝난 뒤에” 실행될 것이라고 기대합니다. 마치 이것이 어떤 교묘한 ;인 것처럼 말이죠. 하지만 defer는 줄에 대한 것이 아닙니다. defer는 함수 종료에 관한 것입니다. 즉, 지연된 호출은 정상 종료 시에도 실행되고, 어떤 분기에서든 조기 return이 일어나도 실행됩니다.
조기 종료가 있는 작은 예제를 보겠습니다. 여기서는 오류로 빠져나가든 그렇지 않든 defer가 "finish" 문자열을 출력합니다:
package main
import (
"errors"
"fmt"
)
func checkAge(age int) error {
fmt.Println("start") // start
defer fmt.Println("finish") // finish (will run on exit)
if age < 0 {
return errors.New("age must be >= 0")
}
return nil
}
func main() {
fmt.Println(checkAge(-10)) // age must be >= 0
}
중요한 점은 "finish"가 함수가 error를 반환하기로 결정한 뒤에 출력되지만, 제어가 최종적으로 호출한 코드로 넘어가기 전에 출력된다는 것입니다.
이를 시각적으로 생각하고 싶다면 모델은 대략 이렇습니다:
flowchart TD
A[execute the function body] --> B{do we hit return?}
B -->|no| A
B -->|yes| C[execute deferred calls in LIFO order]
C --> D[the function actually returns values]
return에 도달하기 전까지 defer는 함수의 “주머니 속”에 들어가 기다립니다. 함수가 바깥으로 나가려는 순간 — defer가 동작합니다.
defer에는 호출을 쓴다
이제 작아 보이지만 자주 실수를 부르는 부분으로 넘어갑니다. defer는 함수 호출을 다룹니다. 즉, 올바른 형태는 defer something(...)입니다. defer는 무엇을 호출할지, 그리고 어떤 인자로 호출할지 알아야 하기 때문에 이것이 중요합니다.
올바른 예는 다음과 같습니다:
package main
import "fmt"
func say(msg string) {
fmt.Println(msg)
}
func main() {
defer say("bye") // bye
fmt.Println("hello") // hello
}
이렇게는 안 됩니다:
// defer say // not allowed: defer expects a call, not a name
이 제약은 모델의 일부입니다. defer는 “준비된 호출”을 스택에 넣습니다. 이제 우리는 강의의 핵심 주제로 갑니다. 이 호출의 인자가 정확히 언제 고정되는지, 그리고 여러 개의 defer가 어떤 순서로 실행되는지입니다.
3. defer의 인자와 실행 순서
defer의 인자는 즉시 계산된다
이제는 그냥 외우는 것이 아니라 몸으로 느껴야 하는 규칙이 나옵니다: 지연 호출의 인자는 defer 문이 실행되는 순간에 계산됩니다, 함수에서 나갈 때가 아닙니다.
즉, defer fmt.Println(i)는 i 값이 나중에 열 번 바뀌더라도, 지금의 i 값을 바로 기억해 둡니다.
예제:
package main
import "fmt"
func main() {
i := 0
defer fmt.Println("defer i =", i) // defer i = 0
i = 10
fmt.Println("now i =", i) // now i = 10
}
만약 "defer i = 10"을 기대했다면, 잘못된 모델을 기대한 것입니다. 여기서는 defer가 인자를 즉시 고정한다는 점에 익숙해져야 합니다. 마치 사진을 찍는 것처럼요.
“지금 계산되는 것”과 “나중에 계산되는 것” 미니 표
| 무엇이 일어나는가 | 언제 일어나는가 |
|---|---|
| defer f(x, y)에서 인자 f(x, y)가 계산된다 | defer가 있는 줄이 실행되는 순간 즉시 |
| 실제 호출 f(...)이 실행된다 | 나중에, 함수가 종료될 때 |
| 여러 개의 defer 순서 | LIFO: 마지막에 추가된 것이 먼저 실행된다 |
여러 개의 defer: LIFO 순서
이제 두 번째 규칙입니다. 이것을 모르면 defer는 커피 찌꺼기로 점치는 것처럼 느껴질 수 있습니다. 함수 안에 여러 개의 defer가 있으면, 그것들은 역순으로 실행됩니다. 접시를 쌓는 것과 같습니다. 마지막에 올린 것이 맨 위에 있고, 맨 위의 것을 먼저 집어 듭니다. 이를 LIFO, 즉 Last In, First Out이라고 부릅니다.
철학 없이 아주 정직한 예제를 보겠습니다:
package main
import "fmt"
func main() {
defer fmt.Print("A") // third
defer fmt.Print("B") // second
defer fmt.Print("C") // first
}
결과는 다음과 같습니다:
CBA
왜냐하면 “지연 호출 스택”이 LIFO로 동작하기 때문입니다.
반복문 안의 defer: 별도의 함정
여기서 쉽게 잘못된 결론을 내릴 수 있습니다. “아, 그럼 반복문 안의 defer는 반복이 끝날 때 실행되겠네.” 아닙니다. 그것은 함수 끝에서 실행됩니다. 그리고 반복문 안에 defer를 백 개 넣으면, 함수 끝에서 백 개의 호출이 역순으로 실행됩니다. 어떤 때는 이게 원하는 동작이지만, 어떤 때는 프로그램을 “인쇄기”처럼 만들어 버리는 예상치 못한 결과가 됩니다.
아주 작은 코드로 보여 보겠습니다:
package main
import "fmt"
func main() {
for i := 0; i < 3; i++ {
defer fmt.Print(i) // 210
}
}
출력은 210이 됩니다. i=0은 가장 먼저 미뤄져서 스택 아래쪽에 있고, i=2는 마지막에 들어가서 위쪽에 있기 때문에 더 먼저 실행됩니다.
defer와 익명 함수: 클로저는 어디에 숨어 있는가
자주 있는 상황은 이렇습니다. “인자는 즉시 계산된다”는 말을 들었는데, 코드를 쓰고 나면 “왜 마지막 값이 출력되지?”라고 보게 됩니다. 이것은 모순이 아닙니다. 여기서는 호출 인자와, 나중에 변수를 읽는 클로저를 구분해야 하기 때문입니다.
defer fmt.Println(i)를 쓰면 i는 호출 인자이며 즉시 계산됩니다.
defer func() { fmt.Println(i) }()를 쓰면, 그 익명 함수에는 인자가 없고, i는 바깥 범위에서 가져옵니다. 즉, deferred 함수가 실행되는 순간 변수 i를 읽게 되며, defer를 걸어 둔 순간이 아닙니다.
거의 같은 두 조각을 비교해 봅시다:
package main
import "fmt"
func main() {
i := 0
defer fmt.Println("arg:", i) // arg: 0
defer func() {
fmt.Println("closure:", i) // closure: 10
}()
i = 10
}
여기서는 두 defer가 “가까이” 있지만, 다르게 동작합니다. fmt.Println("arg:", i)는 i를 즉시 고정하고 0을 기억합니다.
익명 함수는 변수 i를 클로저로 잡고, i가 이미 10이 된 뒤에 그것을 읽습니다.
익명 함수가 “지금의 값”을 기억하게 하려면
값을 매개변수로 넘기면 됩니다. 즉, “클로저에서 읽기”를 “즉시 계산되는 호출 인자”로 바꾸는 것입니다:
package main
import "fmt"
func main() {
i := 0
defer func(v int) {
fmt.Println("v =", v) // v = 0
}(i)
i = 10
}
익명 함수와 함께 defer를 쓰면서도 “값은 defer 시점에 고정된다”는 예측 가능성을 유지하고 싶다면 이것이 좋은 패턴입니다.
4. 예제: defer가 있는 미니 계산기
이제 defer를 여러분이 첫날부터 익히는 프로그램 스타일에 연결해 봅시다. 입력을 읽고, 파싱하고, 계산하고, 출력합니다. 여기서는 파일 같은 “실제 자원”은 다루지 않겠지만, 아주 실용적인 일을 해 보겠습니다: “함수에 들어옴 / 함수에서 나감” 로그입니다. 이런 것은 간단한 문제에서도 디버깅에 도움이 되며, defer는 이를 거의 공짜로 만들어 줍니다.
compute라는 함수가 연산을 수행하고 오류를 반환할 수 있다고 가정해 봅시다(0으로 나누기, 알 수 없는 연산). 우리는 이 함수가 오류로 빠져나가더라도 언제나 끝에서 "compute: end"를 출력하길 원합니다.
package main
import (
"errors"
"fmt"
)
func compute(op string, a, b int) (int, error) {
fmt.Println("compute: start") // compute: start
defer fmt.Println("compute: end") // compute: end
if op == "/" && b == 0 {
return 0, errors.New("division by zero")
}
if op == "+" {
return a + b, nil
}
if op == "/" {
return a / b, nil
}
return 0, errors.New("unknown operation")
}
func main() {
res, err := compute("/", 10, 0)
fmt.Println("res =", res, "err =", err) // res = 0 err = division by zero
}
이 예제의 핵심은 defer가 우리에게 함수의 보장된 “꼬리 부분”을 준다는 점입니다. 우리는 매 return마다 fmt.Println("compute: end")를 직접 쓰지 않습니다. 한 번만 쓰고 잊어버리면 됩니다.
5. defer 스택의 ذهن적 모델
defer에서는 코드를 머릿속으로 “재생”하는 습관이 매우 도움이 됩니다. 특히 면접에서는 물론, 일상적인 개발에서도 놀라움이 줄어듭니다. 이를 위해 아주 단순한 그림 하나를 기억하면 좋습니다. 각 함수에는 내부적인 지연 호출 스택이 있습니다. defer를 만나면 새 호출을 맨 위에 올립니다. 함수가 바깥으로 나가면, 맨 위부터 꺼내서 실행합니다.
LIFO 모델을 머릿속에 두지 않으면 쉽게 틀릴 수 있는 예제를 의도적으로 써 보겠습니다:
package main
import "fmt"
func demo() {
defer fmt.Print("1") // third
fmt.Print("A") // immediate
defer fmt.Print("2") // second
fmt.Print("B") // immediate
defer fmt.Print("3") // first
fmt.Print("C") // immediate
}
func main() {
demo() // ABC321
}
여기서는 이렇게 읽는 것이 좋습니다: “지금 A, B, C를 출력한다. 그리고 demo()에서 나갈 때 3, 2, 1을 출력한다.” 결과는 "ABC321"입니다.
이 예측 가능성이 바로 defer가 정직한 도구로 여겨지는 이유입니다. 두 가지 규칙만 알면 텔레파시가 필요하지 않습니다(인자는 즉시, 호출은 LIFO).
6. defer를 다룰 때의 전형적인 실수
실수 1: defer가 “줄 바로 다음”에 실행된다고 기대하기.
이런 버그는 finally가 있거나, 사람들이 블록 끝의 정리 작업에 익숙한 언어를 배웠을 때 자주 생깁니다. Go에서 defer는 함수에 묶여 있습니다. 함수가 종료되지 않으면 defer는 실행되지 않습니다. 반복문 안에 defer를 넣었다면, 그것은 “반복이 끝날 때” 실행되지 않고 함수 전체가 종료되기를 기다립니다.
실수 2: defer가 “끝에서 최신 인자를 가져간다”고 생각하기.
규칙은 반대입니다. 인자는 defer가 실행되는 순간에 즉시 계산됩니다. 나중에 바뀌는 변수에서 이것이 특히 잘 보입니다. 지연 코드가 “마지막 상태”를 보게 하고 싶다면, 그 값을 인자로 넘기지 말고 클로저 안에서 읽어야 합니다. 다만 그 차이를 분명히 이해한 상태에서 의도적으로 해야 합니다.
실수 3: defer의 인자와 클로저를 혼동한 뒤 출력 결과에 놀라기.
defer fmt.Println(i)를 쓰면 i는 즉시 고정됩니다. defer func(){ fmt.Println(i) }()를 쓰면 인자가 없고, 변수는 나중에 읽힙니다. 그 결과 한 defer는 “옛값”을 출력하고, 다른 하나는 “새값”을 출력합니다. 이것은 Go의 버그가 아니라 서로 다른 메커니즘입니다.
실수 4: 큰 반복문 안에 defer를 너무 많이 두고 결과를 생각하지 않기.
네, defer는 편리합니다. 하지만 반복문 안에 10,000개의 defer를 넣었다면, 함수 종료 시점에야 실행되는 10,000개의 지연 호출이 생깁니다. 성능 이야기를 하지 않더라도, 로그가 읽기 어려워지고 동작 순서를 이해하기 힘들어질 수 있습니다. 이런 곳에서는 보통 반복 1회용 래퍼 함수로 defer를 옮기거나, 정말 그게 필요하다면 반복 끝에서 명시적으로 작업을 수행하는 편이 낫습니다.
실수 5: LIFO 대신 “자연스러운 순서”를 기대하다가 반대 순서를 보고 당황하기.
머릿속 모델이 “위에서 아래로 순서대로 실행되겠지”라면, defer 예제들은 모두 “깨진 것처럼” 보일 것입니다. Go에서 순서는 엄격하게 LIFO입니다. 마지막 defer가 먼저 실행됩니다. 특히 한 작업이 다른 작업에 논리적으로 의존할 때 중요합니다(예를 들어 “A를 잡고, 그다음 B를 잡았다면” “놓을 때는 B를 먼저, A를 나중에” 하고 싶을 때 — LIFO는 바로 그것을 자연스럽게 해 줍니다).
GO TO FULL VERSION