자바스크립트 스코프

변수를 찾기 위한 규칙을 스코프라고 한다. 자바스크립트는 렉시컬 스코프를 만든다. / YOU DON'T KNOW JS - 스코프와 클로저

2022-10-09에 씀

YOU DON’T KNOW JS 1편을 읽고 작성했습니다.

스코프

프로그래밍 언어의 기본이 되는 개념은 ‘변수’인 것 같다. 기억해야 하는 값을 변수에 저장해 뒀다가, 필요할 때마다 변수에서 값을 찾아서 쓰는 방식으로 프로그램 내의 상태를 기억할 수 있다.

이 변수를 찾기 위한 규칙을 스코프라고 한다. 스코프는 아래의 규칙을 정의한다.

  1. 변수를 어디에 저장할까?
  2. 저장된 변수를 어떻게 가져올까?

자바스크립트 컴파일레이션

자바스크립트에서 스코프가 어떻게 동작하는지 알기 위해서는 자바스크립트가 컴파일되는 과정부터 시작해야 한다. 자바스크립트는 컴파일러 언어이지만, 전통적인 컴파일러 언어와는 다르게 동작한다. 다른 컴파일러 언어처럼 미리 코드를 컴파일해 두는 방식이 아니라, 자바스크립트는 코드가 실행되기 거의 직전에 코드를 컴파일한다.

코드를 미리 컴파일하지 않는 특징 때문에 최적화를 진행할 시간이 적다. 그렇기 때문에 몇 가지 트릭을 사용하기도 한다. (레이지 컴파일, 핫 리컴파일, JITs - 동적 번역 등…)

컴파일레이션의 과정은 아래 세 가지로 나눌 수 있다.

  1. 토크나이징 (렉싱)

문자열을 쪼개서 토큰이라고 불리는 의미 있는 조각으로 만든다.

이 과정에서 소스코드 문자열을 분석해 토큰에 의미를 부여하는 방식으로, 상태를 유지하며 토크나이징을 진행하는 방식을 렉싱이라고 부른다. 의미가 부여된 토큰은 렉시컬 토큰이라고 부른다. (참고 - 위키피디아)

  1. 파싱

토큰으로 문법 구조에 맞는 트리를 구성한다. 이때 생성되는 트리를 AST(추상 구문 트리, Abstract syntax tree)라고 부른다.

  1. 코드 생성

AST를 자바스크립트 엔진이 실행할 수 있는 실행 코드로 변환한다.

위의 과정에서, 렉시컬 스코프가 생성되는 시점은 렉싱이 일어나는 시점이다.

컴파일러가 자바스크립트 코드를 컴파일하면, 엔진이 컴파일된 코드를 실행한다. 이 짧은 코드는 아래와 같은 과정을 거쳐 실행된다.

1var a = 2;

그래서 호이스팅이 발생하는구나!!
왜냐하면, 선언문은 일반적으로 컴파일러가 수행하는 컴파일 단계에 처리되고,
할당문은 엔진이 수행하는 실행 단계에 처리되기 때문이다.
따라서, 결과적으로는 선언문이 코드의 최상단으로 끌어올려져 처리된 것처럼 동작한다. (호이스팅!)

엔진이 스코프에서 변수를 찾는 경우는 두 가지가 있다.

엔진이 스코프에서 변수를 찾을 때, 해당 스코프에 변수가 존재하지 않는다면 그 상위 중첩 스코프에서 변수를 찾는다. 계속 변수를 찾지 못하다가 global 스코프까지 도착하면 변수 탐색은 무조건 종료된다. global 스코프에서도 변수를 찾지 못하면 LHS/RHS 방식에 따라 행동이 결정된다.

렉시컬 스코프

자바스크립트의 스코프는 렉시컬 스코프라고 불린다. 렉싱 타임에 구성되기 때문이다.

왜 렉싱 타임에 스코프가 구성될까? 컴파일 타임에 토크나이징을 진행하면서 변수의 위치를 미리 파악해두고 스코프를 구성해 두면, 엔진이 코드를 실행하다가 변수를 마주쳤을 때 스코프에서 바로 찾아서 꺼내 쓸 수 있어 빠르기 때문이다.

렉싱 타임에 스코프가 형성된다는 것은, 개발자가 작성한 변수와 스코프 블록에 기반해 스코프가 형성된다는 것이다. 즉, 어떤 함수를 호출하는 방식은 스코프에 영향을 주지 않고, 오직 선언 방식에 의해 스코프가 결정된다. 호출 방식에 따라 스코프가 결정되는 방식은 동적 스코프라고 한다.

보통 함수 하나가 스코프 하나를 형성한다. 어떤 함수 내부에 함수를 또 선언할 수 있기 때문에, 스코프도 중첩될 수 있다. 그러나 하나의 스코프가 두 개의 스코프 체인에 속할 수는 없다.

스코프에서 변수를 검색하는 규칙은 다음과 같다.

  1. 해당 스코프에서 변수를 찾았다면 검색을 중단한다.
  2. 해당 스코프에 찾는 변수가 없다면 상위 스코프로 이동해서 검색한다.

한 스코프에 선언한 변수와 같은 이름의 변수를 다른 중첩 스코프에 또 선언할 수도 있다. 이를 섀도잉이라고 한다.

1var v = 123;
2
3function foo() {
4 var v = "hi";
5
6 function bar() {
7 var v = 456;
8 console.log(v);
9 }
10
11 bar();
12}
13
14foo();

bar 안의 console.log(v); 가 호출되면, 스코프에서 변수 v를 검색하기 시작한다. v는 상위 스코프에서도 선언된 변수인데, 그것과 관계 없이 함수 bar가 만드는 스코프에서 변수 v가 선언되어 있기 때문에 bar 안에서 할당한 456이 출력된다.

렉시컬 속이기

렉싱 타임에 존재하지 않던 변수가 실행 시점에 생성되도록 할 수 있다. 그러나 이런 방법은 예측하기 어렵고 성능을 낮추기 때문에 지양해야 한다. 만약 아래와 같은 상황을 엔진이 항상 고려한다면, 컴파일 타임에 미리 확인했던 확인자의 위치가 틀릴 수도 있다는 것을 가정해야 하기 때문에, 성능에 영향을 줄 수 있다.

  1. eval()

eval 함수에 전달된 문자열 아규먼트는 실행 시점에 코드로 삽입된다. 즉, 컴파일 시점에는 없던 코드가 실행 시점에는 추가될 수 있다. 이 코드에서 변수 선언을 한다면, 실행 타임에 변수가 선언되면서 렉시컬 스코프에 수정이 생긴다.

  1. with 문 (deprecated)

객체의 프로퍼티에 접근하려면 <객체 이름>.<property 명> 형식으로 접근해야 한다. with 문을 사용하면 전달된 객체의 프로퍼티를 객체 이름 없이 바로 접근할 수 있다. 객체를 렉시컬 스코프로 취급하는 것이다.

1const obj = {
2 a: 'abc',
3 b: 456,
4}
5
6console.log(obj.a);
7
8with (obj) {
9 console.log(a);
10 c = a; // <- ?
11}

그런데 with 문 안에서 객체가 가지고 있지 않은 프로퍼티에 접근하면, 객체에 프로퍼티로 취급하는 것이 아니라 변수로 취급한다. 따라서 상위 스코프를 검색하기 시작한다.

LHS로 변수에 접근할 경우, 전역 스코프까지 검색했는데도 변수를 찾지 못했다면 전역 스코프에 변수를 선언한다.

스코프 체인을 만드는 것들

스코프 체인은 아래와 같은 특징을 가진다.

함수 스코프

함수로 코드를 감싸면, 감싸진 변수와 내부 함수를 스코프 속에 숨길 수 있다. 이러한 방식으로 최소 권한의 원칙을 지킬 수 있다. (PoLP? 정당한 목적으로 필요한 정보와 리소스에만 접근할 수 있게 해야 한다) 이를 지키지 않는다면, 의도치 않은 방식으로 변수나 함수가 사용되거나 훼손될 수도 있다.

또, 불가피하게 이미 사용되고 있는 변수나 함수의 이름을 사용해야 하는 경우에도 이름의 충돌을 회피할 수 있게 한다. 외부 라이브러리나 모듈을 사용할 경우 이름 충돌이 발생할 수 있는데, 이들의 이름을 덮어쓰는 경우를 방지할 수 있다.

블록 스코프

여기서 말하는 블록 스코프는 var 키워드를 사용해서 선언한 변수에도 해당되는 경우이다. let과 const는 다음 부분에서 다룬다.- with

try-catch 문에서, catch 문이 만드는 블럭은 블럭 스코프를 만든다. catch 문에서 받아오는 변수 err는 catch 문 안에서만 사용 가능하기 때문이다.

1try {
2 throw 1234;
3} catch (err) {
4 console.log(err); // 1234
5}

이를 활용해서 블록 스코프를 폴리필링 할 수도 있다.

1// 블럭 스코프를 사용하는 const 키워드를,
2{
3 const a = 2;
4 console.log(a);
5}
6
7// 이렇게 바꾸어서 ES6 이전 버전에서도 사용할 수 있다
8try { throw undefined } catch (a) {
9 a = 2;
10 console.log(a);
11}

let, const

ES6에서 변수를 선언하는 키워드 letconst 가 추가되었다. 두 키워드로 선언한 변수는 함수 스코프가 아닌 블럭 스코프에 붙을 수 있다.

1function foo() {
2 if (true) {
3 var a = 123;
4 }
5 console.log(a) // 123
6}
7
8function bar() {
9 if (true) {
10 const b = 123;
11 }
12 console.log(b); // ReferenceError: b is not defined
13}

위 예제에서, a는 if 문이 만드는 블럭 안에서 선언되었지만 if문 블럭 바깥의 console.log 부분에서 RHS 참조가 가능했다. var 키워드로 선언된 변수는 함수 스코프에 속하고, 따라서 a는 함수 foo가 만드는 스코프에 속하기 때문이다.

그러나 bar 함수 내부의 console.log 부분에서 b를 RHS 참조했을 때는 ReferenceError가 발생했다. const 키워드로 선언된 b는 if문 블럭이 만드는 블럭 스코프에 속하고, bar 함수가 만드는 스코프에서는 if문이 만든 블럭 스코프 내부의 값에 접근할 수 없기 때문이다.

클로저

어떤 함수의 호출이 끝나고도 스코프의 참조를 내부 스코프가 계속 가지고 있을 때, 그 스코프의 참조를 클로저라고 한다.

1function foo() {
2 var a = 1;
3
4 function bar() {
5 console.log(a);
6 }
7
8 return bar;
9}
10
11const baz = foo(); // baz는 foo 내부에 정의한 함수 bar 객체를 가지고 있다.
12baz(); // bar가 호출된 것이나 마찬가지. 콘솔에 찍히는 값은 1이다.

baz는 foo 함수의 스코프에 대한 클로저를 가지고 있다. foo의 호출은 const baz = foo(); 부분에서 끝났지만, baz가 클로저를 가지고 있기 때문에, baz(bar)가 호출될 때마다 foo가 가지고 있는 a도 호출된다. 이 때 a는 자기가 속한 렉시컬 스코프의 바깥에서 호출된다.

모듈 패턴

클로저를 활용해서, 모듈 패턴을 구현할 수 있다.

  1. 모듈을 사용하기 위해, 최외곽 함수를 최소 한 번 호출해야 한다.
  2. 1번에서 호출한 함수는 객체를 반환하고, 그 객체는 모듈의 내장 함수의 참조를 가진다.
  3. 모듈이 공개하는 함수를 2번의 객체가 가진 프로퍼티를 통해 사용할 수 있다.
프로필 사진

조예진

이전 포스트
YOU DON'T KNOW JS - 타입과 문법
다음 포스트
Next.js에서 mdx 사용하기 (1) - 기본 설정