setTimeout으로 setInterval 함수 구현하기

joonseokhu·2020년 4월 11일
4

모 커뮤니티에서 이제 자바스크립트를 막 배우신듯한 분이 setTimeout을 가지고 주기적으로 반복되는 함수를 만드시려고 끙끙대시다 안풀려서 질문을 올린 글을 보게 되었다.

setInterval을 쓰면 된다고 알려드리는 글을 쓰고나서 문득 생각해보니 setTimeout만 가지고도 주기적인 반복을 다룰 수 있겠다는 생각이 들었다.

그래서 한번 만들어보기로 했다.

비동기 코드 이후 비동기를 실행하는 법

setTimeout은 비동기 함수이다. 자바스크립트에서 비동기는 순차적인 코드 실행 순서에 영향을 받지 않는다. 비동기 코드를 실행하면서 그 순서를 보장받고 싶으면 해당 비동기 함수가 제공하는 규칙을 따라야 한다. 전통적으로 그 규칙은 보통 콜백 이다. setTimeout 역시 비동기 완료시점에 실행되는것이 보장되어야 하는 코드는 콜백에 넣도록 정해져있다.

setTimeout(() => {
  console.log('2초가 지났다.')
}, 2000);

만약 어떤 비동기 작업 이후에 또다른 비동기작업을 실행시키고 싶다면 전자의 콜백에서 후자를 실행시켜야 한다.

setTimeout(() => {
   console.log('2초가 지났다.')
   setTimeout(() => {
     console.log('그리고 다시 2초가 지났다.')
   }, 2000)
}, 2000)

두번째 비동기 작업 이후 세번째로 실행될 비동기 코드가 있다면, 비동기함수의 콜백 안쪽에 실행시켰던 비동기함수의 콜백 안쪽에서 실행시켜야 한다.

이런식으로 비동기 콜백 다섯개가 순차적으로 실행되도록 만들려고 하면 이렇게 된다.

setTimeout(() => {
  console.log('2초가 지났다.')
  setTimeout(() => {
    console.log('또다시 2초가 지났다.')
    setTimeout(() => {
      console.log('또다시 2초가 지났다.')
      setTimeout(() => {
        console.log('또다시 2초가 지났다.')
        setTimeout(() => {
          console.log('또다시 2초가 지났다.')
        }, 2000)
      }, 2000)
    }, 2000)
  }, 2000)
}, 2000)

흔히 불리는 callback hell의 형태가 되었다. 그리고 우리는 반복적인 작업을 시키려고 하는데, 하다보니깐 반복해야 하는 바로 그 코드를 우리가 직접 여러번 치고 있다.

문자그대로 삽질이 되었다.

재귀함수를 통한 해결

재귀함수란 스스로를 호출하는 함수를 말한다.

// 실행해보지 말자. 브라우저에서 실행시킬 경우 브라우저가 종료될 수도 있다.
const recurse = () => {
  console.log('실행됨')
  recurse()
}

재귀함수는 한번 호출되면, 함수 스스로의 코드가 다시 스스로를 호출하게 된다. 그리고 그건 또다시 스스로 호출하게 되며, 이게 무한히 반복되는 코드이다. 물론 실제로 재귀함수를 사용하는 상황에서는 재귀문을 조건문 안에 넣는다거나 하는 식으로 도중 어느 조건에서 재귀가 중단되도록 만든다.

보통 재귀함수는 잘 쓰이지 않는다. 무한반복이 필요하다면 for/while 문으로도 충분히 구현할 수 있는데다가, 가독성 면에서나 성능면에서 for/while 문은 재귀함수보다 더 좋게 평가된다.

하지만 우리가 구현하려고 하는 기능은 순차적 로직으로는 구현이 불가능하다. for/while 문은 무조건 각각의 반복적인 작업이 한번에 이루어져야 하며, 이는 우리가 만들려고 하는 것과 성격이 다르기 때문에 기본적으로 재귀를 통해서만 반복적인 작업을 만들어야 한다.

기본 형태

  const myInterval = () => {
    setTimeout(() => {
      console.log('실행됨')
      myInterval()
    }, 1000)
  }

실행

myInterval()

함수를 새로 만들고, 그 안에서 setTimeout 을 실행시킨뒤, 완료시 콜백에 재귀하면 아주 간단한 나만의 setInterval 함수가 만들어진다.

바깥에서 인자 받아서 사용하기

  const myInterval = (callback, interval) => {
    setTimeout(() => {
      callback()
      myInterval(callback, interval)
    }, interval)
  }

실행

myInterval(() => {
  console.log('실행')
}, 500)

setInterval 함수는 첫번째 인자로 매 반복시점마다 호출할 함수를 쓰도록 하며, 두번째 인자로 반복되는 간격을 쓰도록 한다. 그에 맞게 우리의 setInterval도 인자로 콜백과 반복 간격을 받도록 만든다. 인자로 받은 콜백은 아까 작성했던 setTimeout의 완료 콜백 안에서 실행시켜 주고, setTimeout의 지연시간에 인자로 받은 반복간격을 넣는다. 함수를 재귀호출 하고있는 코드에도 인자로 받은 callbackinterval값을 넣어주자. 재귀호출된 함수는 기존함수와 구분되는 새로운 변수스코프를 가진다. (단, 이전 함수의 변수스코프가 종료되는것은 아니다.) 그렇기 때문에 재귀호출되는 함수에도 인자를 전달해서 다시 넣어줘야 인자를 받을 수 있다..

이정도만으로도 기본적인 setInterval의 기능은 완성했다. 한가지 빠진게 있는데, 저 반복 작업을 종료하는 방법이 없다는 것이다.

재귀함수가 멈추지 않고 무한히 반복되고는 있지만 재귀를 시키는 코드가 setTimeout에 의한 매번 지연되고 있기 때문에, 런타임이 마비될 걱정은 없다. 하지만 특정 조건에서 멈추는 기능을 필요로 할 수도 있으니 만들도록 해보자.

함수 한번 더 묶기

원래는 myInterval 함수 스스로가 재귀호출을 하고 있었지만, 조금 변형해보자.

myInterval 안에 tick 이라는 또하나의 함수를 만들고, 원래 하던 setTimeout과 재귀호출을 myInterval이 아닌 tick이 하도록 만든다.

myInterval 내에서 아무도 tick을 실행시키지 않고 있기 때문에, tick을 한번 실행해주는 코드도 써준다.

  const myInterval = (callback, interval) => {

    const tick = () => {
      setTimeout(() => {
        callback()
        tick()
      }, interval)
    };
    
    tick();
    
  }

이제 바깥을 감싸고 있는 myInterval은 재귀함수가 아니라 일반함수가 되었다. 매번 새로 실행되는게 아니므로, 인자로 받아오던 callback, interval 값은 tick을 재귀호출할 때 따로 넣지 않아도 유지된다. tick은 매번 새로 호출될때마다 자신의 바깥 스코프인 myInterval에서 필요한 값을 그냥 가져오면 된다.

클로져 사용하기

바깥쪽 함수인 myInterval이 일반함수가 되어 함수스코프 변수가 매번 초기화 되지 않기 때문에 여기엔 변수를 저장해둘 수도 있고, 중간에 바꿀 수도 있다.
myInterval 함수가 호출될 때 함수를 리턴하게 하고 그 함수를 통해 myInterval의 함수스코프 값을 변경해보자.

  const myInterval = (callback, interval) => {
    let flag = true;
    
    const tick = () => {
      setTimeout(() => {
        console.log({ flag })
        callback()
        tick()
      }, interval)
    };
    
    tick();
    
    const stop = () => {
      flag = false;
    }
    
    return stop;
  }

호출하면서 myInterval의 리턴값을 변수에 담으면 클로져 함수가 담긴다.

실행

const stopper = myInterval(() => {
  console.log('실행')
}, 800);

setTimeout(() => {
  console.log('인터벌 실행 도중 받아온 클로져함수를 실행해보기')
  stopper();
}, 5000)

함수스코프 변수엿던 flag의 값이 도중에 false로 바뀌며, 재귀호출되고 있던 tick 함수 안쪽에서도 그게 반영된다.

이제 setTimeout 의 콜백에서 flag의 값이 false가 되면 재귀를 중단하게 만든다.
if/else 문으로도 처리할 수 있지만, 그냥 if 문만 쓰고 리턴해버려도 된다.
사실 이게 더 보기에 깔끔하기 때문에 모 책에선 비동기 콜백에서 조건문 블록 대신 이런식으로 쓰라고 권장하고 있다.

  const myInterval = (callback, interval) => {
    let flag = true;
    
    const tick = () => {
      setTimeout(() => {
        console.log({ flag })
        if (!flag) return;
        
        callback()
        tick()
      }, interval)
    };
    
    tick();
    
    return () => { flag = false };
  }

실행

const stopper = myInterval(() => {
  console.log('실행')
}, 800);

setTimeout(() => {
  console.log('인터벌 실행 도중 받아온 클로져함수를 실행해보기')
  stopper();
}, 5000)

진짜 setInterval은 리턴값으로 클로져를 주는게 아니라 프로세스 아이디를 주며, clearInterval 함수로 인터벌을 해제하도록 만들어져있지만, 우리는 실행환경의 프로세스까지 직접 건드릴수 있는 권한은 없으니 오히려 좀 더 편한 방법인 클로져를 통해 인터벌 종료를 구현해봤다.

더 나아가서

기왕 나만의 인터벌 함수를 만드는거, 기존 setInterval에는 없는 유용한 기능들을 더 만들어보자.

반복횟수 설정할 수 있게 하기

n회 반복되고 나면 자동으로 종료되는 기능을 추가해보자. 우선 세번째 파라미터로 횟수를 입력받도록 한다. 이름은 times로 정했다.

재귀함수가 호출을 반복할때마다 지금까지 몇번 반복되었는지 계속 기억해주는 코드가 필요하다.
앞서 flag 변수가 그랬듯이, 값을 저장하려면 변수가 바깥쪽 함수에 있어야겠다. 한번도 반복되지 않은 상태로 시작해 점점 늘어날 것이기 때문에 초기값은 0이다.

  const myInterval = (callback, interval, times) => {
    let flag = true;
    let count = 0;
    
    const tick = () => {
      setTimeout(() => {
        if (!flag) return;
        
        callback()
        tick()
      }, interval)
    };
    tick();
    return () => { flag = false };
  }

tick 함수 안쪽 setTimeout의 콜백에서, count의 값을 하나씩 늘려주면 된다.
그리고, count의 값이 times에 다달았을 때, 재귀문이 멈추도록 만들면 된다.
또, myInterval 함수를 호출할 때, 최대 반복횟수를 따로 정하지 않을 수도 있다. 그때는 반복횟수와 상관없이 반복해야 하기 때문에 고려하면서 조건문을 만든다.

  const myInterval = (callback, interval, times) => {
    let flag = true;
    let count = 0;
    
    const tick = () => {
      setTimeout(() => {        
        if (!flag) return;
        if (times && count >= times) return;

        console.log({ count })
        callback()
        tick()
        
        count++
      }, interval)
    };
    tick();
	return () => { flag = false };
  }

실행

myInterval(() => {
  console.log('실행')
}, 200, 10);

count 값은 각 반복마다 == 매 재귀마다 서로 다른 값인 셈이 된다. 그 부분을 생각하면 바깥 함수의 상태 변수가 아닌 재귀함수인 tick의 인자를 매번 늘려주는 식으로도 작성 가능하다.

  const myInterval = (callback, interval, times) => {
    let flag = true;
    
    const tick = count => {
      setTimeout(() => {        
        if (!flag) return;
        if (times && count >= times) return;

        console.log({ count })
        callback()
        tick(count + 1)
      }, interval)
    };
    tick(0);
    return () => { flag = false };
  }

콜백함수에 인자 전달

반복할때마다 그게 몇번째 반복인지 알수 있게 되었으니, 이걸 콜백에게 인자로 넘겨주면 좋을것 같다. 원래 setInterval 함수의 콜백은 기본적으로 인자에 아무런 값도 들어오지 않는다. setInterval 함수를 실제로 사용할땐 지금 몇번째 반복인지 저장해두기 위해 상위 스코프에 굳이 반복 횟수를 새는 변수 하나를 더 만들어 써야하는데, 우리의 인터벌 함수에선 이럴 필요가 없게 만들어 줄 수 있다.

  const myInterval = (callback, interval, times) => {
    let flag = true;
    
    const tick = count => {
      setTimeout(() => {        
        if (!flag) return;
        if (times && count >= times) return;
        
        callback(count);
        tick(count + 1)
      }, interval)
    };
    tick(0);
    return () => { flag = false };
  }

실행

const stop = myInterval(count => {
  console.log(`현재 ${count}번째 반복중`)
}, 200, 10);

반복이 종료되었을 때를 위한 또 하나의 콜백

매 반복마다 호출되는 콜백 외에, 반복이 완전히 종료되었을때를 위한 콜백을 만들어보자.
종료시점을 횟수 등에 의해 사용자가 명시하지 않고, 사용자가 제어하지 못하는 어떤 요소(예를들어 외부 요소에 의해 트리거되는 이벤트)에 의해 stop함수가 실행되도록 코드를 작성할 수도 있다. 그리고 종료시점에 맞춰서 임의의 동작을 하는 함수를 호출하고 싶을지도 모를 일이다.

네번째 파라미터 end로 인터벌 종료시 콜백을 받아보자

  const myInterval = (callback, interval, times, end) => {
    let flag = true;
    
    const tick = count => {
      setTimeout(() => {        
        if (!flag) return;
        if (times && count >= times) return;
        
        callback(count);
        tick(count + 1)
      }, interval)
    };
    tick(0);
    return () => { flag = false };
  }

인터벌의 종료시점은 근데 우리가 어떻게 알 수 있을까? setTimeout 콜백 안에 있는 return 문이 바로 종료시점이다. return 될 때 end 함수를 호출해주자.
end 함수는 사용자가 따로 지정하지 않았을 수도 있으니 처리를 한번 해주자.

  const myInterval = (callback, interval, times, end) => {
    let flag = true;
    
    const tick = count => {
      setTimeout(() => {
        if (!flag) return (end && end(count));
        if (times && count >= times) return (end && end(count));
        
        callback(count);
        tick(count + 1)
      }, interval)
    };
    tick(0);
    return () => { flag = false };
  }

실행

const stopInterval = myInterval(count => {
  console.log(`현재 ${count}번째 반복중`)
}, 200, 20);

setTimeout(() => {
  stopInterval()
}, 2400)

@todo 등차간격 반복이 아닌 교차간격 반복하기
@todo 이벤트에미터 패턴 적용
@todo 메서드체이닝 적용

profile
풀스택 집요정

1개의 댓글

comment-user-thumbnail
2022년 4월 29일

도움이 많이 됐습니다! 좋은글 감사합니다!
setInterval 타이머 오차가 커서 setTimeout 방식으로 변경했습니다.
근데 사이트 로딩 직후 및 초기에는 정확한데
그 이후로 시간이 좀 지나면 오차가 많이 생깁니다.(setInterval 보다는 오차 많이 줄음)
원래 js 타이머는 어느정도 오차를 감수하고 사용해야 하나요?

답글 달기