javascript 콜백사용의 위험성(클로저 그리고 let와 for문)

들어가기

콜백의 비동기적 프로그래밍을 사용할때 주의할점이 좀 있다. 자바스크립트의 클로저와 함께 사요할때 콜백사용의 주의점을 정리해보겠다.

비동기 콜백사용시 클로저영역 변수 사용의 위험성

비동기적 실행에서 혼란스럽고 에러도 자주 일어나는 부분은 스코프와 클로저가 비동기적 실행에 영향을 미치는 부분이다. 함수를 호출하면 항상 클로저가 만들어진다. 매개변수를 포함해 함수 안에서 만든 변수는 모두 무언가가 자신에 접근할 수 있는 한 계속 존재한다.

일단 아래 코드를 보고 실행결과를 예측해보자.

1
2
3
4
5
6
7
8
9
10
11
function countdown(){
let i;
console.log("\nCountdown start...");
for(i =5; i >= 0; i--){
setTimeout(function(){
console.log(i===0 ? "GO!" : i);
}, ( 5 - i ) * 1000);
}
}

countdown();

위 코드의 실행 결과

위 코드의 실행결과를 예측했다면, 이미 당신은 자바스크립트 클로저 마스터! 결과를 보면 GO! 라는 문구는 아예 찍히지도 않고 -1만 6번 찍히는것을 확인 할 수 있다.

setTimeout의 호출에 사용되는 i는 정상적으로 5-5, 5-4, 5-3, 5-2, 5-1, 5-0 총 여섯번 호출된다.

문제는 setTimeout에 등록된 콜백의 호출시점과 이 콜백이 접근하는 i가 속한 클로저의 위치이다. let으로 선언된 변수는 블록 스코프에 소속된다. i는 현재 countdouwn 함수의 블록스코프에 소속되어 있으므로, 정작 setTimeout함수의 등록된 콜백이 실행될 시점에는 for 문에 의해 i의 값은 -1상태가 되어버린 상태이다. 따라서 위와 같은 결과가 나오게 된다.

위 코드에서 let i의 선언 위치를 countdouwn 함수의 블록스코프가 아니라, setTimeout을 호출하는 for문의 statement에 선언하면 정상적으로 코드가 동작하다.

1
2
3
4
5
6
7
8
9
10
11
function countdown(){
//let i;
console.log("\nCountdown start...");
for(let i =5; i >= 0; i--){
setTimeout(function(){
console.log(i===0 ? "GO!" : i);
}, ( 5 - i ) * 1000);
}
}

countdown();

위 코드의 실행 결과

결과를 보면 카운팅이 정상적으로 되고 GO가 콘솔에 찍히는 것을 확인 할 수있다.

하지만 난 위 코드가 정확히 이해가 가지 않는다. 비록 let i 선언이 for문의 스코프에 선언되었지만, 정작 setTimeout의 콜백이 호출되는 시점은 for문이 종료된 시점 이다. 즉 이번에도 콜백에서 사용하는 i는 for문의 statement에 선언되었지만 여전히 -1이라고 생각 했었다.

여기에 대해 찾아보니 for문에서 let 변수를 선언했을때의 특이점이 있다. for문의 Statement에 let로 선언된 변수는 for문의 반복횟수 만큼의 클로저 영역을 생성한다. 음 함수가 아니라 블록스코프도 클로저라고 불러도 되는지는 잘 모르겠지만...

즉 위의 코드에서 setTimeout이 호출되는 시점에 전달되는 for문 statement에 선언된 let i는 총 6번 전달되며, 그 시점별로 6개의 클로저가 생성된다. setTimeout에 전달된 콜백은 자신이 setTimeout으로 호출될 당시의 for반복문의 클로저의 i 에 접근하게 되는 것이다.

물론 반복횟수별로 일종의 클로저 영역이 생성되므로, 브라우저 별로 성능저하가 있다고는 한다.

햇갈리지 말아야 하는 점은 for문의 블록스코프에 선언된 let가 아니라 for문의 statement에 선언된 let가 위처럼 반복횟수별로 클로저를 생성한다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function countdown(){
//let i;
console.log("\nCountdown start...");
for(let i =5; i >= 0; i--){
console.log("---i:"+ i);
let a = 0;
setTimeout(function(){
console.log("----a:" + a++);
console.log(i===0 ? "GO!" : i);
}, ( 5 - i ) * 1000);
}
}

countdown();

위 코드의 실행 결과

콜백이 a에 접근할 때에는 이미 for문의 동작이 모두 끝난 상태이다. 그리고 for문의 블록스코프에 선언된 let a는 반복횟수 만큼 생성되는 클로저의 영역에 포함되지 않는다.

마무리

promise등 콜백의 대안이 있긴 하지만, 아직도 콜백은 많이 사용되는 것 같다. 콜백을 사용할때 콜백이 접근할 영역의 스코프가 정확히 어디인지 주의하며 코딩할 필요가 있다. 끝!