Javacsript si Wierd - 2-5. Scope Closer-thumbnail

Javacsript si Wierd - 2-5. Scope Closer

Scope Closer
452

Scope Closer

Closer란 어떤 함수가 자신이 속해 있는 lexical scope를 기억했다가, 설령 그 lexical scope가 실행 과정에서 더 이상 필요가 없어졌다고 하더라도 해당 lexical scope가 차지하던 memory를 해제하는 것이 아니라, 함수가 그 scope에 접근을 마칠 때까지 유지시키는 기능을 뜻한다. 어떤 함수가 처음 선언된 lexical scope를 기억하는 행위를 모두 Closer가 작동했다고 말할 수 있는 것이다.

말로는 너무 장황하니 차근차근 코드를 따라가보자.

설명 1 - 기본 함수

function fnOuter() {
  var a = 1;

  function fnInner() {
    console.log(a);
  }

  return fnInner;
}

var anotherFn = fnOuter();

anotherFn(); // 1

fnOuter의 lexical scope에 접근할 수 없어보이는(실제로도 그렇다) anotherFn이 variable a에 접근했다! 무슨 일이 벌어진 걸까?

Variable anotherFn에 할당하는 것을 끝으로 fnOuter()의 실행이 끝났다. 그러므로 당연히 fnOuter()의 lexical scope는 사라져야 한다. Garbige collector를 이용해 Engine이 사용되지 않는 memory를 해제할 것이니까. 하지만 놀랍게도 fnOuter의 lexical scope는 살아있다. fnInner()는 여전히 fnOuter의 lexical scope에 대한 참조를 가진다. 그러므로 fnInner()anotherFn이 실행된 직후 자신이 실행되면서 참조를 유지하고 있던 fnOuter의 lexical scope에 접근해 a 의 값을 찾기 위해 RHS 검색을 실행한다. 이것이 바로 Closer다.

이것을 더 극적으로 보여주기 좋은 예시는 아래와 같다.

var fn;

function foo() {
  var a = 1;

  function baz() {
    console.log(a);
  }

  fn = baz;
}

function bar() {
  fn();
}

foo();

bar(); // 1

설명 2 - setTimeout, for문

setTimeout을 가지고도 Closer를 느껴볼 수 있다.

function getMessage(message) {
  setTimeout( function timer(){
    console.log(message);
  }, i*1000);
}

가장 흔하게 Closer의 예시로 사용되는 반복문도 살펴보자. setTimeout을 곁들여서.

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

// 6
// 6
// 6
// 6
// 6

우리는 1초 뒤부터 1초 간격을 두고 실행되어 차례로 1, 2, 3 ,4, 5를 볼 수 있기를 바랐다. 하지만 문제는 i가 global scope에 선언되었고, setTimeout 함수는 for 반복문이 다 돌아 i가 6이 된 이후에야 global scope에서 i를 찾아내 출력하게 된다는 것이다.

우선은 각각의 반복문이 실행될 때 반복문이 각각의 scope를 갖게 만들어보자. 기억력이 좋다면, 이전의 글 "Functional Scope"에서 본 IIFE가 기억날 것이다. IIFE가 실행될 때마다 그들은 자신만의 scope를 가지고, global scope(혹은 상위 scope)를 오염시키지 않는다는 장점을 가지고 있었다.

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

// 6
// 6
// 6
// 6
// 6

하지만 여전히 6만을 내뱉고 있다. 눈치가 빠르다면 여기서의 문제가 무엇인지는 눈치챘을지 모르겠다. 문제는 자신만의 scope 내에 당시의 i값을 저장하지 못했기 때문에 결국 한 단계 위 scope에 가서 i값을 찾아 출력하고 있는 것이다. 아래와 같이 i값을 그때그때 IIFE lexical scope에 저장해주면 드디어 우리가 원하던대로 코드가 돌아간다.

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

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

// 1
// 2
// 3
// 4
// 5

"Functional Scope" 글을 보고 왔다면 두 코드는 style의 차이일 뿐이라는 걸 알 수 있을 것이다.

자, 혹시 letconst를 기억하는가? let은 자신이 속한 block을 closed scope로 바꾸는 능력이 있다. IIFE를 활용한 이유와 같은 이유로, let을 활용해보자.

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

// 1
// 2
// 3
// 4
// 5

더 간단하게는 아래와 같이 쓸 수 있다.

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

// 1
// 2
// 3
// 4
// 5

let 선언문이 for문 조건 안에서 실행되면 반복문 도입부에서 let으로 선언된 variable은 한 번 선언되고 끝나는 것이 아니라, 반복할 때마다 선언되게 된다. 그러므로 위의 let j=i;가 생략되어도 우리가 원해는대로 코드가 동작한다. Block scope와 Closer가 협동해 마법을 부린 것이다.

설명 3 - Module pattern

흔하디 흔한 module들에서도 역시 Closer가 열일하고 있다.

function someModule() {
  var name = "good module";
  var datas = [1, 2, 3, 4, 5];

  function showDatas() {
    console.log(datas);
  }
  function showUpperName() {
    console.log(name.toUpperCase());
  }

  return {
    showDatas: showDatas,
    showUpperName: ShowUppername
  }
}

var exampleModule = someModule();

exampleModule.showDatas(); // [1, 2, 3, 4, 5]
exampleModule.showUpperName(); // GOOD MODULE

눈치를 챈 사람도 있겠지만, 결국 모듈도 Closer를 사용하기 위해선 두 가지 조건을 만족해야 한다.

  1. 최외곽 Wrapper 함수가 호출되어야 한다 -> Scope 생성
  2. Wrapper 함수의 반환값에는 최소 하나의 메서드를 참조하고 있어야 하고, 그 메서드(들) 중 최소 하나가 inner lexical scope에 대한 참조(즉, Closer)를 가져야 한다.

IIFE pattern으로 module을 바꾸거나 parameter로 값을 입력받을 수도 있다. 위에서부터 보아왔다면 중복되는 내용임을 충분히 알 수 있으니 생략하도록 한다.

참고로, ES6부터는 각각의 file을 하나의 module처럼 인식한다. 하여, 예시로 들었던 function 기반의 module은 runtime 이전까지 해석되지 않는 반면, ES6 module은 cmopiler가 이미 참조들 간의 관계를 알고 있고, 실제 참조들이 유효한지를 compile 시에 체크한다. 만일 오류가 있으면 초기에 오류를 발생시킨다. Runtime까지 기다리지 않는 것이다.


참고 서적 : 카일 심슨, 2017, 한빛미디어, 『YOU DON'T KNOW JS: 타입과 문법, 스코프와 클로저』