스코프, 클로저, 호이스팅, 변수 선언

Taehun Jeong·2023년 3월 12일
0
post-thumbnail

스코프

스코프는 자바스크립트에서 현재 실행중인 컨텍스트를 의미한다. 스코프는 변수, 함수의 사용 가능한 유효 범위를 나타낸다. 즉, 변수나 표현식이 해당 스코프 내에 존재하지 않는다면 사용할 수 없다. 스코프는 전역 스코프와 지역 스코프로 나눌 수 있다. 전역 스코프는 코드 어디에서든 참조할 수 있다. 지역 스코프는 해당 지역과 그 하위 지역에서만 사용할 수 있다. 자바스크립트 스코프는 다음의 특징들을 갖는다.

전역 스코프

어디에서나 접근 가능한 스코프를 의미한다.

var x = 10; // 전역 변수

function foo () {
  // 선언하지 않은 식별자
  y = 20;
  console.log(x + y);
}

foo(); // 30

이때, 위의 코드에서 전역 변수 x를 선언하고 식별자 없이 변수 y를 선언하면 정상적으로 x + y의 결과를 출력한다. 이는 선언하지 않은 식별자에 값을 할당하면 전역 객체의 프로퍼티가 되기 때문이다. foo 함수가 호출되면 자바스크립트 엔진은 변수 y에 값을 할당하기 위해 먼저 스코프 체인을 통해 선언된 변수인지 확인한다. 이때 foo 함수의 스코프와 전역 스코프 어디에서도 변수 y의 선언을 찾을 수 없으므로 참조 에러가 발생해야 하지만 자바스크립트 엔진은 y = 20window.y = 20으로 해석하여 프로퍼티를 동적 생성한다. 결국 y는 전역 객체의 프로퍼티가 되어 마치 전역 변수처럼 동작한다. 이러한 현상을 암묵적 전역이라 한다.

함수 레벨 스코프

함수 내부에서 선언된 변수는 해당 함수의 스코프에서만 유효하며, 함수 외부에서는 접근할 수 없다.

var x = 'global';

function foo() {
	var x = 'local';
    console.log(x); // local

    function bar() {  // 내부함수
		console.log(x); // local
    }

    bar();
}

foo();
console.log(x); // global

전역변수 x와 지역변수 x가 중복 선언되었다. 전역 영역에서는 전역변수만이 참조 가능하고 함수 내 지역 영역에서는 전역과 지역 변수 모두 참조 가능하나 위 예제와 같이 변수명이 중복된 경우, 지역변수를 우선하여 참조한다. 내부함수는 자신을 포함하고 있는 외부함수의 변수에 접근할 수 있다. 위 코드에서 bar를 실행하면 가장 인접한 지역의 스코프로부터 var x = 'local'를 참조한다.

렉시컬 스코프

자바스크립트는 렉시컬 스코프(Lexical scope)를 따른다. 렉시컬 스코프는 변수를 어디에서 선언했는지에 따라 스코프가 결정된다는 의미이다. 즉, 변수를 선언한 함수나 블록의 스코프에서 변수를 찾는다.

var x = 1;

function foo() {
    var x = 10;
    bar();
}

function bar() {
    console.log(x);
}

foo(); // 1
bar(); // 1

위 예제의 실행 결과는 함수 bar의 상위 스코프가 무엇인지에 따라 결정된다. 두가지 패턴을 예측할 수 있는데 첫번째는 함수를 어디서 호출하였는지에 따라 상위 스코프를 결정하는 것이고 두번째는 함수를 어디서 선언하였는지에 따라 상위 스코프를 결정하는 것이다. 첫번째 방식으로 함수의 상위 스코프를 결정한다면 함수 bar의 상위 스코프는 함수 foo와 전역일 것이고, 두번째 방식으로 함수의 스코프를 결정한다면 함수 bar의 스코프는 전역일 것이다.

프로그래밍 언어는 이 두가지 방식 중 하나의 방식으로 함수의 상위 스코프를 결정한다. 첫번째 방식을 동적 스코프(Dynamic scope)라 하고, 두번째 방식을 렉시컬 스코프 또는 정적 스코프(Static scope)라 한다.

따라서, 위 코드를 실행할 경우 bar는 렉시컬 스코프를 따라 전역 변수 var x = 1를 참조한다.

중첩 스코프

자바스크립트에서는 함수나 블록 내부에 다른 함수나 블록이 중첩될 수 있다. 이렇게 중첩된 스코프에서는 내부 스코프에서 외부 스코프의 변수를 참조할 수 있다. 중첩 스코프는 가장 인접한 지역을 우선하여 참조한다.

function foo() {
    const x = 1;

    function bar() {
		const y = 2;
    
		function baz() {
			const z = 3;
			console.log(x + y + z);
		}
      
    	baz();
	}
  
	bar();
}

foo(); // 6

위의 코드에서 변수 x, y, z는 각각 foo(), bar(), baz() 함수의 스코프에 선언되어 있으며, 이들은 모두 서로 다른 중첩 스코프 내에서 선언된 변수이다. baz() 함수에서는 세 변수에 모두 접근하여 그 값들의 합을 출력한다. 이러한 특징을 사용해 중첩 스코프를 이용하여 함수 내부에 선언된 변수를 외부에서도 접근할 수 있도록 만드는 것을 클로저(Closure)라고 한다.


클로저

클로저는 어떤 데이터(어휘적 환경)와 그 데이터를 조작하는 함수를 연관시켜주기 때문에 유용하다. 따라서, 오직 하나의 메소드를 가지고 있는 객체를 일반적으로 사용하는 모든 곳에 클로저를 사용할 수 있다. 다음은 글자 크기를 선택할 수 있는 버튼을 눌러 문서의 텍스트 크기를 변경하는 것을 클로저를 사용해 구현한 예시이다.

HTML

<p>Some paragraph text</p>
<h1>some heading 1 text</h1>
<h2>some heading 2 text</h2>

<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>

CSS

body {
  font-family: Helvetica, Arial, sans-serif;
  font-size: 12px;
}

h1 {
  font-size: 1.5em;
}
h2 {
  font-size: 1.2em;
}

JavaScript

function makeSizer(size) {
  return function() {
    document.body.style.fontSize = size + 'px';
  };
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;

makeSizer 함수는 size를 파라미터로 받는다. 내부 함수에서는 이를 클로저를 사용해 접근하여 document.body.style.fontSize를 해당 크기로 변경한다.

비동기 처리를 이용한 방식에도 클로저를 사용할 수 있다.

function outer() {
  const message = "Hello";

  setTimeout(function inner() {
    console.log(message);
  }, 1000);
}

outer(); // "Hello" after 1 second

outer() 함수 내부에서 setTimeout() 함수를 호출하고, 내부에서 inner() 함수를 선언하여 1초 후에 console.log()를 호출한다. 이때, inner() 함수는 message 변수에 접근할 수 있다. 이렇게 클로저를 사용하면, message 변수가 setTimeout() 함수가 실행될 때마다 다시 선언되지 않으므로, 항상 동일한 값을 출력한다.

function plus() {
  let a = 1;
  setTimeout( ()=>{
      console.log(++a);
      debugger;
  }, 1000);
  return a;
}

const result = plus();
console.log('result :', result);

위의 예시에서의 결과로는 result : 1이 출력되고 1초 뒤 2가 출력된다. console.log('result :', result)를 통해 return a까지 실행함으로써 plus()의 실행은 종료되었지만 setTimeout()에서는 정상적으로 a의 값에 접근하여 결과를 출력한다. debugger를 통해 Scope 탭으로 이동하면 Closure (plus)에 a: 2가 포함되어 있는 것을 확인할 수 있을 것이다. 실행이 종료된 함수의 값을 클로저를 통해 접근할 수 있다는 것이다.


호이스팅

호이스팅(Hoisting)이란 인터프리터가 변수와 함수의 메모리 공간을 선언 전에 미리 할당하는 것을 의미한다. 변수 선언에 관해 설명하기에 앞서 var를 통한 호이스팅의 문제점에 대해 이해하기 위해 먼저 설명한다. 변수의 경우, 변수의 선언은 호이스팅되지만, 초기화는 호이스팅되지 않는다. 변수를 선언하기 전에 사용하면 undefined가 할당되며, 이는 에러를 발생시킬 수 있다. 함수의 경우, 함수 선언식(함수 선언문)은 전체 함수가 호이스팅되지만, 함수 표현식(함수 리터럴)은 호이스팅되지 않는다.

console.log(x);
var x = 10;

변수의 호이스팅 예시이다. x가 선언되기 전에 console.log(x)를 통해 호출했지만 undefined가 출력된다. 즉, 변수 x는 메모리에 이미 올라가 있지만, 아직 값이 할당되지 않은 상태이기 때문이다.

catName("클로이");

function catName(name) {
  console.log("제 고양이의 이름은 " + name + "입니다");
}

// 제 고양이의 이름은 클로이입니다

함수의 호이스팅 예시이다. 함수 catName()이 선언되기 전에 호출했지만, 정상적으로 동작한다. 하지만 함수 표현식의 경우를 살펴보자.

catName("클로이");

var catName = function(name) {
  console.log("제 고양이의 이름은 " + name + "입니다");
};

// TypeError: catName is not a function

앞의 예시를 함수 표현식으로 바꾼 것이다. 변수 catName은 호이스팅되었지만, 함수 자체는 호이스팅되지 않았으므로 TypeError가 발생하는 것이다.

호이스팅이 발생하는 원리는 다음과 같다.

  • 자바스크립트 엔진은 코드를 실행하기 전 실행 가능한 코드를 형상화하고 구분하는 과정(실행 컨텍스트를 위한 과정)을 거친다.
  • 자바스크립트 엔진은 코드를 실행하기 전 실행 컨텍스트를 위한과정에서 모든 선언(var, let, const, function, class)을 스코프에 등록한다.
  • 코드 실행 전 이미 변수선언/함수선언이 저장되어 있기 때문에 선언문보다 참조/호출이 먼저 나와도 오류 없이 동작한다. (정확히는 var 키워드로 선언한 변수와 함수 선언문일 경우 오류 없이 동작한다. 이는 선언이 파일의 맨 위로 끌어올려진 것 처럼 보이게 한다.)

var, let, const

자바스크립트에서 변수를 선언하는 방법은 var, let, const로 3가지가 있다. var은 함수 스코프를 사용하며, let, const는 블록 스코프를 사용한다는 차이점이 있다.

var

var는 변수를 선언하고, 선택적으로 초기화할 수 있다. 지금은 구 버전의 자바스크립트의 잔재로 여겨지며, 사용을 기피하고 있다. 사용을 기피하는 이유는 다음과 같다.

  1. 함수 스코프
    letconst는 블록 스코프를 사용하지만, var는 함수 스코프를 사용한다. 변수가 선언된 함수 내에서만 사용이 가능하므로, 전역 범위로 변수가 노출되거나 함수 내에서 변수를 수정해도 그 내용이 반영되지 않을 수 있다. 다음의 코드를 보자.

    var x = "global";
    
    function example() {
    	var x = "local";
    	console.log(x); // "local"
    }
    
    example();
    console.log(x); // "global"

    위의 코드에서 변수 x를 "global"로 초기화하고 example 함수를 통해 x의 값을 "local"로 변경했지만, 함수 내에서의 변경이 x에 반영되지 않는다. 이는 전역에서의 x와 함수 example에서의 x가 서로 다른 변수로 취급되었기 때문이다.

  2. 호이스팅의 발생

    function run() {
    	console.log(foo); // undefined 
    	// Hoisting in action !
    
    	var foo = "Foo";
    	console.log(foo); // Foo
    }
    
    run();

    위 코드에서 var foo = "Foo"이전에 foo를 호출함으로써 호이스팅이 발생한다. 따라서 선언 이전의 변수를 사용 가능하며 그 값은 undefined로 출력된다.

  3. 전역 객체 바인딩
    var을 통해 전역 변수를 선언하고 사용할 수 있다. 이는 변수의 의도치 않은 변경으로 인해 코드의 유지·보수를 어렵게 하고, 어디에서 사용되는지 파악하기 힘들게 하여 코드 가독성을 떨어뜨린다.

  4. 재선언
    var는 같은 이름의 변수를 재선언할 수 있다. 때문에 코드 가독성을 크게 떨어뜨릴 수 있는 문제를 내포하고 있다.

let, const

모든 코드 블록(함수, if 문, for 문, while 문, try/catch 문 등 {...}) 내에서 선언된 변수는 코드 블록 내에서만 유효하며 코드 블록 외부에서는 참조할 수 없다. 즉, 코드 블록 내부에서 선언한 변수는 지역 변수이다. 이를 블록 레벨 스코프라고 한다. 대부분의 프로그래밍 언어는 블록 레벨 스코프(Block-level scope)를 따르지만 자바스크립트는 함수 레벨 스코프(Function-level scope)를 따른다. ES6부터 letconst를 통해 자바스크립트에서도 블록 레벨 스코프를 사용할 수 있다. 앞서 언급한 var의 문제점들을 이를 통해 해결할 수 있다.

function run() {
    let foo = "Foo";
    console.log(foo); // Foo
    {
        let bar = "Bar";
        console.log(foo, bar); // Foo Bar
    }
    console.log(foo); // Foo
    console.log(bar); // ReferenceError
}

run();

let foo = "Foo"은 블록 스코프를 사용하므로 run() {...} 내에서 선언되어 유효하게 접근할 수 있다. let bar = "Bar"run() {...}의 내부 블록에서 선언되었으므로 보다 상위에 있는 foo를 참조할 수 있다. 하지만 그 외부에서 bar를 호출할 경우 ReferenceError가 발생한다. 블록 스코프에 따라 유효하지 않은 접근이기 때문이다.

function checkHoisting() {
	console.log(foo); // ReferenceError
	let foo = "Foo";
	console.log(foo); // Foo
}

checkHoisting();

letconst로 선언한 변수는 호이스팅되지만 ReferenceError가 발생한다. 이는 선언과 초기화가 동시에 이뤄지는 var와 달리 선언 단계와 초기화 단계가 나뉘어져 진행되기 때문이다. 그 사이에는 해당 값들이 TDZ(Temporal Dead Zone)에 위치하기 때문에 참조를 시도할 경우, ReferenceError를 반환하는 것이다. TDZ의 개념은 변수가 선언되기 전에 접근하는 것을 금지하고, 변수 선언 이전에 접근하면 ReferenceError가 발생하도록 하는 것이다. 이는 let과 const로 선언한 변수가 초기화되기 전에 접근하는 것을 방지하며, 이를 통해 코드의 안정성을 높이고 에러를 사전에 방지한다.

var foo = "Foo";  // globally scoped
let bar = "Bar"; // not allowed to be globally scoped

console.log(window.foo); // Foo
console.log(window.bar); // undefined

var를 통해 전역변수를 선언한 것과 let을 통해 전역변수를 선언한 것이다. 위와 같이 선언하면 foo는 window, 즉 전역 객체의 프로퍼티가 되지만 bar는 전역 객체의 프로퍼티로 추가되지 않는다. 따라서, undefined가 출력된다.

const foo = "Foo1";
const foo = "Foo2";

// SyntaxError: Identifier 'foo' has already been declared

letconst는 재선언을 방지한다. 변수가 선언되면, TDZ가 시작되며 이후 변수의 초기화가 진행되면 TDZ가 종료된다. TDZ 종료 이후에야 변수에 접근할 수 있으므로 이러한 원리로 변수의 재선언을 막는 것이다.


References

mdn web docs) 스코프
PoiemaWeb) 스코프
mdn web docs) 문법과 자료형
XOR) 4 Reasons Why ‘var’ is Considered Obsolete in Modern JavaScript
PoiemaWeb) let, const와 블록 레벨 스코프
mdn web docs) 클로저
HANAMON) [JavaScript] 호이스팅(Hoisting)이란?
태나미) let과 const는 호이스팅되지 않는다?? TDZ란

profile
안녕하세요

0개의 댓글