Web: JavaScript의 실행 컨텍스트(LexicalEnvironment, 스코프 체인)

Heechan
HcleeDev
Published in
11 min readJun 17, 2022
Photo by Hello I'm Nik on Unsplash

학부 때 프로그래밍 언어 수업을 들었던게 생각이 난다. 결과는 악몽이었지만 과정은 재미있긴 했다. 그때 지금껏 별 생각 없이 사용했던 언어가 굴러가기 위해서는 꽤 많은걸 신경써야 했다.

이번주는 JavaScript에서 명령들이 어떻게 실행되는지, 그 속에서는 어떤 일이 벌어지고 있는지 알아보자.

이 내용은 <코어 자바스크립트>를 읽고 공부한 내용을 기반으로 한다.

실행 컨텍스트란?

실행 컨텍스트란 실행될 코드에 필요한 정보를 모아놓은 객체다.

근데 코드가 실행되는데 필요한 정보라는게 뭘까?

let a = 0;function print(num: number) {
console.log(num);
}
print(a); // 이 줄이 실행될 때 필요한 정보는?

숫자를 집어넣으면 콘솔에 로그를 찍어주는 아주 간단한 코드다. 여기서 print(a) 라는 명령이 실행되기 위해서는 어떤 정보가 필요할까?

일단 a 가 필요해보인다. 함수에 숫자를 넣어줘야 하니까 그 값을 알아야 할 것이다.

그리고 그 함수인 print 에 대한 정보도 알아야 한다. 그냥 코드 읽는 입장에서는 print 뒤에 () 가 있으니 뭔가 함수인 것 같긴 한데, 그 print 가 어떤 것인지에 대한 정보도 찾을 수 있어야 할 것이다. 지금 이 코드에선 나오지 않지만, JavaScript에서는 this 가 무엇인지에 대한 정보도 가지고 있어야 할 것이다.

그냥 코드를 읽는 우리 입장에서야 바로 몇줄 위에 print 도 있고, 바로 위에 a 도 있으니 바로바로 찾을 수 있을 것이다. 하지만 컴퓨터 입장에서는 그렇게 찾기가 힘들다. 사람처럼 “아, 그거 그쯤 있었지”하고 찾아올라가는 것이 안된다.

이런 정보들을 객체에 담아두는 것이 바로 실행 컨텍스트다.

생각해보면 실행될 코드 한 뭉치 한 뭉치마다 다른 환경이 필요하다는 것을 알 수 있을 것이다.

let a = 0;function print(num: number) {
let b = 1;
console.log(num);
}
print(a);
console.log(b); // 여기선 b를 참조할 수 없다

이 코드를 보면, func print 내부에서 사용할 수 있는 변수와 밖에서 사용할 수 있는 변수의 차이가 있다는 것을 본능적으로 느낄 수 있을 것이다.

print 가 실행될 때는, Argument로 받은 num , 내부에 정의된 b , 그리고 외부에 정의되어있는 a 를 참조할 수 있을 것이다.

하지만 마지막 줄의 console.log(b) 에서는 function print 속에 있는 b 를 참조할 수는 없을 것이다.

이를 실행 컨텍스트와 함께 분석해보려면 콜 스택에 대해 알아야 한다.

https://ko.wikipedia.org/wiki/%EC%8A%A4%ED%83%9D

스택은 위 그림처럼 한쪽에서 입력과 출력이 모두 일어나는 형태다. JavaScript에서 명령을 실행하는 원리, 순서도 이 스택을 기반으로 한다.

어떤 명령이 실행될 때, 필요한 환경 정보를 모아 실행 컨텍스트를 구성한다. 컨텍스트가 새롭게 생기면 이를 콜 스택 상단에 쌓아둔다. JS는 콜스택에 쌓여있는 컨텍스트들을 위에서부터 하나씩 꺼내 처리하면서 전체적인 코드의 흐름을 보장한다.

그러면 다시 코드를 보면서 콜 스택이 어떤 식으로 작동하는지 확인해보자.

// (2)let a = 0;function print(num: number) {
let b = 1;
console.log(num);
}
print(a); // (3)// (4)console.log(b);// (5)

(1)은 완전 처음이라 보고, 지금 이 코드도 전역에서 실행되는 코드이니 (2)부터 시작하도록 하겠다. 이때 전역 컨텍스트가 만들어지고, (2)부터 코드가 실행된다.

아까 말했듯, 컨텍스트는 코드를 실행하기 위한 정보를 담고 있어야 한다. 따라서 (2)의 코드가 실행되기 전에 한번 훑어보고 a 라는 변수, print 라는 클로저가 있다는 것을 컨텍스트 객체에 저장하는 과정이 있다. 이를 호이스팅이라고 하고, 뒤에서 한 번 더 언급할 예정이다.

a 도 정의되고, print 도 정의된 후, (3)까지 왔다. 이때는 print 라는 코드를 실행해야 하기 때문에, print 를 위한 실행 컨텍스트를 구성해 콜 스택에 쌓아야 한다.

이때도 print 를 실행하기 전에 컨텍스트에 필요한 정보를 저장해야 한다. 이 경우에는 받아온 num , 내부에 있는 b 를 저장해둘 것이다.

그렇게 구성된 컨텍스트를 콜 스택에 쌓는다. 이렇게 되면 전역 컨텍스트는 실행되던 중 (3)에서 멈추게 되고, 새롭게 콜 스택에 쌓인 print 가 실행될 것이다.

그게 이 그림에서 (3)까지라고 보면 된다.

이제 print(a) 가 다 실행되고 난 후를 생각해봐야 한다. 다 실행되고 나면 콜 스택에서 print 는 없어질 것이다. 그러면 다시 전역 컨텍스트가 콜 스택의 가장 위에 있게 되고, 기존에 (3)에서 멈췄던 코드가 다시 돌아가면서 (4)로 넘어가게 될 것이다.

그리고 마지막 코드까지 실행이 완료되고 나면 전역 컨텍스트도 실행이 다 되었으니 콜 스택에서 빠져나간다. 그 상태가 (5) 시점이다.

이렇게 보면 어째서 전역 컨텍스트에서 b 를 참조할 수 없는지 납득할 수 있다. 전역 컨텍스트는 b 에 대한 정보를 가지고 있을 일이 없다는 것을 확인할 수 있었을 것이다.

컨텍스트에 실제로 들어있는 정보는— LexicalEnvironment

위에서 컨텍스트를 구성해서 콜 스택에 넣는다는 말을 계속 했는데, 실제 컨텍스트는 무엇이 들어있을까?

  • VariableEnvironment
  • LexicalEnvironment
  • This Binding

이 세 가지가 들어있다고 한다.

VariableEnvironment와 LexicalEnvironment가 담고 있는 정보는 변수와 함수에 대한 정보다. 둘의 차이점은 VariableEnvironment는 처음 컨텍스트가 구성될 때 그런 정보를 담아둔 후 스냅샷으로 유지하고, LexicalEnvironment는 이 VariableEnvironment를 복사한 후 변경 사항을 계속 반영해간다는 차이가 있다.

This Binding은 이 컨텍스트 내에서 this 를 참조할 때 어떤 객체를 참조할지에 대한 정보를 가지고 있다. 다만 this 에 대한 자세한 사항은 이번에 살펴보진 않을 것이므로, 그렇구나 하고 넘기면 될 것 같다.

그러면 VariableEnvironment나 LexicalEnvironment나 거의 비슷하므로 퉁쳐서 Environment라고 부르겠다.

Environment의 environmentRecord 는 매개변수, 변수, 함수 등의 식별자를 저장한다. 이는 코드가 실행되기 전, Environment가 구성될 때 미리 코드를 쭉 읽어나가며 정의해야 하는 것들을 순서대로 수집한다.

function printOnlyPositive(num: number) {
function isPositive() {
return num > 0;
}
let b = 1;
if (isPositive()) console.log(num);
}
printOnlyPositive(1); // 이 줄이 실행될 때 어떤 수집이 일어날까?

대충 코드가 이렇다고 생각해보자. 이때 printOnlyPositive(1) 이 호출되면 어떤 일이 벌어질까? 순서대로 훑어가면서 필요한 값들을 수집해야 한다.

우선 Argument부터 수집한다. num 으로 들어온 값이 1이니, environmentRecord{ num: 1 } 이라는 정보를 추가한다.

그리고 isPositive 라는 이름의 함수도 하나 선언되어있다. 이에 대한 정보도 environmentRecord 에 추가한다. 정확히는 몰라도 대충 { num: 1, isPositive: function () } 이런 느낌으로 저장될 것이다.

마지막으로 let b = 1 로 변수가 하나 선언되어있다. 다만 이의 경우에는 b 라는 것이 존재한다는 것만 잡아두지 1이라는 값까지 바로 할당해주지는 않는다. 따라서 { num: 1, isPositive: function (), b: ? } 이런 느낌의 environmentRecord 가 만들어질 것이다.

이렇게 환경이 구성된 후 코드가 실행되는 상황을 생각해보자.

let b = 1 이 실행될 때는 LexicalEnvironment에 있는 environmentRecordb: 1 이라는 정보를 담아준다.

if (isPositive()) 가 실행될 때는 environmentRecord 로부터 함수를 가져와실행시킬 것이다. 그 후 조건이 만족되면 console.log(num)num 에 대한 정보를 가져와 코드를 실행할 것이다.

이 일련의 과정에서 Hoisting, 호이스팅이라는 특이한 상황이 생긴다.

위에서 말한 것처럼 코드를 실행하기 전에 한번 훑어보면서 식별자들을 미리 잡아두기 때문에 재밌는 일이 많이 생기곤 한다. 이에 대해 자세히 알아두면 코드 작성 때 심심찮게 도움이 된다.

이 호이스팅에 대해서도 과거에 되게 길게 작성해둔 글이 있다. 호이스팅에 대해서도 알아두면 Environment를 이해하기 좋으니, 궁금하다면 이 글도 함께 읽어보면 좋을 것 같다.

outerEnvironmentReference, 스코프 체인

근데 Environment에 environmentRecord 만 있는게 아니다. outerEnvironmentReference 도 있다. 이름을 보면 상위 컨텍스트의 Environment를 가리키는 참조임을 유추할 수 있다. 그런데 이 정보는 왜 들고 있는걸까?

바로 위에서 봤던 코드를 다시 살펴보자.

function printOnlyPositive(num: number) {
function isPositive() {
return num > 0;
}
let b = 1;
if (isPositive()) console.log(num);
}
printOnlyPositive(1);

위에서는 스리슬쩍 넘어갔었는데, isPositive() 가 실행되는 순간에도 주목해야 한다. 이 친구도 분명히 새로운 컨텍스트를 만들어서 시작하는 것이기 때문에 새로운 Environment가 만들어질 것이다.

isPositive 가 호출되어 코드가 실행되기 전, 코드를 쭉 훑어보면서 environmentRecord 를 만드는 과정이 있을 것이다.

function isPositive() {
return num > 0;
}

그런데 argument도 없고, 내부에서 선언된 변수나 함수도 없다. 이 경우에는 딱히 environmentRecord 에 기록할 것도 없다. 하지만 실행할 때 코드를 보니 num 을 참조하고 있다. 아니, environmentRecord 는 비어있는데 num 을 못찾는 것 아닌가? 라는 의문이 든다.

하지만 이 코드는 정상적으로 구동된다. num 을 어디서 가져오는걸까?

isPositive 실행 컨텍스트에는 outerEnvironmentReference 값으로 printOnlyPositive 실행될 때 만들어진 컨텍스트를 가리키고 있다.

대충 이런 상황이다.

isPositive 의 Environment에서 num 을 찾지 못했다면, outerEnvironmentReference 가 가리키는 Environment로 넘어가 거기에는 num 이 있는지 확인해서 사용한다.

따라서 위 상황에서는 printOnlyPositive Environment에 있는 num 을 발견해서 사용한다.

이렇게 Environment가 순서대로 연결되어있는 것을 스코프 체인이라고 한다.

결론

사실 글로 잘 전달되었는지 모르겠다. 요즘 책 쓰시는 분들 대단하다고 느낀다.

이런 내용을 알고 코딩하는 것과 모르고 코딩하는 것의 느낌이 확실히 다르다. 언어 자체의 동작 원리에 대해서 알고 있을 때 뭔가 버그도 더 잘 잡아내고 다른 사람 도와주기도 좋고…

앞으로도 이런 공부를 더 해야겠다 싶다.

참고한 것

<코어 자바스크립트> — 정재남

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science