Clousure(클로저)

MaxlChan·2020년 7월 19일
3
post-custom-banner

자바스크립트 공부를 시작하고 나서 가장 이해하기 어려웠던 개념 중 하나가 바로 이 클로저였다.
처음에는 이름만 보고서는 영화 클로저, Closer(2004)와 비슷한 의미인 줄 알고, 가까운? 다가갈 수 있는?
이 정도 느낌으로 다가왔는데, 결론적으로.. 전혀 아니다(일단 단어부터 틀리다, clousure다.) 부끄러운 과거,,

해당 개념을 이해하기까지 MDN이나 다른 블로그를 각각 10번은 본 것 같다.
그만큼 초심자에게 쉽지 않은 개념인 것은 확실하다.
지금까지 클로저에 대해 이해한 내용을 차근차근 정리해보고자한다.

🚨 올바르지 않은 내용이 있을 경우 댓글로 남겨주시면 감사드리겠습니다.

클로저(Clousure)란?

“A closure is the combination of a function and the lexical environment within which that function was declared.”
클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.

출처 - MDN

클로저(closure)는 내부함수가 외부함수의 맥락(context)에 접근할 수 있는 것을 가르킨다.

출처 - 생활코딩

시작에 앞서 클로저의 정의를 써보았다.

처음에 해당 클로저의 정의를 봤을 때 정말 이게 한국말인가 싶었다.
일부러 이해 못하게 하려고 말 장난하는 것 같았다.
그래도 해당 개념에서 가장 유심히 봐야할 대목은 아래와 같다고 생각한다.

  1. 함수가 선언됐을 때의 렉시컬 환경(Lexical environment),
  2. 내부함수가 외부함수의 맥락(context)에 접근

일단 클로저란 개념 자체를 이해하기 위해서는 '실행 컨텍스트'에 대한 개념 이해가 우선되어야 완전한 이해가 될 수 있다.

클로저에 대한 이해를 빠르게 도울 수 있을 정도로만 '실행 컨텍스트'에 대해 살펴보고 얕게 살펴본 후 다시 저 혼란스러운 정의들을 이해해보자.

실행 컨텍스트(Execution Context)

문제는 이 실행 컨텍스트라는 이름부터도 익숙치 않다. 실행 문맥이라..
일단 컨텍스트(문맥)이라는 것이 대체 무엇인지 간단하게 생각해보자.

상황(환경)1
여행가이드가 관광지에 도착해서 여행객들에게 말하는 상황
"자 이제 도착했으니 우리 모두 내립시다"
-> 진짜 같이 내리자는 것을 의미

상황(환경)2 -
버스에서 내리려고 하는데 문앞에 사람이 가로막고 있는 상황
"내립시다"
-> 비켜달라고는 것을 의미

위 예시에서 볼 수 있듯이 같은 '내립시다'라는 표현이 문맥(환경)에 따라 다르게 사용될 수 있다.
즉, 언어가 제대로 이해되고 사용되기 위해서는 반드시 언어가 사용되는 환경이 필요하다.
결국 컨텍스트(문맥)라는 의미는 대략 '환경'이라는 의미로 나는 이해했다.

그럼 이것을 자바스크립트에서 말하는 실행 컨텍스트란?

실행 컨텍스트란 실행 가능한 코드가 실행되기 위해 필요한 환경이다.

실행 컨텍스트는 크게 전역 실행 컨텍스트, 함수 실행 컨텍스트로 나뉘어진다.

그리고 실행 컨텍스트(물리적으로 객체의 형태를 띔)는 3가지 정보(프로퍼티)를 가진다.

  1. 변수 객체(Variable Object) - 변수, 매개변수, arguments(함수의 경우), 함수 선언
  2. 스코프 체인(Scope Chain)
  3. this value

그럼 위 3가지 프로퍼티를 중심으로 간단한 예제를 통해 실행 컨텍스트가 어떻게 형성되고 코드가 실행되는 지 살펴보자.

var a = 1;

function outerFunc(b) {
  var c = 3;

  function innerFunc() {
    var d = 4;
    console.log(a + b + c + d);
  }
  
  innerFunc();
}
  
outerFunc(2);

먼저 전역 코드로 진입하면 전역 실행 컨텍스트가 생성된다.

이 때 전역 실행 컨텍스트는 3가지 프로퍼티를 가지게 된다.

변수 객체(Variable Object) == Global Object

  • arguments : null,
  • 변수 : [a, outerFunc]

스코프 체인(Scope Chain) == 일종의 리스트이다

  • [Global Scope]

this value

  • window 객체(Global Object)

이후 코드가 순차적으로 위에서부터 실행됨으로서 변수 a에 1이 할당되고 outerFunc함수가 실행된다.

그럼 다시outerFunc함수 실행 컨텍스트가 생성되고, 아래와 같은 프로퍼티를 가지게 된다.

변수 객체(Variable Object) == Activation Object

  • arguments : [{ b : 2 }]
  • 변수 : [c, innerFunc]

스코프 체인(Scope Chain)

  • [OuterFunc Function Scope, Global Scope]

this value

  • window 객체(Global Object)

이후 코드가 다시 위에서부터 실행되고 변수 c에 3이 할당되고 innerFunc함수가 실행된다.

innerFunc함수 실행 컨텍스트가 생성되고, innerFunc 실행 컨텍스트의 프로퍼티는 아래와 같다.

변수 객체(Variable Object) == Activation Object

  • arguments : null
  • 변수 : [d]

스코프 체인(Scope Chain)

  • [innerFunc Function Scope, OuterFunc Function Scope, Global Scope]

this value

  • window 객체(Global Object)

마지막으로 함수 코드가 실행되어 변수 d에 4가 할당되고
console.log(a + b + c + d)가 실행된다.

여기서 중요한 것은!

비록 innerFunc함수에는 변수 a, b, c가 존재하지 않지만,

해당 변수에 접근하기 위해 스코프체인의 0번째 인덱스부터 1, 2(글로벌 스코프)까지 순차적으로 검색한다.

innerFunc의 실행 컨텍스트에 담긴 스코프체인 프로퍼티를 통해

  1. 스코프체인[0]인 innerFunc 실행 컨텍스트의 변수 객체(Variable Object)를 참조하여 d 값에 접근

  2. 스코프체인[1]인 상위함수 outerFunc 실행 컨텍스트의 변수 객체(Variable Object)를 참조하여 b, c값에 접근

  3. 스코프체인[2]인 전역 실행 컨텍스트의 전역 객체(Global Object)를 참조하여 a값에 접근

  4. 결국10(1 + 2 + 3 + 4)을 출력하게 된다.

실행 컨텍스트 스택

출처 - PoiemaWeb

실행 컨텍스트는 생성이 될 때마다 논리적 스택 구조를 가지는 새로운 실행 컨텍스트 스택이 생성된다.

스택은 LIFO(Last In First Out, 후입 선출)의 구조를 가지는 나열 구조이다.

var a = 1;

function outerFunc(b) {
  var c = 3;

  function innerFunc() {
    var d = 4;
    console.log(a + b + c + d);
  }
  
  innerFunc();
}
  
outerFunc(2);

실행 컨텍스트 스택을 기준으로 위 예시 코드를 간단히 살펴보자.

  1. 전역코드로 진입하고 전역 실행 컨텍스트 스택이 쌓인다
  2. outerFunc함수가 실행되고 outerFunc함수 실행 컨텍스트가 스택에 쌓인다.
  3. innerFunc함수가 실행되고 innerFunc함수 실행 컨텍스트가 스택에 쌓인다.
  4. innerFunc함수가 종료되고 실행 컨텍스트 스택에서 소멸된다.
  5. outerFunc함수가 종료되고 실행 컨텍스트 스택에서 소멸된다.
  6. 이제 전역 실행 컨텍스트만에 스택에 남아있고, 전역 실행 컨텍스트는 어플리케이션이 종료될 때, 실행 컨텍스트 스택에서 소멸된다.

기본적으로 함수 실행 컨텍스트가 소멸하면,
함수의 상위 스코프에서는 해당 함수 실행 컨텍스트가 저장하고 있는 변수 객체(Variable Object)에 대해
접근하거나 변화를 추적할 수 없다.

왜냐하면?
상위스코프는 하위스코프에 접근 할 수 없기 때문이기도 하고,
이미 함수 실행 컨텍스트가 스택에서 소멸하면서 가지고 있던 정보나 환경도 함께 소멸하기 때문이다.

function a() {
  var name = "chan";
}

a(); // a 함수 실행 컨텍스트가 생성

console.log(name); // 스택에서 실행 컨텍스트가 소멸된 이후이므로, 변수 name에 접근 불가

하지만 실행 컨텍스트가 소멸되어도
소멸된 실행 컨텍스트의 변수 객체에 접근할 수 있는 방법이 존재한다.
바로 클로저를 이용하면 된다!

클로저는 함수 자신이 선언, 생성될 때의 주변 환경을 기억하는 함수이다.

클로저는 반환된 내부함수가 자신이 생성 혹은 선언됐을 때의 환경(Lexical environment)인 스코프를 기억하여, 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수를 말한다.

특정 함수의 상위 스코프는 함수를 호출하는 위치나 시점이 아니라
함수를 어디에서 선언하였는지에 따라 결정된다. 아래 예제를 살펴보자.

var a = 1;

function outerFunc(b) {
  var c = 3;

  function innerFunc() {
    var d = 4;
    c++;
    console.log(a + b + c + d);
  }
  
  return innerFunc;
}
  
var contextOne = outerFunc(2);
var contextTwo = outerFunc(3);

contextOne(); // 11
contextTwo(); // 12
contextTwo(); // 13
contextTwo(); // 14

outerFunc함수를 실행시켜 반환된 innerFunc함수를 contextOne변수에 담았다. 그 다음 contextOneouterFunc함수 스코프 밖에서 호출했음에도 불구하고, 변수 a, c와 매개변수 b에 값에 이상없이 접근하여 11을 호출하였다.

그 이유는 outerFunc함수가 실행되었을 당시 함수 실행 컨텍스트에 의해

  • 각 변수들(b, c, 함수 innerFunc)의 값
  • 스코프
  • this

가 저장되고 해당 실행 컨텍스트에서 선언(생성)된 innerFunc함수는 그 주변 환경을 기억하기 때문이다.

contextTwo의 경우에도 마찬가지이다. 하지만 contextOne과 다르게 12를 출력하는 이유는 innerFunc함수가 생성될 당시 주변환경이 다르기 때문이다.(매개변수 b의 값이 다름)

결론적으로 코드 상에서는 똑같이 선언되었지만, 다른 함수 실행 컨텍스트를 통해 생성된 함수는 각기 다른 환경을 기억하고 있으며, 그 기억하고 있는 외부환경에 접근할 수 있는 함수를 클로저라고 한다.

그리고 contextTwo를 계속적으로 실행하면 출력하는 값이 1씩 올라가는 것을 확인할 수 있다. 위를 통해 볼 수 있듯 해당 내부 함수는 주변 환경의 변화에 대해 지속적으로 추적하고 접근할 수 있다.

마지막으로 위 정리된 개념들을 토대로, 어느 곳이든지 클로저를 다루면 고정적으로 나오는 코드 예제를 이해해보고 글을 마치도록하겠다.

var a = [];

for (var i = 0; i < 5; i++) {
  a[i] = function foo() {
    console.log(i);
  };
} // a = [f, f, f, f, f];

for (var j = 0; j < 5; j++) {
  a[j](); // ?
}

클로저의 내용을 이해하지 못하고 직관적으로 보았을 때, a 배열에 담기는 함수들이 가지고 있는 i값은 각각 0~4를 할당된 것처럼 보인다. 하지만 실제로 각 함수를 실행하면 5가 5번 출력된다.

그 이유는 for문을 통해서 5번 foo함수가 생성되는데, 5번 모두 생성 될 때 함수의 주변 환경에서의 변수i값은 이미 반복문이 끝난 전역변수, 5이기 때문이다.(변수 선언문 var는 블록스코프를 따르지 않음.)

위와 같이 범할 수 있는 오류를 줄이기 위해 클로저를 이용해보자.

var a = [];

for (var i = 0; i < 5; i++) {
  a[i] = function bar(j) {  // 매개변수 j는 호출될 때마다 1씩 올라감 
    return function foo() {
      console.log(j);  // foo 함수가 생설될 당시의 주변 환경을 기억
    }
  }(i);
} // a = [f, f, f, f, f];

for (var j = 0; j < 5; j++) {
  a[j](); // 0 1 2 3 4
}

위 코드는 즉시 호출 함수 표현식(Immediately Invoked Function Expressions, 줄여서 IIFE)를 통해 해결한 것이다. 각각 다른 함수 실행 컨텍스트를 통해 선언된 foo가 생성될 당시의 주변 환경도 달라지는 점(bar 함수를 실행할 때 인자로 넘기는i의 값이 다르기 때문에 매개변수 j가 다름)을 활용하였다.

var a = [];

function foo(j) {
  function bar() {
    console.log(j);
  }
  
  return bar; 
}

for (var i = 0; i < 5; i++) {
  a[i] = foo(i);
} // a = [f, f, f, f, f];

for (var j = 0; j < 5; j++) {
  a[j](); // 0 1 2 3 4
}

즉시 호출 함수 표현식을 안쓰고도 위처럼 작성하여 해결할 수 있다.

그 동안 클로저에 대해서 개념을 이해하지 못하고 있을때 조차 은연 중에 클로저를 써왔던 것 같다. 왜냐하면 지금보니 클로저 없이 코드를 작성해서 구현한다는 것은 불가능하기 때문이다. 😱
앞으로 클로저를 언제 어떻게 사용하고 있는지 항상 분명하게 인지하면서 코드를 작성하는 연습을 해야겠다.

참고

profile
한가지를 알아도 제대로 알자
post-custom-banner

2개의 댓글

comment-user-thumbnail
2020년 7월 29일

클로저 영화를 기억하는 한 사람으로써 저도 그걸 생각했었거든요. 마무리투수를 클로저라고도 하죠. 내부함수, 외부함수 개념으로 생각하면 참 쉬운데, 오히려 용어로 인한 오해로 인해 처음에 저도 조금 헤맸습니다.

1개의 답글