Closure | 클로저

클로저는 렉시컬 스코프에 의존해 코드를 작성한 결과로 발생한다.

모든 코드에서 클로저는 생성되고 사용된다.

핵심

클로저는 함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 때에도 이 스코프에 접근할 수 있게 하는 기능이다.

function foo() {
var a = 2;
function bar() {
console.log(a);
}
bar();
}
foo();

함수 bar는 렉시컬 스코프 검색 규칙을 통해 바깥 스코프의 변수 a에 접근할 수 있다.

a를 참조하는 barㄹ르 설명하는 가장 정확한 방식은 렉시컬 스코프 검색 규칙에 따라서 설명하는 것이고 이 규칙은 클로저의 일부일 뿐이다.

함수 bar는 foo 스코프에 대한 클로저를 가진다.

bar는 foo 스코프에서 닫힌다. bar는 중첩되어 foo안에 존재하기 때문이다.

function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz();

함수 bar는 foo의 렉시컬 스코프에 접근할 수 있고 bar 함수 자체를 값으로 넘긴다.

이 코드는 bar를 참조하는 함수 객체 자체를 반환한다.

foo를 실행하여 반환하는 값을 baz라 불리는 변수에 대입하고 함수를 호출했다.

내부 함수인 bar를 호출한 것이다. 그러나 이 경우 함수 bar는 함수가 선언된 렉시컬 스코프 밖에서 실행됐다.

일반적으로 foo가 실행된 후에는 foo의 내부 스코프가 사라졌다고 생각할 것이다.

왜냐면 엔진이 가비지 콜렉터를 사용해 더는 사용하지 않는 메모리를 해제시킨다.

foo의 내용을 사용하지 않는 상황이라면 사라졌다보는게 맞다.

그러나 클로저가 그렇게 내버려두지 않는다.

foo의 내부 스코프는 여전히 사용중이므로 해제되지 않는다.

선언된 위치덕에 bar는 foo 스코프에 대한 렉시컬 스코프 클로저를 가지고 foo는 bar가 나중에 참조할수 있도록 스코프는 살려둔다.

bar는 여전히 해당 스코프에 대한 참조를 가지는데 그 참조를 클로저라고 부른다.

foo 선언이 끝나고 수 밀리 초 후 변수 baz를 호출할때 해당 함수는 원래 코드의 렉시컬 스코프에 접근할 수 있고 이는 함수가 변수 a에 접근할 수 있다는 의미다.

함수는 원래 코드의 렉시컬 스코프에서 벗어나 호출됐다.

클로저는 호출된 함수가 원래 선언된 렉시컬 스코프에 계속해서 접근할 수 있도록 허용해준다.

이제 나는 볼 수 있다.

function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}
wait("Hello Closure");

내부 함수 timer를 setTimeout에 인자로 넘겼다. timer 함수는 wait 함수의 스코프에 대한 스코프 클로저를 가지고 있으므로 변수 message에 대한 참조를 유지하고 사용할 수 있다.

wait 실행 1초후 wait의 내부 스코프는 사려저야 하지만 익명 함수가 여전히 해당 스코프에 대한 클로저를가지고 있다.

엔진 내부 깊숙한 곳의 내장 함수 setTimeout에는 아마 fn이나 func로 불릴 인자의 참조가 존재한다.

엔진은 해당 함수 참조를 호출하여 내장함수 timer를 호출하므로 timer의 렉시컬 스코프는 여전히 온전하게 남아 있다.

클로저

자체의 렉시컬 스코프에 접근할 수 있는 함수를 인자로 넘길 때 그 함수가 클로저를 사용하는 것을 볼 수 있다.

var a = 2;
(function IIFE() {
console.log(a);
})();

이 코드는 작동하지만 정확히 말해 클로저가 사용된 것은 아니다.

IIFE함수가 자신의 렉시컬 스코프밖에서 실행된 것이 아니기 때문이다.

IIFE 함수는 선언된 바로 그 스코프 안에서 호출됐다.

변수 a는 클로저가 아니라 일반적인 렉시컬 스코프 검색을 통해 가져왔다.

IIFE는 스코프를 생성하고 클로저를 사용할 수 있는 스코프를 만드는 가장 흔한 도구중 하나일 뿐이다.

반복문과 클로저

for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}

이 코드의 목적은 예상대로 1,2,3,4,5까지 1초마다 출력되는 것이다. 하지만 실제로는 1초마다 6이 5번 출력된다.

출력된 값은 반복문이 끝났을 때의 i의 값을 반영한 것이다.

timeout 함수의 콜백은 반복문이 끝나고 나서 작동한다.

사실 setTimeout(..., 0)이었다 해도 해당 함수 콜백은 반복문이 끝나고 동작하기 때문에 결과로 매번 6을 출력한다.

애체오 문법적으로 기대한 것과 같이 코드를 작동시키려면 반복마다 각각의 i의 복제본을 잡아두는 것이다.

그러나 반복문 안에 함수들은 반복마다 따로 정의됐음에도 모두 같이 클로벌 스코프 클로저를 공유해 해당 스코프 안에는 오직 하나의 i만이 존재한다.

반복마다 하나의 새로운 닫힌 스코프가 필요하다.

for (var i = 0; i <= 5; i++) {
(function() {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
})();
}

결과는 제대로 동작하지 않는다. 각각의 timeout 함수 콜백은 확실히 반복마다 각각의 IIFE가 생성한 자신만의 스코프를 가진다.

그러나 닫힌 스코프 만으로는 부족하다. 이 스코프는 비어있기 때문이다.

IIFE는 빈 스코프이므로 각 스코프는 반복마다 i의 값을 저장할 변수가 필요하다.

for (var i = 0; i <= 5; i++) {
(function() {
var j = i;
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})();
}

IIFE를 사용해서 반복마다 새로운 스코프를 생성하는 방식으로 timeout 함수 콜백이 원하는 값을 제대로 저장한다.

그 변수를 가진 새 닫힌 스코프를 반복마다 생성해 사용할 수 있다.

다시 보는 블록 스코프

반복마다 IIFE를 사용해 하나의 새로운 스코프를 생성했다. 실제로 필요했던 것은 반복별 블록 스코프였다.

키워드 let은 본질적으로 하나의 블록을 닫을 수 있는 스코프로 바꾼다.

for (var i = 1; i <= 5; i++) {
let j = i;
setTimeout(function timer() {
console.log(j);
}, j * 1000);
}

let 선어문이 for 반복문 안에서 사용되면 특별한 방식으로 작동한다.

반복문 시작 부분에서 let으로 선언된 변수는 한 번만 선언되는 것이 아니라 반복할때마다 선언된다.

따라서 해당 변수는 편리하게도 반복마다 이전 반복이 끝난 이후의 값으로 초기화한다.

for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}

모듈

function foo() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log("something");
}
function doAnother() {
console.log(another.join(" ! "));
}
}

내부 함수 doSomething과 doAnother는 foo 내부 스코프에 대한 렉시컬 스코프를 가집니다.

function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log("something");
}
function doAnother() {
console.log(another.join(" ! "));
}
return {
doSomething,
doAnother
};
}
var foo = CoolModule();
foo.doSomething();
foo.doAnother();

이 와 같은 자바스크립트 패턴을 모듈이라고 한다.

가장 흔한 모듈 패턴 구현 방법은 모듈 노출이다.

  1. CoolModule은 그저 하나의 함수일 뿐이지만 모듈 인스턴스를 생성하려면 반드시 호출해야한다. 최외각 함수가 실행되지 않으면 내부 스코프와 클로저는 생성되지 않는다.

  2. CoolModule 함수는 객체를 반환한다. 해당 객체는 내장 함수들에 대한 참조를 가지지만 내장데이터 변수에 대한 참조는 가지지 않는다. 내장 데이터 변수는 비공개로 숨겨져 있다.

doSomething과 doAnother는 모듈 인스턴스의 내부 스코프에 포함하는 클로저를 가진다.

이 모듈 패턴을 사용하려면 두 가지 조건이 있다.

  1. 하나의 최외각 함수가 존재하고 이 함수는 최소 한 번은 호출되어야 한다.

  2. 최외각 함수는 최소 한 번은 하나의 내부 함수를 반환해야 한다. 그래야 해당 내부 함수가 비공개 스코프에 대한 클로저를 가져 비공개 상태에 접근하고 수정할 수 있다.

하나의 함수 속성 만을 가지는 객체는 진정한 모듈이 아니다. 함수 실행 결과로 반환된 객체에 데이터 속성들은 있지만 닫힌 함수가 없다면 그 객체는 진정한 모듈이 아니다.

이 패턴에서 오직 하나의 인스턴스, 싱글톤만 생성하는 모듈이다.

var foo = (function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log("something");
}
function doAnother() {
console.log(another.join(" ! "));
}
return {
doSomething,
doAnother
};
})();
foo.doSomething();
foo.doAnother();

앞의 코드에서 모듈 함수를 IIFE로바꾸고 즉시 실행시켜 반환 값을 직접 하나의 모듈 인스턴스 확인자 foo에 대입시켯다.

모듈은 함수이므로 인자를 받을 수 있다.

function CoolModule(id) {
function identify() {
console.log(id);
}
return {
identify
};
}
var foo1 = CoolModule("foo 1");
var foo2 = CoolModule("foo 2");
foo1.identify(); // foo 1
foo2.identify(); // foo 2

효과적인 모듈 패턴 중 다른 하나는 공개 API로 반환하는 객체에 이름을 정하는 방식이다.

var foo = (function CoolModule(id) {
function change() {
publicAPI.identify = identify2;
}
function identify1() {
console.log(id);
}
function identify2() {
console.log(id.toUpperCase());
}
var publicAPI = {
change,
identify: identify1
};
return publicAPI;
})("foo module");
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

공개 API 객체에 대한 내부 참조를 모듈 인스턴스 내부에 유지하면 모듈 인스턴스를 내부에서부터 메서드와 속성을 추가 또는 삭제하거나 값을 변경하는 식으로 수정할 수 있다.

현재의 모듈

많은 모듈 의존성 로더와 관리자는 본질적으로 이 패턴의 모듈 정의를 친숙한 API 형태로 감싸고 있다.

var MyModules = (function Manager() {
var modules = {};
function define(name, deps, impl) {
for (var i = 0; i < deps.length; i++) {
deps[i] = modules[deps[i]];
}
modules[name] = impl.apply(impl, deps);
}
function get(name) {
return modules[name];
}
return {
define,
get
};
})();

이 코드의 핵심부는 modules[name] = impl.apply(imp1,deps)이다

이 부분은 의존성을 인자로 넘겨 모듈에 대한 정의 래퍼 함수를 호출하여 반환값은 모듈 API를 이름으로 정리된 내부 모듈 리스트에 저장한다.

MyModules.define("bar", [], function() {
function hello(who) {
return "Let me introdule: " + who;
}
return {
hello
};
});
MyModules.define("foo", ["bar"], function(bar) {
var hungry = "hippo";
function awesome() {
console.log(bar.hello(hungry).toUpperCase());
}
return {
awesome
};
});
var bar = MyModules.get("bar");
var foo = MyModules.get("foo");
console.log(bar.hello("hippo")); // let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

foo와 bar 모듈은 모두 공개 API를 반환하는 함수로 정의됐다.

foo는 bar의 인스턴스를 의존성 인자로 받아 사용할 수도 있다.

모든 모듈 관리자는 앞에서 언급한 모듈 패턴의 특성을 모두 가진다.

즉 이들은 함수 정의 래퍼를 호출하여 해당 모듈의 API인 반환 값을 저장한다.

미래의 모듈

ES6는 모듈 개념을 지원하는 최신 문법을 추가했다.

모듈 시스템을 불러올 때 ES6는 파일을 개별 모듈로 처리한다.

각 모듈은 다른 모듈 또는 특정 API 멤버를 불러오거나 자신의 공개 API 멤버를 내보낼 수도 있다.

함수 기반 모듈은 정적 패턴이 아니다. 따라서 이들 API는 런타임 전까지 해석되지 않는다.

실제로 모듈의 API를 런타임에 수정할 수 있다.

반면 ES6 모듈 API는 정적이다. 따라서 컴파일러는 이 사실을 알아서 컴파일레이션 중에 불러온 모듈의 API 멤버 참조가 실제로 존재하는지 확인할 수 있다.

API 참조가 존재하지 않으면 컴파일러는 컴파일시 초기 오류를 발생시킨다.

전통적인 방식처럼 변수 참조를 위해 동적 런타임까지 기다리지 않아도 된다.

ES6 모듈은 inline 형식을 지원하지 않고 반드시 개별 파일에 정의되어야 한다.

브라우저와 엔지은 기본 모듈 로더를 가진다.

모듈을 불러올 때 모듈 로더는 동기적으로 모듈 파일을 불러온다.

// bar.js
function hello(who) {
return "Let me introduce:" + who;
}
export hello;
// foo.js
import hello from 'bar';
var hungry = 'hippo';
function awesome() {
console.log(hello(hungry).toUpperCase())
}
export awesome;
// baz.js
module foo from 'foo';
module bar from 'bar';
console.log(bar.hello('rhino'))
foo.awesome();

키워드 import는 모듈 API에서 하나 이상의 멤버를 불러와 특정 변수에 묶어 현재 스코프에 저장한다. 키워드 module은 모듈 API 전체를 불러와 특정 변수에 묶는다. 키워드 export는 확인자를 현재 모듈의 공개 API로 내보낸다.

모듈 파일의 내용은 스코프 클로저에 감싸진 것으로 처리된다.

정리하기

클로저는 함수를 렉시컬 스코프 밖에서 호출해도 함수는 자신의 렉시컬 스코프를 기억하고 접근할 수 있는 특성을 의미한다.

클로저는 다양한 형태의 모듈 패턴을 가능하게 하는 매우 효과적인 도구이기도 하다.

모듈은 두 가지 특성을 가져야 한다.

  1. 최외곽 래퍼 함수를 호출하여 외곽 스코프를 생성한다.

  2. 래핑 함수의 반환 값은 반드시 하나 이상의 내부함수 참조를 가져야 하고 그 내부 함수는 래퍼의 비공개 내부 스코프에 대한 클로저를 가져야한다.