8 min to read
JS의 근간. 실행 컨텍스트 ①
렉시컬 스코프에서 실행 컨텍스트 까지
들어가며
오랫만에 글을 이 블로그에 쓰는 것 같습니다. 대학 졸업을 목전에 두고, 본격 ‘취준생’으로서의 삶을 살면서, 너무 이때까지, 단순한 것들의 반복인 코드 작성에만 재미를 느끼면서, ‘기본기’가 부족했던 것 같습니다. 기술면접때 느꼈던 ‘알고 있다 생각했지만, 설명할 정도로 잘 알지 못했던’ 지식들을 되짚어보고, 저의 언어로 정리하기 위해서 이 글을 작성하려 합니다.
한번에 모든것을 정리한다기 보다는, ‘잘 읽히는 글’을 목표로 최대한 쉬운 말로, 컴퓨터공학과 수업을 대략적으로 들었다면(특히 컴파일러 시간), 이해할 수 있도록 적는것이 본 시리즈글의 목표가 되겠습니다.
읽다보면, ‘대체 왜…?’ 같은 느낌이 없잖아 들 수 있습니다. 이건, JS가 일반적인 용도로 사용할 프로그래밍 언어로 개발된 것이 아니라, 단 일주일만에 웹 브라우저에서 작동할 스크립트로 시작한 이유인것에 기인하니, ‘어쩔 수 없군’ 하면서 이해하는것이 최선이지 싶어요…
렉시컬 스코프
스코프 체이닝에 대한 간단한 리마인드
스코프에 대한 간단한 코드 예제들과 함께 글을 시작하고자 합니다.
var x = 2;
function foo() {
var x = 100;
console.log(x);
}
foo(); // 콘솔에 뭐가 출력될까?
정말로 간단한 예제를 하나 가져왔습니다. foo
가 호출된 이후, 콘솔에는 뭐가 출력이 될까요? 대부분의 사람들이 100
이 출력됨을 바로 알 수 있을 것입니다. 전역변수 x
도 있지만, 가까운 스코프에 있는 x
가 접근되어서 출력이 되지요.
그렇다면, 지역변수 x
가 없었다면?
var x = 2;
function foo() {
console.log(x);
}
foo(); // 콘솔에 뭐가 출력될까?
지역변수 x
가 없으니, 전역변수 x
가 사용되어, 2가 출력되겠지요. 변수를 접근할 때, 가까운 스코프부터 먼 스코프까지 가는 과정을 우리는 자연스럽게 내재화 하였습니다.
상위 스코프를 결정하는 전략
다음 예제의 실행결과를 예측해 봅시다.
var x = 1;
function foo() {
var x = 10;
bar();
}
function bar() {
console.log(x);
}
foo(); // case 1
bar(); // case 2
case1과 case2의 결과는 무엇일까요? bar
에는 x
가 정의되어 있지 않아, 직관으로 얻을 수 있는 경우의 수는 다음과 같다고 생각합니다.
-
함수를 어디서 호출 했는지에 따라 함수의 상위 스코프 결정 –> 즉 case1은
10
(foo
내부가 상위 스코프), case2는1
(전역이 상위 스코프) -
함수를 어디서 정의했는지에 따라 함수의 상위 스코프 결정 –> 즉 case1, case2 모두
1
(전역이 상위 스코프)
그리고 실제 위 코드를 실행시키면 양쪽 케이스 모두 1
이라는 결과를 받음을 볼 수 있습니다. JS는 2번 전략을 사용하고 있는 것이죠.
아까 소개한 두 전략은, 상위 스코프를 결정하는 전략입니다. 1번 전략은 어디서 호출 되었는지 알아야 하기 때문에, 런타임에 결정되니 동적 스코프를 사용한다고 하고, 2번 전략은, 함수 정의를 읽기만 하면, 런타임에 어떻게 되건 상관이 없으니 정적 스코프를 사용한다고 하고, 다른 말로 렉시컬 스코프를 사용한다고 합니다. 사족으로, C계열 언어들은 대부분 렉시컬 스코프를 따른다고 합니다.
렉시컬 스코프란 함수 정의 시점의 스코프 환경을 기준으로 상위 스코프가 결정되는 방식을 의미합니다.
실행 컨텍스트
제목부터 ‘JS의 근간’ 이라고 적었지만, 사실 단순하게 줄여서 말하면 너무나도 간단합니다.
코드가 실행되는 환경에 대한 구체적인 정보를 가지고 있는 객체
라고 줄일 수 있거든요.
이번 글에서는 실행 컨텍스트에 대한 상대적으로 단순한 개념들만 다루지만, 다음 글들에서 설명할 더욱 깊은 개념까지 이해하면, 다음과 같은 것들을 이해할 수 있으리라 생각합니다.
- JS의 스코프
- 스코프를 기반으로 식별자와 식별자에 바인딩된 값을 관리하는 방식
- 호이스팅의 발생이유
- 클로저의 동작방식
- 태스크큐와 함께하는 이벤트 핸들러와 동작방식
거기에 더해서 var
이 ‘나쁜 것’ 으로 취급되는 이유도 알 수 있을 겁니다.
소스코드 타입과 컨텍스트 관리
JS에서는 소스코드의 종류에 따라서 컨텍스트가 관리되는 방식이 다릅니다.
- 전역 코드
- 함수 코드
- 모듈 코드
- eval 코드
이 중, eval 코드는, eval
함수 자체가 안전하게 쓰기 참 어려우니, 세가지만 최대한 필요한 부분만 짚어보겠습니다.
전역 코드와 전역 실행 컨텍스트
전역 코드의 정의는 다음과 같습니다.
전역에 존재하는 소스코드. 전역에 존재하는 함수, 클래스 등의 내부 코드는 포함되지 않는다.
내부 코드가 포함되지 않는다는 것만 주의하면 개념상 어려울 부분도 없습니다. (왜 볼드까지 치면서 강조를 했다면, 정말 웃긴 사실이지만, ‘선언’ 자체는 전역 코드에 들어가기 때문입니다… 자세한 사항은 후에 다룹니다)
전역 코드의 ‘세부 정보’들을 담고 있는, 전역 실행 컨텍스트가 어떤 것을 하는지 대략 설명해보겠습니다.
- 전역 변수를 관리하기 위해 최상위 스코프인 전역 스코프를 생성
var
키워드로 생성된 전역 변수와, 함수 선언문으로 선언된 전역 함수를 전역 객체의 프로퍼티와 메서드로 바인딩하고 참조할 수 있게 한다.
함수 코드와 함수 실행 컨텍스트
함수 코드의 정의는 다음과 같습니다.
함수 내부에 존재하는 소스코드. 함수 내부에 중첩된 함수, 클래스등의 내부 코드는 포함하지 않는다.
전역 변수의 정의 처럼 딱히 복잡하지 않습니다. 중첩된 함수나 클래스에 대한 코드에 대한 컨텍스트는 감싸고 있는 함수의 컨텍스트에서 분리된 새로운 컨텍스트를 생성하기 때문에, 내부코드는 포함되지 않는다 라는 어찌보면 그렇게 썩 복잡하지 않은 설명이라 생각합니다.
함수 코드의 ‘세부 정보’를 담고 있는, 함수 실행 컨텍스트가 담고 있는, 함수 실행 컨텍스트가 하는 일을 간단히 정리해보겠습니다.
- 지역 스코프를 생성. 함수 내 지역변수, 매개변수,
arguments
객체를 관리 - 그렇게 생성한 지역 스코프를 전역 스코프에서 시작된 스코프 체인에 연결
모듈 코드
웹 페이지를 작성하면서, 여러개의 js를 가져오기 위해서는 옛날에는 이런 방법밖에 없었습니다.
<script src="./data.js"></script>
<script src="./index.js"></script>
그리고 a.js와 b.js의 내용이 다음과 같다고 해봅시다.
data.js
let foo = 10;
index.js
console.log(foo); // 10
이렇다면, ‘한 파일처럼 동작 하는것인가?’ 생각할 수 있지만, 아래의 상황을 직접 실행해보면, 그것이 아니었음을 알 수 있습니다.
// data.js
console.log(foo); //Uncaught ReferenceError
//index.js
var foo = 10; // var로 선언한경우는 window객체의 property로 등록됨
만약 아예 한 파일로 동작한다면, var
의 호이스팅에 의해서 undefined
를 받아야겠죠
이런 애매모호함을 해결하기 위해서, ES6에서는 모듈 기능이 추가되었습니다. 아예 분리를 하자는 것이죠
<script src="./~" type="module" />
이러한 모듈로 취급되는 코드들은 모듈 코드별로 독립적인 모듈 스코프를 생성합니다. 이를 위해 모듈 코드가 평가되면, 모듈 실행 컨텍스트가 생성됩니다.
‘평가’ 라는 단어에 대한 설명은 바로 다음 섹션에서 할 예정이니 찬찬히 읽어주시기 바랍니다.
JS 코드가 실행되기까지
단순 직독직해가 아닌 JS
JS는 언어 이름부터 ‘script’ 가 들어가있어, 단순 인터프리터 언어라고 생각할 수 있지만, 사실 그렇지만은 않습니다.
한 줄 한 줄을 읽고 바로 실행하는 것이 아니라, 우선 코드를 ‘평가’ 해서 실행 컨텍스트를 만들기 위한 정보들을 모으고, ‘실행’ 단계에서, 아까 언급한 실행 컨텍스트를 통해 실행 컨텍스트를 참조하고, 갱신하면서 코드의 실행이 이루어 집니다.
정말로 간단한 코드 몇줄을 봅시다.
var x;
x = 1;
‘평가’ 단계에서 x
가 실행 컨텍스트에 등록되고, ‘실행’ 단계에서 1
이라는 값이 할당된다고 볼 수 있겠습니다.
실행 컨텍스트의 역할
앞부분에서, 간단하게 ‘참조하고 갱신한다’ 라고 간략히 이야기 했지만, 구체적으로 어떻게 이루어지는지를 다루고, 이 글을 마무리 하고자 합니다.
const x = 1;
const y = 2;
function foo(a) {
const x = 10;
const y = 20;
console.log(a + x + y);
}
foo(100);
console.log(x + y);
1. 전역 코드 평가
선언문만 먼저 실행됩니다. const x
, const y
, function foo(a)
의 선언이 먼저 처리되는 것이지요.
이 때, var
로 선언된 전역 변수와 함수 선언문으로 정의된 전역 함수는 전역 객체의 프로퍼티와 메서드가 됩니다.
2. 전역 코드 실행
전역 코드에 대한 런타임이 시작되어, 순차적으로 실행됩니다.
이 때, 함수가 호출되면 (본 예제에서는 foo(100)
) 전역코드의 실행을 일시중단 하고, 함수 내부로 진입합니다.
3. 함수 코드 평가
함수 내부에 진입하게 되면, 전역 코드를 평가했던 것 처럼, 선언문을 우선적으로 처리합니다. 매개변수와 지역 변수 선언문을 우선적으로 처리합니다.
그 결과로 생성된 매개변수와 지역변수가 함수 실행 컨텍스트가 관리하는 스코프에 등록되고 arguments
객체가 생성됩니다. 그리고 이 때, 말 많고 탈 많은, this
바인딩이 결정됩니다.
4. 함수 코드 실행
함수 코드의 평가가 완료되었으면, 런타임이 시작됩니다.
매개변수와 지역변수에 값이 할당되고, console.log
가 호출되겠죠. 그리고 원래의 전역 흐름으로 돌아가게 됩니다.
실행 컨텍스트 관리는?
JS를 포함한 여러 C스타일 언어들이, 함수를 호출하고, 원래 흐름으로 돌아가는 그러한 흐름을 유지하기 위해서, ‘스택’ 자료구조를 이용한다는 점을, 컴파일러 수업 시간 등에 들어봤을 것 입니다. JS도 그런식으로 유지 됩니다만, 이 글의 분량의 적정성을 유지하기 위해서, 다음 글에서 다루도록 하겠습니다.
그리고 선언에 의해 생성된 식별자(변수, 함수, 클래스)등을 스코프를 나누어 등록하고, 상태변화를 유지하며, 상위 스코프와도 연결이 되야합니다. 이를 스코프 체인이라고 하는데, 이 역시 다음 글에서 다루도록 하겠습니다.
마치며
최대한 실행 컨텍스트의 이해를 위해서 필요한 부분을 쉬운 언어로 재구성하여, 적어보았습니다. 사실 실행 컨텍스트에 대한 글은 정말로 많고, 영상자료도 많습니다. 그럼에도 이 글을 작성한 이유는, 앞으로의 기술면접에 섰을 때, ‘나의 언어’로 이야기 하기 위한 기반을 닦기 위함이라고 말씀 드릴 수 있을 듯 합니다. 그리고, 혹여나 이 글이 구글 검색결과에 노출되어 다른 분들이 도움을 받게 된다면 더욱이 좋은 일이고요
다음 글 부터는 실행 컨텍스트 스택과, 렉시컬 환경에 대한 내용을 다루고자 합니다. 최대한 ‘저의 말’로 풀이를 해보겠습니다. 끝까지 읽어주셔서 감사합니다!
참고 문헌
- 모던 자바스크립트 딥 다이브 (이웅모 저)
- 13.5장 렉시컬 스코프
- 23장 실행 스코프 ( ~ 23.3 실행 컨텍스트의 역할)
- 미누도 개발한다 - 일반스크립트와 모듈스크립트