8. 스코프

 

8-1. 스코프란?

스코프(scope)는 변수, 함수, 클래스 등의 식별자가 유효한 범위를 말한다. 식별자는 선언한 위치에 따라 접근할 수 있는 유효 범위가 달라진다. 스코프는 대부분의 프로그래밍 언어에 존재하는 공통적인 개념이다. 하지만 자바스크립트 var 키워드의 스코프는 다른 언어의 스코프와 조금 다른 방식으로 동작하기 때문에, 우선 const 키워드로 스코프의 기본 개념에 대해 살펴보자.
 
const num1 = 1; { const num2 = 2; console.log(num2); // 2 } function scopeFunction1() { const num3 = 3; function scopeFunction2() { const num4 = 4; } } console.log(num1); // 1 console.log(num2); // Uncaught ReferenceError: num2 is not defined console.log(num3); // Uncaught ReferenceError: num3 is not defined console.log(num4); // Uncaught ReferenceError: num4 is not defined
식별자는 자신이 선언한 위치에서만 유효하기 때문에 블록 내부에서 선언한 식별자는 해당 블록 내부에서만 접근할 수 있다. num2, num3, num4는 블록 안에서 선언한 지역 변수이기 때문에 외부에서 접근했을 때 Uncaught ReferenceError가 발생하는 것이다.
 

8-2. var 키워드

자바스크립트에선 원래 변수를 선언할 때 var 키워드를 사용했지만, ES6 이후 let과 const 키워드가 추가되었다. var과 let, const 키워드는 스코프 동작 방식이 다르다. 이 키워드들에 대해 순서대로 알아보자.
 

8-2-1. 키워드 없이 선언과 할당 가능

var 키워드는 다른 프로그래밍 언어들의 변수 선언과는 다른 독특한 방식으로 동작하고, 여러 문제점을 가지고 있다. 가독성이 나쁘고 유지 보수가 어렵기 때문에 ES6에서 let과 const 키워드가 도입된 이후로는 잘 사용되지 않는다. 우선 다음 예제를 보자.
str = 'Hello World'; console.log(str); // 'Hello World'
다른 프로그래밍 언어에 익숙한 독자라면, 이 코드 이전에 str이라는 변수가 이미 선언되어 있고, 해당 코드는 str에 값을 재할당하는 것으로 생각하기 쉬울 것이다. 하지만 해당 코드는 변수를 선언함과 동시에 값을 할당한 것이다. var 키워드는 이렇게 키워드를 생략한 선언과 할당이 가능하다. 이런 방식은 해당 코드가 선언인지, 재할당인지 명확하지 않아 가독성을 떨어트릴 뿐만 아니라 치명적인 에러를 일으킬 수도 있다. 만약 홈페이지 회원의 이메일 주소를 수정해야 한다고 가정해 보자.
var memberMailAddress = 'example1@gmail.com'; memberMailaddress = 'example2@gmail.com'; // var 키워드 생략 console.log(memberMailAddress); // example1@gmail.com console.log(memberMailaddress); // example2@gmail.com
개발자는 memberMailAddress라는 변수의 값을 수정해야 하지만, 실수로 대소문자를 헷갈려서 memberMailaddress라는 새로운 변수를 선언하고 말았다. 이렇게 되면 실제 회원의 이메일 주소는 수정되지 않아 이전 주소 그대로 남게 되는 것이다.
 

8-2-2. 중복 선언 가능

var 키워드의 또 다른 특징은 중복 선언이 가능하다는 것이다. 도서관 대출/반납 프로그램을 만든다고 생각해 보자.
var bookNumber = 800; // 도서 분류 번호 // ... var bookNumber = 678893870518; // ISBN (도서 식별 번호) console.log(bookNumber); // 678893870518
개발자는 bookNumber라는 변수를 선언하고 도서 분류 번호를 할당했지만, 이를 잊어버리고 실수로 같은 이름의 변수를 새로 선언하고 ISBN을 할당했다. 이제 bookNumber에는 ‘678893870518’이라는 ISBN이 할당되고, 도서 분류 번호는 ‘800’은 사라져 버렸다. 중복 선언이 불가능한 다른 프로그래밍 언어에서는 에러가 발생함으로 개발자가 바로 에러가 생겼다는 걸 알 수 있지만, var는 중복 선언이 허용된 키워드이기 때문에 이런 문제를 바로 깨닫지 못하고 뒤늦게 알게 될 수 있다.
 

8-2-3. 변수 호이스팅

자바스크립트의 모든 선언은 호이스팅(Hoisting)이 일어난다. 호이스팅은 자바스크립트 엔진이 런타임 이전에 선언문을 먼저 실행하는 것을 말한다. var 키워드는 let, const 등의 호이스팅과는 다른 방식으로 호이스팅된다. var 키워드는 선언과 초기화가 동시에 호이스팅 되기 때문에 let, const와는 달리 선언 이전에 변수에 접근해도 에러가 발생하지 않는다.
console.log(str); // undefined var str = 'Hello World!'; console.log(str); // Hello World!
위의 예제와 같이 str 변수를 선언하기 이전에 값에 접근해도 에러가 발생하지 않고 undefined을 반환하는 것을 확인할 수 있다. 하지만 선언과 초기화만 호이스팅 되는 것이기에, 값의 할당은 할당문에 도달해야 이루어진다.
 

8-2-4. 함수 레벨 스코프

다른 프로그래밍 언어와 다르게 var 키워드는 블록 레벨 스코프(block-level scope)를 지원하지 않고, 함수 레벨 스코프(function-level scope)만 지원한다. 다음 예제를 보자.
var str = 'global'; { var str = 'block1'; { var str = 'block2'; } } console.log(str); // block2
다른 프로그래밍 언어들과 다르게, var 키워드는 블록 내부에서 선언한 변수도 전역 변수로 취급한다. 이는 if문, for문 등 함수 블록을 제외한 모든 블록에서 동일하게 적용된다.
var sum = 0; for(var i = 1; i <=10; i++){ sum +=i; } console.log(sum); // 55 console.log(i); // 11
위의 예제는 1부터 10까지의 숫자를 더하기 위해 만든 for문이다. 만약 let 키워드를 사용했다면 for문을 벗어난 i는 참조할 수 없어 Uncaught ReferenceError가 발생해야 하지만, 해당 예제에선 에러가 발생하지 않고 11을 출력한다. 이러한 동작 방식은 의도와 다르게 값을 변경시켜 혼란을 가져오고 메모리를 낭비하는 부작용이 있다.
단, var 키워드도 함수 레벨에서의 스코프는 정상적으로 작동된다.
var str = "global"; function func1() { var str = "func1"; function func2() { var str = "func2"; } } console.log(str); // global
블록 내부의 변수를 전부 전역 변수로 취급하는 이전 예제들과 다르게 함수 내부에서 선언한 변수는 지역 스코프가 지원된다.

8-3. let, const 키워드

let과 const는 var 키워드의 치명적인 문제들을 해결하기 위해 ES6에서 새로 도입된 키워드이다.
 

8-3-1. 중복 선언 불가능

let과 const 키워드는 var 키워드와 달리 중복 선언이 불가능하다.
var varNum = 1; var varNum = 2; console.log(varNum); let letNum = 1; let letNum = 2; // Uncaught SyntaxError: Identifier 'letNum' has already been declared console.log(letNum);
해당 예제는 ‘let letNum = 2’에서 에러가 발생하여 실행되지 않는다.
💡
자바스크립트 표준 문법에 따르면 let과 const는 중복 선언이 불가능하다. 하지만 예외적으로 크롬 개발자 도구 등의 콘솔 모드에서는 동일한 변수를 재선언 해도 에러가 발생하지 않는다. 단, 같은 줄에서 동일한 변수 두 개 이상을 선언하면 에러가 발생하며, 다른 줄에서 선언한 경우에만 에러가 생기지 않는다.
 

8-3-2. 블록 레벨 스코프

let, const 키워드는 함수 레벨 스코프인 var 키워드와 다르게 블록 레벨 스코프를 지원한다.
let num1 = 1; { let num2 = 2; console.log(num2); // 2 } function func1() { let num3 = 3; function func2() { let num4 = 4; } } for(let i = 1; i <=3; i++) { console.log(i); // 1, 2, 3 } console.log(i); // Uncaught ReferenceError: i is not defined console.log(num1); // 1 console.log(num2); // Uncaught ReferenceError: num2 is not defined console.log(num3); // Uncaught ReferenceError: num3 is not defined console.log(num4); // Uncaught ReferenceError: num4 is not defined
위의 예제와 같이 함수를 포함한 모든 블록 내부에서 선언된 변수는 블록 내부에서만 접근할 수 있다.
 

8-3-3. const 키워드

const 키워드는 let 키워드와 유사하지만 재할당이 불가능하다는 차이점이 있다.
const str = 'Hello World!'; str = 'Hi World!'; // Uncaught TypeError: Assignment to constant variable
const로 선언한 변수에 값을 재할당하려고 하면 에러가 발생한다.
 

8-4. 스코프 체인

8-4-1. 전역 스코프와 지역 스코프

앞서 변수들의 특징에 대해 알아보며 언급되었던 전역 스코프(global scope)와 지역 스코프(local scope)에 대해 살펴보자.
전역(global)은 블록 혹은 함수 안에 포함되지 않은 바깥의 영역을 말한다.
const globalStr = 'global scope'; if(true){ const ifStr = 'if block scope'; console.log(ifStr); // if block scope console.log(globalStr); // global scope } for(let i = 0; i < 1; i++){ const forStr = 'for block scope'; console.log(forStr); // for block scope console.log(globalStr); // global scope } function func1() { const func1Str = 'function1 scope'; console.log(func1Str); // function1 scope console.log(globalStr); // global scope function func2() { const func2Str = 'function2 scope'; console.log(func2Str); // function2 scope console.log(globalStr); // global scope } func2(); } func1(); console.log(globalStr); // global scope console.log(ifStr); // Uncaught ReferenceError: ifStr is not defined console.log(forStr); // Uncaught ReferenceError: forStr is not defined console.log(func1Str); // Uncaught ReferenceError: func1Str is not defined console.log(func2Str); // Uncaught ReferenceError: func2Str is not defined
전역에서 선언한 globalStr 변수는 전역과 블록 내부 어느 곳에서든 참조할 수 있다. 하지만 블록 내부, 즉 지역(local) 스코프에서 선언한 변수는 해당 블록 내부에서만 참조할 수 있다.
그렇다면 이러한 지역 스코프는 어떤 방식으로 작동하는 걸까?
 

8-4-2. 렉시컬 환경

우선 다음 예제를 보자.
const str = 'global'; const str2 = 'global2'; { // 첫 번째 내부 블록 const str = 'block1'; console.log(str); // block1 console.log(str2); // global2 { // 두 번째 내부 블록 const str = 'block2'; console.log(str); // block2 console.log(str2); // global2 } } console.log(str); // global console.log(str2); // global2
여러 블록에서 str이라는 변수를 선언했는데 자바스크립트는 어떻게 각 블록에 해당하는 str 변수를 출력할 수 있는 걸까? 그리고 블록 밖에서 설정한 str2 변수를 어떻게 블록 안에서 출력할 수 있는 걸까? 이는 각각의 블록이 렉시컬 환경(Lexical Environtment)이라는 내부 객체를 가지고 있기 때문이다.
렉시컬 환경은 해당 스코프의 정보를 담고 있는 환경 레코드(Environment Record)와 부모 스코프의 정보를 담고 있는 외부 렉시컬 환경 참조(outer Lexical Environment Reference)를 가지고 있다.
위의 예제에서 변수 str과 str2를 호출하는 과정을 살펴보자. 전역 스코프의 외부 렉시컬 환경 참조는 null이고, 환경 레코드는 const str = 'global'이라는 값을 가지고 있다. 그렇기에 전역에서 console.log(str)을 출력하면 'global'이라는 값이 나온다.
그리고 첫 번째 내부 블록에서 str을 호출했을 때 외부 렉시컬 환경 참조는 전역 스코프이고, 환경 레코드는 const str = 'block1'라는 값을 가지고 있다. 따라서 console.log(str)을 출력하면 'block1'라는 값이 나온다. 하지만 str2 값은 해당 스코프의 환경 레코드 안에 없기 때문에, 외부 렉시컬 환경 참조를 통해 전역 스코프에서 str2 값을 찾아내 ‘global2’라는 값을 출력한다.
두 번째 내부 블록도 마찬가지다. 우선 내부 스코프 안의 환경 레코드에서 str 값을 찾고, str2 값은 블록의 환경 레코드 안에 없기 때문에 외부 렉시컬 환경 참조를 통해 거슬러 올라가서 전역 환경 레코드 안에 있는 값 ‘global2’을 출력한다.
이렇게 렉시컬 환경을 통해 내부 스코프에서부터 차례로 검색하는 걸 스코프 체인(scope chain)이라고 한다. 안에서부터 검색하기 때문에, 여러 스코프에서 같은 이름의 식별자를 선언한 경우에는 스코프 체인 상 가장 먼저 발견된 식별자에만 접근할 수 있다.
그렇기 때문에 스코프 체인으로 연결되어 있다고 해서 모든 변수에 접근할 수 있는 것은 아니다.
const scopeChain = 'global'; { const scopeChain = 'block'; console.log(scopeChain); // block }
 
이 예제에서는 scopeChain를 호출했을 때 블록 내부에 값이 있기 때문에 전역에서 선언한 const scopeChain = 'global'이 출력되지 않고 블록 안에서 선언한 'block'이 출력된다. 따라서 블록 안에서는 전역 공간에서 선언한 ‘global'이라는 값에는 접근할 수 없다. 이러한 방식을 변수 은닉화(variable shadowing)라고 부른다.
 

8-5. 전역 변수

8-5-1. 가비지 컬렉션

개발자가 직접 메모리 관리를 해야 하는 C, C++ 등과 다르게 자바스크립트는 가비지 컬렉션(Garbage Collection)이 자동으로 메모리를 관리해 준다. 가바지 컬렉션은 객체가 생성되었을 때 해당 객체를 메모리에 등록하고, 해당 메모리를 누구도 참조하지 않을 때 자동으로 메모리를 해제한다. 다음 예제로 살펴보자.
const str1 = 'global'; function func() { const str2 = 'block1'; console.log(str2); } func(); //block1 console.log(str1); // block1 console.log(str2); // Uncaught ReferenceError: str2 is not defined
함수 func()에서 생성된 지역 변수 str2는 함수 스코프 안에서만 존재하며, 함수 블록이 끝나면 소멸한다. 함수 밖의 누구도 해당 변수를 참조하고 있지 않기 때문에, 가비지 컬렉션이 메모리를 청소해 준다.
 

8-5-2. 전역 변수의 문제점

가비지 컬렉션이 자동으로 메모리를 관리해 주는 지역 변수와는 다르게 전역 변수는 애플리케이션(application)과 생명 주기(life Cycle)를 같이 한다. 전역 변수는 애플리케이션이 종료할 때까지 계속해서 메모리 공간을 점유한다. 지역 변수에 비해 메모리를 낭비하게 되는 것이다. 이 밖에도 전역 변수는 여러 가지 문제점을 가지고 있다.
전역 변수는 스코프 체인 가장 바깥에 존재하기 때문에, 내부 스코프에서 전역 변수에 접근하려면 검색 속도가 오래 걸린다.
const price = 20000; const tax = 0.15; function taxCalculate() { return price * tax; } console.log(taxCalculate()); // 3000
이 예제에서는 함수 taxCalculate()를 통해 price에 tax를 곱해 세금의 액수를 구하고 있다. 이 코드에서 변수 tax가 함수 taxCalculate() 안에서만 쓰이는 변수라면, 전역 변수에서 선언할 이유가 없다.
function taxCalculate() { const tax = 0.15; return price * tax; }
이렇게 함수 내부에서 지역 변수로 선언하는 것이 스코프 체인 검색 시간을 단축해 줄 것이다.
또한 자바스크립트는 분리된 파일도 전역 스코프를 공유한다. 이는 의도치 않게 다른 파일 내의 전역 변수를 사용하게 되는 실수로 이어지기도 한다.
그렇기 때문에 전역 변수는 반드시 사용해야 하는 경우가 아니라면 되도록 사용하지 않는 것이 좋다.