티스토리 뷰

책이름: You don't know JS : 타입과 문법, 스코프와 클로저
주관적으로 머리에 남기고 싶은 부분들만 정리하였습니다. 자세한 내용은 책을 참고하시는게 좋습니다. 

스코프란 무엇인가?

특정 장소에 변수를 저장하고 나중에 그 변수를 찾는 데는 잘 정의된 규칙이 필요하다. 이런 규칙을 스코프(scope)라 한다.

 

1.1 컴파일러 이론

자바스크립트는 일반적으로 동적 또는 인터프리터 언어로 분류하나 사실은 컴파일러 언어이다.

  • 전통적인 컴파일러 언어와는 많이 다르다는 것은 자명한 사실이다. 코드를 미리 컴파일하거나 컴파일한 결과를 분산시스템에서 이용할 수 있는 것은 아니기 때문

전통적인 컴파일러 언어의 처리 과정은 보통 3단계를 거치는데 이를 컴파일레이션 이라고 부른다.

  1. 토크나이징(tokenizing)/ 렉싱(lexing)
  2. 파싱(parsing)
  3. 코드 생성 

토크나이징/ 렉싱

문자열을 나누어 토큰(token)이라 불리는 (해당 언어에) 의미 있는 조각으로 만드는 과정

예시: "var = 2;"를 토큰으로 나누면, var/ a / = / 2/ ; 로 쪼갤 수 있다.

*"빈칸"은 하나의 토큰으로 취급될수도 아닐 수도 있다. (빈칸이 의미가 있느냐 없느냐에 따라 다름)

 

파싱

토큰 배열을 프로그램의 문법 구조를 반영하여 중첩 원소를 갖는 트리 형식으로 바꾸는 과정

파싱의 결과로 만들어진 트리를 AST(Abstract syntax tree, 추상 구문 트리) 라 부른다. 

예시: var a = 2; 는 변수 선언이라 부르는 최상위 노드에서 시작, 최상위 노드는 a의 값을 가지는 확인자(identifier)와 대입 수식이라 부르는 자식노드를 가진다. 대입 수식 노드는 2라는 값을 가지는 숫자 리터럴을 자식 노드로 가진다. 

 

코드 생성

AST를 컴퓨터에서 실행 코드로 바꾸는 과정 

*이 부분은 언어에 따라 or 목표하는 플랫폼에 따라 크게 달라진다. 

var a = 2 를 나타내는 AST를 기계어 집합으로 바꾸어 실제로 'a'라는 변수를 생성(메모리를 확보하는 일)하고 값을 저장할 방법이 있다고 치면 된다.( 엔진이 필요한 변수를 생성하고 저장할 것이라고 가정)

 

자바스크립트 엔진이 기존 컴파일러와 다른 점은 자바스크립트 컴파일레이션을 미리 수행하지 않아서 최적화할 시간이 많지 않다는 것 -> 보통 코드가 실행되기 겨우 수백만의 분의 일초 전에 수행한다.

즉, 어떤 자바스크립트 조각이라도 실행되려면 먼저(바로 직전에) 컴파일되어야 한다. 

 


1.2 스코프 이해하기

  • 엔진: 컴파일레이션의 시작부터 끝까지 전 과정과 자바스크립트 프로그램 실행을 책임
  • 컴파일러: 엔진의 친구, 파싱과 코드 생성의 모든 잡일을 한다.
  • 스코프: 엔진의 또 다른 친구, 선언된 모든 확인자(변수) 검색 목록을 작성하고 유지한다. 또한, 엄격한 규칙을 강제하여 현재 실행 코드에서 확인자의 적용 방식을 정한다. 

프로그램 var a =2;를 보면 하나의 구문으로 보이지만 엔진은 두 개의 서로 다른 구문으로 본다. 하나는 컴파일러가 컴파일레이션을 처리할 구문이고, 다른 하나는 실행 과정에서 엔진이 처리할 구문이다. 

요약하자면, 별개의 두 가지 동작을 취하여 변수 대입문을 처리한다. 첫째, 컴파일러가 변수를 선언한다(현재 스코프에 미리 변수가 선언되지 않은 경우). 둘째, 엔진이 스코프에서 변수를 찾고 변수가 있다면 값을 대입한다. 

 

2단계에서 컴파일러가 생성한 코드를 실행할 때 엔진은 변수 a가 선언된 적이 있는지 스코프에서 검색한다.

여기서 LHS, RHS 검색 참조를 한다. 간단히 설명하면 아래와 같다.

변수를 검색하는 이유는 변수에 값을 대입(LHS 참조)하거나 변수의 값을 얻어오기 위해서다.(RHS 참조)
  • LHS(Left Hand Side): 대입할 대상
  • RHS(Right Hand Side): 대입한 값

1.3 중첩 스코프

스코프: 확인자(Identifier) 이름으로 변수를 찾기 위한 규칙의 집합

대개 고려해야 할 스코프는 여러 개이다. 

 

하나의 블록이나 함수는 다른 블록이나 함수 안에 중첩될 수 있으므로 스코프도 다른 스코프 안에 중첩(Nested)될 수 있다.

  • 대상 변수를 현재 스코프에서 발견하지 못하면 엔진은 다음 바깥의 스코프로 넘어가는 식으로 변수를 찾거나 글로벌 스코프라 불리는 가장 바깥 스코프에 도달할 때까지 계속한다. 

▶중첩 스코프 탐사 시 사용하는 간단한 규칙

  • 엔진은 현재 스코프에서 변수를 찾기 시작하고, 찾지 못한다면 한 단계씩 올라간다(밖을 향해 나간다는 소리).
  • 최상위 글로벌 스코프에 도달하면 변수를 찾든 못 찾든 검색을 멈춘다.  

1.4 오류 

📚 LHS와 RHS를 구분하는 것이 중요한 이유는 두 종류의 검색 방식은 변수가 아직 선언되지 않았을 때(검색한 모든 스코프에서 찾지 못했을 때) 서로 다르게 동작하기 때문입니다. 
{
  function foo(a) {
    console.log(a + b);
    b = a;
  }
  foo(2);
}

-> Reference Error  b를 찾을 수 없음  이렇게 스코프에서 찾지 못한 변수는 '선언되지 않은 변수'라 한다. 

 

RHS 검색이 중첩 스코프 안 어디에서도 변수를 찾지 못하면 엔진이 ReferenceError 를 발생시킨다. 

반면, 엔진이 LHS 검색을 수행하여 변수를 찾지 못하고 최상위 층에 도착했을 때 프로그램이 "strict mode"로 동작하고 있는 것이 아니라면 글로벌 스코프는 엔진이 검색하는 이름을 가진 새로운 변수를 생성해서 엔진에게 넘겨준다. 즉, "그건 없는데 내가 널 위해 하나 만들어줄게" 이다. (자동적, 암시적으로 글로벌 스코프에 같은 이름의 변수가 생성된다.)

ES5부터 지원하는 Strict 모드는 Normal/Relaxed/Lazy mode 와 다르게 여러 며에서 다르게 작동한다. 예를 들어, Strict 모드에서는 글로벌 변수를 자동으로 또는 암시적으로 생성할 수 없다. 그래서 위와 같은 상황에서 LHS검색은 아무것도 얻지 못하고 엔진은 RHS의 경우와 비슷하게 ReferenceError를 발생시킨다. 

 

RHS 검색 결과 변수를 찾았지만 그 값을 가지고 불가능한 일을 하려고 할 경우,

e.g.) 함수가 아닌 값을 함수처럼 실행하거나 null 이나 undefined 값을 참조할 때 엔진은 TypeError를 발생시킨다. 

ReferenceError : 스코프에서 대상을 찾았는 지와 관계
TypeError: 스코프 검색은 성공했으나 결괏값을 가지고 적합하지 않거나 불가능한 시도를 했을 경우 

Chapter2  렉시컬 스코프

스코프는 2가지 방식으로 작동한다.

  1. 일반적이며 다수의 프로그래밍 언어가 사용하는 방식: 렉시컬 스코프
  2. Bash Scripting 이나 Perl의 일부 모드와 같은 몇몇 언어에서 사용하는 방식: 동적 스코프 
{
  function foo(a) {
    var b = a * 2;
    function bar(c) {
      console.log(a, b, c);
    }
    bar(b * 3);
  }
  foo(2); // 2 4 12 
}
//안쪽 버블부터 설명
// bar의 스코프를 감싸고 있고, 해당 스코프는 하나의 확인자 c만 포함 
// foo의 스코프를 감싸고 있고, 해당 스코프는 3개의 확인자(a,bar,b)를 포함
//글로벌 스코프를 감싸고 있고, 해당 스코프 안에는 오직 하나의 확인자(foo)만 포함

자바스크립트가 채택한 것은 렉시컬 스코프, 렉시컬 스코프는 렉싱 타임에 정의되는 스코프이다. 프로그래머가 코드를 짤 때 변수와 스코프 블록을 어디서 작성하는 가에 기초하여 렉서가 코드를 처리할 때 확정된다. 

  • 어떤 함수가 어디서 또는 어떻게 호출되는 지와 상관없이 함수의 렉시컬 스코프는 함수가 선언된 위치에 따라 정의된다. 
  • 렉시컬 스코프의 검색 과정은 a, b, c(변수이름임)와 같은 일차 확인자 검색에만 적용된다. 코드에서 foo.bar.baz의 참조를 찾는다고 하면 렉시컬 스코프 검색은 foo 확인자를 찾는데 사용되지만, 일단 foo를 찾고나서는 객체 속성 접근 규칙을 통해서 bar와 baz의 속성을 각각 가져온다.

 

중첩 스코프에 있을 때, 스코프는 목표와 일치하는 대상을 찾는 즉시 검색을 중단하기 때문에 같은 이름의 변수가 있더라도 자신의 스코프에 알맞은 변수가 있다면 바깥 스코프로 탐색하러 가지 않는다. 

글로벌 변수는 자동으로 window같은 글로벌 객체에 속한다. 따라서 글로벌 변수를 직접 렉시컬 이름으로 참조하는 것뿐만 아니라 글로벌 객체의 속성을 참조해 간접적으로 참조할 수도 있다. e.g.) window.a

2.2 렉시컬 속이기

가능은하지만 성능을 떨구기때문에 코드 작성 시 권장하지 않는 방법이다.(그냥 재밌어 보여서 정리함)

2.2.1 eval() 

자바스크립트의 eval() 함수는 문자열을 인자로 받아들여 실행 시점에 문자열의 내용을 코드의 일부분처럼 처리한다. 

즉, 처음 작성한 코드에 프로그램에서 생성한 코드를 집어넣어 마치 처음 작성될 때부터 있던 것처럼 실행된다. -> 이미 존재하는 렉시컬 스코프를 수정하는 것이다. (어떤 경우는 eval()은 프로그래머가 작성했던 때의 렉시컬 스코프를 런타임에 수정할 수 있다.)

strict 모드에서 eval을 사용하게 되면 자체적인 렉시컬 스코프를 이용한다. 즉, eval()내에 실행된 선언문은 현재 위치의 스코프를 실제로 수정하지 않음

이 외에도 방법이 있지만 동적으로 생성한 코드를 프로그램에서 사용하는 경우는 굉장히 드물고 사용할 때, 성능 저하를 감수할 만큼 활용도가 높지 않아 지양해야 한다. 

 

eval과 with가 엔진이 컴파일 단계에서 수행한 스코프 검색과 관련된 최적화 작업을 무산시키기 때문에 최악의 경우를 대비해 진행했던 최적화 결과가 무효화됐다고 가정해야 한다. 그래서 둘 중 하나만 사용해도 코드는 더 느리게 동작하므로 이 방식은 사용하지 말자

 

Chapter 3 함수 vs 블록 스코프

2챕터의 스코프는 컨테이너 또는 바구니 구실을 하는 일련의 '버블'이고 변수나 함수 같은 확인자가 그 안에 선언된다. 

버블의 경계는 중첩되고 그 경계는 프로그래머가 코드를 작성할 때 결정된다. 

 

3.1 함수 기반 스코프

자바스크립트는 함수 기반 스코프를 사용한다. 

3.2 일반 스코프에 숨기

함수에 대한 전통적인 개념 

  • 함수를 선언하고 그 안에 코드를 넣는다. 
  • 작성한 코드에서 임의 부분을 함수 선언문으로 감싼다. 이는 해당 코드를 "숨기는" 효과를 낸다. 

위와 같이 하면, 해당 코드 주위에 새로운 스코프 버블이 생성된다. 즉, 감싸진 코드 안에 있는 '모든 변수' 또는 '함수 선언문'은 이전 코드에 포함됐던 스코프가 아니라 새로이 코드를 감싼 함수의 스코프에 묶인다.

변수와 함수를 숨겨 접근 권한을 최소한으로 제한하는 이유는 모든 변수와 함수가 글로벌 스코프에 존재한다면 어느 중첩된 하위 스코프에서도 이들에 접근할 수 있기 때문이다. 비공개로 남겨둬야 할 많은 변수나 함수를 노출시키면 안된다. 

전부 노출된 함수 예시

  function doSomething(a) {
    b = a + doSomethingElse(a * 2);
    console.log(b * 3);
  }
  function doSomethingElse(a) {
    return a - 1;
  }
  var b;
  doSomething(2); // 15

-> 변수 b와 dosomthingElse 는 doSomething의 비공개 영역이다. 하지만 해당 변수와 함수에 접근할 수 있는 것은 불필요하면서 위험하다. 접근 가능한 확인자는 의도적이든 아니든 생각치못한 방식으로 사용될 수 있다. 

적절히 사용하기 위해서는 비공개 부분을 doSomething() 스코프 내부에 숨겨야 한다. 

// 변경 후
  function doSomething(a) {
    function doSomethingElse(a) {
      return a - 1;
    }
    var b;
    b = a + doSomethingElse(a * 2);
    console.log(b * 3);
  }
  doSomething(2);

3.3 스코프 역할을 하는 함수 

위의 방법은 동작은 하나 완벽한 동작은 아니다. 즉시 실행 함수를 통하면 이 문제를 해결할 수 있다. 

{
  var a = 2;
  (function foo() {
    var a = 3;
    console.log(3);
  })();
  console.log(a); //2 
}

-> 코드를 보면 function 키워드가 아니라 (function ... 으로 시작한다. 이 코드에서의 함수는 보통의 선언문이 아니라 함수 표현식으로 취급된다. 함수 이름 foo를 자기 내부에 숨기면 함수를 둘러싼 스코프(여기서는 전역 스코프)를 불필요하게 오염시키지 않는다.

여기서 볼 수 있는 함수 선언문과 함수 표현식의 중요한 차이: 함수 이름이 어디의 확인자로 묶이느냐와 관련 

📚선언문과 표현식을 구분하는 가장 쉬운 방법은 function 이라는 단어가 구문에서 어디에 위치하는 가를 살펴보면 된다. (한 줄에서가 아니라 하나의 독립 구문에서 ) 'function' 키워드가 구문의 시작 위치에 있다면 함수 선언문이고, 다른 경우는 함수 표현식이다. 

3.3.1 익명 vs 기명

함수 표현식을 콜백 인자로 사용하는 사례 

 setTimeout(function () {
    console.log('1초 기다릴게!');
  }, 1000);

-> 해당 방식을 익명함수 표현식이라고 하는데 보면 콜백함수로 들어가는 함수에 이름(확인자의 이름)이 없다.

 함수 표현식은 이름 없이 사용할 수 있지만, 함수 선언문은 이름이 빠지면안된다. 

익명 함수 표현식은 빠르고 쉽게 입력할 수 있는 장점이 있지만 기억해야할 단점이 있다. 

  1. 스택 추적 시 표시할 이름이 없어 디버깅이 어렵다 
  2. 이름 없이 함수 스스로 재귀 호출을 하려면 폐기 예정인 arguments, callee 참조가 필요하다. 자기 참조가 필요한 또 다른 예로는 한 번 실행하면 해제되는 이벤트 처리함수가 있다.
  3. 이름은 해당 함수의 기능에 대해서 간편하게 설명하는 용도인데 이 이름이 없기 때문에 한 눈에 기능을 식별하기 어렵다

인라인 함수 표현식은 매우 효과적이고 유용하다. 익명이냐 기명이냐가 함수 표현식의 성능에 문제를 일으키진 않지만 기명함수로 쓰는 것이 앞선 단점들을 보완할 수 있어 권장된다. 

 //setTimeout의 콜백함수가 timeoutHandler 라는 기명함수이다. 
 setTimeout(function timeoutHandler() {
    console.log('안녕 난 이름을 가지고 있는 함수 표현식이야, 1초 기다릴게!');
  }, 1000);

 

3.3.2 함수 표현식 즉시 호출하기 (IIFE, 즉시 호출 함수 표현식)

 let a = 2;
  (function IIFE() {
    let a = 3;
    console.log(a); 
  })();//3 
  console.log(a); //2

함수를 ()로 감싸면 함수를  표현식으로 바꾸게 되는데 바로 뒤에 ()를 붙이면 함수를 실행할 수 있다.

  • 익명 함수 표현식에서 가장 흔하게 사용되며 IIFE를 기명으로 사용하면 위의 이유로 더 나은 면이 있다. 

IIFE의 변형된 형태

 let a = 2;

  (function IIFE(def) {
    def(window);
  })(function def(global) {
    let a = 3;
    console.log(a); //3 
    console.log(global.a);// 책에서는 2, 실제 콘솔 undefined
  });

 

->콘솔에는 3과 undefined 가 나오지만 책의 설명에 따르면 함수 표현식 def는 코드 후반부에 정의되어 코드 전반부에 정의된 IIFE함수의 인자로 넘겨지게 된다. 결국, 인자 함수 def가 호출되고 window가 global 인자로 넘겨진다. 

예상 결과로는 console.log(a) 시 3이 나오고 global.a 는 2가 나오게 된다. let 을 책의 예문대로 var로 바꿔봐도 브라우저 콘솔에는 a가 이미 선언되어 있다고 에러를 발생시킨다. 

VM120:1 Uncaught SyntaxError: Identifier 'a' has already been declared at <anonymous>:1:1

3.4 스코프 역할을 하는 블록 

 for (let i = 0; i < array.length; i++) {
    console.log(array[i]);
  }

변수 i를 for 반복문의 시작부에 선언하는 이유는 보통 i를 오직 for반복문과 관련해서 사용하려 하기 때문이다. 그러고는 변수 i가 둘러싼 (함수 또는 글로벌) 스코프에 포함된다는 사실을 무시한다.

블록 스코프의 목적은 "변수를 최대한 사용처 가까이에서 최대한 작은 유효범위를 갖도록 선언하는 것이다."
최소 권한 노출의 원칙을 확장하여 정보를 함수 안에 숨기고, 나아가 정보를 코드 블록 안에 숨기기 위한 도구

👾주의: var 키워드를 사용할 때 변수를 어디에서 선언하는 지는 중요한 문제가 아니다. 선언된 변수는 항상 둘러싸인 스코프에 속하기 때문이다. -> 그래서 let/ const 를 사용한다. 

 

3.4.2 try/catch 

자바스크립트 ES3에서 try/catch 문 중 catch 부분에서 선언된 변수는 catch 블록 스코프에 속한다. 

 try {
    nothing();
  } catch (error) {
    console.log(error);
  }
  //ReferenceError: nothing is not defined
  //at <anonymous>:2:5

-> 변수 error 를 외부에서 참조하면 오류가 난다. 

 

3.4.3 let 

위에서 살펴본 자바스크립트의 블록 스코프 기능은 비주류적인 요소를 통해서 구현된 것이다. 

ES6에서 이런 상황이 바뀌면서 새로운 키워드 let이 채택됐다. let은 var 같이 변수를 선언하는 다른 방식이다. 

키워드 let은 선언된 변수를 둘러싼 아무 블록(일반적으로 {중괄호})의 스코프에 붙인다. 명시적이진 않지만 let은 선언한 변수를 위해 해당 블록 스코프를 이용한다고 말할 수 있다.  

 

호이스팅(끌어올리기)

  • 선언문 어디에서 선언됐든 스코프 전체에 존재하는 것처럼 취급되는 작용, 최상단으로 끌어올려지는 듯한 현상

let을 사용한 선언문은 속하는 스코프에서 호이스팅 효과를 받지 않는다. 따라서 let으로 선언된 변수는 실제 선언문 전에는 명백하게 존재하지 않는다. 

{
  console.log(bar);
  let bar = 2;
  //   Uncaught ReferenceError: Cannot access 'bar' before initialization
  //     at <anonymous>:2:15
  // (anonymous) @ VM569:2
}

블록스코프가 유용한 또 다른 이유는 메모리를 회수하기 위한 클로저 그리고 가비지 콜렉션과 관련 있다. 관계없는 메모리를 많이 잡아먹는 자료구조를 블록스코프를 통해 엔진에게 더는 필요없다는 사실을 명료하게 알릴 수 있다.

-> 이 부분은 제대로 이해가 가지 않으므로 추가 정리하겠음 

 

명시적으로 블록을 선언하여 변수의 영역을 한정하는 것은 효과적인 코딩 방식으로 익혀두면 좋다. 

 

let 반복문

let j;
  for (j = 0; j < 10; j++) {
    let i = j; //re-bound each iteration;
    console.log(i);
  }

let 선언문은 둘러싼 함수(또는 글로벌)스코프가 아니라 가장 가까운 임의의 블록에 변수를 붙인다. 따라서 이전에 var 선언문을 사용해서 작성된 코드는 함수 스코프와 숨겨진 연계가 있을 수 있으므로 코드리팩토링을 위해서는 단순히 var를 let으로 바꾸는 것 이상의 노력이 필요하다. 

 

3.5 정리

  • 자바스크립트에서 함수는 스코프를 이루는 가장 흔한 단위이다. 다른 함수 안에서 선언된 변수와 함수는 본질적으로 다른 '스코프'로 부터 '숨겨진' 것이다. 
  • 함수는 결코 유일한 스코프 단위가 아니며 블록 스코프는 함수만이 아니라(일반적으로 { }) 임의의 코드 블록에 변수와 함수가 속하는 개념이다. 
  • 블록 스코프는 var 함수 스코프를 완전히 대체할 수 없다. 함수 스코프와 블록 스코프 기술을 같이 사용할 수 있어야 한다.

Chapter 4 호이스팅

변수가 어디서 어떻게 선언되는지에 따라 변수가 다른 여러 수준의 스코프에 붙게 되는 과정을 이해할 수 있었다. 함수 스코프와 블록 스코프 모두 이 점에서는 똑같은 규칙에 따라 작동한다. 한 스코프 안에서 선언도니 변수는 바로 그 스코프에 속한다. 

key point : 선언문이 스코프의 어디에 있는 지에 따라 스코프에 변수가 추가되는 과정에 미묘한 차이가 있다.
console.log(a); //예상 2, but undefined
var a = 2;

4.2 컴파일러는 두 번 공격한다

❗ 자바스크립트 엔진이 코드를 인터프리팅하기 전에 컴파일 한다는 사실을 기억해라. 컴파일레이션 단계 중에는 모든 선언문을 찾아 적절한 스코프에 연결해주는 과정이 있었다. 바로 이 과정이 렉시컬 스코프의 핵심이라고 배웠다. 

 

이제 변수와 함수 선언문 모두 코드가 실제 실행되기 전에 먼저 처리된다고 보면 된다. 'var a = 2;' 를 하나의 구문이라고 생각할 수 있지만 자바스크립트에서는 아래와 같이 두 개의 구문으로 본다

  • var a;
  • a = 2;

첫째 구문은 선언문으로 컴파일레이션 단계에서 처리된다. 둘째 구문은 대입문으로 실행 단계까지 내버려둔다. 

아래와 같이 실행된다고 보면된다. 

{
  var a;
  console.log(a);
  a = 2;
}
🐱‍👤 이 과정을 비유적으로 말하자면 변수와 함수 선언문은 선언된 위치에서 코드의 꼭대기로 끌어올려진다. 이렇게 선언문을 끌어올리는 동작을 호이스팅이라고 한다. 즉, 달걀(선언문)이 닭(대입문)보다 앞선다. 
선언문만 끌어올려지고 다른 대입문이나 실행 로직은 제자리에 그대로 둔다. 호이스팅으로 코드 실행 로직 부분이 재배치된다면 큰 혼란이 생길 수 있다. 
 foo();
  function foo() {
    console.log(a); //undefined
    var a = 2;
  }

->함수 foo의 선언문(앞의 예에서 실제 함수의 값은 포함한다)은 끌어올려졌기 때문에 foo를 첫째줄에서 호출할 수 있다. 호이스팅이 스코프 별로 작동한다는 점도 유의해야 한다. 앞에서는 오직 글로벌 스코프만 포함된 예시였지만 예제의 함수 foo() 함수 내에서도 변수 a가 명백히 프로그램의 꼭대기가 아니라 foo()의 꼭대기로 끌어올려진다. 

위의 예시를 실행단계로 나타내면 아래와 같다.

 function foo(){
    var a; 
    console.log(a);
    a = 2;
  }
  foo();

🔥함수 선언문은 위처럼 끌어올려지지만(호이스팅 적용) 함수 표현식은 다르다!

{
  foo(); //TypeError
  var foo = function bar() {
    console.log('안녕하세요.');
  };
}

->변수 확인자 foo는 끌어올려져 둘러싼 스코프에 붙으므로 foo() 호출은 실패하지 않고, 레퍼런스에러도 발생하지 않는다. 그러나 foo는 아직 값을 가지고 있지 않는데(마치 foo가 함수 표현식이 아니라 진짜 선언문으로 생성된 것처럼), foo()가 undefined 값을 호출하려해서 TypeError라는 오작동을 발생시킨다. 

한 가지, 더 기억할 점은 함수 표현식이 이름을 가져도 그 이름 확인자는 해당 스코프에서 찾을 수 없다는 점이다. 

위처럼 bar 라는 기명 함수를 변수 foo에 담는 함수 표현식인데 글로벌 스코프에서 bar()를 따로 호출하려고 하면 ReferenceError 를 발생시킨다.  

 

위의 코드를 호이스팅을 적용하면 아래와 같다. 

{
  var foo;
  foo();
  bar();

  foo = function(){
    var bar = ...self...
  }
}

4.3 함수가 먼저다

함수와 변수 선언문은 모두 끌어올려진다. 하지만 미묘한 차이가 있는데 먼저 함수가 끌어올려지고 그다음으로 변수가 올려진다. 

{
  foo();
  var foo;

  function foo() {
    console.log(1);
  }
  foo = function () {
    console.log(2);
  };
}

-> 결과값으로 2가 아니라 1이 출력된다. 

  • var foo가 (그래서 무시된)중복 선언문이라는 점을 보면, var foo는 function foo() 선언보다 앞서 선언됐지만, 함수 선언문이 일반 변수 위로 끌어올려졌다. 많은 중복 변수 선언문이 사실상 무시됐지만 중복 함수 선언문은 앞선 것들을 곂쳐 쓴다. 
  • 이를 통해 같은 스코프 내에서의 중복 정의가 얼마나 나쁜 방식이고 혼란스러운 결과를 내는 지 알 수 있다. 
주의: 블록 내 함수 선언은 지양하는 것이 좋다. e.g.) if 절 안에 함수 선언문이라든가.. 

4.4 정리

var a = 2은 하나의 구문처럼 보이지만, 자바스크립트 엔진은 var a; 와 a = 2 를 두개의 독립된 구문으로 본다. 첫번째 구문은 컴파일러 단계에서 처리하고 두번째 구문은 실행 단계에서 처리한다. 
즉, 스코프의 모든 선언문은 어디서 나타나든 실행 전에 먼저 처리된다는 점이다. '호이스팅'이라 불리는 이 과정은 (변수와 함수) 선언문 자체는 옮겨지지만 함수 표현식의 대입문을 포함한 모든 대입문은 끌어올려 지지 않는다.  

Chapter 5 클로저

전제

  • 클로저는 자바스크립트의 모든 곳에 존재한다. 
  • 클로저는 렉시컬 스코프에 의존해 코드를 작성한 결과로 그냥 발생한다.

5.2 핵심

클로저란 함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 때에도 이 스코프에 접근할 수 있게하는 기능 

 

{
    function foo() {
        let a = 2;
        function bar() {
          console.log(a);//2
        }
        bar();
      }
      foo();
}

-> 중첩 스코프를 다룰 때 보았던 예시와 비슷하며 함수 bar()은 렉시컬 스코프 검색 규칙을 통해 바깥 스코프의 변수 a에 접근할 수 있다.(RHS 참조 검색, "a 값이 뭐야?")

a를 참조하는 bar()를 설명하는 가장 정확한 방식은 렉시컬 스코프 검색 규칙에 따라 설명하는 것이며, 이 규칙은 클로저의 일부일뿐이다. 

function foo() {
    let a = 2;
    function bar() {
      console.log(a);
    }
    return bar;
  }
  let baz = foo();
  baz(); //2 클로저가 발견됐다.

-> 함수 bar()는 foo()의 렉시컬 스코프에 접근할 수 있고, bar() 함수 자체를 값으로 넘긴다. foo함수는 bar를 참조하는 함수 객체 자체를 반환한다. 

  • foo()를 실행하여 반환한 값을 baz라 불리는 변수에 대입하고 실제로는 baz함수를 호출했다. 그저 다른 확인자 참조로 내부 함수인 bar()를 호출한 것이다. bar()는 실행됐지만 이 경우 함수 bar는 함수가 선언된 렉시컬 스코프 밖에서 실행된 것이다. 

일반적으로는 foo()가 실행된 후에는 foo()의 내부 스코프가 사라졌다고 생각할 것이다. 이것은 엔진이 가비지 콜렉터를 고용해 더는 사용하지 않는 메모리를 해제시키기 때문이다. 더는 foo()의 내용을 사용하지 않는 상황이라면 사라졌다고 보는 게 맞다.

사실 foo의 내부 스코프는 여전이 '사용 중'이므로 해제되지 않는다. 누가 그 스코프를 사용하는 중일까? 바로 bar()자신이다. 선언된 위치 덕분에 bar()는 foo() 스코프에 대한 렉시컬 스코프 클로저를 가지고, foo()는 bar()가 나중에 참조할 수 있도록 스코프를 살려둔다. 즉, bar()는 여전히 해당 스코프에 대한 참조를 가지는데, 이 참조를 클로저라 부른다.

 

클로저는 호출된 함수가 원래 선언된 렉시컬 스코프에 계속해서 접근할 수 있도록 허용한다. 물론, 어떤 방식이든 함수를 값으로 넘겨 다른 위치에서 호출하는 행위는 모두 클로저를 작용한 예다.

 

function foo() {
    let a = 2;
    function baz() {
      console.log(a);
    }
    bar(baz);
  }
  function bar(fn) {
    fn(); //여기가 클로저
  }
  foo(); //2

함수 넘기기를 간접적으로 할 수도 있다. (위의 예시처럼 bar에 인자로 baz를 직접 넘기는게 아니라)

let fn;
  function foo() {
    let a = 2;
    function baz() {
      console.log(a);
    }
    fn = baz;
  }
  function bar() {
    fn();
  }
  foo();
  bar();//2

어떤 방식으로 내부 함수를 자신이 속한 렉시컬 스코프 밖으로 수송하든 함수는 처음 선언된 곳의 스코프에 대한 참조를 유지한다. 즉, 어디에서 해당 함수를 실행하든 클로저가 작용한다. 

 

클로저 다른 예시

 function wait(msg) {
    setTimeout(function timer() {
      console.log(msg);
    }, 1000);
  }
  wait('hello closure');

-> 내부 함수 timer를 setTimeout()에 인자로 넘겼다. timer함수는 wait함수의 스코프에 대한 스코프 클로저를 가지고 있으므로 변수 msg에 대한 참조를 유지하고 사용할 수 있다. 

  • wait () 실행 1초 후, wait의 내부 스코프는 사라져야 하지만 익명의 함수가 여전히 해당 스코프에 대한 클로저를 가지고 있다. 엔진 내부 깊숙한 곳의 내장 함수 setTimeout()에는 fn나 func 정도로 불릴 인자의 참조가 존재한다. 엔진은 해당 함수 참조를 호출하여 내장 함수 timer 를 호출하므로 timer의 렉시컬 스코프는 온전히 남게된다. 
클로저는 기술적으로 보면 선언할 때 발생하지만, 바로 관찰할 수 있는 것은 아니다. 

5.4 반복문과 클로저

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

-> 예상 결과는 1 ~ 5까지 한번에 하나씩 일 초마다 출력하는 것이지만, 실제 코드는 1초마다 6만 5번 출력된다. 

반복문이 끝나는 조건은 i가 <= 5가 아닐때, 즉 6일때이다. 즉, 출력된 값은 반복문이 끝났을 때의 i값을 반영한 것이다. 

timeout 함수 콜백은 반복문이 끝나고 나서야 작동한다.

기대와 같이 출력하려면 어떻게해야할까?(간단하게는 i를 let으로 바꿔주면됨) 

  • 필요한 것은 반복마다 각각의 i복제본을 잡아두는 것이다. 그러나, 반복문 안 총 5개의 함수들은 반복마다 따로 정의됐음에도 모두 같이 글로벌 스코프 클로저를 공유해 해당 스코프 안에는 오직 하나의 i만 존재하게 된다.
  • 여기서 필요한 것은 더 많은 닫힌 스코프다. (그래서 let) 구체적으로 말하면 반복마다 하나의 새로운 닫힌 스코프가 필요하다. IIFE로 해결가능한가? 그것도 아니다. timeout 함수 콜백은 반복마다 각각의 IIFE가 생성한 자신만의 스코프를 가지지만 해당 스코프가 비어있기 때문에 원하는 대로 동작하지 않는다. 즉, IIFE는 빈 스코프일 뿐이니 각 스코프에 자체 변수가 필요하다. -> 반복마다 i의 값을 저장할 변수가 필요하다. 
  for (var i = 1; i <= 5; i++) {
    (function () {
      var j = i;
      setTimeout(function timer() {
        console.log(j);
      }, j * 1000);
    })();
  }

혹은 아래와 같이 할수도 있다. 

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

IIFE를 사용하여 반복마다 새로운 스코프를 생성하는 방식으로 timeout 함수 콜백은 원하는 값이 제대로 저장된 변수를 가진 새 닫힌 스코프를 반복마다 생성해 사용할 수 있다. -> 반복마다 IIFE를 사용해 하나의 새로운 스코프를 생성(실제로 필요했던 것은 반복 별 블록 스코프)

 

블록 스코프에 변수를 선언하는 let 선언문을 배웠던 것을 기억하자. 키워드 let은 본질적으로 하나의 블록을 닫을 수 있는 스코프로 바꾼다. 또한, let 선언문이 for 반복문 안에서 사용되면 특별한 방식으로 작동한다. 반복문 시작 부분에서 let으로 선언된 변수는 한 번만 선언되는 것이 아니라 반복할 때마다 선언된다. 따라서 해당 변수는 편리하게도 반복마다 이전 반복이 끝난 이후의 값으로 초기화된다. 

 

5.5 모듈

클로저의 능력을 활용하면 표면적으로는 콜백과 상관없는 코드 패턴들이 있다. 

그 중 가장 강력한 패턴인 모듈을 살펴보자.

function CoolModule() {
    let something = 'something';
    let another = [1, 2, 3];

    function doSomething() {
      console.log(something);
    }

    function doAnother() {
      console.log(another.join('!'));
    }
    return {
      doSomething: doSomething,
      doAnothor: doAnother,
    };
  }
  let cong = CoolModule();
  cong.doAnothor(); // something
  cong.doSomething(); //1!2!3!

내부 데이터 something, another 그리고 함수 doSomething과 doAnother은 모두 cong()의 내부 스코프를 렉시컬 스코프(당연히 클로저도)로 가진다. 위와 같은 자바스크립트 패턴을 '모듈'이라 부른다. 가장 흔한 모듈 패턴 구현 방법인 모듈 노출이며, 앞의 코드는 이것의 변형체이다. 

 

해당 모듈 패턴을 사용하려면 두 가지 조건이 있다. 

  1. 하나의 최외곽 함수가 존재하고, 이 함수가 최소 한 번은 호출되어야 한다.(호출할 때마다 새로운 모듈 인스턴스 생성)
  2. 최외곽 함수는 최소 한 번은 하나의 내부 함수를 반환해야 한다. 그래야 해당 내부 함수가 비공개 스코프에 대한 클로저를 가져 비공개 상태에 접근하고 수정할 수 있다.

해당 패턴에서 오직 하나의 인스턴스, '싱글톤'만 생성하는 모듈은 모듈 함수에 IIFE로 바꾸고 즉시 실행시켜 반환 값을 직접 하나에 모듈 인스턴스 확인자(변수명 알아서)에 대입시키는 것이다. 

 

5.5.2 미래의 모듈

ES6는 모듈 개념을 지원하는 최신 문법을 추가했다. 모듈 시스템을 불러올 때 ES6는 파일을 개별 모듈로 처리한다. 각 모듈은 다른 모듈 또는 특정 API 멤버를 불러오거나 자신의 API멤버를 내보낼 수 있다.

  • ES6 모듈 시스템 e.g. import, export 
함수 기반 모듈은 정적으로 알려진(컴파일러가 읽을 수 있는) 패턴이 아니다. 따라서 이들 API의 의미는 런타임 전까지 해석되지 않는다. 즉, 실제로 모듈 API를 런타임에 수정할 수 있다는 말이다. 

반면, ES6 모듈 API는 정적이다(API가 런타임에 변하지 않는다.) 따라서 컴파일러는 이 사실을 알고 있어서 (파일을 불러오는 때와) 컴파일레이션 중에 불러운 모듈의 API 멤버 참조가 실제로 존재하는 지 확인할 수 있다.  API참조가 존재하지 않으면, 컴파일러는 컴파일 시 초기 오류를 발생시킨다. -> 즉, 런타임 직접 프로그램을 실행시키지 않고도 그 전에 오류를 알 수 있음

ES6 모듈은 inline형식을 지원X , 반드시 개별 파일(모듈당 파일 하나)에 정의되어야 한다. 브라우저와 엔진은 기본 모듈 로더를 가진다. 모듈을 불러올 때 모듈 로더는 동기적으로 모듈 파일을 불러온다. 

 

함수-클로저 모듈처럼 모듈 파일의 내용은 스코프 클로저에 감싸진 것으로 처리된다. 

 

5.6 정리

클로저는 표준이며, 함수를 값으로 마음대로 넘길 수 있는 렉시컬 스코프 환경에서 코드를 작성하는 방법이다. 

클로저는 함수를 렉시컬 스코프 밖에서 호출해도 함수는 자신의 렉시컬 스코프를 기억하고 접근할 수 있는 특성을 의미한다. 또한, 클로저는 다양한 형태의 모듈 패턴을 가능하게 하는 매우 효과적인 도구이기도 하다. 

 


외 다른 추가적인 정보

콘솔

브라우저에서는 console이 개발자 툴의 콘솔 창과 연결되어 있지만, 노드js및 기타 서버사이드 자바스크립트 환경에서는 대개 자바스크립트 환경 시스템 프로세스의 표준 출력(stdout), 표준 에러(stderr) 스트림과 연결된다. 

네이티브 프로토타입

네이티브 프로토타입을 수정하거나 확장하지 말아라.

<script>들

브라우저로 접속하는 대다수의 웹사이트나 애플리케이션은 다수의 자바스크립트 코드 파일로 구성되고, 이 파일들은 대개 <script src=""></script> 태그로 하나씩 페이지에 읽어들인다. 또 인라인 형태로 <script> //JS코드 들어가욥 <script> 사이에 코드를 넣기도 한다. 

이렇게 따로 떨어진 코드/파일 조각은 개별적인 프로그램으로 동작한다.(모든 경우는 아니지만 대다수가) 그렇기 때문에 한 스크립트에서 에러가 나면(인라인/외부 로딩 둘 다) 독립적인 개발 자바스크립트류 해당 프로그램만 실패 후 중단되며, 다른 후속 스크립트는 아무런 영향을 받지않고 실행된다. 프로그램들이 서로 공유하는 건 단일 전역 객체(브라우저에서 window)가 유일하다.

 


스코프와 렉시컬 this

동적스코프 

렉시컬 스코프는 엔진이 변수를 찾는 검색 방식과 위치에 대한 규칙이다. 렉시컬 스코프의 주요 특성은 이 스코프가 프로그래머가 코드를 작성할 때 결정된다는 것

그렇다면 동적 스코프는 런타임에 결정된다고 보면 되는가? 동적 스코프는 함수와 스코프가 어떻게 어디서 선언됐는지 상관없고, 오직 어디서 호출됐는지 연관된다. 다르게 말하면, 동적 스코프 체인은 코드 내 스코프의 중첩이 아니라 콜스택(call stack)과 관련있다. 

정확히 말하자면 자바스크립트는 동적 스코프를 사용하지 않고 렉시컬 스코프만 사용한다. 단, this 메커니즘이 동적 스코프와 비슷한 면이있다.

 주요 차이점

  • 렉시컬 스코프는 작성할때, 동적 스코프(그리고 this)는 런타임에 결정된다.
  • 렉시컬 스코프는 어디서 함수가 선언됐는지와 관련/ 동적 스코프는 어디서 호출 

->this 는 함수가 어디서 호출됐는지와 관련

 

댓글