티스토리 뷰

들어가며
var text = 'Hello, ';

function greeting() {
  var name = 'Pewww';
  
  return function() {
    console.log(text + name);
  }
}

var g = greeting();
g(); // 'Hello, Pewww' 출력

 

예전에도 클로저 예제 분석에 대한 글을 썼었지만, (지금은 삭제한 상태)

제대로 이해하지 못한 상태였고, 저 스스로도 확실히 과정이나 결과에 대해 납득하기 어려웠습니다.

계속 그렇게 찝찝한 상태로 있다가 얼마 전, 내용을 다시 정리해보고 싶다는 생각이 들었고,

저번 보다는 좀 더 이해도가 높아졌다고 생각하여 글을 다시 써보려고 합니다.

솔직히 완벽히 이해했다고는 말할 수 없지만, 제가 고민하며 정리한 내용을 보고 다른 분들께서도 이해가 되지 않던, 혹은 풀리지 않던 문제의 실마리가 되었으면 합니다.

다음에 다시 클로저 예제 분석에 대한 글을 쓸 때에는 완벽히 이해한 제 모습을 기대하며, 포스팅 시작해보도록 하겠습니다.

 

 

분석
var i;

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

아마 클로저에 대해 찾아보신 분들은 한 번쯤은 봤을 법한 예제입니다.

모두가 결과물이 0, 1, 2, 3, 4가 출력되는 것이 아닌 5가 5번 출력이 된다고 알고 있지만, 막상 그 결과에 대해 쉽게 납득하기 어려운 분들도 많을 것 입니다.

이해하기 쉽게 그림을 그리며 한 번 설명해보겠습니다.

 

1. for 루프 시 setTimeout 구문이 실행되며, setTimeout은 콜스택에 쌓인다.

※ setTimeout 내부 익명함수는 Anonymous Function의 약자인 A.F로 축약하여 사용하겠습니다.

 

1번 과정

 

2. 웹 API 중 하나인 타이머가 생성되며, 익명함수(A.F)는 Web APIs로 이동합니다.

 

2번 과정

 

3. setTimeout이 완료되고 콜스택에서 제거됩니다.

 

3번 과정

 

4. 설정한 타이머가 지난 후 익명함수(A.F)는 콜백 큐로 이동됩니다.

타이머를 0으로 설정했지만, 실제로는 최소 4ms가 지난후에야 콜백 큐로 이동된다고 합니다!

 

4번 과정

 

5. 이 작업이 5번 반복되고, for 루프는 종료됩니다. (- 콜백 큐에는 5개의 익명함수가 차례로 쌓인 상태입니다.)

 

5번 과정

 

6. 루프가 모두 종료되어 현재 콜스택이 비어있는 상태이므로, 이벤트 루프는 콜백큐의 익명함수를 하나씩 콜스택으로 올립니다.

 

6번 과정

 

7. 익명함수가 실행됩니다.

 

이해를 돕기 위해 선행적으로 알아야 하는 개념을 말씀드리자면,

 

1. 함수는 선언될 때 스코프가 생성되며, 우리는 이것을 렉시컬 스코핑이라고 한다.

var name = 'Hello';

function showName() {
  // 함수는 선언될 때 스코프가 생성됩니다.
  // 따라서 console 구문에서의 name은 전역에 선언된 name(= 'Hello')를 가리킵니다.
  console.log(name);
}

function callShowName() {
  var name = 'Bye';
  // 만약 name = 'Bye'의 구문이었다면, 전역 변수 name의 값이 'Bye'로 바뀌기 때문에
  // showName 함수를 호출 했을 때, 콘솔은 'Hello'가 아닌 'Bye'를 출력하게 됩니다.
  showName();
}

callShowName(); // 'Hello' 출력

위의 예제에서 'Bye'가 아닌 'Hello'가 출력되는 이유 역시 렉시컬 스코핑 때문이며, 이에 따라 함수의 스코프 체인 역시 선언 시에 결정됩니다.

 

2. 함수는 호출될 때, 컨텍스트가 생성된다.

 

함수는 호출될 때, 함수 실행 컨텍스트가 생성됩니다.

이에 따라 활성 객체가 만들어지는데, 쉽게 말해 해당 컨텍스트에서 실행에 필요한 여러 정보들이 담긴 자바스크립트 객체라고 생각하시면 됩니다.

크게, 변수 객체(Variable Object), 스코프 체인(Scope Chain), this의 프로퍼티들로 나뉘어 지는데 앞으로 V.O, S.C, this로 축약하여 설명 이어가도록 하겠습니다.

 

함수가 호출되기 전 생성되는 전역 실행 컨텍스트를 먼저 살펴보자면 다음과 같이 나타낼 수 있습니다.

 

전역 실행 컨텍스트

 

arguments는 없는 상황이고, i라는 변수가 등록되어 있습니다.

스코프 체인은 전역 변수 객체가 연결되어 있으며, this는 window를 가리킵니다.

 

그럼 이제 실제 익명함수가 호출되었을 때의 함수 실행 컨텍스트를 살펴볼까요?

함수 실행 컨텍스트

arguments와 variable 모두 없는 상황이고 this는 여전히 window를 가리키고 있습니다.

스코프 체인은 자기 자신 (= 익명 함수의 변수 객체)과 전역 변수 객체가 연결 되어 있습니다.

 

익명 함수가 실행될 때, 콘솔에 i를 출력하려 합니다.

하지만, 자신의 변수 객체에 i라는 프로퍼티를 찾을 수 없는 상황입니다.

그럼 그냥 undefined를 출력할까요?

 

아닙니다.

스코프 체인으로 전역 변수 객체가 연결되어 있기 때문에, 우리는 보다 상위 스코프인 전역 스코프를 탐색할 수 있습니다.

(우리는 이것을 스코프 체이닝이라고 부릅니다.)

 

탐색 시, i를 성공적으로 찾았는데 문제는 for 루프가 모두 돌면서 i는 이미 5로 변화된 상태라는 것 입니다.

따라서, 현재 콜 스택에 있는 익명함수와 콜백 큐에 대기 중인 익명함수는 유감스럽게도 모두 5로 변화된 i를 참조하고 있는 것이죠.

 

그렇기 때문에, 우리는 0, 1, 2, 3, 4가 콘솔에 출력되길 바랐지만 정작 5가 5번 출력되는 현상을 볼 수 밖에 없던 것 입니다.

그림으로 간단히 나타내보자면, 아마 위와 같은 상황일 것 입니다.

 

상황

 

그렇다면, 해결책 중 하나인 IIFE를 적용하면 어떻게 될까요?

IIFE로 해결하기
var i;

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

함수가 선언될 때 스코프가 생성되고 스코프 체인 역시 정해진다고 말씀 드렸었습니다.

따라서, IIFE는 자기 자신 (IIFE의 변수 객체)과 더불어 스코프 체인으로 전역 변수 객체 역시 연결되어 있습니다.

setTimeout 내부 익명함수는 자기 자신(익명 함수의 변수 객체)과 함께, 상위 스코프들인 IIFE와 전역 변수 객체가 연결될 것 입니다.

 

이제 실제 루프를 돌며, IIFE가 호출되고 IIFE의 컨텍스트 역시 생성이 됩니다.

아까와 다른 점은 i를 j라는 이름의 인자로 받고 있다는 점 인데, 이를 그림으로 나타내보겠습니다.

 

IIFE 실행 컨텍스트

 

또한, IIFE 함수가 실행되면 setTimeout 구문 역시 실행되고, 내부의 익명함수가 호출됩니다.

함수가 호출되면, 자연스럽게 컨텍스트가 생성되며 익명함수의 컨텍스트는

 

익명 함수 실행 컨텍스트

 

위와 같을 것 입니다.

 

따라서, 루프가 모두 돌고 나서는 서로 다른 5개의 컨텍스트들이 생성될 것이고,

컨텍스트 내에서, 스코프 체인을 통해 상위 스코프의 변수 객체에 접근 가능한 전체적인 모습을 나타내보면 아마 아래와 같지 않을까 생각합니다.

 

유기적으로 연결되어 있는 모습

어쨌든, 익명함수가 실행되고 콘솔에 j를 출력하고자 할 때, 아까와 마찬가지로 자신의 변수 객체에 j를 찾을 수 없는 상황입니다.

따라서, 스코프 체이닝으로 바로 윗 단계인 IIFE 스코프를 탐색하게 되고, 해당 변수 객체에서 j를 성공적으로 찾게 되어 체이닝은 종료됩니다.

각 IIFE 컨텍스트 내부 변수 객체에서 j는 0, 1, 2, 3, 4의 값을 가지므로 콘솔에는 우리가 원하던 결과인 0, 1, 2, 3, 4가 출력됩니다.

 

주의
// 1번
var i;

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

// 2번
var i;

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

 

주의할 1번 예제를 보면, IIFE를 실행하는데 i를 넘겨주지 않고 있습니다.

 

즉, IIFE 컨텍스트 내부 변수 객체는 arguments와 variable 모두 null이 되는 것이죠.

따라서, 실제 setTimeout 내부 익명함수가 실행되어 콘솔에 i를 출력하고자 할 때,

자신의 변수 객체에서는 i를 찾을 수 없으니 상위 스코프인 IIFE 스코프를 탐색하게 되고, 이 곳에서도 찾을 수 없으니 결국은 전역 스코프를 탐색하여 이미 5로 변해버린 i를 참조하게 됩니다.

 

그리고 2번 예제에서는, setTimeout 내부에서 IIFE를 실행하고 있습니다.

이 경우 함수가 즉시 실행되기 때문에, 콘솔에 원하는 결과가 출력 될 수는 있어도 설정한 timer(1000ms)는 무시됩니다.

 

 

정리

함수는 호출할 때 환경(컨텍스트)이 만들어집니다.

함수가 만들어질 때, 그 시점의 환경에 대한 참조를 물고 가는 것을 우리는 클로저라고 합니다.

참조를 물고 가기 때문에, 이미 외부 함수의 생명 주기가 끝났더라도, 내부 함수는 그 외부 함수의 변수를 참조할 수 있게 됩니다.

 

하나 더 간단한 예시를 들어보겠습니다.

var text = 'Hello, ';

function greeting() { // 외부 함수
  var name = 'Pewww';
  
  return function() { // 내부 함수
    console.log(text + name);
  }
}

var g = greeting();
g(); // 'Hello, Pewww' 출력

위의 코드에서, greeting() 함수는 익명 함수를 반환하고 있습니다.

var g = greeting(); 구문에서 greeting() 함수는 실행된 이후, 실행 컨텍스트 스택에서 제거됩니다.

이에 따라, 변수인 name도 같이 소멸되어 내부 함수의 console 구문에서 name에 대해 접근할 수 없어 보이는데, 실제로 g()를 실행했을 때

콘솔에는 정상적으로 'Hello, Pewww'가 출력됩니다.

 

위에서 설명했던 내용들을 곱씹어보면, 그다지 이해하기 어렵지 않은 내용일 것 입니다.

 

greeting() 함수 실행 컨텍스트의 활성 객체는 사라지지 않고 유효하기 때문에 (활성 상태 유지), 외부함수인 greeting이 종료되더라도 내부 익명 함수에서는 name에 대한 접근이 가능하게 됩니다.

(아래는 이해를 돕기 위해 [[Scopes]] 프로퍼티를 찍은 모습입니다.)

 

[[Scopes]] 프로퍼티

 

자바스크립트에서 가비지 콜렉터(일명 G.C) 는 참조(Reference)를 바탕으로 동작하는데, 이처럼 클로저에 의해 참조 되고 있으면 가비지 콜렉터는 정상적으로 메모리를 회수하지 못 하게 됩니다.

이 점 때문에, 클로저를 사용할 경우 메모리 누수에 대해 조심해야 한다고 얘기하는 것 이며,

사용 후 참조를 없애주는 것이 중요합니다.

 

실제 브라우저 상에서 가비지 콜렉터가 동작하는 모습을 간접적으로(?) 확인할 수 있는데, 이 부분에 대해서는 추후 포스팅 해 볼 예정입니다.

 

 

마치며

내용을 완벽히 이해하려면, 더욱 폭넓은 지식이 필요하다는 것을 깨달았습니다.

최대한 자세히 내용을 담아보려 했는데, 그러지 못 한 것 같아 아쉬움이 많이 남습니다.

 

Ps. 저 역시 공부하는 입장이기 때문에, 오해의 소지나 잘못된 내용이 있을 수도 있습니다...

그런 부분들이 존재 할 경우 댓글로 정정해주시면 정말 감사하겠습니다! 😀

댓글