스코프

11hertz·2024년 3월 3일

YDKJSY

목록 보기
5/9
post-thumbnail

1.1 책에 대하여

프로그래밍 언어를 컴파일 언어와 인터프리터를 거치는 스크립트 언어로 구분하면, JS는 일반적으로 스크립트 언어로 분류
JS로 작성한 프로그램 대부분은 위에서 아래로 한 줄씩 처리된다고 가정
하지만 실제 JS는 실행 전 별도의 단계에서 파싱, 컴파일이 일어남
개발자가 지정한 변수와 함수, 블록의 위치는 파싱/컴파일 단계에서 스코프 규칙에 따라 분석되고, 그 결과에 따라 결정된 스코프 구조는 대부분 런타임 조건에 영향을 받지 않음

JS에서 함수는 일급 값(first-class value)이기 때문에 숫자나 문자열처럼 변수에 할당할 수 있고 다른 곳으로 넘기는 것도 가능
다른 곳으로 넘긴 함수 내에서 외부 변수를 사용하는 경우, 해당 변수는 어딘가에 접근해야 하므로 JS에서는 함수를 프로그램 내 어디에서 실행했는지와는 상관 없이 함수를 정의할 때 결정된 스코프를 유지하며, 이를 클로저라고 함

모듈은 코드 정리를 도와주는 디자인 패턴이며 클로저를 통해 공개(public) 메서드가 모듈 내부 스코프에 있는 접근이 제한된 변수나 함수에 접근할 수 있도록 해준다는 특징이 있음

1.2 컴파일 vs 인터프리트

컴파일레이션은 텍스트 형식으로 작성한 코드를 처리해서 컴퓨터가 이해할 수 있는 작업 지시 목록으로 바꾸는 일련의 과정
보통 한 번에 소스 코드가 변환되며, 변환 결과는 추후에 실행 가능한 형태(대게 파일 형태)로 저장

인터프리테이션(interpretation)은 개발자가 작성한 프로그램을 기계가 해석할 수 있는 명령으로 변환한다는 점에서 컴파일과 유사하나 처리 방식이 다름
컴파일레이션은 프로그램을 한 번에 처리하는 반면, 인터프리테이션은 소스 코드를 한 줄씩 변환한다는 차이가 있음
줄 하나 혹은 문 하나는 다음 줄에 있는 소스 코드가 실행되기 바로 직전에 실행 됨

컴파일레이션과 인터프리테이션은 대개 상호 배타적인 모델임
하지만 인터프리테이션은 실제 소스 코드를 한 줄씩 실행하는 방식 말고 다른 방식으로 작동하는 경우가 있어 주의할 필요가 있음
최신 JS 엔진은 프로그램을 처리할 때 수많은 종류의 컴파일레이션과 인터프리테이션을 사용

1.3 코드 컴파일

JS가 컴파일 언어인지 아닌지를 따지는 것이 중요한 이유가 있음
스코프는 주로 컴파일 중에 결정되며 스코프를 정복하려면 컴파일레이션과 실행이 어떻게 연관되는지 이해하는 것이 중요
고전 컴파일러 이론에서는 프로그램이 컴파일러의 다음 세 가지 주요 단계를 거처 처리된다고 정의

1) 토크나이징(tokenizing) / 렉싱(lexing)
문자열을 토큰(token)이라 불리는 의미 있는 조각으로 쪼갬
띄어쓰기 같은 공백은 토큰이 될 수도 있고 안 될 수도 있는데, 공백이 해당 프로그래밍 언어에서 의미가 있는지 없는지에 따라 토큰이 될지 아닐지 결정

토크나이징과 렉싱에는 미묘하고 학술적인 차이가 존재
둘의 차이는 토큰을 무상태(stateless) 방식으로 인식하는지와 상태 유지(stateful) 방식으로 인식하는지에 있음
토크나이저(tokenizer)가 별개의 토큰으로 분리할 지 아니면 다른 토큰의 일부로 처리할지를 결정할 때 상태 유지 파싱 규칙을 적용한다면 이는 렉싱이라 할 수 있음

var a = 2;
// 1단계를 거치면 이 코드는 var, a, =, 2, ;로 조각남

2) 파싱
토큰 배열을 프로그램 문법 구조를 반영하는 중첩 원소 기반의 트리인 AST(Abstract Syntax Tree)로 바꿈

var a = 2;
// 파싱을 거치면 변수 선언(VariableDeclaration)이라 불리는 최상위 노드와 a의 값을 가지는 식별자 (Identifier) 노드, 할당식(AssignmentExpression)이라 불리는 노드를 자식 노드로 가진 트리가 됨
여기서 할당식 노드는 2라는 값을 가지는 숫자 리터럴(NumericLiteral)을 자식 노드로 가짐

3) 코드 생성
AST를 컴퓨터가 실행 가능한 코드로 변환
코드 생성 단계는 언어 혹은 목표하는 플랫폼 등에 따라 크게 달라질 수 있음

var a = 2;
// JS엔진은 해당 코드를 AST로 바꾸고 AST를 컴퓨터가 실행 가능한 코드로 바꾸는데, 이 과정에서 실제 a라는 변수가 생성되고(메모리 확보 등도 같이 진행), 그 후 변수 a에 2가 저장됨

JS 엔진은 위의 세 단계보다 훨씬 복잡하게 돌아감
파싱과 코드 생성 단계에서 실행 최적화를 위해 몇 가지 추가 작업(불필요한 중복 요소 제거 등)이 진행되며 프로그램 실행 중 컴파일이나 최적화를 다시 하는 경우도 있음

다른 언어와 달리 JS 컴파일레이션은 구축 단계에서 일어나는 것이 아니기 때문에 JS 엔진은 충분한 시간을 확보하지 못한 채 맡은 임무나 최적화를 수행
컴파일레이션은 보통 코드가 실행되기 전, 수백만분의 일초 내에 완료되어야 함
이러한 제약 아래 가장 빠른 성능을 보장하기 위해 JS 엔진은
가능한 한 모든 종류의 꼼수(레이지 컴파일(lazy compile)이나 핫 리컴파일(hot re-compile) 등과 같은 JIT)를 사용함

1.3.1 필수 두 단계

JS가 어떻게 프로그램을 처리하는 지 관찰할 때 중점적으로 봐야 할 것은 프로그램 처리는 (최소) 파싱과 컴파일 두 단계에서 일어난다는 것
이는 파싱과 컴파일이 먼저 일어나고 그 다음에 실행이 된다는 것을 말함
ECMA 명세서에 '컴파일레이션이 반드시 필요하다' 라고 적혀 있지는 않지만 선 컴파일 후 실행(compile-then-execute) 접근 방식을 취하지 않으면 명세서에서 요구하는 동작을 충족할 수 없음
선 컴파일 후 실행 접근 방식을 입증할 수 있는 세 가지 특징으로는 구문 오류, 초기 오류, 호이스팅이 있음

1) 구문 오류

var greeting = '안녕하세요'
console.log(greeting);
greeting = .'안녕!';
// SyntaxError: unexpected token.

예시를 실행하면 '안녕!' 문자열 앞에 있는 온점(.) 때문에 '안녕하세요.'가 출력되지 않고 SyntaxError 발생

JS 엔진 입장에서 세 번째 줄에 구문 오류가 있다는 사실을 알 수 있는 유일한 방법은 첫째 줄과 둘째 줄을 실행하기 전(프로그램을 실행하기 전)에 전체 프로그램을 먼저 파싱하는 방법 뿐

2) 초기 오류

console.log('잘 지내시죠?');
saySomething('안녕하세요.', '안녕!');
// Uncaught SyntaxError: Duplicate parameter name not allowed in this context
function saySomething(greeting, greeting) {
	'use strict';
    console.log(greeting);
}

첫째 줄에는 문제가 없지만 예씨를 실행하면 '잘 지내시죠?'라는 메시지가 출력되지 않음
구문 오류와 마찬가지로 프로그램 실행 전 SyntaxError가 출력
ECMA 명세서에 따르면 엄격 모드에서 프로그램을 실행할 때, 가이드를 어긴 경우 초기 오류를 발생시키는데, 이번 예씨의 오류가 바로 초기 오류임

JS엔진이 매개변수 greeting이 중복되었는지 어떻게 알았는지, 전처리 구문 'use strict';는 매개변수보다 더 아래쪽인 함수 본문에 있는데도 매개변수 목록을 처리하는 동안 함수 saySomething()을 엄격 모드로 실행해야 한다는 것은 어떻게 알았는지에 대한 궁금증에 대한 답변으로는, 프로그램 실행 전에 코드 전체가 파싱된다는 가정만이 이러한 현상을 설명할 수 있음

3) 호이스팅

function saySomething() {
	var greeting = '안녕하세요.';
    {
    	greeting = '잘 지내시죠?'; // 여기서 오류가 발생
        let greeting = '안녕!';
        console.log(greeting);
    }
}
saySomething();
// ReferenceError: Cannot access 'greeting' before initialization

콘솔에 출력되는 ReferenceError는 greeting ='잘 지내시죠?'가 있는 줄에서 발생
변수 greeting은 var greeting = '안녕하세요.'가 아닌 그 다음 줄인 let greeting = '안녕!'에서 선언이 이루어짐

JS 엔진 입장에서 다음 구문에서 이름(greeting)이 같고 스코프는 불록인 변수가 선언되었기 때문에 오류가 발생한다는 것을 알 수 있는 유일한 방법은 오류가 발생한 문이 실행되기 전, 프로그램 내 스코프와 변수 관계 전부를 사전에 파악하는 것 뿐
프로그램이 실행되기 전 파싱이 이루어져야만 이런 스코프와 선언에 관한 처리가 정확해짐

예시에서 ReferenceError는 코드 greeting = '잘 지내시죠?'에서 변수 greeting에 너무 빨리 접근하려 해서 발생하는데, 이러한 현상이 발생하는 정확한 원인은 TDZ 때문

JS를 컴파일 언어로 분류하는 것은 실행 가능한 바이너리 혹은 바이트코드(bytecode) 파일이 생성되는 배포 모델과 관련이 없음
JS로 작성한 코드는 처리 및 분석 시 여러 절차를 거치는데 이 절차들은 코드 실행 전 반드시 일어나고 이는 논란의 여지가 없으며 실제로도 관찰 가능

1.4 컴파일러체

선언을 제외하고 프로그램 내 모든 변수와 식별자는 할당의 타깃(target)이나 값으 소스(source) 둘 중 하나의 역할을 함
할당된 값이 있다면 변수는 할당의 '타깃'이며, 그렇지 않으면 변수는 값의 '소스'가 됨
변수 처리를 위해서 JS 엔진은 변수가 나타날 때마다 가장 먼저 변수 각각에 타깃과 소스라는 역할 이름표를 붙임

var students = [
	{ id: 14, name '카일' },
	{ id: 73, name '보라' },
    { id: 112, name '지수' },
    { id: 6, name '호진' },
];
function getStudentName(studentID) {
	for(let student of students) {
    	if(student.id = studentID) {
        	return student.name;
        }
    }
}
var nextStudent = getStudentName(73);
console.log(nextStudent); // 보라

1.4.1 할당의 타깃

student = [ ... ]; // 할당 연산
nextStudent = getStudentName(73); 
// 예시코드의 이 부분 역시 할당 연산

직접 할당 연산자를 쓴 두 가지 경우 외에도, 예시 코드 내에 세 개의 타깃 할당 연산이 쓰임

1)
for (let student of students) {
// 루프가 돌 때마다 student에 값을 할당
2)
getStudentName(73);
// 타깃과 무관해 보이지만, 인수 73이 매개변수 student에 할당
3)
function getStudentName(studentID) {

function 키워드로 선언한 함수 선언은 타깃 참조의 특수한 케이스
코드에서 식별자 getStudentName은 컴파일 타임에 선언되고 = function(studentID) 역시 컴파일레이션 과정에서 처리됨
또한 getStudentName과 함수의 관계는 할당문이 실행될 때 설정되는 것이 아니라 스코프가 구성되기 시작하는 시점에 자동으로 설정되며, 이렇게 함수와 변수의 관계가 자동으로 설정되는 것을 함수 호이스팅이라 함

1.4.2 값의 소스

for (let student of students) {

해당 코드에서 student는 타깃이지만 students는 소스 참조
조건문 if(student.id === studentID)에서 student와 studentID는 둘 다 소스 참조
특히 student는 return student.name에서 소스 참조 역할을 함
getStudentName(73)에서 getStudentName은 함수 참좃값의 소스 참조
console.log(nextStudent)에서 console은 소스 참조이며 nextStudent 역시 마찬가지로 소스 참조

예시 코드의 id, name, log의 역할은 변수 참조가 아니라 프로퍼티

1.5 런타임에 스코프 변경하기

스코프는 프로그램이 컴파일될 때 결정되고, 런타임 환경에는 영향을 받지 않음
비엄격 모드에서는 런타임에도 프로그램의 스코프를 수정할 수 있는 방법 두 가지가 있고, 이 방법을 사용하면 규칙을 깰 수 있으나 두 방법 모두 사용해서는 안 됨
위험하고 개발자를 혼란스럽게 하기 때문이기도 하고, 가능하면 엄격 모드에서 작업하는 것이 좋기 때문
(엄격 모드에서는 두 가지 방법 모두 사용할 수 없음)
일부 프로그램에서 우연히 이러한 방법들을 마주칠 수 있으므로 미리 학습해두면 좋음

런타임에도 스코프 수정이 가능하게 하는 두 가지 방법

1) eval() 함수
eval()은 컴파일과 실행의 대상이 되는 문자열 형태의 소스 코드를 받는데, 이 소스 코드는 런타임에 컴파일되고 실행됨
eval()에 넘기는 소스 코드에 var나 function 선언이 있는 경우, 이 선언들은 eval()이 실행중인 스코프를 변경

function badIdea() {
	eval('var oops = '이런!';');
    console.log(oops);
}
badIdea(); // 이런!

eval()을 사용한 줄이 없었다면 console.log(oops)에서 oops를 찾을 수 없기 때문에 ReferenceError 발생
eval()이 런타임에 함수 badIdea()의 스코프를 수정했기 때문에 오류가 발생하지 않음

eval()을 쓰지 말아야 하는 이유는 badIdea()가 실행될 때마다 컴파일과 최적화가 이미 끝난 스코프를 다시 수정하기 때문에 CPU 자원이 사용되기 때문

2) with 키워드
with 키워드 역시 런타임에 스코프 수정을 가능하게 함
with는 특정 객체의 스코프를 지역(local) 스코프로 동적으로 변환
스코프가 변환되면 새로운 지역 스코프에서는 객체의 프로퍼티가 식별자가 되기 때문에 객체를 통하지 앟고 바로 사용 가능

var badIdea = { oops : '이런!' };
with (badIdea) {
	console.log(oops); // 이런!
}

예시에서 전역(global) 스코프는 수정되지 않으나 컴파일 타임이 아닌 런타임에 badIdea 자체가 스코프로 변하기 때문에 이 스코프 안에서 badIdea의 프로퍼티 oops는 변수가 됨
성능과 가독성 측면에서 좋지 않으니 가능하면 사용하지 않는 것이 좋음

eval()과 with는 무슨 일이 있더라도 사용하지 말아야 하며 특히 선언 관련 코드를 받는 eval()만큼은 사용하지 않아야 함
두 방법 모두 엄격 모드에서는 사용이 불가능하니 엄격 모드를 사용하는 것을 추천

1.6 렉시컬 스코프

JS에서는 스코프가 컴파일 타임에 결정되는데, 이렇게 컴파일 타임에 결정되는 스코프를 렉시컬 스코프(lexical scope)라고 함
렉시컬 스코프에서 렉시컬(lexical)은 컴파일레이션 세 단계 중 렉싱과 관련이 있음

함수 안에 변수를 선언하면 컴파일러는 함수를 파싱할 때 변수 선언을 처리하고 함수의 스코프와 선언을 연결
변수를 블록 스코프(let이나 const)로 선언했다면 var로 선언한 것과 달리 스코프는 함수 범위가 아니고 가장 가까운 불록이 됨
변수 참조(타깃 역할을 하는지 또는 소스 역할을 하는지)는 해당 변수가 렉시컬적으로 사용 가능한(lexically available) 여러 스코프 중 하나에서 결정되어야 하며, 그렇지 않으면 변수가 선언하지 않은 상태(undeclared)가 되어 높은 확률로 오류가 발생
변수가 현재 스코프에 선언되어 있지 않은 경우에는 다음 외부 스코프를 참조하며, 이런 프로세스는 식별자가 일치하는 변수 선언을 찾거나 전역 범위에 도달해 더 이상 찾을 곳이 없을 때까지 계속 진행

컴파일레이션은 스코프와 변수의 메모리 예약 관점에서 실제로는 아무것도 하지 않음
컴파일레이션 중에는 그 어떤 프로그램도 실행되지 않기 때문
컴파일 도중에는 프로그램 실행에 필요한 모든 렉시컬 스코프가 들어간 지도가 만들어짐
런타임에 사용할 모든 코드가 들어간 계획안이 이때 만들어지며, 렉시컬 환경(environment)이라고 칭해지는 스코프가 전부 정의되고 각 스코프에 해당하는 식별자(변수)가 추가
컴파일 중에는 스코프를 식별하기만하고 실제 각 스코프를 실행해야만 하는 런타임 전까지는 스코프가 생성되지 않음

profile
Practice Makes Perfect!

0개의 댓글