자바스크립트 개발을 좀 해봤다는 사람이 만약 개발자 채용 면접을 보게된다면 클로저에 대한 질문을 반드시 받을 것이다(감히 확신한다!). 그만큼 클로저는 자바스크립트를 처음 접했을 때 적지 않은 혼란을 주는 개념이고 또 중요하다. 혹시 다음과 같은 코드를 보며 혼란스러웠던 경험이 있지 않은가?
function foo(param){
var a = param;
return function(){
console.log(a);
}
}
var test = foo('closure!');
test();
실행결과는 다음과 같다.
위의 예제를 처음 접했다면 의문이들기 마련이다. foo 는 익명 함수를 리턴한뒤 종료된다. 이 시점에 foo 내부의 변수인 a 도 가비지콜렉터(GC)에 의해 정리되는 것이 정상적인 흐름처럼 보인다. 하지만 놀랍게도 test 함수를 실행하면 이미 날아가 버린줄 알았던 a 의 값이 멀쩡하게 콘솔에 찍힌다.
이는 사실 foo 가 GC에 의해 정리되지 않았기 때문이다. 다시말해 foo 내부의 익명 함수가 자신을 둘러싼 환경을 기억한 것이다. 이렇듯 클로저란 개념은 어떤 함수가 그 함수를 둘러싼 환경을 기억하는 것인데 여전히 이런 설명만으로는 감을 잡기가 어렵다. 클로저를 이해하기 위해서는 먼저 자바스크립트의 스코프 개념에 대해 알아볼 필요가 있다.
스코프
자바스크립트는 ECMAScript의 표준 명세를 따른다. 또한, ES5까지 함수 레벨의 스코프를 지원해왔다. 즉, 함수 내부에서 선언된 변수는 함수 전체 범위에서 사용할 수 있었다. 다음과 같이 말이다.
function foo() {
if (true) {
var color = 'blue';
}
console.log(color); // blue
}
foo();
위 코드를 실행하면 콘솔에는 blue 가 찍힌다. 만약 함수 스코프가 아닌 블록 스코프였다면 color 라는 변수는 if문의 중괄호 안에서만 유효했을 것이다. 하지만 중괄호를 벗어나서도 여전히 유효하다. 자바스크립트의 함수 내에서 선언된 변수는 함수 전체를 유효한 스코프로 가지는, 함수 스코프를 따르기 때문이다. 사실 여기에도 중요한 원리가 숨어있다. 바로 호이스팅이란 개념이다.
❙ 호이스팅
위 예제에서 color는 if 블록안에서 선언된 것 같아 보인다. 하지만 자바스크립트 엔진은 변수를 이렇게 처리한다.
function foo() {
var color;
if (true) {
color = 'blue';
}
console.log(color); // blue
}
foo();
차이가 보이는가? color 의 선언부가 함수의 맨 꼭대기로 끌어올려졌다. 이렇듯, 자바스크립트 엔진은 내부에서 할당된 변수는 무조건 함수의 시작 위치로 끌어올려서 선언한다. 이를 호이스팅이라고 한다. 그리고 호이스팅때문에 이런 일도 가능해진다.
function foo() {
a = 2;
var a;
console.log(a); // 2
}
foo();
변수가 선언되기도 전에 값이 할당되었다. 하지만 콘솔에는 문제없이 2가 찍힌다. 자바스크립트 엔진이 변수 a 의 선언부를 함수의 시작 위치로 호이스팅하기 때문이다.
❙ 블록 레벨 스코프
그런데, ES6가 발표되면서는 자바스크립트도 블록 레벨의 스코프를 지원하게 되었다. 다음과 같이 말이다.
function foo() {
if(true) {
let color = 'blue';
console.log(color); // blue
}
console.log(color); // ReferenceError: color is not defined
}
foo();
var 대신 let 이라는 키워드로 변수를 선언해주었다. 이는 변수가 블록 레벨의 스코프를 가진다는 뜻이다. 따라서 if 블록 안에서 선언된 color 라는 변수는 중괄호 안에서만 유효하며, if 블록을 벗어나서는 더 이상 참조할 수 없는 것이다. 블록 레벨 스코프를 지정하는 또 다른 키워드로 const가 있다. const는 상수를 선언하기 위해 쓰인다.
❙ 렉시컬 스코프
여기서 만족할 수 없다. 한층 더 깊이 들어가보자. 자바스크립트의 스코프의 첫번째 특징이 함수 레벨 스코프(ES6부터는 블록 스코프 지원)라면, 두번째 특징이 바로 렉시컬 스코프이다. 렉시컬 스코프는 동적 스코프와 비교하면 이해가 쉽다. 간단히 둘을 설명하자면 이렇다.
동적 스코프는 런타임에 함수가 실행되는 컨텍스트에서 결정되는 반면, 렉시컬 스코프는 코드가 작성된 컨텍스트에서 결정된다.
역시 예를 들어보는게 빠를 것 같다.
var x = 'global';
function foo() {
var x = 'local' ;
bar();
}
function bar() {
console.log(x);
}
foo();
bar();
위 코드의 출력 결과를 예상해보자. bar 의 출력 결과는 당연히 global 일 것 같다. 그런데 foo 는? foo 내부에서 변수를 재할당해주고 있으니 local 이 출력되지 않을까? 하지만 실행 결과는 다음과 같다.
왜 둘다 global 이 찍힌 것일까. 이는 자바스크립트가 렉시컬스코프를 가지기 때문이다. 자바스크립트의 스코프 규칙은 런타임이 아닌 소스코드 자체의 문맥을 기준으로 한다. 소스코드가 작성됐을 때의 컨텍스트를 바탕으로 스코프를 결정하고, 런타임시에 이를 변경하지 않는 것이다. 아래 그림과 같이 말이다.
하지만 Perl과 같이 다이나믹 스코프를 따르는 언어들은 런타임에 스코프를 결정하기 때문에 local , global 이 출력된다.
❙ 스코프 체인
그렇다면 중첩 함수의 경우에 스코프는 어떻게 처리될까. 다음 예제의 출력 결과를 예상해보자.
var globalColor = 'red';
function foo() {
var fooColor = 'blue';
function bar() {
var barColor = 'yellow';
console.log(barColor);
console.log(fooColor);
console.log(globalColor);
}
bar();
}
foo();
출력은 아래와 같다.
언뜻 당연한 결과인듯 하지만 이 예제의 경우 자바스크립트 엔진은 스코프 체인이라는 개념을 이용한다. 자바스크립트는 각 함수가 작성됐을때의 컨텍스트를 기준으로 스코프를 가진다고 했다. 다시 말하지만 이는 런타임시 변경되지 않는다. 그렇다면 각 스코프는 스스로 고유한 환경을 가지고 있으며, 소스코드가 작성됐을때 이 환경을 저장(기억)해둔다고 생각할 수 있다. 이것이 렉시컬 환경(Lexical environment)이다.
중첩 함수에서, 안쪽 함수가 자신의 렉시컬 환경을 뒤져도 변수값을 찾을 수 없을 때는 바깥쪽 함수의 렉시컬 환경을 참조한다. 만약 그래도 없다면 그 바깥의 렉시컬 환경을 참조한다. 이렇게 바깥쪽 렉시컬 환경이 null이 될때까지 쭉 참조를 이어가는 것이 스코프 체인이라는 개념이다. 아래 그림과 같이 체인처럼 엮여서 참조가 이루어지는 것이다.
클로저(Closure)
드디어 대망의 클로저다. 결국 이 클로저에 대한 설명이 본 포스팅의 목적이었다. 나는 글의 서두에서 클로저를 어떤 함수가 그 함수를 둘러싼 환경을 기억하는 것이라고 정의한바있다. 하지만 이제 렉시컬 스코프라는 개념을 받아들였으니 조금 수정을 가해보자.
클로저 = 함수가 함수를 둘러싼 렉시컬 환경을 기억하는 것
여기까지 무리없이 받아들였다면 이제 다음의 예제를 보고 실행 결과를 예측해보자.
var color = 'red';
function foo() {
var color = 'blue'; // 2
function bar() {
console.log(color); // 1
}
return bar;
}
var baz = foo(); // 3
baz(); // 4
실행해보면 blue 가 출력된다. 글의 서두에서 클로저의 예를 보여줬는데 이제 그 비밀을 풀 시간이다. 다음과 같이 진행될 것이다.
1. bar 는 foo 의 렉시컬 환경을 참조하며, 이를 저장한다.
2. bar 는 baz 라는 글로벌 변수에 할당된다.
3. baz 호출시 color 값을 찾는다. 하지만 찾을 수 없자 저장했던 바깥 환경을 참조한다.
4. 바깥 환경인 foo 에서 color 값을 찾았고 blue 가 출력된다.
여기서 중요한 사실은 foo 가 종료된 이후에도 foo 의 렉시컬 환경을 참조할 수 있다는 것이다. 함수가 종료되면 본래 가비지콜렉터(GC)가 이를 회수해야 하는데, bar 가 여전히 foo 의 환경을 참조(저장)하고 있다. 따라서 GC는 함부로 이를 회수하지 못하는 것이다. 아래 그림과 같은 상황이다.
❙ Timeout 클로저 예제
function count() {
var i;
for (i = 1; i < 10; i += 1) {
setTimeout(function timer() {
console.log(i);
}, i*100);
}
}
count();
위 코드는 꽤 유명한 예제인데, 실행해보면 당연히 1부터 9까지가 출력될 것 같다. 하지만 결과는 이렇다.
앞의 숫자는 콘솔에서 중복 출력의 횟수를 의미한다. 즉, 숫자 10이 9번 중복 출력이 되었다. 왜 그런것인지 과정을 차근차근 밟아보자.
일단 setTimeout 은 count 의 렉시컬 환경을 저장한다. 그리고나서 setTimeout 은 브라우저에 타임아웃 이벤트를 요청한 뒤 호출스택에서 제거된다(이 과정에 대한 자세한 설명은 지난글을 참조하자). 0.1초가 지난 후 timer 가 호출스택에 올라와 실행되는데, 그 사이에 이미 i 는 10으로 증가되어 있다. timer 는 i 를 출력하기 위해 count 의 렉시컬 환경을 참조하는데, 그 값은 항상 10이기 때문에 당연히 모든 경우에 10이 출력되는 것이다.
어떻게 의도했던 동작을 하도록 고칠 수 있을까? 스코프를 이용하면 된다. count 와 timer 사이에 스코프를 추가하여 timer 가 이를 참조하도록 하는 것이다. 다음과 같이 말이다.
function count() {
var i;
for (i = 1; i < 10; i += 1) {
(function(countingNumber) {
setTimeout(function timer() {
console.log(countingNumber);
}, i * 100);
})(i);
}
}
count();
이렇게 하면 timer 는 count 의 렉시컬 환경을 바로 참조하는 것이 아닌, 중간의 countimgNumber 환경을 참조하여 i 값을 읽어온다. 따라서 원하는 출력 결과를 확인할 수 있다. 직접 한번 확인해보도록 하자!
마무리하며
예제는 TOASTMeetup!에 이민규님께서 작성하신 글을 많이 참조했다. 사실 글 내용도 많은 부분을 참조했지만 되도록 필요한 내용만 발췌요약하여 쉽게 쓰려고 노력했다. 하지만 훨씬 좋은 글이니 여유가 된다면 이민규님의 글을 읽어보시길 추천한다.
'IT > 개발지식' 카테고리의 다른 글
[자바스크립트] undefined와 null의 차이 (0) | 2018.02.18 |
---|---|
[자바스크립트] debugger 활용법 (0) | 2018.02.04 |
[자바스크립트] this의 개념과 활용법 (2) | 2018.01.20 |
[자바스크립트] 비동기 처리의 원리 (0) | 2017.12.24 |
[자바스크립트] 이벤트의 동작 원리 (2) | 2017.12.10 |