클로저 (프로그래밍)
더 많은 작업
- Closure; 클로저; 렉시컬 클로저; 함수 클로저
- 함수와 그 함수가 만들어질 때의 렉시컬 환경을 한데 묶은 값
클로저(closure, lexical closure, function closure)는 함수와, 그 함수가 정의될 때 둘러싸고 있던 환경(렉시컬 환경)을 묶어 놓은 것이다. 함수 안에서 쓰지만 함수 바깥에서 정의된 변수, 곧 자유 변수(free variable)를 클로저가 붙잡아(capture) 두기 때문에, 정의된 자리를 벗어나 전혀 다른 곳에서 호출되어도 그 변수들을 계속 쓸 수 있다. 이름은 열려 있던 자유 변수를 정의 시점의 환경으로 "닫아(close)" 닫힌 식(closed expression)을 만든다는 데서 왔다.
클로저는 함수를 값처럼 넘기고 돌려줄 수 있는 함수형 프로그래밍 언어(LISP, Scheme, ML, Haskell)에서 자연스럽게 쓰이는 개념이고, 파이썬, Ruby, Lua, 자바스크립트 같은 다중 패러다임 언어에도 대부분 들어 있다. 현장에서는 특히 자바스크립트에서 콜백과 이벤트 처리기를 만들 때 매일같이 쓰인다. C++은 C++11(2011)의 람다 식, Java는 Java 8(2014)의 람다 식으로 클로저를 쓸 수 있게 되었다. C나 초기 Java처럼 함수를 값으로 다루지 않는 언어에 익숙한 사람에게는 낯선 개념이어서, 자바스크립트를 처음 배울 때 흔히 막히는 부분으로 꼽힌다.
리치 히키가 만든 LISP 방언인 프로그래밍 언어 Clojure도 한국어로 "클로저"라고 읽지만, 이 문서의 클로저와는 다른 것이다.
동작 관점에서 클로저는 함수(코드 또는 함수 포인터)와 환경을 함께 담은 레코드다. 환경은 함수의 자유 변수 각각을, 클로저가 만들어진 순간 그 이름이 묶여 있던 값이나 저장 위치(참조)에 대응시킨 표다.[1] 평범한 함수와 달리 클로저는 그 변수의 유효 범위(scope) 밖에서 호출되어도 붙잡은 변수에 접근할 수 있다.
MDN은 자바스크립트 클로저를 "함수와 그 함수를 둘러싼 상태(렉시컬 환경)에 대한 참조의 조합"이라고 정의한다.[2]
function plus(a) {
return function (x) { // a는 이 안쪽 함수의 자유 변수
return a + x;
};
}
const plus3 = plus(3); // a = 3을 기억하는 클로저
const plus5 = plus(5); // a = 5를 기억하는 클로저
plus3(10); // 13
plus5(10); // 15
plus3과 plus5는 같은 고차 함수 plus에서 나왔지만 각자 만들어질 때의 a를 따로 기억하므로 다르게 동작한다. plus가 반환한 뒤에는 a가 들어 있던 호출이 끝났는데도 a는 사라지지 않는다.
정확히 말하면 안쪽 함수의 정의 자체는 아직 클로저가 아니다. 바깥 함수가 실제 인자로 실행되어 자유 변수가 값에 묶이는 순간 클로저가 만들어지고, 그것이 반환된다. 또 함수가 자유 변수가 정의된 바로 그 범위 안에서만 실행된다면 정의 환경과 실행 환경이 같아서 클로저인지 아닌지 구별할 의미가 없다. 클로저가 차이를 만드는 것은 함수가 자신이 태어난 범위 밖으로 나갈 때다.
클로저는 흔히 익명 함수와 같은 뜻으로 쓰인다. 많은 언어에서 익명 함수가 곧 클로저를 만들기 때문에 편하게 섞어 부르지만, 엄밀히는 다르다. 익명 함수는 이름 없이 적은 함수 리터럴이라는 문법 개념이고, 클로저는 자유 변수가 묶인 함수 인스턴스라는 실행 시점의 값이다. 이름 있는 중첩 함수도 클로저가 될 수 있고, 자유 변수가 없는 익명 함수는 붙잡을 것이 없다.
def f(x):
def g(y): # 이름 있는 중첩 함수
return x + y
return g # 클로저 반환
def h(x):
return lambda y: x + y # 익명 함수로 만든 클로저
assert f(1)(5) == h(1)(5) == 6
- 1960년대: 클로저 개념은 람다 계산(λ-calculus) 식을 기계적으로 평가하는 방법을 연구하며 생겼다.
- 1964년: 피터 랜딘(Peter Landin)이 논문 "The Mechanical Evaluation of Expressions"에서 SECD 기계로 식을 평가하는 방법을 설명하며, 환경 부분(environment part)과 제어 부분(control part)으로 이루어진 것을 클로저라고 불렀다.[1] 조엘 모지스(Joel Moses)는 자유 변수가 렉시컬 환경에 묶여 닫힌 람다 식을 가리키는 말로 클로저를 처음 쓴 사람을 랜딘으로 보았다.
- 1970년: 렉시컬 범위의 일급 함수를 지원하려고 PAL 언어가 클로저를 언어 기능으로 처음 완전히 구현한 것으로 알려져 있다.
- 1975년: 제럴드 서스먼(Gerald Sussman)과 가이 스틸(Guy Steele)이 렉시컬 범위를 쓰는 LISP 방언 Scheme을 발표하며 이 용어를 썼고, 이후 널리 퍼졌다.[3]
- 1980년대: 서스먼과 에이블슨(Harold Abelson)은 SICP에서 "클로저 성질"이라는 말을 전혀 다른 뜻(자료를 합치는 연산이 그 결과를 다시 재료로 받을 수 있는 수학적 닫힘 성질)으로도 썼다. 저자들 스스로 이 용어 겹침을 아쉬워했다.
- 2000년대 이후: C# 2.0(2005)의 익명 메서드와 C# 3.0(2007)의 람다 식, C++11(2011), Java 8(2014)이 차례로 클로저를 갖추면서 주류 언어 대부분이 클로저를 지원하게 되었다.[4]
클로저는 보통 함수 코드를 가리키는 포인터와, 생성 시점의 렉시컬 환경(쓸 수 있는 변수들)을 나타내는 자료를 함께 담은 자료 구조로 구현한다. 나중에 클로저를 호출하면, 호출하는 곳의 환경이 아니라 클로저가 붙잡은 환경에서 비지역 변수를 찾는다.
모든 지역 변수를 선형 스택에 두는 구현에서는 완전한 클로저를 지원하기 어렵다. 함수가 반환하면 스택 프레임과 함께 지역 변수가 사라지는데, 클로저는 그 변수가 바깥 함수보다 오래 살아 있기를 요구하기 때문이다. 그래서 붙잡힌 변수는 대개 힙(heap)에 옮겨 두고, 그 변수를 참조하는 클로저가 모두 사라질 때까지 살려 둔다. 클로저를 기본으로 지원하는 언어가 대부분 가비지 컬렉션을 쓰는 이유다. 스택 기반 언어에서 함수를 일급 값으로 다루기 어려운 이 문제를 funarg 문제(functional argument problem)라고 한다.
가비지 컬렉션 없이 클로저를 지원하는 언어는 다른 방법을 쓴다.
- C++: 클로저가 참조로 붙잡은 객체의 수명을 늘려 주지 않는다. 대상이 사라진 뒤 호출하면 정의되지 않은 동작이다.[5]
- Rust: 소유권과 빌림 검사기로 수명을 컴파일 시점에 검사하고, 오래 살아야 하는 클로저는 move로 값을 넘겨받게 한다.
- D 언어: 버전 1에서는 스택 변수를 참조하는 델리게이트가 반환 뒤 무효가 되었지만, 버전 2에서는 탈출하는 클로저가 쓰는 변수만 골라 힙에 자동 할당한다.
Lua는 클로저가 붙잡은 바깥 변수를 업밸류(upvalue)라고 부른다. 컴파일러 쪽에서는 클로저를 명시적 환경 인자를 받는 보통 함수로 바꾸는 클로저 변환(closure conversion)이나 람다 끌어올리기(lambda lifting), 함수 값을 자료 구조와 apply 함수로 바꾸는 탈함수화(defunctionalization)로 구현한다.
언어마다 렉시컬 환경과 변수 바인딩의 뜻이 달라 클로저가 붙잡는 대상도 다르다.
| 방식 | 뜻 | 언어 예 |
|---|---|---|
| 참조(변수 자체) 캡처 | 변수가 가리키는 저장 위치를 공유. 클로저 안팎의 변경이 서로 보인다 | 자바스크립트, 파이썬, Scheme, Ruby, C#, Go |
| 값 캡처 | 만들 때의 값을 복사. 공유 상태가 없다 | ML 계열, Java(final·effectively final 변수만), PHP의 use 기본값 |
| 골라 쓰기 | 변수별로 값이나 참조를 지정 | C++ 캡처 목록, PHP use (&$x), Swift 캡처 목록, Rust move |
| 지연 계산 캡처 | 값이 아니라 아직 계산하지 않은 식(thunk)을 붙잡음 | Haskell |
참조 캡처 예를 보면, 같은 환경을 붙잡은 클로저 여럿이 변수 하나를 함께 바꾼다.
let f, g;
function foo() {
let x = 1;
f = () => ++x;
g = () => --x;
}
foo();
f(); // 2
g(); // 1
g(); // 0 f와 g는 같은 x를 공유한다
Haskell에서는 나눗셈 x / y를 붙잡아도 클로저가 실제로 그 값을 쓸 때에야 계산되므로, 0으로 나누는 오류도 클로저를 호출하는 순간에 드러난다.
자바스크립트는 모든 함수가 클로저다. 가장 유명한 함정은 반복문 안에서 var로 선언한 변수를 붙잡는 경우다. var는 함수 단위 범위라 반복 전체에 변수가 하나뿐이고, 모든 클로저가 반복이 끝난 뒤의 마지막 값을 본다.[2]
var fns = [];
for (var i = 0; i < 3; i++) {
fns.push(() => i);
}
fns.map(f => f()); // [3, 3, 3]
// 해결 1: let은 블록 범위라 반복마다 새 바인딩이 생긴다(ES2015)
for (let j = 0; j < 3; j++) fns[j] = () => j; // [0, 1, 2]
// 해결 2: 즉시 실행 함수(IIFE)로 반복마다 새 범위를 만든다(ES2015 이전 방식)
for (var k = 0; k < 3; k++) {
(function (n) { fns[n] = () => n; })(k);
}
모듈 패턴은 즉시 실행 함수가 만든 범위 안에 변수를 숨기고, 그 변수를 다루는 함수들만 객체로 내보내는 방식이다. 클래스 문법과 ES 모듈이 나오기 전 자바스크립트에서 비공개 상태를 흉내 내던 표준 방법이었다.[2]
const counter = (function () {
let count = 0; // 바깥에서 직접 접근할 수 없다
return {
increment() { count += 1; },
value() { return count; },
};
})();
counter.increment();
counter.value(); // 1
counter.count; // undefined
this는 렉시컬 환경이 아니라 호출 방식으로 정해지므로, 메서드를 떼어 콜백으로 넘기면 this가 바뀐다. Function.prototype.bind로 묶거나, this를 렉시컬하게 붙잡는 화살표 함수를 쓴다.
파이썬 클로저는 바깥 변수를 읽을 수는 있지만, 안쪽 함수에서 대입하면 새 지역 변수가 만들어진다. 바깥 함수의 변수를 바꾸려면 파이썬 3.0에서 들어온 nonlocal 선언이 필요하다.[6]
def make_counter():
count = 0
def inc():
nonlocal count # 없으면 UnboundLocalError
count += 1
return count
return inc
c = make_counter()
c(); c() # 2
c.__closure__[0].cell_contents # 2, 붙잡힌 변수는 셀(cell) 객체에 들어 있다
파이썬 클로저는 늦은 바인딩(late binding)을 한다. 변수의 값을 클로저를 만들 때가 아니라 호출할 때 찾으므로, 반복문에서 만든 람다가 모두 마지막 값을 쓴다. 공식 FAQ가 권하는 해법은 기본 인자로 값을 그 자리에서 묶는 것이다.[7]
squares = [lambda: x ** 2 for x in range(5)]
squares[2]() # 16 (x가 마지막 값 4)
squares = [lambda n=x: n ** 2 for x in range(5)]
squares[2]() # 4
파이썬의 데코레이터와 functools.partial도 클로저로 만든다. 자세한 문법은 파이썬 람다 함수 문서를 참고한다.
Java의 지역 클래스와 익명 클래스는 바깥 메서드의 지역 변수 가운데 final인 것만 읽을 수 있었다. Java 8의 람다 식도 같은 규칙을 따르되, final을 적지 않아도 초기화 뒤 한 번도 바뀌지 않는 사실상 final(effectively final) 변수면 쓸 수 있다. 람다는 새 범위를 만들지 않으므로 바깥 지역 변수와 같은 이름의 매개변수를 선언할 수도 없다.[8] 결국 Java는 값 캡처만 하므로 공유 상태 문제가 생기지 않는다. 바꿀 수 있는 상태를 공유하려면 원소 하나짜리 배열이나 AtomicInteger 같은 변경 가능한 객체를 final 참조로 붙잡는 우회법을 쓴다.
int base = 10; // 사실상 final
Function<Integer, Integer> add = x -> x + base;
// base = 20; // 이 줄이 있으면 위 람다가 컴파일 오류
AtomicInteger counter = new AtomicInteger();
Runnable inc = counter::incrementAndGet; // 참조는 final, 내용은 변경 가능
C++11 람다 식은 캡처 목록으로 무엇을 어떻게 붙잡을지 명시한다. 컴파일러는 람다마다 이름 없는 함수 객체 타입을 만들고, 값으로 붙잡은 변수는 그 객체의 멤버로 복사한다.[5]
| 캡처 목록 | 뜻 |
|---|---|
| [] | 아무것도 붙잡지 않음(함수 포인터로 변환 가능) |
| [=] | 쓰인 변수를 모두 값으로 |
| [&] | 쓰인 변수를 모두 참조로 |
| [x, &y] | x는 값, y는 참조 |
| [this] / [*this] | 객체를 참조로 / 복사로(C++17) |
| [p = std::move(ptr)] | 초기화 캡처(C++14), 이동 전용 값도 붙잡을 수 있음 |
값으로 붙잡은 변수는 기본적으로 읽기 전용이며 바꾸려면 mutable을 붙인다. 참조로 붙잡은 지역 변수보다 람다가 오래 살면 댕글링 참조가 된다. [=]로 this를 암시적으로 붙잡는 것은 C++20에서 폐지 예정(deprecated)이 되었다. 람다를 저장할 때 쓰는 std::function은 타입 소거 때문에 약간의 부담이 있다.
Rust 클로저는 붙잡은 값을 어떻게 다루느냐에 따라 세 트레이트 가운데 하나 이상을 자동으로 구현한다.[9]
| 트레이트 | 붙잡은 값을 | 호출 횟수 |
|---|---|---|
| FnOnce | 클로저 밖으로 옮겨 버릴 수 있음(모든 클로저가 구현) | 한 번 |
| FnMut | 바꿀 수 있지만 옮기지는 않음 | 여러 번 |
| Fn | 읽기만 함(또는 아무것도 안 붙잡음) | 여러 번, 동시에 불러도 안전 |
move 키워드를 붙이면 본문이 소유권을 꼭 필요로 하지 않아도 붙잡은 값의 소유권을 클로저로 옮긴다. 다른 스레드로 넘기는 클로저에 주로 쓴다.
let list = vec![1, 2, 3];
std::thread::spawn(move || println!("{list:?}")) // list의 소유권이 스레드로 넘어간다
.join()
.unwrap();
- PHP: 익명 함수는 Closure 클래스로 구현된다. 바깥 변수는 use (...)로 명시해 기본적으로 값으로 가져오고, use (&$x)로 참조를 가져온다. 화살표 함수(fn)는 쓰인 바깥 변수를 자동으로 값 캡처한다.[12]
- Swift·Objective-C: 클로저가 self를 강하게 붙잡은 채 self의 속성에 저장되면 순환 참조가 생긴다. 캡처 목록 [weak self]나 [unowned self]로 끊는다.[13] Apple은 C·C++·Objective-C 비표준 확장으로 블록(^ 문법)을 넣었는데, 일반 지역 변수는 값으로 읽기 전용 캡처되고 __block을 붙인 변수만 참조로 붙잡힌다.
- Ruby: Proc.new로 만든 클로저 안의 return은 그 클로저를 만든 메서드까지 빠져나가지만, lambda 안의 return은 람다만 끝낸다. Smalltalk의 ^도 메서드에서 빠져나가는 비지역 반환이다. 이처럼 return, break 같은 제어문이 클로저 경계에서 어떻게 동작하는지도 언어마다 다르다.
- Scheme: 클로저가 가장 자연스럽게 쓰이는 언어로, 같은 환경을 공유하는 클로저 둘이 set!으로 비밀 변수를 주고받는 식의 예가 교과서에 흔하다.
- C: 클로저가 없다. 함수 포인터와 사용자 데이터를 가리키는 void* 포인터를 함께 넘기는 콜백 관례로 비슷한 효과를 낸다. GCC 확장의 중첩 함수는 바깥 함수가 반환한 뒤에는 쓸 수 없다.
- 콜백과 이벤트 처리기: 등록할 때의 문맥(버튼 ID, 요청 정보 등)을 붙잡아 두었다가 나중에 호출될 때 쓴다. 콜백 함수 문서 참고.
- 함수 공장과 커링: 인자 일부를 미리 채운 함수를 만든다. 위의 plus(3)이 그 예로, 커링과 부분 적용은 클로저로 구현된다.
- 캡슐화(정보 은닉): 클로저만 접근할 수 있는 비공개 변수를 만든다. 호출할 때마다 상태가 이어지는, 메서드 하나짜리 객체와 같다. 이렇게 쓰면 참조 투명성이 깨져 순수 함수는 아니게 된다. 캡슐화 문서 참고.
- 메모이제이션: 캐시 사전을 클로저에 가두어 함수 결과를 기억한다.
function memoize(fn) {
const cache = new Map(); // 클로저만 아는 캐시
return (n) => {
if (!cache.has(n)) cache.set(n, fn(n));
return cache.get(n);
};
}
- 지연 실행과 제어 구조: 클로저는 호출하기 전까지 아무것도 하지 않으므로 사용자 정의 제어 구조를 만들 수 있다. Smalltalk는 if, while 같은 제어 구조를 모두 클로저(블록)를 받는 메서드로 정의한다.
- 데코레이터와 미들웨어: 원래 함수를 붙잡은 새 함수를 돌려주어 로깅, 인증, 재시도 같은 기능을 덧씌운다.
- 객체 시스템 구현: 같은 환경을 공유하는 여러 클로저로 객체를 흉내 낼 수 있어, 클로저와 객체는 서로를 구현할 수 있는 관계로 자주 비교된다.
- 메모리 누수: 클로저가 살아 있는 동안 붙잡은 환경도 해제되지 않는다. 오래 사는 이벤트 처리기나 타이머가 큰 객체나 DOM 노드를 붙잡으면 메모리 누수가 된다. 처리기를 제거하거나 필요한 값만 따로 꺼내 붙잡는다. 자바스크립트 엔진에 따라 같은 범위에서 만든 클로저들이 하나의 환경 객체를 공유해, 한 클로저가 쓰지도 않는 큰 변수를 다른 클로저 때문에 계속 붙잡는 경우도 있는 것으로 알려져 있다.
- 순환 참조: 참조 카운팅을 쓰는 환경(Swift·Objective-C의 ARC, 예전 브라우저의 DOM 객체 등)에서는 객체가 클로저를, 클로저가 그 객체를 붙잡으면 서로를 해제하지 못한다.[13]
- 반복 변수 함정: 참조 캡처와 늦은 바인딩 때문에 반복문에서 만든 클로저가 모두 마지막 값을 보는 버그가 흔하다.
- 댕글링 참조: 가비지 컬렉션이 없는 C++에서 참조로 붙잡은 지역 변수가 먼저 사라지면 정의되지 않은 동작이다.
- 성능: 호출마다 함수 객체와 환경을 새로 만들면 할당과 간접 호출 비용이 든다. MDN은 필요 없는데 함수 안에서 함수를 만들지 말고, 생성자에서 메서드를 클로저로 만들기보다 프로토타입에 두라고 권한다.[2] Kotlin은 이 비용을 없애려고 고차 함수에 inline을 붙여 람다를 호출 지점에 펼칠 수 있게 한다.
- 동시성: 여러 스레드가 같은 클로저 변수를 바꾸면 경쟁 상태가 생긴다. Java가 값 캡처만 허용하고 Rust가 Send·Sync와 move로 이를 검사하는 것도 이 때문이다.
- ↑ 1.0 1.1 P. J. Landin, "The Mechanical Evaluation of Expressions", The Computer Journal 6(4), 1964
- ↑ 2.0 2.1 2.2 2.3 Closures, MDN Web Docs
- ↑ Gerald J. Sussman, Guy L. Steele Jr., "SCHEME: An Interpreter for Extended Lambda Calculus", MIT AI Memo 349, 1975
- ↑ The history of C#, Microsoft Learn
- ↑ 5.0 5.1 Lambda expressions, cppreference
- ↑ PEP 3104: Access to Names in Outer Scopes
- ↑ Python Programming FAQ: Why do lambdas defined in a loop with different values all return the same result?
- ↑ Lambda Expressions, The Java Tutorials, Oracle
- ↑ The Rust Programming Language, 13.1 Closures
- ↑ foreach iterator variable is now scoped within the iteration, .NET compatibility docs
- ↑ Fixing For Loops in Go 1.22, The Go Blog
- ↑ PHP Manual: Anonymous functions
- ↑ 13.0 13.1 Automatic Reference Counting: Strong Reference Cycles for Closures, The Swift Programming Language