본문 바로가기

JavaScript

변수를 찾기 위해 정의된 규칙 #스코프

반응형

1. 스코프란 무엇인가


프로그래밍 언어는 변수에 값을 저장하고 → 저장된 값을 가져다 쓰고 → 수정하는 패러다임을 가진다. 이 기능은 프로그램에서 상태(state)를 나타낼 수 있게 해준다. 그렇다면

 

 

✔️ 변수는 어디에 저장되는가?

✔️ 필요할 때 프로그램은 어떻게 변수를 찾는가?

 

 

특정 장소에 변수를 저장하고 그 변수를 찾는 데는 잘 정의된 규칙이 필요한데 이를 스코프라고 부른다. 그렇다면 스코프 규칙은 어디서 어떻게 정의되는걸까? 스코프 규칙에 대한 컴파일러 이론을 확인해 보자.

 

 

1.1. 컴파일러 이론

보통 자바스크립트는 ‘동적' 또는 ‘인터프리터'언어로 분류하나 사실은 ‘컴파일러 언어'다. 어떤 자바스크립트 조각이라도 실행되려면 먼저(보통 바로 직전에) ‘컴파일’되어야한다. 자바스크립트 컴파일러는 프로그램을 받아 컴파일하여 바로 실행될 수 있게 한다.

전통적인 컴파일러 언어의 처리 과정에서는 프로그램을 이루는 소스 코드가 실행되기 전에 보통 3단계를 거치는데, 이를 ‘컴파일레이션'이라고 한다.

 

  1. 토크나이징 & 렉싱
  2. 파싱 : 토근 배열을 프로그램 문법 구조를 반영해 중첩 원소를 갖는 트리(AST) 형태로 바꾸는 과정
  3. 코드 생성 : AST를 컴퓨터에서 실행 코드로 바꾸는 과정

 

자바스크립트 엔진은 이 세 가지 단계 뿐 아니라 많은 부분에서 다른 프로그래밍 언어의 컴파일러보다 훨씬 복잡하다. 자바스크립트 엔진은 파싱과 코드 생성 과정에서 불필요한 요소를 삭제하는 과정을 거쳐 실행 시 성능을 최적화한다.

자바스크립트 컴파일레이션은 보통 코드가 실행되기 겨우 수백만 분의 일초 전에 수행한다. 자바스크립트 엔진은 가능한 한 가장 빠른 성능을 내려 한다. 간단히 말하자면, 어떤 자바스크립트 조각이라도 실행되려면 보통 바로 직전에 컴파일되어야 한다는 말이다. 자바스크립트 컴파일러는 프로그램 “var a = 2;”를 받아 컴파일하여 바로 실행될 수 있게 한다.

 

 

1.2 스코프 이해하기

  • 엔진 : 컴파일레이션의 시작부터 끝까지 전 과정과 자바스크립트 프로그램 실행을 책임진다
  • 컴파일러 : 토크나이징과 렉싱 & 파싱 & 코드 생성의 모든 잡일을 도맡아 한다
  • 스코프 : 선언된 모든 변수 검색 목록을 작성하고 유지한다. 또한 엄격한 규칙을 강제하여 현재 실행 코드에서 변수의 적용 방식을 정한다.

 

✔️ 컴파일러 관련 용어, 컴파일러체 (Compiler Speak)

  • LHS(Left-Hand Side) 검색 : 변수가 대입 연산자의 왼쪽에 있을 때 수행. 값을 넣어야 하므로 변수 컨테이저 자체를 찾는다.
  • RHS(Right-Hand SIde) 검색 : 변수가 대입 연산자의 ‘왼편이 아닌 쪽’에 있을 때 수행. 단순히 특정 변수의 값을 찾는 것과 다름 없다. → “Retrieve(가져오라) his/her (그의/그녀의) source(소스) !”

 

// a에 대한 참조는 RHS 참조(대입한 값)
// a에 아무것도 대입하지 않기 때문이다.
// 대신 a의 값을 가져와 console.log()에 넘겨준다
console.log( a ); 

// a에 대한 참조는 LHS 참조 (대입할 대상)
// 현재 a 값을 신경 쓸 필요 없이 ‘= 2’ 대입 연산을 수행할 대상 변수를 찾기 때문이다. 
a = 2;

 

 

1.3 중첩 스코프 (Nested Scope)

하나의 블록이나 함수는 다른 블록이나 함수 안에 중첩될 수 있으므로 스코프도 다른 스코프 안에 중첩될 수 있다. 따라서 대상 변수를 현재 스코프에서 발견하지 못하면 엔진은 다음 바깥의 스코프로 넘어가는 식으로 변수를 찾거나 글로벌 스코프라 부르는 가장 바깥 스코프에 도달할 때까지 계속한다.

 

 

1.4 오류

LHS와 RHS를 구분하는 것이 왜 중요할까? 이 두 종류의 검색 방식은 변수가 아직 선언되지 않았을 때 (검색한 모든 스코프에서 찾지 못했을 때) 서로 다르게 동작하기 때문이다.

변수 검색 실패 시

  • LHS : 자동적, 암시적으로 글로벌 스코프 같은 이름의 새로운 변수가 생성된다. (Strict Mode 동작이라면 ReferenceError’가 발생)
  • RHS : 다시는 변수를 찾을 수 없다. 스코프에서 찾지 못한 변수는 ‘선언되지 않은 변수’라 부르며 엔진이 **‘ReferenceError’**를 발생시킨다.

 

여기서 ReferenceError는 스코프에서 대상을 찾았는지와 관계가 있다. 반면에 TypeError는 스코프 검색은 성공했으나 결괏값을 가지고 적합하지 않거나 불가능한 시도를 한 경우를 의미한다.

 

 

정리하기

스코프는 어디서 어떻게 변수(확인자)를 찾는가를 결정하는 규칙의 집합이다.

변수를 검색하는 이유는 변수에 값을 대입하거나(LHS 참조) 변수의 값을 얻어오기 위해서다(RHS 참조)

LHS 참조는 대입 연산 과정에서 일어난다.

  • 스코프와 관련된 대입 연산 : ‘=’ 연산자, 인자를 함수의 인자로 넘겨줄 때(암시적 인자 대입)

자바스크립트 엔진은 코드를 실행하기 전에 먼저 컴파일하는데, 이 과정에서 엔진은 “var = 2;”와 같은 구문을 독립된 두 단계로 나눈다.

  1. var a는 변수 a를 해당 스코프에 선언한다. 이 단계는 코드 실행 전에 처음부터 수행된다.
  2. a=2는 변수 a를 찾아 값을 대입한다. (LHS 참조)

LHS, RHS 참조 검색은 모두 현재 실행 중인 스코프에서 시작한다. 그리고 변수를 찾지 못했을 경우 한 스코프씩 중첩 스코프의 상위 스코프로 넘어가며 변수를 찾느다. 이 작업은 최상위 층 글로벌 스코프에 이를 때까지 계속하고 대상을 찾았든, 못 찾았든 작업을 중단한다.

LHS, RHS 참조 방식은 변수가 아직 선언되지 않았을 때 서로 다르게 동작한다.

 

RHS 참조가 대상을 찾지 못하면 ReferenceError가 발생한다.

LHS 참조가 대상을 찾지 못하면 자동적, 암시적으로 글로벌 스코프에 같은 이름의 새로운 변수가 생성된다. (Strict Mode의 경우 ReferenceError 발생)

 

 

 

2. 렉시컬 스코프


개발자가 코드를 작성할 때 함수 어디에 선언하는지에 따라 정의되는 스코프를 말한다.

 

 

2.1 렉스타임

일반적인 언어의 컴파일러는 첫 단계를 전통적으로 토크나이징 또는 렉싱이라 불리는 작업으로 시작한다. 렉싱 처리 과정에서는 소스 코드 문자열을 분석하여 상태 유지 파싱의 결과로 생성된 토큰에 의미를 부여한다.

 

약간 순환적인 정의를 하면 렉시컬 스코프는 렉싱 타임(Lexing Time)에 정의되는 스코프다. 즉, 렉시컬 스코프는 개발자가 코드를 짤 때 변수와 스코프 블록을 어디서 작성하는가에 기초해서 렉서가 코드를 처리할 때 확정된다.

 

소스 코드의 스코프를 분석할 때 스코프를 겹쳐진 ‘버블’이라고 가정하면 이해하기 쉽다. 스코프 버블은 스코프 블록이 쓰이는 곳에 따라 결정되는데, 스코프 블록은 서로 중첩될 수 있다. 각각의 함수가 새로운 스코플 버블을 생성한다고 생각해도 좋다. 어떤 함수의 버블도 동시에 다른 두 스코프 버블 안에 존재할 수 없다. 어떤 함수도 두 개의 부모 함수 안에 존재할 수 없는 것처럼 말이다.

 

 

2.1.1 검색

스코프는 목표와 일치하는 대상을 찾는 즉시 검색을 중단한다. 여러 중첩 스코프 층에 걸쳐 같은 변수 이름을 정의할 수 있는데 이를 **섀도잉(shadowing)**이라 한다. 더 안쪽의 변수가 더 바깥쪽의 변수를 숨기는 것이다.

 

 

✔️ 숨겨져 있어서 접근할 수 없는 글로벌 변수에는 어떻게 접근할 수 있을까?

window 객체를 통해서 접근할 수 있다.

 

글로벌 변수는 자동으로 웹 브라우저의 window 같은 글로벌 객체에 속한다. 따라서 글로벌 변수를 직접 렉시컬 이름으로 참조하는 것뿐만 아니라 글로벌 객체의 속성을 참조해 간접적으로 참조할 수도 있다.

 

// 하지만 글로벌이 아닌 섀도잉 변수는 접근할 수 없다
window.a

 

 

2.2 렉시컬 속이기

런타임에 스코프를 수정하거나 새로운 렉시컬 스코프를 만드는 방법은 **eval()**과 **with()**가 있다. eval 은 하나 이상의 선언문을 포함하는 코드 문자열을 해석하여 렉시컬 스코프가 있다면 런타임에 이를 수정한다. with 는 객체 참조를 하나의 스코프로, 속성을 확인자로 간주하여 런타임에 완전히 새로운 렉시컬 스코프를 생성한다.

 

이런 방식의 단점은 엔진이 컴파일 단계에서 수행한 스코프 검색과 관련된 최적화 작업을 무산시킨다는 점이다. 따라서 둘 중 하나라도 사용하면 코드는 더 느리게 동작하므로 되도록 이 방식을 사용하지 말자.

 

 

 

 

3. 함수 vs 블록 스코프


3.1 함수 기반 스코프

정확히 어떤 것이 새로운 버블을 만들까? 함수만 버블을 만들까? 자바스크립트의 다른 자료 구조는 스코프 버블을 생성하지 못할까?

함수는 자바스크립트에서 스코프를 이루는 가장 ‘흔한' 단위다. (함수는 자바스크립트의 ‘유일한’ 스코프 단위가 아니다! 예시로는 아래에서 설명할 블록 스코프가 있다.) 다른 함수 안에서 선언된 변수와 함수는 본질적으로 다른 ‘스코프'로부터 ‘숨겨진' 것이다. 이는 좋은 소프트웨어를 위해 적용해야 할 디자인 원칙이다.

function foo(a) {
	var b = 2;
	// some code..
	function bar() {
		// ...
	}
	// more code..
	var c = 3;
}

앞의 코드에서 foo()의 스코프 버블은 변수 a, b, c와 bar를 포함한다. bar() 는 자체 스코프 버블이 있고 글로벌 스코프도 마찬가지다. 글로벌 스코프에는 하나의 변수가 있는데 바로 ‘foo’ 다. a, b, c, bar 모두 foo()의 스코프 버블에 속한다. foo() 바깥에서는 이들에게 접근할 수 없다. 다음 코드는 호출된 변수가 글로벌 스코프에는 없기 때문에 ReferenceError 오류를 발생시킨다.

bar(); // fails
console.log( a, b, c ); // all 3 fails

하지만 이 모든 확인자 (a, b, c, foo, bar)는 foo() 안에서 접근할 수 있고 bar() 안에서도 이용할 수 있다. (bar 내부에서 섀도 확인자가 선언되지 않았을 때)

 

3.2 일반 스코프에 숨기

함수에 대한 전통적인 개념은 이렇다.

  • 함수를 선언하고 그 안에 코드를 넣는다. 바꿔 생각해보는 것도 꽤 유용하다.
  • 작성한 코드에서 임의 부분을 함수 선언문으로 감싼다. 이는 해당 코드를 ‘숨기는' 효과를 낸다.

달리 말하면, 함수의 스코프로 둘러싸서 변수와 함수를 숨길 수 있다는 말이다. 이 원칙은 모듈/객체의 API와 같은 소프트웨어를 설계할 때 필요한 것만 최소한으로 남기고 나머지는 ‘숨겨야'한다는 것이다. 이것이 더 나은 코드를 만들기 위한 조건 중 하나다.

 

3.2.1 충돌 회피

변수와 함수를 스코프 안에 숨기는 것의 또 다른 장점은 같은 이름을 가졌지만 다른 용도를 가진 두 변수가 충돌하는 것을 피할 수 있다는 것이다. 글로벌 네임스페이스 모듈 관리를 위해서라도 스코프를 염두해 함수를 숨겨가며 작성해야 한다.

 

3.3 스코프 역할을 하는 함수

함수 선언문 함수 표현식의 중요한 차이는 함수 이름이 어디의 변수로 묶이느냐와 관련이 있다.

 

// 1. 함수 선언문
var a = 2;
function foo() {
	var a = 3;
	console.log(a); // 3
}
foo();

console.log(a); // 2

// 2. 함수 표현식
var a = 2;
(function foo() {
	var a = 3;
	console.log(a); // 3
})();
foo();

console.log(a); // 2

첫째 코드에서 함수 이름 foo는 함수를 둘러싼 스코프에 묶이고, foo()라는 이름을 통해 직접 호출했다.

둘째 코드에서 함수 이름 foo는 함수를 둘러싼 스코프에 묶이는 대신 함수 자신의 내부 스코프에 묶였다. 즉, “(function foo() {...})” 라는 표현식에서 확인자 foo는 오직 ‘...’가 가리키는 스코프에서만 찾을 수 있고 바깥 스코프에서는 발견되지 않는다. 함수 이름 foo를 자기 내부에 숨기면 함수를 둘러싼 스코프(예를 들면 글로벌 스코프)를 불필요하게 오염시키지 않는다.

 

 

3.3.1 익명 vs 기명

setTimeout( function() {
	console.log("I waited 1 second!");
}, 1000 );

이와 같이 함수 표현식을 콜백 인자로 사용하는 사례에 익숙할 것이다. 이런 방식을 ‘익명 함수 표현식'이라 부르는데, 이는 함수에 확인자 이름이 없기 때문이다**. 함수 표현식**은 이름 없이 사용할 수 있지만, 함수 선언문에는 이름이 빠져서는 안된다. 이름 없는 함수 선언문은 자바스크립트 문법에 맞지 않다.

익명 함수 표현식은 편리하지만, 함수 표현식을 사용할 땐 몇 가지 기억할 단점이 있다.

 

  1. 익명 함수는 스택 추적 시 표시할 이름이 없어서 디버깅이 더 어려울 수 있다.
  2. 이름 없이 함수 스스로 재귀 호출을 하려면 폐기 예정인 arguments.callee 참조가 필요하다. 자기 참조가 필요한 또 다른 예로는 한 번 실행하면 해제되는 이벤트 처리 함수가 있다.
  3. 이름은 보통 쉽게 이해하고 읽을 수 있는 코드 작성에 도움이 되는데, 익명 함수는 이런 이름을 생략한다. 기능을 잘 나타내는 이름은 해당 코드를 그 자체로 설명하는 데 도움이 된다.

 

함수 표현식에 이름을 사용하면 특별한 부작용 없이 상당히 효과적으로 앞의 단점을 해결할 수 있다. 따라서 함수 표현식을 사용할 때 이름을 항상 쓰는 것이 가장 좋다.

 

 

3.3.2 함수 표현식 즉시 호출하기

var a = 2;
(function foo() { // () : 함수를 표현식으로 변경
	var a = 3;
	console.log(a);
})(); // () : 함수 실행
foo();

console.log(a);

()로 함수를 감싸면 함수를 표현식으로 바꾸는데, 마지막에 ()를 덧붙이면 함수를 실행할 수 있다. 함수를 둘러싼 첫 번째 ()는 함수를 표현식으로 바꾸고, 두 번째 ()는 함수를 실행시킨다. 이를 **즉시 호출 함수 표현식(IIFE)**라고도 부르는데 이는 가장 흔하게 사용된다. Immediately (즉시), Invoked (호출), Function (함수), Expression (표현식) 3.3.1에서 설명한 이유대로 기명 IIFE를 사용하는 것이 더 좋은 습관이다.

 

3.4 스코프 역할을 하는 블록

for (var i=0; i<10; i++){
	console.log(i);
}

변수 i를 for 반복문의 시작부에 선언하는 이유가 뭘까?

보통 i를 오직 for 반복문과 관련해서 사용하려 하기 때문이다. 그러고는 변수 i가 실제로는 둘러싼(함수 또는 글로벌) 스코프에 포함되는 사실을 무시한다. 즉, 블록 스코프의 목적대로 바로 변수를 최대한 사용처 가까이에서 최대한 작은 유효 범위를 갖도록 선언하는 것이다.

 

with

지양해야 할 구조이긴 하지만 블록 스코프의 형태를 보여주는 예시로 with 문 안에서 생성된 객체는 바깥 스코프에 영향 주는 일 없이 with 문이 끝날 때까지만 존재한다.

try/catch

try/catch 구문 중 catch 부분에서 선언된 변수는 catch 블록 스코프에 속한다. 즉, catch 구문 안에서 정의된 변수는 다른 스코프 내에서 참조하면 ReferenceError 오류가 발생한다.

let

ES6부터 사용되는 키워드 let은 선언된 변수를 둘러싼 아무 블록 {}의 스코프에 붙인다. 즉 let 선언문은 둘러싼 함수(또는 글로벌) 스코프가 아니라 가장 가까운 임의의 블록에 변수를 붙인다.

특히 let을 사용한 선언문은 속하는 스코프에서 호이스팅(끌어올리기) 효과를 받지 않는다. 즉 let으로 선언된 변수는 실제 선언문 전에는 명백하게 ‘존재'하지 않는다.

{
	console.log(bar); // ReferenceError
	let bar = 2;
}

 

 

  • 가비지 콜렉션 (Garbage Collection)
function process(data) {
	// ...
}
var someReallyBigData = { ... };

process( someReallyBigData );

var btn = document.getElementById("my_button");
btn.addEventListener("click", function click(evt) {
	console.log("button clicked");
}, /*capturingPhase=*/false);

click 함수가 해당 스코프 전체의 클로저를 가지고 있기 때문에 자바스크립트 엔진은 데이터를 남겨둘 것이다.

function process(data) {
	// ...
}

// anything declared inside this block can go away after.!
{
	let someReallyBigData = { ... };
	process( someReallyBigData );
}

var btn = document.getElementById("my_button");
btn.addEventListener("click", function click(evt) {
	console.log("button clicked");
}, /*capturingPhase=*/false);

위 코드와 같이 명시적으로 블록을 선언하여 변수의 영역을 한정하는 것은 효과적인 코딩 방식이다.

 

const

let과 같이 블록 스코프를 생성하지만 선언된 값은 고정된다. (상수)

 

 

정리하기

함수는 결코 ‘유일한’ 스코프 단위가 아니다. 블록 스코프는 함수만이 아니라 (일반적으로 { } 같은) 임의의 코드 블록에 변수와 함수가 속하는 개념이다.

 

ES3부터 시작해서 try/catch 구조의 catch 부분은 블록 스코프를 가진다. ES6에서는 키워드 let이 추가되어 임의의 코드 블록 안에 변수를 선언할 수 있다.

 

쉽게 착각하지만 블록 스코프는 var 함수 스코프를 완전히 대체할 수 없다. 두 기능은 공존하며 개발자들은 함수 스코프와 블록 스코프 기술을 같이 사용해야 한다. 적절하게 읽히고 유지 보수가 쉬운 코드를 작성하기 위해 상황에 따라 적절한 곳에 사용하면 된다.

 

 

 

Ref


[도서] 카일 심슨, ⌜YOU DON'T KNOW JS - 타입과 문법, 스코프와 클로저⌟, 한빛미디어, 2017

반응형