Skip to main content

Command Palette

Search for a command to run...

[2장] 실행 컨텍스트

Updated
17 min readView as Markdown
[2장] 실행 컨텍스트
W

안녕하세요! 프론트엔드 개발자 woodstock입니다.

저는 매일 조금씩 발전하고자 하는 마음으로 개발공부를 시작했고, 이 블로그는 그 과정에서 배우고 성장하는 이야기를 담고 있습니다.

여러분의 피드백과 조언은 언제나 환영합니다! 함께 배우고 성장하는 과정을 즐길 수 있기를 기대합니다.

1. 실행 컨텍스트란?

실행 컨텍스트는 실행할 코드에 제공할 환경 정보들을 모아놓은 객체로, 자바스크립트의 동적 언어로서의 성격을 가장 잘 파악할 수 있는 개념이다.

자바스크립트는 어떤 실행 컨텍스트가 활성화되는 시점에 선언된 변수를 위로 끌어올리고(호이스팅), 외부 환경 정보를 구성하고, this값을 설정하는 등의 동작을 수행하는데, 이로 인해 다른 언어에서는 발견할 수 없는 특이한 현상들이 발생한다.

스택

스택은 출입구가 하나뿐인 깊은 우물 같은 데이터 구조이다. 비어있는 스택에 순서대로 데이터 a, b, c, d를 저장했다면, 꺼낼 때는 반대로 d, c, b, a의 순서로 꺼낼 수 밖에 없다.

이미지 출처: "코어 자바스크립트", 정재남, 위키북스, 2019, p. 37.

만약 스택이 저장할 수 있는 데이터의 양을 초과하여 데이터를 넣으려고 한다면, 스택은 넘치는 문제가 발생한다. 이러한 문제를 '스택 오버플로우(stack overflow)'라고 하며, 많은 프로그래밍 언어들은 스택 오버플로우가 발생할 경우 에러를 던진다.

이미지 출처: "코어 자바스크립트", 정재남, 위키북스, 2019, p. 37.


큐는 양쪽이 모두 열려있는 파이프를 떠올리면 된다. 종류에 따라 양쪽 모두 입력과 출력이 가능한 큐도 있으나 보통은 한쪽은 입력만, 다른 한쪽은 출력만을 담당하는 구조를 말한다. 후자의 경우 비어있는 큐에 순서대로 데이터 a, b, c, d를 저장했다면 꺼낼 때도 역시 a, b, c, d로 꺼낼 수 밖에 없다.

이미지 출처: "코어 자바스크립트", 정재남, 위키북스, 2019, p. 37.


실행 컨텍스트와 콜 스택

앞서 실행 컨텍스트를 실행할 코드에 제공할 환경 정보들을 모아놓은 객체라고 하였다. 동일한 환경에 있는 코드들을 실행할 때 필요한 환경 정보들을 모아 컨텍스트를 구성하고, 이를 콜 스택에 쌓아올렸다가, 가장 위에 쌓여있는 컨텍스트와 관련 있는 코드들을 실행하는 식으로 전체 코드의 환경과 순서를 보장한다.

여기서 '동일한 환경', 즉 하나의 실행 컨텍스트를 구성할 수 있는 방법으로 전역공간, eval()함수, 함수 등이 있고, ES6에서는 블록[]에 의해서도 새로운 실행 컨텍스트가 생성된다.

실행 컨텍스트와 콜 스택 예제

// -------------------------- (1)
var a = 1;
function outer() {
  function inner() {
    console.log(a); // undefined
    var a = 3;
  }
  inner(); // ------------ (2)
  console.log(a); // 1
}
outer(); // ---------------- (3)
console.log(a); // 1
  • 전역 컨텍스트가 콜 스택에 담긴다. (1) 최상단의 공간은 코드 내부에서 별도의 실행 명령이 없어도 브라우저에서 자동으로 실행하므로 자바스크립트 파일이 열리는 순간 전역 컨텍스트가 활성화된다고 이해하면 된다.

  • 콜 스택에는 전역 컨텍스트 외에 다른 덩어리가 없으므로 전역 컨텍스트와 관련된 코드들을 순차로 진행하다가 (3)에서 outer함수를 호출하면 자바스크립트 엔진은 outer에 대한 환경 정보를 수집해서 outer실행 컨텍스트를 생성한 후 콜 스택에 담는다.

  • 콜 스택의 맨 위에 outer 실행 컨텍스트가 놓인 상태가 됐으므로 전역 컨텍스트와 관련된 코드의 실행을 일시중단하고 대신 outer실행 컨텍스트와 관련된 코드, 즉 outer함수 내부의 코드들을 순차로 실행한다.

  • 다시 (2)에서 inner함수의 실행 컨텍스트가 콜 스택의 가장 위에 담기면 outer컨텍스트와 관련된 코드의 실행을 중단하고 inner함수 내부의 코드를 순서대로 진행할 것이다.

  • inner함수 내부에서 a변수에 값 3을 할당하고 나면 inner 함수의 실행이 종료되면서 inner실행 컨텍스트가 콜 스택에서 제거된다.

  • 그러면 아래에 있던 outer컨텍스트가 콜 스택의 맨 위에 존재하게 되므로 중단했던 (2)의 다음 줄부터 이어서 실행한다.

  • 변수 a의 값을 출력하고 나면 outer함수의 실행이 종료되어 outer실행 컨텍스트가 콜 스택에서 제거되고, 콜 스택에는 전역 컨텍스트만 남아있게 된다.

  • 그런 다음, 실행을 중단했던 (3)의 다음 줄부터 이어서 실행한다. a변수의 값을 출력하고 나면 전역 공간에는 더는 실행할 코드가 남아있지 않아 전역 컨텍스트도 제거되고, 콜 스택에는 아무것도 남지 않은 상태로 종료된다.

이미지 출처: "코어 자바스크립트", 정재남, 위키북스, 2019, p. 39.


스택 구조를 잘 생각해보면 한 실행 컨텍스트가 콜 스택의 맨 위에 쌓이는 순간이 곧 현재 실행할 코드에 관여하게 되는 시점임을 알 수 있다. 기존의 컨텍스트는 새로 쌓인 컨텍스트보다 아래에 위치할 수밖에 없기 때문이다.

이렇게 어떤 실행 컨텍스트가 활성화될 때 자바스크립트 엔진은 해당 컨텍스트에 관련된 코드들을 실행하는 데 필요한 환경 정보들을 수집해서 실행 컨텍스트 객체에 저장한다. 이 객체는 자바스크립트 엔진이 활용할 목적으로 생성할 뿐 개발자가 코드를 통해 확인할 수는 없다.

여기에 담기는 정보들은 다음과 같다.

이미지 출처: "코어 자바스크립트", 정재남, 위키북스, 2019, p. 40.
  • VariableEnvironment : 현재 컨텍스트 내의 식별자들에 대한 정보 + 외부 환경 정보. 선언 시점의 LexicalEnvironment의 스냅샷으로, 변경 사항은 반영되지 않음.

  • LexicalEnvironment : 처음에는 VariableEnvironment와 같지만, 변경 사항이 실시간으로 반영됨.

  • ThisBinding : this식별자가 바라봐야 할 대상 객체.



2. VariableEnvironment

VariableEnvironment에 담기는 내용은 LexicalEnvironment를 같지만 최초 실행 시의 스냅샷을 유지한다는 점이 다르다. 실행 컨텍스트를 생성할 때 VariableEnvironment에 정보를 먼저 담은 다음, 이를 그대로 복사해서 LexicalEnvironment를 만들고, 이후에는 LexicalEnvironment를 주로 활용하게 된다.

VariableEnvironment와 LexicalEnvironment의 내부는 environmentRecord와 outerEnvironmentReference로 구성되어 있다. 초기화 과정 중에는 사실상 완전히 동일하고 이후 코드 진행에 따라 서로 달라지게 된다.

3. LexicalEnvironment

LexicalEnvironment는 마치 사전에서 정보를 찾는 것과 비슷하다. 예를 들어, 사전에서 '바나나'를 검색하면 '칼로리가 높고 당분이 많은 알칼리성 식품으로, 칼륨, 카로틴, 비타민C가 함유되어 있다'는 설명을 볼 수 있다. 이와 유사하게, lexicalEnvironment는 '현재 컨텍스트 내에는 a, b, c와 같은 식별자들이 있고 그 외부 정보는 D를 참조하도록 구성되어있다'를 사전에서 접하는 느낌으로 모아놓은 것이다.

3-1 environmentRecord와 호이스팅

environmentRecord에는 현재 컨텍스트와 관련된 코드의 식별자 정보들이 저장된다. 컨텍스트를 구성하는 함수에 지정된 매개변수 식별자, 선언한 함수가 있을 경우 그 함수 자체, var로 선언된 변수의 식별자 등이 식별자에 해당한다. 컨텍스트 내부 전체를 처음부터 끝까지 쭉 훑어나가며 순서대로 수집한다. >

참고

전역 실행 컨텍스트는 변수 객체를 생성하는 대신 자바스크립트 구동 환경이 별도로 제공하는 객체, 즉 전역 객체를 활용한다. 전역 객체에는 브라우저의 window, Node.jsglobal객체 등이 있다. 이들은 자바스크립트 내장 객체가 아닌 호스트 객체로 분류된다.

변수 정보를 수집하는 과정을 모두 마쳤더라도 아직 실행 컨텍스트가 관여할 코드들은 싱행되기 전의 상태이다. 코드가 실행되기 전임에도 불구하고 자바스크립트 엔진은 이미 해당 환경에 속한 코드의 변수명들을 모두 알고 있게 되는 셈이다.

그렇다면 엔진의 실제 동작 방식 대신에 '자바스크립트 엔진은 식별자들을 최상단으로 끌어올려놓은 다음 실제 코드를 실행한다'라고 생각하더라도 코드를 해석하는 데는 문제가 없을 것이다. 여기서 호이스팅이란 개념이 등장한다.

호이스팅이란, '끌어올리다'라는 의미로, 자바스크립트 엔진이 실제로 끌어올리지는 않지만 편의상 끌어올린 것으로 간주하여 변수 정보를 수집하는 과정을 더욱 이해하기 쉽게 만드는 가상의 개념이다.

호이스팅 규칙

매개변수와 변수에 대한 호이스팅 (1) - 원본코드

function a (x) {    // 수집 대상 1(매개변수)
  console.log(x);    // (1)
  var x;            // 수집 대상 2(변수 선언)
  console.log(x);    // (2)
  var x = 2;        // 수집 대상 3(변수 선언)
  console.log(x);    // (3)
}
a(1)

호이스팅이 되지 않았을 때 (1)에는 함수 호출 시 전달한 1이 출력되고, (2)는 선언된 변수 x에 할당한 값이 없으므로 undefined가 출력되고, (3)에서는 2가 출력될 것으로 예상할 수 있다.

그렇다면, 실제로는 어떨까?

주의

지금부터는 자바스크립트 엔진의 구동 방식을 좀 더 사람의 입장에서 이해해보고자 코드를 몇 차례 변경할 것이다. 실제 엔진은 이러한 변환 과정을 거치지 않으니 오해하지 말자.

1번 예제처럼 인자들과 함께 함수를 호출한 경우의 동작을 살펴보면, arguments에 전달된 인자를 담는 것을 제외하면 아래의 2번 예제 코드 내부에서 변수를 선언한 것과 다른 점이 없다. 특히 LexicalEnvironment 입장에서는 완전히 같다. 그러니 인자를 함수 내부의 다른 코드보다 먼저 선언 및 할당이 이뤄진 것으로 간주할 수 있다.

매개변수와 변수에 대한 호이스팅 (2) - 매개변수를 변수 선언/할당과 같다고 간주해서 변환한 상태

function a () {        // 수집 대상 1(매개변수 선언)
  console.log(x);    // (1)
  var x;            // 수집 대상 2(변수 선언)
  console.log(x);    // (2)
  var x = 2;        // 수집 대상 3(변수 선언)
  console.log(x);    // (3)
}
a();

이 상태에서 변수 정보를 수집하는 과정, 즉 호이스팅을 처리해 보자.

environmentRecord는 현재 실행될 컨텍스트의 대상 코드 내에 어떤 식별자들이 있는지에만 관심이 있고, 각 식별자에 어떤 값이 할당될 것인지는 관심이 없다. 따라서 변수를 호이스팅할 때 변수명만 끌어올리고 할당 과정은 원래 자리에 그대로 남겨둔다. 매개변수의 경우도 마찬가지이다.

environmentRecord의 관심사에 맞춰 수집 대상 1, 2, 3을 순서대로 끌어올리고 나면 다음과 같은 형태로 바뀐다.

매개변수와 변수에 대한 호이스팅 (3) - 호이스팅을 마친 상태

function a () {        
  var x;            // 수집 대상 1의 변수 선언 부분
  var x;            // 수집 대상 2의 변수 선언 부분
  var x;            // 수집 대상 3의 변수 선언 부분

  x = 1;            // 수집 대상 1의 할당 부분
  console.log(x);    // (1)
  console.log(x);   // (2)
  x = 2;            // 수집 대상 3의 할당 부분
  console.log(x);    // (3)
}
a(1);

이제 호이스팅이 끝났으니 실제 코드를 실행할 차례이다.(스코프체인 수집 및 this할당 과정은 여기서 생략한다.)

  • 변수 x를 선언한다. 이때 메모리에서는 저장할 공간을 미리 확보하고, 확보한 공간의 주솟값을 변수 x에 연결한다.

  • 다시 변수 x를 선언한다. 이미 선언된 변수 x가 있으므로 무시한다.

  • x에 1을 할당하라고 한다. 우선 숫자 1을 별도의 메모리에 담고, x와 연결된 메모리 공간에 숫자 1을 가리키는 주솟값을 입력한다.

  • x를 출력하라고 한다. (1) (2) 모두 1이 출력된다.

  • x에 2를 할당하라고 한다. 숫자 2를 별도의 메모리에 담고, 그 주솟값을 든 채로 x와 연결된 메모리 공간으로 간다. 여기에는 숫자 1을 가리키는 주솟값이 들어있었는데, 이걸 2의 주솟값으로 대치한다. 이제 변수 x는 숫자 2를 가리키게 된다.

  • x를 출력하라고 하니 (3)에서는 2가 출력되고, 이제 함수 내부의 모든 코드가 실행됐으므로 실행 컨텍스트가 콜 스택에서 제거된다.

(1) 1. (2) undefined, (3) 2로 출력될 것이라고 예상한 것과는 달리, 실제로는 (1) 1, (2) 1, (3) 2라는 결과가 나왔다. (2)에서 undefined가 아닌 1이 출력된다는 건 호이스팅 개념을 정확히 이해해야 예측할 수 있는 결과이다.

함수 선언의 호이스팅 (1) - 원본코드

function a () {
  console.log(b);    // (1)
  var b = 'bbb';    // 수집 대상 1(변수 선언)
  console.log(b);    // (2)
  function b () {}    // 수집 대상 2(함수 선언)
  console.log(b)    // (3)
}
a();

마찬가지로 출력 결과를 미리 예상해보자.

(1)에는 b의 값이 없으니 에러가 나거나 undefined가 나오고, (2)는 'bbb', (3)은 b함수가 출력될 것 같다.

그렇다면, 실제로는 어떨까?

a함수를 실행하는 순간 a함수의 실행 컨텍스트가 생선된다. 이때 변수명과 함수 선언의 정보를 위로 끌어올린다(수집한다).

변수는 선언부와 할당부를 나누어 선언부만 끌어올리는 반면 함수 선언은 함수 전체를 끌어올린다. 수집 대상 1과 2를 순서대로 끌어올리고 나면 다음과 같은 형태로 변환된다.

함수 선언의 호이스팅 (2) - 호이스팅을 마친 상태

function a () {
  var b;            // 수집 대상 1. 변수는 선언부만 끌어올린다.
  function b () {}    // 수집 대상 2. 함수 선언은 전체를 끌어올린다.

  console.log(b);    // (1)
  b = 'bbb';        // 변수의 할당부는 원래 자리에 남겨둔다.
  console.log(b);    // (2)
  console.log(b);    // (3) 
}
a();

해석의 편의를 위해 한 가지 더 바꿔보겠다. 호이스팅이 끝난 상태에서의 함수 선언문은 함수명으로 선언한 변수에 함수를 할당한 것처럼 여길 수 있다.

함수 선언의 호이스팅 (3) - 함수 선언문을 함수 표현식으로 바꾼 코드

function a () {
  var b;            
  var b = function b () {}    // 바뀐 부분

  console.log(b);    // (1)
  b = 'bbb';
  console.log(b);    // (2)
  console.log(b);    // (3) 
}
a();

이제 실행 컨텍스트 내부의 코드를 차례대로 실행할 차례이다.

  • 변수 b를 선언한다. 이때 메모리에서는 저장할 공간을 미리 확보하고, 확보한 공간의 주솟값을 변수 b에 연결해둔다.

  • 다시 변수 b를 선언하고, 함수 b를 선언된 변수 b에 할당하라고 한다. 이미 선언된 변수 b가 있으므로 선언 과정은 무시한다. 함수는 별도의 메모리에 담길 것이고, 그 함수가 저장된 주솟값을 b와 연결된 공간에 저장한다. 이제 b는 함수를 가리키게 된다.

  • 변수 b에 할당된 함수 b를 출력한다(1).

  • 변수 b에 'bbb'를 할당하라고 한다. b와 연결된 메모리 공간에는 함수가 저장된 주솟값이 담겨있었는데 이걸 문자열 'bbb'가 담긴 주솟값으로 덮어쓴다. 이제 변수 b는 문자열 'bbb'를 가리키게 된다.

  • (2)와 (3) 모두 'bbb'가 출력되고, 이제 함수 내부의 모든 코드가 실행됐으므로 실행 컨텍스트가 콜 스택에서 제거된다.

호이스팅 고려하지 않은 상태에서는 (1) 에러 또는 undefined, (2) 'bbb', (3) b 함수로 예상했지만, 실제로는 (1) b 함수, (2) 'bbb', (3) 'bbb'라는 전혀 다른 결과가 나왔다.

함수 선언문과 함수 표현식

함수 선언문

  • function 정의부만 존재하고 별도의 할당 명령이 없는 것
  • 반드시 함수명이 정의되어야 한다.

함수 표현식

  • function을 별도의 변수에 할당하는 것
  • 함수명이 없어도 된다.
    • 기명 함수 표현식 : 함수명을 정의한 함수 표현식
    • 익명 함수 표현식 : 일반적으로 함수 표현식은 함수명을 정의하지 않은 익명 함수 표현식을 말한다.

함수를 정의하는 세 가지 방식

function a() {
  /* ... */
} // 함수 선언문. 함수명 a가 곧 변수명.
a(); // 실행 OK.

var b = function() {
  /* ... */
}; // (익명) 함수 표현식. 변수명 b가 곧 함수명.
b(); // 실행 OK.

var c = function d() {
  /* ... */
}; // 기명 함수 표현식. 변수명은 c, 함수명은 d.
c(); // 실행 OK.
d(); // 에러!

참고

기명 함수 표현식은 주의할 점이 하나 있는데, 바로 외부에서는 함수명으로 함수를 호출할 수 없다는 점이다. 함수명은 오직 함수 내부에서만 접근할 수 있다.

그렇다면 기명 함수 표현식에서 함수명은 어떤 용도로 쓰일까? 과거에는 기명 함수 표현힉은 함수명이 잘 출력됐던 반면 익명 함수 표현식은 undefined 또는 unnamed라는 값이 나왔었다. 이 때문에 기명 함수 표현식이 디버깅시 어떤 함수인지를 추적하기에 익명 함수 표현식보다 유리한 측면이 있었으나 이제는 모든 브라우저들이 익명 함수 표현식의 변수명을 함수의 name프로퍼티에 할당하고 있다.

한편 c 함수 내부에서는 c()로 호출하든 d()로 호출하든 잘 실행된다. 따라서 함수 내부에서 재귀함수를 호출하는 용도로 함수명을 쓸 수 있다. 다만 c()로 호출해도 되는 상황에서 굳이 d()로 호출해야할 필요가 있을지는 의문이다.


함수 선언문과 함수 표현식 (1) - 원본 코드

console.log(sum(1, 2));
console.log(multiply(3, 4));

function sum(a, b) {
  // 함수 선언문 sum
  return a + b;
}

var multiply = function(a, b) {
  // 함수 표현식 multiply
  return a * b;
};

실행 컨텍스트의 lexicalEnvironment는 두 가지 정보를 수집하는데, 여기서는 그 중 environmentRecord의 정보 수집 과정에서 발생하는 호이스팅을 살펴보는 중이다.

함수 선언문은 전체를 호이스팅하지만, 함수 표현식은 변수 선언부만 호이스팅한다. 이는 함수가 값으로 취급될 수 있기 때문이다. 함수를 다른 변수에 값으로 할당하는 것이 함수 표현식이며, 여기서 함수 선언문과 함수 표현식의 큰 차이가 발생한다.

함수 선언문과 함수 표현식 (2) - 호이스팅을 마친 상태

var sum = function sum(a, b) {
  // 함수 선언문은 전체를 호이스팅한다.
  return a + b;
};
var multiply; // 변수는 선언부만 끌어올린다.
console.log(sum(1, 2));
console.log(multiply(3, 4));

multiply = function(a, b) {
  // 변수의 할당부는 원래 자리에 남겨둔다.
  return a * b;
};
  • 메모리 공간을 확보하고 확보된 공간의 주솟값을 변수 sum에 연결.
  • 또 다른 메로리 공간을 확보하고 그 공간의 주솟값을 multiply에 연결.
  • sum함수를 또 다른 메모리 공간에 저장하고, 그 주솟값을 앞서 선언한 변수 sum의 공간에 할당. 이로써 변수 sum은 함수 sum을 바라보는 상태가 됨.
  • sum을 실행. 정상적으로 실행되어 3이 나옴.
  • 현재 multiply에는 값이 할당되어 있지 않기 때문에 'multiply is not a function'이라는 에러 메세리 출력. 해당 에러로 인해 이후의 코드는 실행되지 않은 채 런타임 종료.

sum함수는 선언 전에 호출해도 문제없이 실행된다. 어떻게 작성해도 오류를 내지 않기때문에 더 쉽게 느껴질 수 있지만, 큰 혼란을 일으키는 원인이 되기도 한다.

개발자 A가 선언한 함수sum이 100번째 줄에 위치한다고 가정해보자. 어느날 새로 입사한 B가 5000번째 줄에서 sum함수를 새로 선언한 후, 테스트를 거치지 않고 배포까지 해버린다.

함수 선언문의 위험성

// ...
 60 console.log(sum(3, 4));
// ...
 100 function sum(x, y) {
      return x + y;
 102 }
// ...
 200 var a = sum(1, 2);
// ...
5000 function sum(x, y) {
5001  return x + ' + ' + y + ' = ' + (x + y);
5002 }
// ...
5010 var c = sum(1, 2);
5011 console.log(c);
// ...

전역 컨텍스트가 활성화될 때 전역공간에 선언된 함수들이 모두 가장 위로 끌어올려진다. 동일한 병수명에 서로 다른 값을 할당할 경우 나중에 할당한 값이 먼저 할당한 값을 덮어씌운다. 따라서 코드를 실행하는 중에 실제로 호출되는 함수는 맨 마지막에 선언된 함수뿐이다.

A가 의도했던 함수는 숫자로된 결과값을 반환하는 것이지만, 실제로는 전혀 다른 문자열을 반환하게 되는 것이다. 결과적으로는 문제가 생기고 있지만, 정작 문제의 원인이 되는 sum함수는 아무런 에러를 내지 않기 때문에 해당 버그를 찾아서 수정하기가 어려워진다.

만약 A와 B 모두 sum함수를 함수 표현식으로 정의했다면 어땠을까?

5000번째 줄 이전까지는 A의 의도대로, 이후부터는 B의 의도대로 잘 작동했을 것이다. 뿐만아니라 sum함수를 처음 선언한 100번째 줄보다 이전 줄에 sum함수를 호출하는 코드가 있었다면 그 줄에서 바로 에러가 검출되므로 더욱 빠르고 손쉽게 디버깅할 수 있었을 것이다.

상대적으로 함수 표현식이 안전하다

// ...
  60 console.log(sum(3, 4)); // Uncaught Type Error: sum is not a function
// ...
 100 var sum = function(x, y) { 
 101   return x + y;
 102 };
// ...
 200 var a = sum(1, 2);
// ...
5000 var sum = function(x, y) {
5001   return x + ' + ' + y + ' = ' + (x + y);
5002 };
// ...
5010 var c = sum(1, 2);
5011 console.log(c);
// ...

위 코드는 극단적인 예시이긴 하지만, 원활한 협업을 위해서는 전역공간에 함수를 선언하거나 동명의 함수를 중복 선언하는 경우는 없어야만 한다. 그러나 만에 하나 전역공간에 동명의 함수가 여럿 존재하는 상황이라 하더라도 모든 함수가 함수 표현식으로 정의되어 있었다면 위와 같은 상황은 일어나지 않았을 것이다.

3-2 스코프, 스코프체인, outerEnvironmentReference

스코프란 식별자의 유효 범위를 의미한다. 어떤 경계 A의 외부에서 선언한 변수는 A의 내부와 외부 모두에서 접근이 가능하지만, A의 내부에서 선언한 변수는 오직 A의 내부에서만 접근할 수 있다. 이러한 스코프의 개념은 대부분의 프로그래밍 언어에서 존재한다.

자바스크립트에서도 스코프가 존재한다. 하지만 ES5까지의 자바스크립트는 전역 공간을 제외하면 오직 함수에 의해서만 스코프가 생성된다. 이러한 스코프를 이용하여 식별자의 유효 범위를 검색하는 것을 스코프 체인이라고 한다.

스코프 체인을 가능하게 하는 것은 LexicalEnvironment의 두 번째 수집 자료인 outerEnvironmentReference이다. 이 자료를 이용하여 내부 스코프에서 외부 스코프의 변수나 함수를 참조할 수 있다.

스코프 체인

outerEnvironmentReference는 현재 호출된 함수가 선언될 당시의 LexicalEnvironment를 참조한다. 여기서 '선언될 당시'는 콜 스택 상에서 어떤 실행 컨텍스트가 활성화된 상태일 때를 의미한다.

함수를 선언하는 행위도 하나의 코드에 지나지 않으며, 모든 코드는 실행 컨텍스트가 활성화된 상태에서 실행되기 때문이다.

따라서, outerEnvironmentReference는 현재 함수가 호출된 시점이 아닌, 함수가 선언될 당시의 LexicalEnvironment를 참조한다는 것을 기억해야 한다.

예를 들어, A함수 내부에 B함수를 선언하고, 다시 B함수 내부에 C함수를 선언한 경우, C함수의 outerEnvironmentReference는 B함수의 LexicalEnvironment를 참조한다. 마찬가지로 B함수의 outerEnvironmentReference는 A함수의 LexicalEnvironment를 참조한다.

이런 식으로 outerEnvironmentReference는 연결 리스트 형태를 띄게 된다. 계속해서 '선언 시점의 LexicalEnvironment'를 찾아 올라가면 마지막에는 전역 컨텍스트의 LexicalEnvironment가 있다.

각 outerEnvironmentReference는 자신이 선언된 시점의 LexicalEnvironment만 참조하기 때문에, 가장 가까운 요소부터 차례대로만 접근할 수 있고 다른 순서로 접근하는 것은 불가능하다.

이러한 구조적 특성 덕분에 여러 스코프에서 동일한 식별자를 선언한 경우에는 무조건 스코프 체인 상에서 가장 먼저 발견된 식별자에만 접근할 수 있다.

스코프 체인

01 var a = 1;
02 var outer = function() {
03  var inner = function() {
04    console.log(a); // undefined
05    var a = 3;
06  };
07  inner();
08  console.log(a); // 1
09 };
10 outer();
11 console.log(a); // 1
  • 01: 전역 컨텍스트가 활성화 된다. 전역 컨텍스트의 environmentRecord에 { a, outer } 식별자를 저장한다. 전역 컨텍스트는 선언 시점이 없으므로 전역 컨텍스트의 outerEnvironmentReference에는 아무것도 담지기 않는다.(this: 전역 객체)
  • 01, 02: 전역 스코프에 있는 변수 a에 1을, outer에 함수를 할당한다.
  • 10: outer함수를 호출한다. 이에 따라 전역 컨텍스트의 10번째 줄의 코드는 임시중단되고, outer실행 컨텍트스가 활성화 되어 2번째 줄로 이동한다.
  • 02: outer실행 컨텍스트의 environmentRecord에 { inner }식별자를 저장한다. outerEnvironmentReference에는 outer함수가 선언될 당시의 LexicalEnvironment가 담긴다. outer함수는 전역 공간에서 선언됐으므로 전역 컨텍스트의 LexicalEnvironment를 참조복사한다. 이를 { GLOBAL, { a, outer } }라고 표기하자. 첫 번째는 실행 컨텍스트의 이름, 두 번째는 environmentRecord 객체이다.(this: 전역 객체)
  • 03: outer스코프에 있는 변수 inner에 함수를 할당한다.
  • 07: inner함수를 호출한다. 이에 따라 outer실행컨텍스트의 코드는 7번째 줄에서 임시중단되고, inner실행 컨텍스트가 활성화되어 3번째 줄로 이동한다.
  • 03 : inner실행 컨텍스트의 environmentRecord에 { a }식별자를 저장한다. outerEnvironmentReference에는 inner함수가 선언될 당시의 LexicalEnvironment가 담긴다. inner함수는 outer함수 내부에서 선언됐으므로 outer함수의 LexivalEnvironment, 즉 { outer, { inner } }를 참조복사한다.(this: 전역 객체)
  • 04: 식별자 a에 접근하고자 한다. 현재 활성화 상태인 inner컨텍스트의 environmentRecord에서 a를 검색한다. a가 발견됐는데 여기에는 아직 할당된 값이 없다.(undefined 출력)
  • 05: inner스코프에 있는 변수 a에 3을 할당한다.
  • 06: inner함수 실행이 종료된다. inner실행 컨텍스트가 콜 스택에서 제거되고, 바로 아래의 outer실행 컨텍스트가 다시 활성화되면서, 앞서 중단했던 7번째 줄의 다음으로 이동한다.
  • 08: 식별자 a에 접근하고자 한다. 이때 자바스크립트 엔진은 활성화된 실행 컨텍스트의 LexicalEnvironment에 접근한다. 첫 요소의 environmentRecord에서 a가 있는지 찾아보고,없으면 outerEnvironmentReference에 있는 evcironmentRecord로 넘어가는 식으로 계속해서 검색한다. 예제에서는 두 번째, 즉 전역 LexicalEnvironment에 a가 있으니 그 a에 저장된 값 1을 반환한다.(1 출력)
  • 09: outer함수 실행이 종료된다. outer실행 컨텍스트가 콜 스택에서 제거되고, 바로 아래의 전역 컨텍스트가 다시 활성화되면서, 앞서 중단했던 10번째 줄의 다음으로 이동한다.
  • 11: 식별자 a에 접근하고자 한다. 현재 활성화 상태인 전역 컨텍스트의 environmentRecord에서 a를 검색한다. 바로 a를 찾을 수 있다(1 출력) 이로써 모든 코드의 실행이 완료되며, 전역 컨텍스트가 콜 스택에서 제거되고 종료한다.

다음은 표를 통해 스코프 체인을 이해하는 방법이다.

이미지 출처: "코어 자바스크립트", 정재남, 위키북스, 2019, p. 57.

> L.E(LexicalEnvironment), e(environmentRecord), o(outerEnvironmentReference), [숫자](코드 줄 번호)

  • 전역 컨텍스트: 전역 스코프에서 생성된 변수에만 접근할 수 있다.
  • outer 컨텍스트: outer 함수 내부에서는 outer 및 전역 스코프에서 생성된 변수에 접근할 수 있지만, inner 스코프 내부에서 생성된 변수에는 접근하지 못한다.
  • inner 컨텍스트: inner 함수 내부에서는 inner, outer, 전역 스코프 모두에 접근할 수 있다.

이렇게 스코프 체인을 타고 접근 가능한 변수의 수는 점점 늘어난다.

스코프 체인 상에 있는 변수라고 해서 무조건 접근 가능한 것은 아니다.

  • 위 코드 상의 식별자 a는 전역 공간에서도 선언했고 inner함수 내부에서도 선언했다.
  • inner함수 내부에서 a에 접근하려고 하면 무조건 스코프 체인 상의 첫 번째 인자, 즉 inner스코프의 LexicalEnvironment부터 검색할 수 밖에 없다.
  • inner스코프의 LexicalEnvironment에 a식별자가 존재하므로 스코프 체인 검색을 더 진행하지 않고 즉시 inner LexicalEnvironment상의 a를 반환하게 된다.

즉, inner함수 내부에서 a변수를 선언했기 때문에 전역 공간에서 선언한 동일한 이름의 a변수에는 접근할 수 없는 셈이다. 이를 변수 은닉화라고 한다.

참고

크롬 브라우저 환경에서는 스코프 체인 중 현재 실행 컨텍스트를 제외한 상위 스코프 정보들을 개발자 도구의 콘솔을 통해 간단히 확인할 수 있다.

확인하는 방법은 다음과 같이 함수 내부에서 함수를 출력하는 것이다.

여기서 한 가지 재미있는 점은, 함수 내부에서 실제로 호출할 외부 변수들의 정보만 보여준 다는 점이다. 위 예제에서는 outer스코프에 inner변수만 노출이 된다.

다음과 같이 inner함수 내부에서 b변수를 호출하면 b도 노출이 된다.

아마 브라우저 성능 향상을 위해 이렇게 처리한 것 같다.

디버거를 이용하면 좀 더 제대로 된 정보를 확인할 수 있는데, console.dir(...)부분을 debugger로 바꾸어 실행하면 된다.


전역변수와 지역변수

전역변수

전역변수는 전역 공간에서 선언한 변수를 뜻한다. 스코프체인 예제에서는 전역 스코프에서 선언한 aouter가 해당된다.

지역변수

지역변수는 함수 내부에서 선언한 변수이다. 스코프체인 예제에서는 outer함수 내부에서 선언한 inner, inner함수 내부에서 선언한 a가 해당된다.

전역 변수는 프로그램의 모든 영역에서 접근할 수 있기 때문에, 다른 코드에 영향을 미칠 수 있다. 이는 코드의 안전성을 해치는 요인 중 하나이다.

함수 선언문과 함수 표현식의 예제코드에서 A가 작성한 코드에서 문제가 된 sum 함수도 전역 변수 이다. 함수 선언문을 함수 표현식으로 고치면 괜찮을 것이라고 설명했지만, 사실 지역 변수로 만들었다면 훨씬 더 안전했을 것이다.

A가 sum 함수를 지역 변수로 선언하기 위해 외부에 x라는 함수를 하나 더 만드는 순간, sum 함수를 호출할 수 있는 영역은 오직 x 내부로 국한된다. 그러면 자연히 sum 함수를 호출하는 다른 코드들도 x 함수 내부로 옮길 수 밖에 없고, 결국 A가 작성했던 코드 대부분이 x 함수 내부로 이전되어 전역 공간에서 접근할 수 없게 된다.

이 상태에서는 B가 전역 공간에 sum을 선언하더라도 그로 인해 A의 코드가 망가질 염려가 전혀 없다. 이처럼 코드의 안전성을 위해 가급적 전역 변수 사용을 최소화 하는 것이 좋다.

4. this

실행 컨텍스트의 thisBinding에는 this로 지정된 객체가 저장된다. 실행 컨텍스트 활성화 당시에 this가 지정되지 않은 경우, this에는 전역 객체가 저장된다.

그밖에는 함수를 호출하는 방법에 따라 this에 저장되는 대상이 다르다. 이에 대해서는 3장에서 자세히 다루고 있다.

정리

실행 컨텍스트는 실행할 코드에 제공할 환경 정보들을 모아놓은 객체로, 전역 공간에서 자동으로 생성되는 전역 컨텍스트와 eval 및 함수 실행에 의한 컨텍스트 등이 있다.

실행 컨텍스트 객체는 활성화되는 시점에 VariableEnvironment, LexicalEnvironment, ThisBinding의 세 가지 정보를 수집한다.

  • VariableEnvironment와 LexicalEnvironment는 동일한 내용으로 구성되지만, LexicalEnvironment는 함수 실행 도중에 변경되는 사항이 즉시 반영되는 반면, VariableEnvironment는 초기 상태를 유지한다.

  • VariableEnvironment와 LexicalEnvironment는 매개변수명, 변수의 식별자, 선언한 함수의 함수명 등을 수집하는 environmentRecord와 바로 직전 컨텍스트의 LexicalEnvironment 정보를 참조하는 outerEnvironmentReference로 구성되어 있다.

호이스팅은 코드 해석을 좀 더 수월하게 하기 위해 environmentRecord의 수집 과정을 추상화한 개념으로, 실행 컨텍스트가 관여하는 코드 집단의 최상단으로 이들을 '끌어 올린다'고 해석하는 것이다. 변수 선언과 값 할당이 동시에 이뤄진 문장은 '선언부'만을 호이스팅하고, 할당 과정은 원래 자리에 남아있게 되는데, 여기서 함수 선언문과 함수 표현식의 차이가 발생한다.

스코프는 변수의 유효범위를 말한다. outerEnvironmentReference는 해당 함수가 선언된 위치의 LexicalEnvironment를 참조한다. 코드 상에서 어떤 변수에 접근하려고 하면 현재 컨텍스트의 LexicalEnvironment를 탐색해서 발견되면 그 값을 반환하고, 발견하지 못할 경우 다시 outerEnvironmentReference에 담긴 LexicalEnvironment를 탐색하는 과정을 거친다. 전역 컨텍스트의 LexicalEnvironment까지 탐색해도 해당 변수를 찾지 못하면 undefined를 반환한다.

전역 컨텍스트의 LexicalEnvironment에 담긴 변수를 전역변수라 하고, 그 밖의 함수에 의해 생성된 실행 컨텍스트의 변수들은 모두 지역변수이다. 안전한 코드 구성을 위해 가급적 전역변수의 사용은 최소화하는 것이 좋다.

this에는 실행 컨텍스트를 활성화하는 당시에 지정된 this가 저장된다. 함수를 호출하는 방법에 따라 그 값이 달라지는데, 지정되지 않은 경우에는 전역 객체가 저장된다.

코어 자바스크립트

Part 12 of 14

이 시리즈에서는 '코어 자바스크립트'를 통해 자바스크립트의 근간을 이루는 핵심 이론들을 정확하게 이해하는 것을 목표로 합니다. 기본 이론들 중에서 ES5와 ES6를 아우르는 중요성이 높은 핵심 개념을 위주로 다루며, 테크닉이나 요령보다는 원리를 이해하는 데 목적을 두고 학습합니다.

Up next

[1장] 데이터 타입 - 북 스터디

코딩 마을 방범대 북 스터디 DAY 1

More from this blog

💥 new Array(length).map()으로 배열 초기화 시 콜백이 실행되지 않는 문제

JavaScript에서 배열을 초기화하려고 new Array(4).map(() => [])을 사용했는데 의도대로 동작하지 않았다. 원인을 찾아보니 JavaScript의 빈 슬롯과 undefined의 차이, 그리고 map() 메서드가 빈 슬롯에 대해 콜백을 실행하지 않는다는 특성때문이었다. 문제 상황 특정 크기의 배열을 만들고 각 요소를 []로 초기화 하려고 했다. const result = new Array(4).map(() => []); co...

Jun 28, 20252 min read17

💥 에러 메시지와 스택 트레이스 분석을 통해 문제 해결하기

JavaScript로 카드 게임 로직을 구현하던 중 TypeError: Cannot read properties of undefined라는 에러를 만났다. 코드가 복잡하고 짐작가는 부분이 단번에 떠오르지 않아 막막했는데, 에러 메세지와 스택 트레이스를 차근차근 분석해가며 원인을 찾아낸 과정을 기록해본다. 에러 발생 게임 로직을 테스트하던 중 특정 입력에서 다음과 같은 에러가 발생했다. TypeError: Cannot read properties...

Jun 25, 20254 min read11

🧐 적절한 함수명과 단일 책임으로 코드 개선하기

이 글에서는 JavaScript로 구현된 보드게임의 로직을 개선하는 과정을 다루며, 개선 과정은 함수명 개선 → 중복 제거 → 책임 분리의 단계로 이루어져있다. 작동하지만 개선이 필요한 코드 처음에 작성한 코드는 기능적으로는 문제없이 동작했지만, 더 나은 방식을 고민하다 보니 여러 개선점을 찾을 수 있었다. function checkLadder(position) { switch (position) { case 4: return 14;...

Jun 23, 20253 min read23

작심삼십일 - TypeScript 강의를 완강하다.

유데미 작심삼십일 챌린지에 참여하다 작심삼십일 챌린지는 유데미의 한국 런칭 3주년을 기념한 이벤트 중 하나로, 30일 동안 유데미 강의를 들으며 인증을 하는 챌린지이다.취업 준비를 하면서 여러가지 요인으로(주로 심리적이지만..) 학습 루틴이 망가졌던 찰나에 이 이벤트를 보게되었고, 마침 기존에 사두고 볼 엄두가 안 났던 타입스크립트 강의가 있었기에 옳다구나! 하고 신청했었다. 챌린지를 통해 무엇을 배웠나? 챌린지의 목표로 정한 강의는 유데미에...

Nov 18, 20242 min read58
작심삼십일 - TypeScript 강의를 완강하다.

woodstock's devlog

215 posts

안녕하세요! 프론트엔드 개발자 woodstock입니다.

제 블로그에서는 프론트엔드 개발에 대한 다양한 주제를 다룹니다.

JavaScript, TypeScript, React, Next.js 등의 기술을 활용하여 실제 프로젝트를 진행하면서 얻은 경험과 지식을 공유하고자 하며, 컴퓨터 과학(CS)의 기본 개념과 알고리즘, 자료구조 등에 대해서도 다룹니다.