포스트

클로저란 무엇인가 — 함수가 바깥 변수를 기억하는 법

클로저를 언어에 종속되지 않은 공통 개념으로 정리한다. 함수와 클로저의 차이, 값 캡처와 참조 캡처의 구분, 언어마다 반복되는 '루프 변수 캡처' 함정을 짚고, C++·Go·Rust·JavaScript가 이 개념을 각각 어떻게 구현했는지 대응시킨다.

클로저란 무엇인가 — 함수가 바깥 변수를 기억하는 법

난이도 입문 · 선행 없음

한 줄 요약

클로저는 함수 + 그 함수가 정의된 환경(주변 변수)을 함께 묶은 것이다. 핵심은 “함수가 자기 바깥의 변수를 기억한다”는 점이다.

흔한 오해부터 — 클로저도 특정 언어 것이 아니다

“클로저는 JavaScript 개념 아니야?”라는 오해가 흔하다. JS가 클로저를 워낙 많이 쓰다 보니 생긴 인상인데, 클로저는 1964년 Peter Landin이 명명한(Lisp·람다 대수 계보) 오래된 개념이다. C++·Go·Rust·Python·Lisp 전부에 있다. “람다는 Java 8 개념 아니야?”와 같은 착각이다.

일반 함수 vs 클로저 — 바깥 변수를 붙잡는다

일반 함수는 자기 매개변수와 지역 변수만 안다. 클로저는 자기가 선언된 스코프의 변수까지 안다. 그 스코프가 끝난 뒤에도.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Go
func counter() func() int {
    count := 0          // counter의 지역 변수
    return func() int {
        count++         // 바깥 변수 count를 캡처
        return count
    }
}

c := counter()
c() // 1
c() // 2  ← count가 살아있다
c() // 3

counter()는 이미 반환돼 끝났는데도 count가 사라지지 않는다. 반환된 익명 함수가 count를 붙잡고 있기 때문이다. 이 “닫아서 가둔다(close over)”에서 클로저(closure)라는 이름이 왔다.

포인트: 클로저는 상태를 가진 함수를 만드는 도구다. 필드 하나짜리 객체(struct + 메서드 하나)를 대신할 수 있다.

값 캡처 vs 참조 캡처 — 무엇을 붙잡는가

클로저가 바깥 변수를 붙잡는 방식은 두 가지다. 이 차이가 언어별 특성과 버그의 근원이다.

 값 캡처 (by value)참조 캡처 (by reference)
붙잡는 것변수의 복사본변수 자체(주소)
바깥에서 변수 변경 시영향 없음 (복사 시점 고정)클로저도 같이 바뀜
위험(상대적으로 안전)원본이 사라지면 댕글링

C++은 이 선택을 손으로 명시해야 한다.

1
2
3
4
5
6
int x = 10;
auto by_value = [x]()  { return x; };   // x의 복사본을 캡처 (10 고정)
auto by_ref   = [&x]() { return x; };   // x 참조를 캡처 (이후 x 변경 반영)
x = 20;
by_value(); // 10
by_ref();   // 20
  • [=] — 쓰는 모든 바깥 변수를 값으로 캡처
  • [&] — 참조로 캡처
  • [x, &y] — 개별 지정

Go·JS·Python은 대개 자동으로 판단해 캡처한다(대체로 참조에 가깝게 동작). Rust는 기본적으로 최소 권한으로 빌려 쓰고, move 키워드로 소유권을 통째로 가져오는 캡처를 선택한다 — 클로저가 원본보다 오래 살아야 할 때(스레드로 넘기기 등) 필요하다.

언어마다 반복되는 함정 — 루프 변수 캡처

클로저의 대표 버그. 반복문 안에서 클로저를 만들며 루프 변수를 참조 캡처하면, 모든 클로저가 같은 변수를 가리켜 마지막 값만 보게 된다.

1
2
3
4
5
6
// JavaScript — var를 쓰면 전형적인 함정
var fns = [];
for (var i = 0; i < 3; i++) {
  fns.push(() => i);     // 모두 같은 i를 캡처
}
fns[0](); fns[1](); fns[2]();   // 3, 3, 3  ← 0,1,2가 아니다!

원인은 세 클로저가 하나의 i를 공유하고, 루프가 끝났을 때 i는 이미 3이기 때문이다. 해법은 반복마다 새 변수를 만드는 것이다 (JS는 let, 값 복사, IIFE 등).

이건 언어를 가리지 않는 함정이다:

  • JavaScript: varlet으로 해결 (블록 스코프로 매 반복 새 바인딩)
  • Go: 1.22 이전엔 루프 변수가 반복 간 공유돼 같은 버그. Go 1.22부터 매 반복 새 변수로 바뀌어 기본 해결됐다.
  • C++: [&]로 캡처한 참조가 루프 밖에서 댕글링되는 더 위험한 형태로 나타난다.

규칙: 반복문에서 클로저를 만들 땐 “무엇을 캡처하고 그게 언제까지 사는지”를 반드시 확인하라.

언어별 구현 한눈에

언어문법캡처 방식특징
C++[캡처](매개변수){ }명시 ([=]/[&]/개별)캡처를 직접 골라야 함. 댕글링 주의
Gofunc() { ... }자동 (참조에 가까움)흔하게 사용. 1.22에서 루프 함정 해결
Rust\|매개변수\| { }최소 빌림, move로 소유권 이동소유권 규칙과 맞물림
JavaScript() => { }자동(참조)클로저를 언어 전반에서 활용
Pythonlambda, 중첩 함수자동, nonlocal로 쓰기늦은 바인딩(late binding) 주의

C++ 상세: 모던 C++ 학습 로드맵 · Go 상세: Go 학습 로드맵

클로저 ≠ 코루틴 (자주 같이 나오는 이유)

둘 다 “호출이 끝나도 상태가 살아남는다”는 공통점 때문에 헷갈리지만, 무엇을 보존하는지가 다르다.

 클로저코루틴
보존하는 것캡처한 변수실행 위치 + 지역 상태 전부
재개(resume) 개념없음 (매번 처음부터 실행)있음 (멈춘 지점부터 재개)

클로저로 “다음 값을 뱉는 함수”를 만들면 generator처럼 보이지만, 매번 함수를 처음부터 다시 도는 것이라 중간에서 멈췄다 재개하는 진짜 코루틴이 아니다. → 코루틴이란 무엇인가

스스로 점검

1. counter()가 반환된 뒤에도 count가 사라지지 않는 이유는?

반환된 클로저가 count캡처(close over)하고 있기 때문이다. 클로저가 살아있는 한 캡처된 변수도 함께 유지된다. 그래서 counter()의 스코프가 끝나도 count는 힙 등에 남아 계속 접근·변경된다.

2. C++ [&] 캡처가 다른 언어보다 위험할 수 있는 이유는?

참조로 캡처한 원본 변수가 클로저보다 먼저 소멸하면 댕글링 참조가 된다(UB). GC가 있는 언어는 캡처된 변수를 살려두지만, C++은 수명을 개발자가 책임지므로 지역 변수를 [&]로 캡처해 함수 밖으로 내보내면 위험하다.

3. 반복문에서 만든 클로저들이 전부 마지막 값을 보는 버그의 원인과 해법은?

원인: 모든 클로저가 하나의 루프 변수를 공유 캡처했고, 루프가 끝났을 때 그 변수는 최종값이기 때문. 해법: 매 반복마다 새 변수를 만든다(JS let, 값 복사, Go 1.22의 반복별 새 변수). “무엇을 캡처하고 언제까지 사는가”를 확인하는 게 핵심.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.