[You Don’t Know JS : 스코프와 클로저] 정리

그니·2019년 2월 13일
3
post-thumbnail

You Don’t Know JS : 스코프와 클로저 내용 정리 입니다.

한빛출판네트워크의 [You Don’t Know JS : 스코프와 클로저]라는 ebook에서 내용을 참조하였습니다.

1.1 컴파일러 이론

자바스크립트는 '동적' 또는 '인터프리터' 언어로 분류되지만 '컴파일러 언어' 이다. 물론 코드를 미리 컴파일하거나 컴파일한 결과를 분산 시스템에서 이용할 수 있는것은 아니다.
하지만 자바스크립트 엔진은 전통적인 컴파일러 언어에서 컴파일러가 하는 일의 상당 부분을 세련된 방식으로 처리한다.

전통적인 컴파일러 언어의 처리 과정에서는 프로그램을 이루는 소스 코드가 실행 되기 전에 보통 3단계를 거친다. 이를 컴파일레이션(compileation)이라 한다.

컴파일레이션(compileation)
1. 토크나이징(Tokenizing)/렉싱(Lexing)
2. 파싱(Parsing)
3. 코드 생성(Code-Generation)

토크나이징(Tokenizing)/렉싱(Lexing)

문자열을 나누어 토큰이라 불리는 (해당 언어에) 의미 있는 조각으로 만드는 과정이다.
ex) "var a = 2;"
토큰 결과

  • var
  • a
  • =
  • 2
  • ;
  • 빈칸이 의미가 있냐 없냐에 따라 토큰인지 아닌지 결정된다.

위처럼 소스 코드 문자열을 토큰으로 나누는 과정이다.
토크나이징과 렉싱의 차이점

 토큰(token)을 인식할 때 무상태 방식으로 하는지 상태유지 방식으로 하는지에 따라 구분된다.
 토크나이저가 상태유지 파싱 규칙을 적용해 
 토큰 a가 별개의 토큰인지 다른 토큰의 일부인지를 파악한다면 렉싱이다.

파싱(Parsing)

토크나이징/렉싱 과정에 의해 생성된 토큰 배열을 프로그램의 문법 구조를 반영하여 중첩 원소를 갖는 트리 형태로 바꾸는 과정이다.
파싱의 결과로 만들어진 트리를 -> AST(추상구문트리(abstract syntax tree))라 한다.
ex) "var a = 2;"
의 트리는 먼저
변수 선언(Variable Declartion)이라 부르는 최상위 노드에서 시작하고,
최상위 노드는 'a'의 값을 가지는 확인자(identifer)와
대입수식(Assignment Expression) 이라 부르는 자식 노드를 가진다.

코드 생성(Code-Generation)

컴퓨터에서 AST -> 실행 코드로 바꾸는 과정이다.
코드 생성은 언어에 따라, 목표하는 플랫폼에 따라 크게 달라진다.
"var a = 2;" 를 나타내는 AST --> 기계어 집합으로 바꾸어 실제로 'a' 라는 변수를 생성(메모리를 확보하는 일 등)하고 값을 저장할 방법이 있다.

엔진이 시스템 리소스를 실제 어떻게 관리하는지에 관한 세부사항은 살펴볼 범위를 넘어선다.
일단 에진이 필요한 변수를 생성하고 저장할 것이라고 가정.

자바스크립트 엔진은 세 가지 단계뿐만 아니라 많은 부분에서 다른 프로그래밍 언어의 컴파일러보다 훨씬 복잡하다.
파싱과 코드 생성 과정에서 불필요한 요소를 삭제하는 과정을 거쳐 실행 시 성능을 최적화한다.

JS 엔진이 기존 컴파일러와 다른 한 가지 이유는 다른 언어와 다르게 JS 컴파일레이션이 미리 수행되지 않아서 최적화할 시간이 많지 않다.

JS 컴파일레이션은 보통 코드가 실행 되기 겨우 수백만 분의 일초 전에 수행한다.
간단히 말하면,
어떤 JS 코드 조각이라도 실행되려면 먼저(보통 바로 직전에!) 컴파일되어야 한다는 것이다.
즉, JS 컴파일러는 프로그램 "var a = 2;" 를 받아 컴파일하여 바로 실행할 수 있게 한다.
-> 그때그때 소스 코드가 실행되기전에 위 컴파일레이션 과정을 JS 컴파일러가 한 뒤 실행 되는듯 싶다..

즉 "var a = 2;" 라는 소스 코드를 JS 엔진이 확인하면
JS 컴파일레이션이 시작되고,
1. "var a = 2;" 를 JS 엔진이 렉싱 과정을 통해 각각의 의미있는 토큰(token)으로 분리하면,
2. 이를 컴파일러가 받아 토큰(Token)배열을 AST(추상구문트리)로 바꾼 다음
3. AST를 JS가 기계어 집합으로 바꾸어 실제로 'a'라는 변수를 생성하고, 2라는 값을 변수에 저장한다.

1.2 스코프 이해하기

1.2.1 출연진

// e.g)👇
var a = 2;
  • 엔진: 컴파일레이션의 시작부터 끝까지 전 과정과 JS 프로그램 실행을 책임진다.
  • 컴파일러: 엔진의 친구로, 파싱(Parsing)과 코드 생성(Code-Generation)의 모든 잡일을 도맡아 한다.
  • 스코프: 엔진의 또 다른 친구로, 선언된 모든 식별자(변수) 검색 목록을 작성하고 유지한다. 또한, 엄격한 규칙을 강제하여 현재 실행 코드에서 식별자의 적용 방식을 정한다.

1.2.2 앞과 뒤

프로그램 "var a = 2;" 를 보면 하나의 구문으로 보인다. 그러나 엔진은 그렇게 보지 않는다.

엔진은 위 프로그램을 두 개의 서로 다른 구문으로 본다.

  • 하나는 -> 컴파일러가 컴파일레이션 과정에서 처리할 구문이고,
  • 다른 하나는 -> 실행 과정에서 엔진이 처리할 구문이다.
  1. 컴파일러가 할 첫 번째 일은 렉싱을 통해 구문을 토큰으로 쪼개는것이다.

  2. 그 후 토큰을 파싱해 AST(추상구문트리) 구조로 만든다.

    코드 생성 과정에 들어가면 컴파일러는 추측과는 다르게 프로그램을 처리한다.
    컴파일러가 다음의 의사 코드로 요약될 수 있는 코드를 생성한다고 생각할 수 있다.

    변수를 위해 메모리를 할당하고 할당된 메모리를 a라 명명한 후 그 변수에 값 2를 넣는다.

  3. 안타깝지만, 이는 그리 정확한 설명이 아니다. 컴파일러는 👇 와 같이 일을 진행한다.

    1. 컴파일러가 "var a" 를 만나면 스코프에 변수 a가 특정한 스코프 컬렉션 안에 있는지 묻는다.
      변수 a가 이미 있다면 컴파일러는 선언을 무시하고 지나가고,
      그렇지 않으면 컴파일러는 새로운 변수 a를 스코프 컬렉션 내에 선언하라고 요청한다.
    2. 그 후 컴파일러는 "a = 2" 대입문을 처리하기 위해 나중에 엔진이 실행할 수 있는 코드를 생성한다. 엔진이 실행하는 코드는
      1) 스코프에 a라 부르는 변수가 현재 스코프 컬렉션 내에서 접근할 수 있는지 확인한다.
      접근 가능 -> 엔진은 변수 a를 사용하고, 접근 불가 -> 엔진은 다른 곳(중첩 스코프)를 살핀다.
      2) 엔진이 마침내 변수를 찾으면 변수에 값 2를 넣고, 못 찾는다면 엔진은 손을 들고🙌 에러가 발생 했다고 소리친다!

요악하면

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

1.2.3 컴파일러체

2단계에서 엔진이 컴파일러가 생성한 코드를 실행할 때 변수 a가 선언된 적 있는지 스코프에서 검색한다.
이때 엔진이 어떤 종류의 검색을 하느냐에 따라 검색 결과가 달라진다.
앞의 경우 변수 a를 찾기 위해 LHS 검색을 수행한다. 다른 종류는 RHS라 한다.
L과 R은 왼쪽 방향과 오른쪽 방향인데 이 기준은 대입 연산을 기준으로 한다.
LHS는 변수가 대입 연산자의 왼쪽에 있을 때 수행, RHS 검색은 변수가 대입 연산자의 오른쪽에 있을 때 수행한다.

RHS검색은 단순히 특정 변수의 값을 찾는 것과 다를 바 없다.
반면 LHS 검색은 변수에 값을 넣어야 하므로 변수 컨테이너 자체를 찾는다.
정확히 말하면 RHS는 '왼편이 아닌 쪽'에 가깝다.
RHS를 쉽게 이해하자면
R: Retrieve(가져오라)
H: his/her(그의/그녀의)
S: source(소스)
-> "가서 값을 가져오라" 라는 뜻으로 이해할 수 있다.

E.G)

// a에 대한 참조는 RHS 참조다. 구문에서 a에 아무 것도 대입하지 않기 때문이다.
// 대신 a의 값을 가져와 console.log에 인자로 넘겨준다.
console.log('a'); 
// a에 대한 참조는 LHS 참조다. 현재 a 값을 신경 쓸 필요 없이 
// '=2' 대입 연산을 수행할 대상 변수를 찾기 때문이다.
a = 2;

NOTE

LHS(대입할 대상)/ RHS(대입한 값)

E.G)function call

function foo(a) { // 인수로 값 2를 매개변수 a에 대입하는 연산 발생 -> a에 대한 LHS 검색 수행.
  console.log( a ); // console 객체에 대한 RHS 검색을 통해 log메소드를 호출. 그리고 a를 사용하기 위해 RHS 수행
}
foo( 2 ); // RHS 참조를 사용 "가서 foo의 값을 찾아 내게 가져와라" 

1.4 오류

LHS와 RHS를 구분하는것이 왜 중요할까?
= 변수가 아직 선언되지 않았을 때(검색한 모든 스코프에서 찾지 못했을 때) 서로 다르게 동작하기 때문이다.
E.G)

function foo(a) {
  console.log(a + b);
  b = a;
}

foo( 2 );

b에 대한 첫 RHS 검색이 실패하면 다시는 b를 찾을 수 없다.
이렇게 스코프에서 찾지 못한 변수는 '선언되지 않은 변수'라 한다.
RHS 검색이 중첩 스코프 안 어디에서도 변수를 찾지 못한다면
엔진이 'ReferenceError' 를 발생시킨다.
여기서 중요한 점은 발생된 오류가 ReferenceError 타입이라는 것이다.

반면에, 엔진이 LHS검색을 수행하여 변수를 찾지 못하고 글로벌 스코프에 도착할 때 프로그램이 'Strict Mode'로 동작하고 있는것이 아니라면, 글로벌 스코프는 엔진이 검색하는 이름을 가진 새로운 변수를 생성해서 엔진에게 넘겨준다.

RHS 검색 결과 변수를 찾았지만 그 값을 가지고 불가능한 일을 하려고 할 경우 즉 숫자를 찾았는데 함수 처럼 호출하려는 경우 TypeError를 발생시킨다.

-- 업데이트 03/12 --
렉싱, 렉서는 렉시컬(어휘)에 의해 지어진 이름갔다고 고수분이 생각하셨다..

0개의 댓글