Javacsript si Wierd - 2-2. Functionanl Scope-thumbnail

Javacsript si Wierd - 2-2. Functionanl Scope

Lexical Scope, Functional Scope
444

Functional Scope

Lexical Scope

Compiler가 하는 작업 중 Tokenizing 혹은 Lexing이라고 불리는 작업이 있다. Lexing을 하는 과정에서 Compiler는 소스코드를 분석하고 parsing 후 생성된 token에 의미를 부여하게 된다. 그리고 여기서 말하는 lexing이 Lexical scope의 어원이다. 즉, Lexical Scope는 lexing을 하는 과정에서 정의된 scope다. 이와 대비되는 개념으로 Dynamic Scope가 있는데, 사실 이 둘의 차이가 어떤 것인지, lexing이란 무엇인지 등에 대해 여기서는 다루지 않도록 한다. 아래의 코드를 보자.

function fn1(a) {
  var b = a + 1;

  function fn2(c) {
    console.log(a, b, c);
  }

  fn2(b + 1);
}

fn1(1); // 1 2 3

fn1은 global scope에 속한다.

a, b, fn2fn1의 scope에 속한다.

cfn2의 scope에 속한다.

따라서 최종적으로 console.log(a, b, c);에서 a와 b와 c를 참조하려 했을 때, 다음과 같은 일이 벌어진다.

우선 a를 찾기 위해 "Engine"fn2 "Scope"에 질문을 하고, fn2 "Scope"가 모른다고 하자 한 단계 위의 fn1 "Scope"에게 물어 a에 할당된 값을 알아낸다(b 역시 마찬가지 과정을 거친다). 하지만 cfn2 "Scope"에 정의된 variable이라 fn1 "Scope"global "Scope"로 올라가지 않고 할당된 값을 알아낼 수 있다.

그리고 이렇게 겹쳐진 function들을 통해 형성된 중첩된 scope들은 모두 functional scope이다.

Merit no.1 - 숨기기

Functional scope를 잘 이용하면 '최소 권한의 원칙'을 지킬 수 있고, 또한 충돌을 방지할 수도 있어 좋다. 아래와 같은 코드를 보자.

function doSomething(a) {
  function doSomethingElse(a) {
    /* ... */
  }

  var result = doSomethingElse(100) + 100;

  return result;
}

doSomething(data);


function fn1() {
  function fn2(a) {
    var i = 10;
    /* ... */
    console.log(a * i);
  }

  for(var i=0; i<10; i++){
    bar(i);
  }
}

fn1();

예시의 doSomething은 자기 자신만이 사용하는 doSomethingElse 함수를 자신의 scope로 가져옴으로써 최소 권한의 원칙을 지켰고, 혹여나 발생할 수 있는 충돌도 방지했다.

아래 예시의 fn1 scope를 보면 fn2 scopefor scope가 있는데, 자주 사용되는 i를 선언하고 할당할 때 var 키워드를 활용해 자신의 scope로 제한시켜 충돌을 방지했음을 알 수 있다.

하지만 누군가는 그런 상상을 할 수 있다. Functional scope를 활용하려면 계속해서 함수를 정의해야 하고(위의 예시에서는 그 결과로 global scope에 확인자 두 개가 생겼다 - doSomething, fn1), 호출해야만 코드가 실행된다.

var name = "Jane";

(function fn() {
  var name = "Juan";

  console.log(name); // Juan
  console.log(fn); // [Function: fn]  
})();

console.log(name); // Jane

위의 pattern은 IIFE(Immediately, Invoked, Function, Expression)라고 불린다. function fn() {...}를 괄호로 감싸면서 이를 표현식으로 바꾸었고, 그 뒤에 따라붙은 ()는 해당 함수를 '즉시' 실행하는 기능을 맡는다. 이로써 function fn은 자기 자신의 scope에 속하게 되었고, global scope를 오염시키지 않게 되었다. IIFE pattern의 다른 형식은 아래와 같다.

(function fn() {
  var name = "Juan";

  console.log(name); // Juan
  console.log(fn); // [Function: fn]  
}());

함수를 실행해주는 ()의 위치가 안쪽으로 들어갔을 뿐, 동작은 같으므로 원하는 style을 따라가면 된다.

IIFE도 결국 함수라는 것을 이용하면 아래와 같은 코드도 가능하다.

var name = "Jane";

(function fn(global) {
  var name = "Juan";

  console.log(name); // Juan
  console.log(fn); // [Function: fn]  

  console.log(global.name); // Jane
})(window);

console.log(name); // Jane

global scope를 인자로 넘겨 global scope에 정의된 variable인 name에 접근했다!

개인적으로는 좀 더 복잡하지만 아래의 방법도 같은 결과를 불러온다. UMD(Universal Module Definition) project에서 많이 쓰인다고 한다.

var name = "Jane";

(function IIFEfn(func) {
  func(window);
})(function fn(global) {
  var name = "Juan";

  console.log(name); // Juan
  console.log(fn); // [Function: fn]  

  console.log(global.name); // Jane
});

console.log(name); // Jane

바로 이해가 되지 않는다면 이전 내용들을 바탕으로 차근차근 생각해보자.

etc

Functional Scope를 보는 김에 관련해서 언급될 수 있는 두 가지 것들을 짚고 넘어가자. 그다지 중요하진 않으니 위에서 골에 쥐가 난 것 같다면 건너뛰어도 좋다.

eval and with

조금이라도 Javascript와 친숙한 사람이라면 eval과 같이 나온 with에조차 의구심 가득한 눈초리를 보낼 수밖에 없다. 그 악명높은 eval이라니! 그렇다, 결론부터 말하자면 둘 다 쓰지 말자는 차원에서 소개하는 것이다. 성능이 저하되기 때문이다.

여기서는 특히나 lexical scope를 수정해버리는 두 메서드의 놀라운 능력을 살펴볼 것이다.

function fn(str, a) {
  eval(str);
  console.log(a, b);
}

var b = 2;

fn("var b = 1;", 10); // 10, 1

다들 알겠지만 eval()은 인자로 받은 string을 코드처럼 실행시킨다. 따라서 fn scope에는 없던 b가 생겨버리는 마법을 부릴 수 있게 되었다. 당연하게도 fn scope에서 b를 찾았으니, global scope에 선언되었던 b는 찬밥신세를 면치 못했다. 참고로 "Strict Mode"에서는 eval()이 자체적인 scope를 사용하게 되므로 조금이나마 낫다.

다음으로는 with를 살펴보자.

var obj = {
  name: "Juan",
  age: 18
}

with (obj) {
  name = "Gerrardo";
  age = 28;
}

obj; // { name: 'Gerrardo', age: 28 }

with는 익숙하지 않을 수 있어 어떨 때 쓰이는 메서드인지를 보여주기 위해 예시를 가져왔다. 매번 귀찮게 obj.name, obj.age에 접근해서 바꾸는 것이 아니라 일괄적으로 객체의 속성을 참조하기 위해 쓰인다.

문제는 아래의 경우다.

function changeAge(obj) {
  with (obj) {
    age = 20;
  }
}

var obj1 = {
  name: "Hwani",
  age: 30,
  nationality: "South Korea"
}
var obj2 = {
  name: "Ivan",
  gender: "M",
  nationality: "USA"
}

changeAge(obj1);
obj1.age; // 20

changeAge(obj2);
obj2.age; // undefined
age; // 20

changeAge는 parameter로 들어온 object의 age를 변경하는 함수다. obj1에는 age가 있어 문제가 없었으나, obj2에는 age가 애초에 없었다.

사실 with는 인자로 들어온 object를 엄연한 하나의 lexical scope로 여긴다(eval은 본인이 속한 lexical scope를 어지럽게 했던 것과 비교해서 기억해보자). obj2 scope에는 아쉽게도 age가 없다. changeAge scope에도 없다. global scope에도 없다(LHS 확인자 참조 과정). 그래서 age = 20;은 결국 global scope에서 실행되고 만다(물론 "Strict Mode"가 아닐 때의 이야기다).

이 두 메서드의 단점은 최적화를 무의미하게 만든다는 것이다. eval()은 lexing time에 lexical scope를 교란시킬 가능성이 있다. with는 상황에 따라 새 lexical scope를 생성해버릴 수 있다. 하여 witheval을 사용했다는 이유만으로도 코드가 느려질 수 있는 것이다.

익명 함수 vs 기명 함수

Scope와는 관련이 크게 없으나, 체크해두면 좋을 내용이다. 아래의 코드를 보자.

setTimeout( function(){
  /* ... */
}, 1000);

익숙하다. 하지만 익명 함수다. 디버깅이 어려울 가능성이 있고, 재귀 호출이 매우 까다로우며(arguments.callee를 사용해야 함), 협업시 이름이 명시된 것에 비해 이해하기가 어렵다. 하여 위의 방식이 우리에게 아주 친숙하더라도, 아래와 같은 방식이 더 권장된다.

setTimeout( function timeoutFn(){
  /* ... */
}, 1000);

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