개발새발 블로그

[2주차] 스코프와 클로저

제로초님 자바스크립트 강의
const numver = [1, 3, 5, 7];
for (let i = 0; i < number.length; i++) {
	setTimeout(() => {
		console.log(number[i]);
	}, 1000 * (i + 1));
}

1초 뒤에 1, 2초 뒤에 3, 3초 뒤에 5, 5초 뒤에 7이 출력된다. 앞의 코드는 다음 코드와 같은 의미이기 때문이다.

const number = [ 1, 3, 5, 7];
setTimeout(() => {
	console.log(number[0]);
}, 1000 * 1);
setTimeout(() => {
	console.log(number[1]);
}, 1000 * 2);
setTimeout(() => {
	console.log(number[2]);
}, 1000 * 3);
setTimeout(() => {
	console.log(number[3]);
}, 1000 * 4);

 

앞에서 작성한 반복문에서 let을 var로 바꾸면 어떻게 될까?

const number = [1, 3, 5, 7];
for (var i = 0; i < number.length; i++) {
	setTimeout(() => {
		console.log(number[i]);
	}, 1000 * (i + 1));
}

단순히 var로 바꿨을 뿐인대 결과가 완전히 달라진다. 콘솔에 undefined만 네 번 출력된다. 

이번에는 number[i] 대신 i를 콘솔에 출력해 보자.

const number = [1, 3, 5, 7];
for (var i = 0; i < number.length; i++) {
	setTimeout(() => {
		console.log(i);
	}, 1000 * (i + 1));
}

모든 i가 4로 나오는 것이 신기할 것이다. 이것이 var와 let의 결정적인 차이이다. 이런 결과가 나오는 이유를 이해하려면 스코프와 클로저를 알아야 한다.

 

1. 블록 스코프와 함수 스코프

모든 변수는 스코프(범위)가 있다. var는 함수 스코프, let는 블록 스코프를 가진다. 무슨 소리일까? 다음 예제를 콘솔에서 실행하면 에러가 발생한다.

function b() {
	var a = 1;
}
console.log(a);
Uncaught ReferenceError: a is not defined

a는 함수 b() 안에 선언된 변수라서 함수 바깥에서는 접근할 수 없다. 이처럼 함수를 경계로 접근 가능 여부가 달라지는 것을 함수 스코프(함수만 신경 씀)라고 한다. 함수 스코프를 다르게 말하면 함수가 끝날 때 함수 내부의 변수도 같이 사라진다고 할 수 있다.

이번에는 if 문 안에 var를 넣어 보겠다.

if (true) {
	var a = 1;
}
a;
< 1

var는 함수 스코프라서 if 문 안에 들어 있어도 if 문 바깥에서 접근할 수 있다. 그런데 let은 다르다.(실행할 때 웹 브라우저를 새로고침해야 한다.) let의 경우는 에러가 발생한다.

if (true) {
	let a = 1;
}
a;
Uncaught ReferenceError: a is not defined

var와 달리 let은 블록 스코프(블록을 신경 씀)라서 그렇다. 블록은 if 문, for 문, while 문, 함수에서 볼 수 있는 중괄호({})를 의미한다. 블록 바깥에서는 블록 안에 있는 let에 접근할 수 없다. let뿐만 아니라 const도 블록 스코프를 가진다. 블록 스코프를 다르게 말하면 블록이 끝날 때 내부의 변수도 같이 사라진다고 할 수 있다.

for 문에 var를 사용해 보겠다. var는 블록과 관계가 없으므로 문제없이 돌아간다. for 문이 끝났을 때 i가 5가 되어 있다는 점에 주목하시오.

for (var i = 0; i < 5; i++) {}
i;
< 5

for 문에 let을 사용하면 에러가 발생한다. for 문 블록 바끝네 변수 i에 접근했기 때문이다. 코드에서 let이 블록 바깥에 있지만, for 문에서는 블록 안에 있는 것으로 친다.

for (let i = 0; i < 5; i++) {}
i;
Uncaught ReferenceError: i is not defined

 

2. 클로저와 정적 스코프

클로저(closure)는 간단히 말해 외부 값에 접근하는 함수이다. 모든 자바스크립트 함수는 클로저가 될 수 있다. 무슨 의미인지 예제 코드로 알아보겠다.

func() 함수는 자신의 외부에 있는 변수 a를 사용하고 있다. 그래서 func() 함수는 클로저이다.

const a = 1;
const func = () => {
	console.log(a);
};

 

다음 함수도 클로저이다. 반환값인 익명 함수는 자신의 외부에 있는 msg 매개변수를 사용하고 있다. 따라서 클로저 이다.

const func = (mst) => {
	return () => {
		console.log(msg);
	};
};

 

클로저가 외부 값에 접근할 수 있는지를 판단하는 기준은 스코프이다. 다음 코드를 실행하면 어떤 결과가 나올까? 이번에도 웹 브라우저를 새로고침하고 실행하여 보시오.

const func = () => {
	console.log(a);
};
if (true) {
	const a = 1;
	func();
};

1이 출력될 것 같지만, Uncaught ReferenceError: a is not defined 에러가 발생한다. func() 함수가 왜 변수 a에 접근하지 못하는지 알려면 a와 func() 함수가 선언된 위치를 봐야 한다. func() 함수가 호출된 위치는 아무런 영향을 주지 않는다.

변수 a는 if문 안에 선언되었고, func() 함수는 if 문 바깥에서 선언되었다. const는 블록 스코프이므로 if 문 바깥에서는 변수 a에 접근할 수 없다. func() 함수도 if 문 바깥에 있으므로 변수 a에 접근할 수 없다.

이처럼 함수가 선언된 위치에 따라 접근할 수 있는 값이 달라지는 현상을 "함수는 정적 스코프를 따른다."라고 표현한다. 선언된 위치가 아니라 호출된 위치에 따라 접근할 수 있는 값이 달라진다면 동적 스코프를 따르게 된다. 자바스크립트는 정적 스코프를 따른다.

 

3. let과 var를 사용한 결과가 다른 이유

스코프와 클로저 개념을 알았으니 이 절 처음에 나온 반복문에서 let과 var를 사용한 결과가 다른 이유를 살펴보겠다.

const number = [1, 3, 5, 7];
for (var i = 0; i < number.length; i++) {
	setTimeout(() => {
		console.log(number[i]);
	}, 1000 * (i + 1));
}

setTimeout()의 콜백함수는 외부 변수 i에 접근하는 클로저이다. 이 때 1000 * (i + 1)과 console.log(number[i])가 같은 시점에 실행된다고 착각하는 경우가 많다. 그러나 setTimeout() 인수인 1000 * (i + 1)은 반복문을 돌 때 실행되고, 클로저는 지정한 시간 뒤에 호출된다. 그런데 반복문은 매우 빠른 속도로 돌아서 클로저가 실행될 때는 이미 i가 4(numbers.length도 4)가 되어 있다.

  • i가 0일 때 setTimeout(콜백, 1000) 실행
  • i가 1일 때 setTimeout(콜백, 2000) 실행
  • i가 2일 때 setTimeout(콜백, 3000) 실행
  • i가 3일 때 setTimeout(콜백, 4000) 실행
  • i가 4일 때 4 < numbers.length는 false이므로 반복문이 끝남
  • 1초 후 콜백 함수 실행(i는 4)
  • 2초 후 콜백 함수 실행(i는 4)
  • 3초 후 콜백 함수 실행(i는 4)
  • 4초 후 콜백 함수 실행(i는 4)

따라서 클로저가 실행될 때 이미 i는 4가 되어 i를 출력하면 4가 출력된다. 그리고 numbers는 인덱스가 3까지 밖에 없으므로 numbers[4]는 undefined가 된다.

그렇다면 let을 쓸 때는 왜 이러한 문제가 발생하지 않았을까? let 코드를 다시 보자.

const numver = [1, 3, 5, 7];
for (let i = 0; i < number.length; i++) {
	setTimeout(() => {
		console.log(number[i]);
	}, 1000 * (i + 1));
}

for 문에 사용한 let은 반복문을 돌 때마다 새로운 블록을 생성한다. 그리고 블록별로 i 변수의 값이 고정된다. 이것도 블록 스코프의 특성이라고 보면 된다. 따라서 클로저 내부의 i 변수도 setTimeout() 함수를 호출할 때의 i 변수와 같은 값이 들어간다.

  • i가 0일 때 블록0 생성, setTimeout(콜백, 1000) 실행, 블록0의 i는 0
  • i가 1일 때 블록1 생성, setTimeout(콜백, 2000) 실행, 블록1의 i는 1
  • i가 2일 때 블록2 생성, setTimeout(콜백, 3000) 실행, 블록2의 i는 2
  • i가 3일 때 블록3 생성, setTimeout(콜백, 4000) 실행, 블록3의 i는 3
  • i가 4일 때 4 < number.length는 false이므로 반복문이 끝남
  • 1초 후 콜백 실행(블록0의 i는 0)
  • 2초 후 콜백 실행(블록1의 i는 1)
  • 3초 후 콜백 실행(블록2의 i는 2)
  • 4초 후 콜백 실행(블록3의 i는 3)

var를 사용하면서 문제를 해결하고 싶다면 i 변수의 값을 고정할 방법을 찾아야 한다. let의 i 변수가 블록마다 값이 고정되는 것처럼 var의 i 변수 값도 고정하면 된다. var의 i 변수는 함수 스코프이므로 함수마다 값이 고정된다. 따라서 클로저가 i 변수 대신 고정된 값을 가리키게 하면 된다. 그래서 다음과 같이 helper()라는 고차 함수를 선언한다.

const number = [1, 3, 5, 7];
function helper(j) {
	return () => {
		console.log(number[j], j);
	}
}
for (var i = 0; i < number.length; i++) {
	setTimeout(helper(i), 1000 * (i + 1));
}
1 0
3 1
5 2
7 3

helper() 함수는 매개변수로 j를 갖고 있는데, j는 i 변수의 값을 고정하는 역할을 한다. 함수 스코프가 하나 더 생겼으므로 i의 값이 j에 지정되면서 고정된다. setTimeout()의 클로저는 이제 i 대신 j를 가리키고 있다.

  • i가 0일 때 setTimeout(helper(0), 1000) 실행, 실행, j는 0
  • i가 1일 때 setTimeout(helper(1), 2000) 실행, 실행, j는 1
  • i가 2일 때 setTimeout(helper(2), 3000) 실행, 실행, j는 2
  • i가 3일 때 setTimeout(helper(3), 4000) 실행, 실행, j는 3
  • i가 4일 때 4 < number.length는 false이므로 반복문이 끝남
  • 1초 후 콜백 실행(j는 0)
  • 2초 후 콜백 실행(j는 1)
  • 3초 후 콜백 실행(j는 2)
  • 4초 후 콜백 실행(j는 3)

또 다른 방법은 for문 대신 forEach() 메서드를 사용하는 것이다. forEach()의 콜백 함수마다 고정된 i 값을 갖고 있어서 가능한 방식이다.

const number = [1, 3, 5, 7];
number.forEach((num, i) => {
	setTimeout(() => {
		console.log(num, i);
	}, 1000 * (i + 1));
});

반복문과 var를 사용할 때 항상 스코프 관련 문제가 생기는 것은 아니다. setTimeout() 같은 비동기 함수와 반복문, var가 만나면 이런 문제가 발생한다. 이러한 현상을 잘 알고 있으면 좋겠지만, 사실 let을 쓰는 것이 가장 편하다. 그래서 요즘은 대부분 var 대신 let을 사용한다.

마지막으로 하나 더, 스코프를 알아 두어야 할 때가 있다. switch 문을 사용하는 상황이다.

const type = 'a';
switch (type) {
	case 'a':
		let name = '제로초';
		break;
	case 'b':
    		let name = '레오';
		break;
	case 'c':
		let name = '체리';
		break;
}
Uncaught SyntaxError: Identifier 'name' has already been declared

case 내부에 변수를 선언하고 있다. 'a', 'b', 'c' 모두 name이라는 이름의 변수를 생성하는데, 같은 블록 스코프 안에서 같은 이름의 변수를 선언하기 때문에 에러가 발생한다. 블록 스코프는 현재 switch 문 하나밖에 없다. 이럴 때는 각자 블록 스코프를 생성하면 해결된다.

const type = 'a'
switch (type) {
	case 'a': {
		let name = '제로초';
		break;
	}
	case 'b': {
		let name = '레오';
		break;
	}
	case 'c': {
		let name = '체리';
		break;
	}
}