JavaScript

스코프와 렉시컬 스코프

ㅇㄱ9 2022. 5. 11. 09:00
728x90

스코프(Scope)

: 특정 장소에 변수를 저장하고 나중에 그 변수를 찾는 데 필요한 잘 정의된 규칙

 

var a = 2;

위의 간단한 코드에서 컴파일러와 엔진은 각각 다음과 같은 일을 수행한다. 

- 컴파일러 : (현재 스코프에 미리 변수가 선언되지 않은 경우) 변수 선언

- 엔진 : 스코프에서 변수를 찾고 변수가 있다면 값을 대입 

 

 이때 엔진이 어떤 종류의 검색(LHS 혹은 RHS)을 하느냐에 따라 검색 결과가 달라지게 되는데 위의 경우에서는 변수 a를 찾기 위해 LHS(Left- Hand Side) 검색을 수행한다. 

 

LHS검색은 변수가 대입 연산자의 왼쪽에 있을 때 수행하고, RHS 검색은 변수가 대입 연산자의 오른쪽에 있을 때 수행한다. 좀 더 살펴보자면 RHS 검색은 단순히 특정 변수의 값을 찾는 것과 다를 바가 없지만 LHS검색은 값을 넣어야 하므로 변수 컨테이너 자체를 찾는다. 때문에 좀 더 정확히 말하면 RHS는 그 자체로는 '대입문의 오른쪽'이 아닌 '왼편이 아닌 쪽'에 가깝다. 

 

console.log(a);

위의 예에서 a에 대한 참조는 RHS 참조이다. 구문에서 a에 아무것도 대입하지 않기 때문이다. 

a = 2;

이 경우 a에 대한 참조는 LHS참조이다. 현재 a값에 신경 쓸 필요 없이 '= 2' 대입 연산을 수행할 대상 변수를 찾기 때문이다. 

 

조금 더 복잡한 예를 살펴보자. 

function foo(a) {
    var b = a;
    return a + b;
}

var c = foo(2);

위의 예에서 LHS검색과 RHS검색을 모두 찾으면 다음과 같다. 

- LHS 검색 (총 3개) : c= foo(2) ; , a = 2 (암시적 인자 대입), b = a ; 

- RHS 검색 (총 4개): foo(2), = a, a..., b... 

 

중첩 스코프 

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

 

중첩 스코프를 탐색할때 사용하는 간단한 규칙은 다음과 같다. 

- 엔진은 현재 스코프에서 변수를 찾기 시작하고, 찾지 못하면 한 단계씩 올라간다. 

- 최상위 글로벌 스코프에 도달하면 변수를 찾았든, 못 찾았든 검색을 멈춘다. 

 

오류

변수가 아직 선언되지 않은 상태일 때 즉, 검색한 모든 스코프에서 변수를 찾지 못했을 때 LHS검색과 RFS검색은 서로 다르게 동작한다. 

 

1) 먼저 RHS 검색이 중첩 스코프 안 어디에서도 변수를 찾지 못하면 엔진은 'Reference Error'를 발생시킨다. 여기서 중요한 점은 발생한 오류가 ReferenceError타입이라는 점이다. 

 

2) 반면, 엔진이 LFS검색에 실패하고 프로그램이 'Strict Mode'로 동작하고 있는 것이 아니라면 글로벌 스코프는 엔진이 검색하는 이름을 가진 새로운 변수를 생성하여 엔진에게 넘겨준다. 즉, "없어, 없었지만 내가 널 위해 하나 만들어주지"라고 생각하면 된다. 

하지만 Strict Mode인 경우 글로벌 변수를 자동으로 또는 암시적으로 생성할 수 없어 엔진은 Reference Error를 발생시킨다. 

 

3) RHS 검색 결과 변수를 찾았지만 그 값을 가지고 불가능한 일을 하려 하는 경우 엔진은 TypeError를 발생시킨다. 

ex) 함수가 아닌 값을 함수처럼 실행하거나 null이나 undefined값을 참조할 때 

 

 

렉시컬 스코프

스코프는 렉시컬 스코프와 동적 스코프 2가지 방식으로 동작하지만 Javascript에서는 렉시컬 스코프를 채용하여 사용한다. 

 

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

  • 토크나이징(tokenizing)/렉싱(lexing) : 문자열을 나누어서 토큰이라는 의미 있는 조각으로 만드는 과정
  • 파싱(parsing) : 토큰 배열을 프로그램의 문법 구조를 반영하여 중첩 원소를 갖는 트리 형태(AST, 추상 구문 트리)로 바꾸는 과정
  • 코드 생성(code-generation) : AST를 컴퓨터에서 실행 코드로 바꾸는 과정

이 중 첫 번째 단계인 렉싱처리과정에서는 소스코드 문자열을 분석하여 상태 유지 파싱의 결과로 생성된 토큰에 의미를 부여하게 되는데 렉시컬 스코프는 렉싱타임에 정의되는 스코프이다. 

바꿔 말해 렉시컬 스코프는 개발자가 코드를 짤 때 변수와 스코프 블록을 어디서 작성하는가에 기초해서 렉서(lexer)가 코드를 처리할 때 확정된다.  

 

예제 코드를 보자

//----------------1---------------
function foo() {
	//------------2-----------
	var b = a * 2;
	function bar(c); {
        //-----3------
          console.log( a, b, c);
        //------------
	}
	bar( b * 3 );
    //-------------------------
}
foo( 2 ); // 2, 4, 12
//----------------------------------

위의 예제는 3개의 중첩 스코프가 존재하고 스코프를 겹쳐진 버블이라고 가정해보자. 

 

버블 1) 글로벌 스코프가 감싸고 있고, 해당 스코프 안에는 오직 하나의 확인자(foo)가 있다. 

버블 2) foo의 스코프를 감싸고 있고, 해당 스코프는 3개의 확인자(a, bar, b)를 포함한다. 

버블 3) bar의 스코프를 감싸고 있고, 해당 스코프는 하나의 확인자(c) 만을 포함한다. 

 

foo의 내부에서 bar 함수를 정의했기 때문에 bar의 버블은 foo의 버블 내부에 완전히 포함된다. 

어떤 함수의 버블도 동시에 다른 두 스코프 버블 안에 존재할 수 없다. 

 

검색

엔진의 스코프 버블의 구조와 상대적 위치를 통해 어디를 검색해야 확인자를 찾을 수 있는지 안다. 

 

만일 위의 코드에서 변수 c가 bar()와 foo() 내부에 모두 존재한다고 가정하면 console.log() 구문은 bar() 내부에 있는 c를 찾아서 사용하고 foo()에 있는 c를 찾으러 가지도 않는다. 

 

스코프는 목표와 일치하는 대상을 찾는 즉시 검색을 중단한다.

여러 중첩 스코프 층에 걸쳐 같은 확인자 이름을 정의할 수 있는데 이를 섀도잉(shadowing)이라고 한다. 

function myFunc() {
    let my_var = 'test';
    if (true) {
        let my_var = 'new test';
        console.log(my_var); // new test
    }
    console.log(my_var); // test
}
myFunc();

섀도잉과 상관없이 스코프 검색은 항상 실행 시점에서 가장 안쪽 스코프에서 시작하여 최초 목표와 일치하는 대상을 찾으면 멈추고, 그전까지는 바깥/위로 올라가면서 수행한다. 

 

어떤 함수가 어디서 어떻게 호출되는지에 상관없이 함수의 렉시컬 스코프는 함수가 선언된 위치에 따라 정의된다. 

 

렉시컬 속이기 

렉시컬 스코프는 개발자가 코드를 작성할 때 결정된다. 런타임에서 렉시컬 스코프를 수정할 수 있을까?

 

자바스크립트에서는 두 가지 방법(eval 함수, 키워드 with)이 있지만 둘 다 권장하는 방법은 아니다. 이는 성능을 저하시키는 원인이 된다. 

1) eval

자바스크립트의 eval() 함수는 문자열을 인자로 받아들여 실행 시점에 문자열의 내용을 코드의 일부분처럼 처리한다. 즉, 처음에 작성한 코드에 프로그램에서 생성한 코드를 집어넣어 마치 처음 작성될 때부터 있던 것처럼 실행한다. 

fuction foo(str, a) {
  eval(str); //cheating!!
  console.log(a,b);
}

var b = 2; 
foo("var b = 3;", 1); // 1,3

console.log()가 호출될 때 a와 b모두 foo()의 스코프에서 찾을 수 있으므로 바깥의 b는 아예 찾지도 않는다. 

기본적으로 코드 문자열이 하나 이상의 변수 또는 함수 선언문을 포함하면 eval()이 그 코드를 실행하면서 eval()이 호출된 위치에 있는 렉시컬 스코프를 수정한다. 

 

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

fuction foo(str) {
  "use strict";
  eval(str); 
  console.log(a);  // ReferenceError: a is nor defined
}

foo("var a = 2");

2) with

with는 일반적으로 한 객체의 여러 속성을 참조할 때 객체 참조를 매번 반복하지 않기 위해 사용하는 기법이다. 

function foo(obj){
  with (obj){
   a = 2;
  }
}

var o1 = {
  a:3
}

var o2 = {
  b:3
}

foo(o1);
console.log(o1.a); //2

foo(o1);
console.log(o2.a); //undefined
console.log(a) //2

위의 예제에서 특이한 부작용을 찾을 수 있는데 대입문 "a=2"가 글로벌 변수 a를 생성한다는 점이다. 

with문은 속성을 가진 객체를 받아 마치 하나의 독립된 렉시컬 스코프처럼 취급한다. 따라서 객체의 속성은 모두 해당 스코프 안에 정의된 확인자로 간주된다. 

 

eval()은 인자로 받은 코드 문자열에 하나 이상의 선언문이 있는 경우 이미 존재하는 렉시컬 스코프를 수정할 수 있지만, with문은 넘겨진 객체를 가지고 사실상 하나의 새로운 렉시컬 스코프를 생성한다. 

 

성능

자바스크립트 엔진은 컴파일레이션 단계에서 상당수의 최적화 작업을 진행한다. 이 최적화의 일부분이 하는 핵심 작업은 렉싱된 코드를 분석하여 모든 변수와 함수 선언문이 어디에 있는지 파악하고 실행 과정에서 확인자 검색을 빠르게 하는 것이다. 

 

그러나 eval()이나 with가 코드에 있다면 대다수 최적화가 의미 없어져서 아무런 최적화도 하지 않은 것이나 마찬가지가 되어버린다. 

 

-----참고자료

- You Don't Know JS : 타입과 문법, 스코프와 클로저 

- [자바스크립트] scope chain 과 variable shadowing에 대해

 

 

728x90
반응형