본문 바로가기

prev/NextJS

GiftWave 프로젝트/휴대폰인증 (setInterval, useInterval)

회원가입 페이지에서 휴대폰 인증 버튼 클릭 시, 인증번호 입력에 대한 대기시간을 만들려했다.

기능은 다음과 같다.

  1. 휴대폰 번호를 누르고 인증요청 버튼을 클릭하면 타이머가 작동한다.
  2. 일정시간 동안 타이머는 작동하고, 시간안에 인증번호를 입력해야된다.
  3. 시간이 종료되면, 인증번호를 다시 요청해야되고, 재요청 클릭시 타이머가 다시 작동한다.

그러면서 setInterval에 대해 알아갈 시간을 가졌고, useInterval이라는 Custom hook을 만들 수 있다는 글을 보게 되었다.
왜 setInterval 말고 useInterval을 쓰면 좋을지와 그 내용을 바탕으로 내가만든 useInterval을 소개하려한다. 

 

 

아래 글들은 두 블로그 글을 보면서 내가 직접 테스트해보고 퍼온 내용이다. 참고하기 바랍니다. 

https://mingule.tistory.com/65

 

React에서 setInterval 현명하게 사용하기(feat. useInterval)

들어가기 Babble의 방 목록 페이지에 들어가면 유저가 생성한 방들이 쭉 나열되어 있는 것을 볼 수 있다. (안타깝게도 유저가 없으면 방도 없다ㅜㅜ) 그리고 이 방들은 5초마다 서버에 요청을 보내

mingule.tistory.com

https://overreacted.io/making-setinterval-declarative-with-react-hooks/

 

Making setInterval Declarative with React Hooks

How I learned to stop worrying and love refs.

overreacted.io

 

 

Setinterval의 이상한 동작

function Counter() {
  let [count, setCount] = useState(0);

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  });

  return <h1>{count}</h1>;
}

보통의 subscription API들은 이렇게 새로 함수가 실행되면, 이전의 subscription을 해제하고 새로운 subscription을 만드는데 setInterval은 그렇지 못하다. 우리가 만든 interval을 해제하기 위해서는 clearInterval을 사용해 직접 Timer를 해제하야한다. 

위에 작성된 코드도 마찬가지로 이러한 이유에서 페이지가 unmount될 때 clearInterval을 해주고 있다. 

 

 

이 코드는 잘 작동할까? 이 코드에는 이상한 동작이 있다. 

setInterval은 사실 우리가 원하는 delay 시간을 100% 보장하지 못하기 때문이다. 

setInterval은 함수를 실행하는 시간조차 delay에 포함시키기 때문에, 만약 함수를 실행하는 시간이 delay 시간보다 길다면 타이머가 제대로 작동하지 않는다. 이런 상황에서 setInterval은 함수의 실행이 종료될 때까지 기다렸다가 함수의 실행이 종료되면 다음 함수를 즉시 실행한다. 1초 마다 한 번씩 함수가 호출되기를 바랬는데, 함수의 실행이 1초보다 길어져버리면 함수가 실행이 끝난 후에 1초를 기다리지 않고 다음 함수를 바로 실행해버린다는 뜻이다.

 

 

아래는 Javascript.info에서 가져온 관련 사진인데, 그림을 보면 더 이해하기 쉽다.

https://ko.javascript.info/settimeout-setinterval

 

 

문제 2. 예기치 못한 Closure

import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  });

  return <h1>{count}</h1>;
}

const rootElement = document.getElementById("root");
setInterval(() => {
  ReactDOM.render(<Counter />, rootElement);
}, 1000);

위와같이 코드를 만들어놓고 실행을 해보면, timer가 정상적으로 작동하지 않는걸 확인해볼 수 있다. 

 

 

이렇게 되는 이유는 useEffect가 첫 render에 count를 capture하기 때문이다. useEffect는 처음 mount되었을 때, setInterval 동작을 실행시킨다. 즉, count가 0일 때 setInterval을 실행시키는 것이다. 여기까지 아무 문제가 없다고 생각할 수 있지만, 여기를 잘 살펴보아야 한다. Closure 개념과 Event Loop를 통한 setInterval의 동작을 한 번 생각해보면 쉽게 이해할 수 있다. 

 

먼저, Closure의 개념에 대해 간단히 생각해보자. 말 그대로, 갇혀있다. 라는 뜻으로 생각해보면 이해하기 좋다.

어떤 내부 함수를 감싸는 외부 함수가 실행되고나서 종료되었다 하더라도, 내부 함수에서 외부 함수의 값에 접근할 수 있는 현상을 Closure라고 한다. 나를 감싸는 외부 함수(setInterval)는 이미 종료되어 사라졌는데, 내(setCount)가 계속 그 값을 기억하고 있는 것이다. 

요 개념을 되뇌이며 아래 이벤트 루프로 넘어가보자.

 

브라우저에서 자바스크립트의 실행 과정을 한 번 간단히 떠올려보자.

자바스크립트는 single thread 언어이기 때문에 자바스크립트 엔진은 한 번에 하나의 작업만 가능하다. 그렇기 때문에 실행 시간이 오래 걸리는 함수를 호출하게 되면 화면이 멈추게 된다. 브라우저에서는 이러한 문제점을 Web API를 사용해 해결한다. Call Stack 에서 비동기 함수가 호출되면 Web API를 통해 Callback Queue에 쌓이게 되고, 이 Queue는 Call Stack이 비면 실행된다. 

 

setInterval 메소드도 브라우저에서 제공하는 Web API 중 하나이기 때문에 호출되면 바로 실행되지 않고 우리가 등록한 delay 시간을 기다렸다가 Callback Queue에 쌓인다. 그리고 Call Stack이 비면 그때 실행된다. 그리고 실행된 setInterval은 한 번 호출된 후에 바로 종료된다. 이제부터 setInterval이 주기적으로 실행하라고 남겨놓은 setCount 함수가 주기적으로 실행된다. 

 

위의 Closure에서 외부 함수가 종료되어도 내부 함수가 그 함수의 값을 기억한다고 했는데, 이게 바로 그 상황이다. setInterval은 종료되었지만, setInterval의 내부 함수인 setCount가 실행될 때마다 그 초기값이었던 0을 기억하고 계속 + 1을 하는 것이다. 다시 실행될 때에도 마찬가지다. setCount가 기억하는 count는 계속 0이기때문에, 값이 계속 1이 된다.

 

function Counter() {
  let [count, setCount] = useState(0);

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

위함수를 처음보았을 때, 아무 문제 없이 Counter가 증가할것이라고 생각했고, Storybook을 이용해서 테스트를 진행해 봤다. 하지만 결과를 확인했을 때는 count가 계속 1에서 머무는 문제가 발생했다.

 

 

 

 해결하기

setState에 callback 넘겨주기

count가 우리가 원하는 대로 동작하려면, count가 0에서 1이 되고, 1이 된 것을 기억했다가 다시 +1을 해서 2가 되어야 한다. 즉, 바뀌기 전의 state를 기억해야한다. 이를 해결하기 위한 다양한 방법들이 있지만, 가장 쉬운 방법은 setState에 callback 함수를 넘겨주는 것이다. setState에서는 이전의 state를 보장할 수 있는 방법을 제공하고 있다. setState에 callback 함수를 넘겨주면 해결된다. 즉, setCount((previousCount) => previousCount + 1)을 하게 되면 문제가 해결된다.

 

이 밖에도 useReducer를 사용하는 방법도 있지만, 이 게시글에서는 따로 다루지 않겠다. 더 궁금한 사람들은 이 링크의 예시를 보면 좋을 것 같다.

 

그런데 이런 방법을 통해 문제를 당장 해결하더라도 setInterval을 React에서 사용하는 것이 불편한 이유가 한가지 존재한다. react의 lifecycle과 다소 벗어난 행동을 한다는 것이다. state가 바뀌면 React는 리렌더링을 하게 되는데, setInterval은 렌더와 관계없이 계속 살아남아있는다. React는 리렌더링을 하면서 이전의 render된 내용들을 다 잊고 새로 그리게 되는데, setInterval은 그렇지 않다. Timer를 새로 설정하지 않는 이상 계속 이전의 내용(props나 state)들을 기억하고 있다.

 

Storybook Test

"use client";
import { useEffect, useState } from "react";

const CounterTest = () => {
  let [count, setCount] = useState(0);

  useEffect(() => {
    let id = setInterval(() => {
      setCount(preCount => preCount + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
};

export default CounterTest;
//CounterTest.stories.tsx

const meta: Meta<typeof CounterTest> = {
  title: "Atom/CounterTest",
  component: CounterTest,
  tags: ["autodocs"],
};

export default meta;

type Story = StoryObj<typeof CounterTest>;

export const Counter = {
  args: {},
};

 

 

 

useInterval 사용하기

이런 문제점들을 해결하기 위해, useInterval이 나왔다.

useInterval은 react에서 기본적으로 제공하는 hook이 아니라, Dan 형님께서 만든 멋진 custom Hook이다.

import { useState, useEffect, useRef } from 'react';

function useInterval(callback, delay) {
  const savedCallback = useRef(); // 최근에 들어온 callback을 저장할 ref를 하나 만든다.

  useEffect(() => {
    savedCallback.current = callback; // callback이 바뀔 때마다 ref를 업데이트 해준다.
  }, [callback]);

  useEffect(() => {
    function tick() {
      savedCallback.current(); // tick이 실행되면 callback 함수를 실행시킨다.
    }
    if (delay !== null) { // 만약 delay가 null이 아니라면 
      let id = setInterval(tick, delay); // delay에 맞추어 interval을 새로 실행시킨다.
      return () => clearInterval(id); // unmount될 때 clearInterval을 해준다.
    }
  }, [delay]); // delay가 바뀔 때마다 새로 실행된다.
}

이 Hook은 interval을 set하고 unmount 되기 전에 clearInterval을 해준다. 즉, React의 Lifrcycle에 맞게 새로 만든 useInterval이라고 볼 수 있다.

savedCallback을 저장할 때에 state 대신 ref를 사용한 이유

useRef와 useState의 가장 큰 차이점은 리렌더링 유무이다.

setState로 State의 값을 바꾸어주면 함수가 새로 실행되면서 리렌더링이 일어난다.

반면 ref.current에 새로운 값을 넣어주더라도 리렌더링이 일어나지 않는다.

 

 

다시 돌아와서 휴대폰 인증 기능 만들기

앞서 설명한 내용은 내가 만들 기능에 대해서 이해하기 쉽게 설명해 놓은 글이 있어서 옮겨왔다.

저걸 응용해서 나는 Timer를 만들 예정이다.

  1. 휴대폰 번호를 누르고 인증요청 버튼을 클릭하면 타이머가 작동한다.
  2. 일정시간 동안 타이머는 작동하고, 시간안에 인증번호를 입력해야된다.
  3. 시간이 종료되면, 인증번호를 다시 요청해야되고, 재요청 클릭시 타이머가 다시 작동한다.

아래는 완성된 코드이다. 이거에 대해서 하나하나 설명할 예정이다.

 

"use client";

import { useEffect, useState } from "react";
import React from "react";
import { TimerVariant } from "./Timer.css";
import { useInterval } from "@hooks";
import classNames from "classnames";

interface TimerProps {
  maxTime: number;
  mode?: "seconds" | "minutes" | "hours";
  trigger?: boolean;
  setTrigger?: React.Dispatch<React.SetStateAction<boolean>>;
  timeout?: React.Dispatch<React.SetStateAction<boolean>>;
}

const Timer = ({
  maxTime,
  trigger,
  setTrigger,
  timeout,
  mode = "minutes",
}: TimerProps) => {
  const [timer, setTimer] = useState<number>(maxTime);

  const hours = Math.floor(timer / 3600)
    .toString()
    .padStart(2, "0");
  const minutes = Math.floor(timer / 60)
    .toString()
    .padStart(2, "0");
  const seconds = (timer % 60).toString().padStart(2, "0");

  timeout && timer === 0 && timeout(true);

  if (timer === 0) {
    setTrigger && setTrigger(false);
  }
  useInterval(
    () => {
      setTimer(timer - 1);
    },
    1000,
    trigger
  );

  useEffect(() => {
    setTimer(maxTime);
  }, [maxTime]);

  return (
    <div className={classNames(TimerVariant[mode])}>
      {mode === "hours" &&
        (hours !== "0" ? <span>{hours}:</span> : <span>00</span>)}
      {minutes !== "0" ? <span>{minutes}:</span> : <span>00:</span>}
      {seconds !== "0" ? <span>{seconds}</span> : <span>00</span>}
    </div>
  );
};

export default Timer;

 

import { useEffect, useRef } from "react";

type Callback = () => void;

const useInterval = (callback: Callback, delay: number, trigger?: boolean) => {
  const savedCallback = useRef<Callback>();

  //Remember the latest callback
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback, trigger]);

  useEffect(() => {
    function tick() {
      savedCallback.current !== undefined && savedCallback.current();
    }

    if (delay !== null && trigger !== false) {
      let id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay, trigger]);
};

export default useInterval;

Timer 함수가 렌더링 되면서 시간이 흐르는 것을 UI 상으로 확인할 수 있다. 이제 Timer를 호출하는 컴포넌트에서 Button을 클릭하면 조건에 따라 Timer를 호출하면 1번을 해결할 수 있다.

 

 

 

 

일정시간 동안 타이머는 작동하고, 시간안에 인증번호를 입력해야된다.

일정 시간이 흐른다음, 타이머는 작동을 멈추가 해야 했다. 그렇게 작동시키기 위해서는 타이머내에 trigger를 만들어서 trigger 동작에 따라 시간을 멈추게 했고, 시간이 0 이 되었을때 트리거를 발동시켜 timer를 멈출 수 있도록 했다.

 

  if (timer === 0) {
    setTrigger && setTrigger(false);
  }

 

 

시간이 종료되면, 인증번호를 다시 요청해야되고, 재요청 클릭시 타이머가 다시 작동한다.

Timer 외부에서 시간이 종료되었음을 알아야 한다고 생각했다. 외부 컴포넌트에서 Timer 컴포넌트로 props를 통해서 set State를 넘겨줬고, 시간이 종료 되었을 때, timeout state를 false로 바꿔주도록 만들었다.