어서와, 개발은 처음이지?

자바스크립트 클로저(그림으로 이해해보자) 본문

[기획] 누구도 알려주지 않은 이야기

자바스크립트 클로저(그림으로 이해해보자)

오지고지리고알파고포켓몬고 2020. 1. 28. 19:15
반응형



이번 글에서는 자바스크립트의 클로저(Closure) 현상에 대해서 알아보겠습니다.

일전에도 클로저에 대한 글을 작성한 적 있는데, 할머니가 이해할 수 있도록 리팩토링 하는 차원에서 새로 작성해보겠습니다.



1. 지극히 통상적인 설명


통상 클로저에 대한 예를 대표하는 코드는 아래와 같습니다.

var name = "홀롤롤롤롤";

function outer() {
  /* 아우터 */
  var name = "yuddomack";

  function inner() {
    /* 이너 */
    return name;
  }

  return inner;
}

var innerFunc = outer();
var myName = innerFunc();

console.log(`name : ${myName}`); // name : yuddomack

이미 javascript에 익숙한 분은 위 코드의 실행에 대한 결과가 당연하다고 느끼실 수 있습니다.


하지만 우리가 컴파일러/인터프리터가 된다고 생각하고, 위 코드를 순서대로 해석해보자면 분명 뭔가 이상한 냄새가 납니다. 




2. 순서대로 해석해보자


우선 코드를 순서대로 해석해봅니다.


1. outer 함수를 실행하면 outer 함수의 스코프에는 var name 변수가 생성/할당 되고, function inner(){ ... }가 생성/할당 됩니다.(실제 순서는 호이스팅에 의거하여 조금 달라지겠지만 위 예제에서는 크게 의식할 부분은 없습니다.)

2. outer 함수는 종료 시점에서 inner 함수(function inner() { ... })를 반환(return)합니다.

3. 이는 글로벌 스코프 영역의 var innerFunc 변수에 담기게 됩니다.

4. 즉, 다음과 같은 상태입니다.

var innerFunc = function inner(){
  /* 이너 */
  return name;
}

4. 다음은 글로벌 스코프 영역에 있는 innerFunc(즉 inner함수)를 실행합니다.

5. 이는 name을 반환(return)하고, var myName 변수에 담기게 됩니다.

6. 자..잠깐? name이 뭔데..?

7. console.log에 myName을 출력합니다.

8. 우리(컴파일러)가 RHS 탐색으로 찾아낸 name은 '홀롤롤롤롤'도, undefined가 아닌, 'yuddomack'을 출력합니다.




3. 왜 이런거죠~ 내가 왜 이런거죠~


outer 함수의 반환으로 인하여 글로벌 스코프 영역으로 꺼내진 inner 함수지만, 이를 실행했을때 inner 함수 내부의 name이 가르키고 있는 것은 공교롭게도

글로벌 스코프 영역의 name인 홀롤롤롤롤도, inner 함수 스코프 영역의 name 즉 undefined도 아닌

outer 함수 스코프 영역의 name(yuddomack)을 참조하고 있습니다!


이렇게 없어진 것만 같은 스코프에 대한 참조를 기억하고 있는 것, 이를 클로저라고 합니다.


클로저는 자바스크립트에서 뿐만 아니라 함수를 1급 객체로 사용할 수 있는 언어에서 확인할 수 있습니다.

python에서도 클로저 현상을 볼 수 있습니다.

def outer():
  name = "yuddomack"

  def inner():
    return name

  return inner

innerFunc = outer()
myName = innerFunc()

print('my name is', myName) # my name is yuddomack

스코프와 참조에 대한 개념이나 단어가 생소하신 분들은 스코프에 대한 글을 먼저 읽어보시길 추천드립니다.


(여러분 이거 다 클로저 때문인거 아시죠?)



4. 마음속에 클로저를 그려보자


3번 항목에서 주저리주저리 써놨지만 '스코프에 대한 참조를 기억하고 있는 것' 이것만 마음속에 새겨두고 계속 나아가봅시다.

이번에는 복잡한 설명은 뒤로하고, 좀 더 깔끔하게 그림으로 표현해보겠습니다.


4-1.

우선 위 코드가 컴파일레이션 과정을 거치면 다음과 같은 스코프가 생겨날 것 입니다.

<실행 과정에서 각 스코프 영역이 생겨남>



4-2.

다음으로 함수가 언제 실행되는지, name이 언제 반환되는지 이런것은 잠깐 잊어버립니다.

현재 생겨난 스코프 영역을 기준으로 inner 함수가 반환할 name이 참조할 것을 RHS 탐색을 기준으로 생각해봅니다.

.

.

.

.

.

.

.

.

.

.

생각해보셨나요? (괜히 뜸들이기)


RHS 탐색에 의거하여 inner이 반환할 name은 가까이 있는 스코프 영역의 name을 참조하게됩니다.


<name에 대한 RHS 탐색>



4-3.

자, 그럼 이 시점에서 다시 중요한 사실을 환기해봅니다.

클로저 - 스코프에 대한 참조를 기억하고 있는 것


느낌이 오시나요?

여지것(컴파일레이션, 스코프, 호이스팅 글에서) 끊임없이 이야기했던 스코프 영역이 클로저 그 자체입니다!


클로저는 항상 우리 곁에 있었습니다. 다만 눈치채지 못했을뿐!


<당신이 이미 알고있던 그것이 클로저다!>


이 카테고리의 지난 글을 통해서, 자바스크립트 코드는 실행과 동시에 컴파일레이션 과정을 거치고, 그와 함께 스코프 영역이 생겨나는 것을 알게됐습니다.


클로저가 스코프에 대한 참조를 기억하고 있는 것이니, 런타임 시점에 inner(innerFunc)의 위치와 상관없이, 함수 내부에서는 컴파일레이션 시점에 이미 생성된 스코프 영역을 기억하고 참조한다라고 결론 지을 수 있겠습니다.




5. 초심자의 맹점


여태까지 내용을 정리하자면 어떤 스코프 안에서 참조할 수 있는 스코프(그 바깥의 모든 스코프) = 클로저 라고 생각할 수 있겠습니다.(outer에서 inner 함수 스코프를 참조 할 수 없겠죠?)


여기서 주의해야 할 부분이 있습니다.


이 클로저(스코프) 영역은 각각 복사(copy)되는것이 아니라 (배열을 함수로 넘겨서 push하면 배열 자체가 늘어나듯) 참조하기 때문에 여러 스코프에서 같은 클로저를 참조하면 의도하지 않은 결과가 발생할 수 있습니다.


말만 듣고 이해하기는 어려우니 코드로 보겠습니다.

function getFuncs() {
  var arr = [];

  for (var i = 0; i < 5; i++) {
    arr[i] = function() {
      console.log(i);
    };
  }

  return arr;
}

var funcs = getFuncs();

for (var i = 0; i < funcs.length; i++) {
  var func = funcs[i];
  console.log(`${i}번째 함수 실행`);
  func();
}

/*
  0번째 함수 실행
  5
  1번째 함수 실행
  5
  2번째 함수 실행
  5
  3번째 함수 실행
  5
  4번째 함수 실행
  5
*/

특이하게도 getFuncs를 통해 반환받은 익명 함수 배열을 실행해보니 0, 1, 2, 3, 4가 나오는 것이 아닌 5가 연속해서 나오는 현상을 볼 수 있습니다.


각 함수들이 같은 클로저를 참조하고 있기 때문입니다.

위 코드가 실행되는 상황을 대략적으로 표현해보면 아래와 같습니다.


<익명 함수들이 같은 클로저 영역을 참조하고 있다>


이 부분은 할머니가 이해할 수 있을 정도로 쉽게 설명하기 어렵네요


이를 방지하기 위한 고전적인 방법으로는 아래와 같이 즉시실행 함수 형태로 i를 지역변수화 하여 클로저를 별도로 생성해주는 방법이 있습니다. 

function getFuncs() {
  var arr = [];

  for (var i = 0; i < 5; i++) {
    arr[i] = (function(i) {
      return function() {
        console.log(i);
      };
    })(i);
  }

  return arr;
}

var funcs = getFuncs();

for (var i = 0; i < funcs.length; i++) {
  var func = funcs[i];
  console.log(`${i}번째 함수 실행`);
  func();
}


위 코드의 실행을 대략적으로 표현하면 아래와 같습니다.


<원래 함수 감싸서 스코프(클로저)를 추가하는 방법>


위 방법이 이해가 잘 안되신다면, 그냥 var를 let으로 고쳐주시면 됩니다.

function getFuncs() {
  var arr = [];

  for (let i = 0; i < 5; i++) {
    arr[i] = function() {
      console.log(i);
    };
  }

  return arr;
}


let이나 const는 블록스코프 단위로 움직이기 때문에 for 블록 안에 있는 코드가 실행될때마다 별도의 클로저가 생성됩니다.(된다고 볼 수 있습니다. 여담으로 es6를 지원하지 않는 환경에서 babel로 let 변수를 사용하는 간단한 반복문을 빌드하면, 이름이 비슷한 var변수가 여러개 생기고 그들을 각각 참조하는 모습을 볼 수 있습니다.)




6. 클로저는 살아있다.


클로저에 대한 참조가 살아있다면, 메모리의 한 자리를 계속 차지하게 됩니다.

모듈 패턴에서 즉시 실행 함수로 내 코드 영역을 구분짓는 이유중에, 캡슐화 뿐만 아니라 이러한 영향도 있음을 간과할 수 없겠습니다.


만약 최적화가 필요하다면, 클로저를 사용하고 참조하고 있는 부분에도 신경써줘야 하겠습니다.(참조가 일어나는 변수를 null로 할당하는 원시적인 방법이 있습니다.)



7. 마치며


타 언어를 주 언어로 사용한 사람이라면 자바스크립트의 몇몇 부분이 괴랄하기 그지 없겠지만, 좀 더 깊게 들여다보면 이유없이 동작하는 것이 아니라 특유의 메커니즘에 기반한 '개성'이라고 받아들일 수 있겠습니다.(이런것들을 이해하는 것이 컴쟁이의 재미가 아닐까 싶습니다!)


이 글에서는 자세히 다루진 않았지만 클로저를 사용하여 private한 형태를 구현할 수 있습니다.

예를 들어서 outer에 변수를 선언하고, 이를 조작하는 함수(혹은 함수들을 담은 객체)를 반환하는 것으로 private한 형태를 구현할 수 있습니다. (외부에서 내부로 접근할 수 없다라는 관점은 모듈 패턴을 구현할 때 유용합니다.)

function module1() {
  var myName = "";

  return {
    setName: function(name) {
      myName = name;
    },
    getName: function() {
      return myName;
    }
  };
}

var m = module1();
m.setName("yuddomack");
console.log(m.getName());

위 내용은 디자인 패턴에 대한 내용을 얘기할 때 자세히 다루겠습니다.


다음은 this에 대해 알아보겠습니다.

Comments