만약에 var ducks = ["첫째 오리", "둘째 오리", "셋째 오리", "넷째 오리", "다섯째 오리"] 이런 배열이 있고 이걸 1초 간격으로 하나씩 화면에 뿌리는 페이지를 만드는 상황이라고 가정합니다.

시간을 지연시키는 함수로 setTimeout(function(){...}, delayMillisecond)가 있습니다. 콜백 함수와 지연할 시간을 밀리세컨드 단위로 입력해서 사용합니다. 1초 간격으로 반복하고 싶다면 이 기능을 for문으로 돌리면 될까요?

var ducks = ["첫째 오리", "둘째 오리", "셋째 오리", "넷째 오리", "다섯째 오리"]
var delaySec = 1000;

/* 1: IIFE를 적용하지 않은 일반적인 코드 */
for (i in ducks) {
    setTimeout(() => {
       console.log(ducks[i])
    }, i == 0 ? 0 : delaySec)
}

이 코드는 일반적인 생각대로 실행되지 않습니다. 결과는 1초 뒤에 한꺼번에 ‘다섯째 오리’만 다섯 마리 복제되며 나머지 오리들은 등장하지도 않습니다.

원인은 자바스크립트의 비동기 관련 문제입니다. 말하자면 for문은 setTimeout의 파라미터로 지정된 함수(콜백 함수라 부르며 지금은 특정 작업이 완료된 이후 실행되는 함수라고 이해하시면 됩니다.)이 실행될때까지 기다리지 않고 ‘1초 뒤에 실행시킬 것이다’ 라고 예약만 걸어버린 다음 한 순간에 자신의 작업(반복문)을 완료합니다. 이 for문이 반복되는 시간은 정말 찰나의 시간이며 setTimeout 내부의 콜백 함수들이 실행될 시간은 for문이 끝나고도 한참 뒤의 시간이기 때문에 for문이 가리키는 i의 값은 4(배열의 5번째 주소)입니다. 그렇기 때문에 5개의 setTimeout이 실질적으로 실행될 시점에는 콜백 함수들은 전부 i4 를 참조하고 있으므로 마지막 배열만 출력되는 것이며 실행 시점도 의도한 바와 다르게 1초 뒤에 일괄적으로 실행되는 것입니다.

 

이 문제를 해결하는 전통적인 방법으로 IIFE(Immediately-Invoked Function Expression; 즉시 실행 함수 표현)이라는 것이 있습니다.  말 그대로 선언된 시점에 바로 실행하는는 것입니다.  표현식은 (function () { // TODO  })(); 으로 작성됩니다. 보기 쉽게 함수 부분을 임의의 변수로 치환한다면 (변수)();의 꼴이라고 볼 수 있습니다. 익명의 함수를 작성했는데 작성하자마자 바로 실행한 것과 같은 효과가 생깁니다. 위의 코드에서 IIFE 를 적용하면 다음과 같습니다.

/* 2: IIFE를 적용한 코드 */
for (i in ducks) {
    (function(ii) {
        setTimeout(() => {
            console.log(ducks[ii])
        }, ii * delaySec)
    })(i);
}

setTimeout문을 IIFE 로 한 번 감싼 다음 파라미터로 끝에 (i)를 부여했습니다.  IIFE를 사용하지 않은 코드는 지연 실행된 콜백 변수들이 모두 변수 i를 가리키지만, IIFE를 사용한 코드는 반복이 실행될 때마다 setTimeout을 감싼 익명 함수가 변수처럼 취급되어 실행되고, 익명 함수 내부의 변수 ii가 가리키는 곳은 각각 함수 내에서 파라미터로 부여받은 지역 변수입니다. 좀 더 이해하기 쉬운 예는 다음과 같고 결과 및 동작원리는 동일합니다.

/* 2: IIFE를 적용한 코드 */
for (i in ducks) {
    var anonFunc = function(ii) {
        setTimeout(() => {
            console.log(ducks[ii])
        }, ii * delaySec)
    }
    anonFunc(i);
}

인덱스 문제는 해결되었지만 delaySec을 고정된 수로 입력하면 여전히 동시에 실행될 것입니다. 실행 시점은 여전히 찰나의 순간이며 모든 익명 함수들이 1초 이후에 실행되도록 설정되어있기 때문입니다. 이것을 delaySec에 for문의 ii를 곱해 그 시간만큼 순차적으로 이어진 효과를 내도록 바꿨습니다. 배열의 주소는 0부터 시작하므로 처음에는 500 * 0 = 0 초, 그 다음에는 500 * 1 = 500 … 이런식으로 진행됩니다.

(2020년 6월 7일 추가: 이 문제는 ES6 이전의 var 변수의 함수 스코프와 연관이 있으며, ES6로 개발 가능한 환경인 경우 ES6에 추가된 let 키워드를 사용하면 IIFE 기법을 사용하지 않아도 됩니다. let은 블록 스코프({...})이기 때문입니다. 이 글 작성 당시 ES6 스펙과 새롭게 추가된 let, const에 대한 스코프에 대한 이해도가 낮아 잘 알지 못했습니다. 변수의 스코프에 관한 글 링크)

(또한 IIFE는 함수 스코프인 for문의 i(var 키워드 또는 전역변수로 선언된 경우) 변수를 함수 내에 고정시키기 위한 클로저 기법의 대표적인 예 중 하나입니다.)

for (let i in ducks) {
    setTimeout(() => {
        console.log(ducks[i])
    }, i * delaySec)
}

 

다른 방법으로 재귀함수를 이용한 방법이 있다고 합니다. (https://gigglehd.com/gg/soft/252878)

/* 3: [심화] 재귀함수를 이용한 방법 */
function recursive(i) {

    if (i < ducks.length) {

        setTimeout(function() {
            console.log(ducks[i])
            i++;
            recursive(i) // 여기에 i++를 적으면 무한 루프에 빠진다 (++i 사용)
        }, i == 0 ? 0 : delaySec)

    }

}
recursive(0)

이러한 자바스크립트의 비동기 문제를 해결하는 다른 방법으로 콜백, Promise, async ~ await 등이 있습니다. 관련 글 자바스크립트: 콜백, Promise, async ~ await 기초 을 참고해주세요.

문의 | 코멘트 또는 yoonbumtae@gmail.com




1개의 댓글

cjoo · 2019년 10월 9일 11:29 오전

잘 읽었습니다. 여담이지만 주어진 예제에서 let 키워드를 쓰는 방법도 있습니다. 🙂

for (let i in ducks)

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다