JavaScript

[JavaScript] 클로저(Closure) 2편

Hoon1994 2022. 10. 27. 10:46

JavaScript

 

✏️ Closure에 대해서 조금 더 알아보자

 

예전에 자바스크립트 클로저에 대해 간단하게 글을 올린적이 있다. 

오늘은 클로저에 대한 예시 코드를 조금 보며 정리를 하려고 한다.

 

사람마다 다르겠지만 클로저의 정의를 내린다면 나는 이렇게 말할 것 같다.

"선언될 때의 환경을 기억하고, 실행의 종료된 함수 안에 변수에 접근할 수 있는 것"

 

const getIncrementCountFunc = () => {
    let count = 0;
    return () => {
    	return ++count
    }
}

const incrementCount = getIncrementCountFunc()

incrementCount() // 1
incrementCount() // 2
incrementCount() // 3

 

getIncrementCountFunc이 실행되면 익명 함수를 리턴하고 종료되기 떄문에 변수 count 또한 더 이상 유효하지 않게 된다.

하지만 해당 함수를 실행해보면 정상적으로 작동하고, 값도 최신 값으로 반영되어 로그에 출력된다.

 

getIncrementCountFunc에 대한 컨텍스트가 종료되고 해당 컨텍스트에서 생성된 변수 또한 더 이상 참조되지 않으면 가비지 컬렉터의 수거 대상이 되어 메모리에서 지워야 하는데, incrementCount 함수에서 count를 참조하고 있기 때문에 가비지 컬렉터의 대상이 되지 않는다. 

 

클로저를 의도하고 사용한 것이라면 개발자가 의도한 것이기 때문에 클로저 함수 사용 후 null을 할당하거나 하면 되지만,
만약 클로저에 대한 이해가 없고 자신이 사용했는지 사용했는지 모르는 상태에서 클로저 패턴이 계속 사용된다면 그건 메모리 누수가 발생할 수 있어 개발자로써 클로저에 대한 이해는 꼭 필요한 부분인 것 같다.

 

다른 예제를 한번 보자.

 

const funcs = [];

for(var i = 1; i <= 10; i++) {
	funcs.push(() => { console.log(i) });
}

for(var j = 0; j < 10; j++) {
	funcs[j]();
}

 

빈 배열을 하나 만들고, for문을 2번 돌렸다.

첫번째 for문에서는 익명 함수에서 i 값을 출력하게 작성하여 배열에 push 했다.

두번째 for문에서는 배열에 들어가있는 함수를 차례대로 실행시켰다.

 

코드만 봤을 때, 콘솔에 출력되는 값은 1,2,3,4.... 차례대로 출력이 될 것처럼 보인다.

하지만 실제 출력되는 값은 11이 연속으로 출력된다.

 

11이 연속으로 출력되는 이유는 var가 블록 스코프가 아닌 함수 스코프라서, for 블록 내부가 아닌 외부에 선언이 되어버린다.

이 부분은 호이스팅과도 연결 고리가 있는데, var는 함수 스코프고 호이스팅 대상이라서 선언부가 상단에 위치하게 된다.

그렇기에, funcs[j]()를 실행 했을 때의 i는 전역 변수인 i를 바라보게 되고 이 i는 for문이 다 돌았기 때문에 11이 되어 있는 상태인 것이다. 

 

// 위 코드가 실행되는 과정

var i; // undefined, 선언 및 초기화
var j; // undefined, 선언 및 초기화
const funcs // 선언 o 초기화 x 선언 이후 TDZ 생성으로 변수 초기화 전까지 접근 시 오류 발생

const funcs = []; // 초기화 

for(i = 1; i <= 10; i++) { 
	funcs.push(() => { console.log(i) });
}

for(j = 0; j < 10; j++) {
	funcs[j]();
}

 

그럼 이 부분을 해결하려면 어떻게 해야할까? 

첫번째로, 엄청나게 간단한 방법이다. var를 let으로 바꿔주면 된다.

 

const funcs = [];

for(let i = 1; i <= 10; i++) {
	funcs.push(() => { console.log(i) });
}

for(var j = 0; j < 10; j++) {
	funcs[j]();
}

 

첫번째 for문의 var i를 let i로 바꿔주면, 1,2,3,4... 순차적으로 출력이 되어 해결이 된다. 

let은 var와 달리 블록스코프이고, for {} 안에서만 접근 가능한 지역 변수가 된다.

var i로 선언 했을 때는 for문 외부에서 i 접근이 가능하지만, let은 접근이 되지 않는다.

 

블록스코프이기 때문에, for문이 실행될 때만 유효한데 funcs에 익명 함수로 i를 참조하게 하기 때문에 이는 클로저라고 볼 수 있다.

for문이 종료되면 i 또한 for문의 컨텍스트에 포함되기 때문에 컨텍스트가 종료된 후에 가비지 컬렉터의 대상이 되어야 하지만 
익명 함수에서 참조하고 있고, 이 익명 함수가 funcs에 보관되고 있기 때문에 가비지 컬렉터의 대상이 되지 않는다.

 

상단에 나는 클로저를 이렇게 정의했다. "선언될 때의 환경을 기억하고, 실행의 종료된 함수 안에 변수에 접근할 수 있는 것"

let으로 선언한 i는 for문의 컨텍스트에 포함되지만 funcs.push 되는 익명 함수 안에서 참조하고 있고, 이 익명 함수가 선언될 때의 환경을 기억하고 있기 때문에 실행이 종료된 함수 안에 변수 (for문 안에 let i)에 접근할 수 있게 되는 것이다. 

 

첫번째 for문을 돌 때 i는 1로, funcs[0]은 이 환경을 기억하고 있다. 

두번째 for문을 돌 때 i는 2로, funcs[1]은 이 환경을 기억하고 있다. 

 

let으로 바꾸는 것 말고, var를 사용하면서 해결할 수 있는 방법은 없을까? (var는 최대한 지양해야 하나 학습을 위해서 생각해봤다)

var 또한 클로저를 이용해서 해결을 할 수 있다.

 

const funcs = [];

for(var i = 1; i <= 10; i++) {
	(function(arg) {
		funcs.push(() => { console.log(arg) });		
	})(i)
}

for(var j = 0; j < 10; j++) {
	funcs[j]();
}

 

즉시실행함수(IIFE)에 감싸주면 해결이 된다. arg는 지역 변수이다. 위 let 예시와 마찬가지로 지역 변수이기 때문에 즉시실행함수가 종료되면 더 이상 유효하지 않게 되나, funcs에 push 해주는 익명 함수가 이를 참조하고 있어 클로저가 된다. 방식이 조금 다르지만, let으로 변경되었을 때 동작하는 원리와 비슷하다고 볼 수 있다. 

 

마지막 예시만 하나 더 보자.

 

for(var i = 1; i <= 10; i++) {
	setTimeout(() => {
		console.log(i)
	}, i * 1000);
}

 

위 코드를 실행했을 때, 1,2,3,4....로 실행될까? 겉으로만 보면 그럴 수 있지만, 아니다.

11이 10번 출력된다. 왜일까. 여러가지 이유가 섞여있다.

 

우선 setTimeout은 비동기적으로 실행된다. setTimeout은 Web API로 실행되면 Web API로 전달된다. 정해진 시간동안 Web API에서  처리하게 되며 정해진 시간이 지나면 setTimeout에 전달된 익명 함수를 Task Queue로 보낸다. 이벤트 루프는 콜스택이 비어있는지 보다가 Task Queue에 있는 익명 함수를 콜스택으로 보내게 된다. 

 

잠시 비동기 실행에 대한 이야기로 넘어갔는데.... 비동기적인 이유도 있지만, 위에 설명한 것처럼 var는 함수 스코프이기 때문에 for {} 내부가 아닌 외부에 선언이 된다. 그렇기 때문에 setTimeout에 전달된 익명함수는 전역 변수인 i를 바라보게 되고, 11이 10번 출력되는 것이다.

 

해결 방법은 다른 예시의 해결 방법과 다르지 않다.

 

// 1. let으로 변경
for(let i = 1; i <= 10; i++) {
	setTimeout(() => {
		console.log(i)
	}, i * 1000);
}

// 2. 지역 변수 생성
for(let i = 1; i <= 10; i++) {
	(function(arg){
		setTimeout(() => {
			console.log(arg)
		}, i * 1000);
	})(i)
}

 

이번 포스팅에서는 클로저에 대해서 조금 더 알아봤다. 프론트엔드라면 꼭 알아야 할, 그런 개념이라고 생각된다. 

하지만 실무에 집중하다보면, 이런 이론, 개념적인 내용이 조금씩 머리에서 희미해질 때가 있는 것 같다.