React에서 카운트 다운 타이머 구현

코드종 영상 많은 도움 되었습니다. 첫번째 질문이네요.

react에서 카운트 다운 타이머를 어떻게 구현하면 될까요?
예를 들어 프로모션 오픈 시간에 맞춰 카운트 다운도 표시하고 그 시간이 되면 구매 버튼이 활성화시켜야 한다면 어떤 방법이 좋을까요? 어찌어찌 만들 수는 있지만 렌더링이 너무 자주 일어날텐데 말이죠.
비슷한 질문이 자바스크립트 개발자 포럼에도 있었어요.

1 Like

:tada:
와우 첫 질문 감사합니다! 안그래도 리액트에서 타이머에 대해서는 한번 코드종 영상으로 다뤄보고 싶었는데 질문하신 내용을 영상으로 만들어 볼게요.

(영상 편집 속도가 느려서… 조금만 기다려주세요.) :pray:

저도 react에서 타이머를 다룰 때 의도한 대로 동작하지 않는 경험을 많이 해봤습니다. 그 이유에 대해서는 면밀히 살펴보다 보면 리액트를 더 이해하게 되는 계기가 되었어요.

관련해서 가장 도움이 되었고 재밌게 읽었던 글은 Dan의 글인데 꼭 읽어보시길 권합니다. (번역된 글들도 있습니다.)

위 글에서 나오는 useInterval을 이용해서 만들면 더욱 쉽게 불필요한 렌더링을 피하도록 만들 수 있습니다. 결론부터 말하자면 다음과 같은 hook을 만들어서 사용할 수 있습니다. (codesandbox)

const CountDownView = ({ targetISOString }) => {
  const remain = useResultOfIntervalCalculator(()=> (new Date(targetISOString)-new Date())/1000, 10);
  return (
    <div>
      남은 시간 : {remain} 
    </div>
  );
};

const useResultOfIntervalCalculator = (calculator, delay) => {
  const [result, setResult] = useState(calculator());

  useInterval(()=>{
    const newResult = calculator();
    if(newResult !== result) setResult(newResult);
  },delay);

  return result;
}

과정을 설명하기에 앞서 질문을 제 나름대로 요약해보자면

Q. 목표 시간까지 얼마나 남았는지에 따라 달라지는 View들을 만들고 싶은데 이럴 때는 어떻게 시간 데이터를 다뤄야 할까요?

제 의식에 흐름대로 설명을 해볼게요.

1. 입력과 출력으로 생각해보기

  • View를 만들 때 저는 무엇이 입력이고 무엇이 출력인지 먼저 따져봅니다.
  • 일단 만들려는 UI의 모습이 출력이고
  • 이 출력을 바꾸게 만드는 값입력입니다. (<= 매우 중요!)

그렇다면 위에서 "목표 시간까지 얼마나 남았는지에 따라 달라지는 View"의 입력은 무엇일까요?

  • 바로 "목표시간까지 얼마나 남았는지"가 바로 입력입니다.

조금 더 구체적으로 얼마나 남았는지 초단위까지 보여주는 View라고 하자면 "남아 있는 초"가 입력입니다.

입력은 “남아 있는 시간”

2.일단 Prop만 이용해서 View를 만들어 보기

const CountDownView = ({remain}) => {
  return (
    <div>
      남은 시간(초) : {remain} 
    </div>
  );
}

const App = ()=>{
  const targetISOString = '2022-01-01T09:00:00.000Z';//한국 시간 기준 2022년 시작 시간
  const remain = Math.floor((new Date(targetISOString) - new Date())/1000);

  return (
  <div>
     <Nav/>
     <CountDownView remain={remain}/>
  </div>
  )
}

3. props 와 hook state

자 그럼 CountDownView를 사용하는 App에서 CountDownView에게 매초 마다 새로운remain을 넣어 주기만 하면 되겠네요?
생각해봅시다. App은 어떻게 해야 CountDownView를 다시 렌더링 시킬 수 있나요? 새로운 remain을 prop으로 넘겨줘야 하는데 이렇게 넘겨주려면 App 스스로도 렌더링이 다시 되어야 합니다. 그러려면 App에서 remain을 별도의 state로 두고 계속 갱신해야 합니다.

const App = ()=>{
  const targetISOString = '2022-01-01T09:00:00.000Z';//한국 시간 기준 2022년 시작 시간
  const calculator = () => Math.floor((new Date(targetISOString) - new Date())/1000);
  const [remain, setRemain] = useState(calculator());
  
  useInterval(()=>{
  	setRemain(calculator());
  }, 100); //일단, 세밀한 업데이트를 위한 100ms으로
  return (
  <div>
     <Nav/>
     <CountDownView remain={remain}/>
  </div>
  )
}

이 방법은 그다지 좋은 방법이 아닙니다. 왜 그럴까요?

매 초마다 다시 그려져야 하는 것은 CountDownView인데 매 초마다 렌더링을 다시 할 필요가 없는 App은 물론 App에서 사용하는 다른 컴포넌트(Nav)들 또한 렌더링이 일어납니다. (이유는 App의 state(hook)가 변하기 때문)

사실 CountDownView를 사용하는 App 입장에서는 "목표시간(targetISOString)"만 알면 되고 이를 1초마다 갱신하고 말고의 CountDownView의 관심사이지 App이 신경쓸 일은 아닙니다. App에서 CountDownView 를 왜 사용하는거죠? App은 자신이 알고 있는 "목표시간"까지의 남은 시간을 표시하고 싶은데 이를 표시하는 것을 직접 구현하지 않고 다른 컴포넌트에게 맡기고 싶어서입니다.

그러니 App은 targetISOString을 CountDownView에게 알려주면 그만입니다.

const App = ()=>{
  const targetISOString = '2022-01-01T09:00:00.000Z';//한국 시간 기준 2022년 시작 시간
  return (
  <div>
     <Nav/>
     <CountDownView targetISOString={remain}/>
  </div>
  )
}

그럼 매초 마다 변경되는 것은 CountDownView의 관심사이니 관련 코드를 CountDownView로 옮겨보면 다음과 같습니다.

const CountDownView = ({ targetISOString }) => {
  const calculator = () => Math.floor((new Date(targetISOString) - new Date())/1000);

  const [remain, setRemain] = useState(calculator());
  useInterval(()=>{
  	setRemain(calculator());
  }, 100);
  
  return (
    <div>
      남은 시간 : {remain} 
    </div>
  );
};

여기서 주목할 점은 CountDownView의 입장에서는 바로 매 초마다 바뀐 입력이 prop으로 왔었는데 이제는 내부의 state(hook)이 되었다는 것입니다. 그럼 더 이상 remain은 CountDownView의 입력이 아닐까요? state는 props은 아니지만 의미적으로는 입력이라 할 수 있습니다. 리액트 컴포넌트는 의미적으로는 순수함수 처럼 동작합니다. 순수함수의 출력을 바꾸는 길은 입력을 바꾸는 길 밖에 없습니다. 출력(UI)을 바꾸는 것은 모두 입력이라 생각하고 리액트 프로그래밍을 하는 것이 개발할 때 사고의 흐름이 더욱 단순 명료해집니다.(이와 관련되서는 기회가 되면 별도의 영상으로 다뤄보겠습니다.)

사실 이 걸로도 충분합니다. 하지만 이렇게 주기적으로 새롭게 계산해야 하는 state가 있을 경우 useState에서도 caclcuator를 호출해주고 또 useInterval에서도 해줘야 하는데 하는 등 뭔가 더 멋지게 만들 수 있을 것 같죠?

정기적으로 계산을 해서 달라져야하는 값을 별도의 hook으로 만들어보면 다음과 같이 바꿀 수 있습니다.

const useResultOfIntervalCalculator = (calculator, delay) => {
  const [result, setResult] = useState(calculator());

  useInterval(()=>{
    const newResult = calculator();
    if(newResult !== result) setResult(newResult);
  },delay);

  return result;
}

이를 이용하면 다음과 같이 사용할 수 있습니다.

const CountDownView = ({ targetISOString }) => {
  const remain = useResultOfIntervalCalculator(()=> (new Date(targetISOString)-new Date())/1000, 10);
  return (
    <div>
      남은 시간 : {remain} 
    </div>
  );
};

이렇게 바꿔보니 더 잘 읽히고 이와 "정기적으로 계산을 해서 달라져야하는 값"을 화면에 뿌려줄 때 useResultOfIntervalCalculator 를 사용할 수 있겠죠?

매초가 아닌 시간이 목표시간이 지났는지 여부에 따라 다르게 보여줘야 하는 경우에 useResultOfIntervalCalculator 를 사용해보자면 다음과 같습니다.

function App() {
  const targetISOString = "2022-01-01T09:00:00.000Z";
  const isNotYet = useResultOfIntervalCalculator(
    () => new Date(targetISOString) - new Date() > 0,
    10
  );
  return (
    <div className="App">
      <Nav />
      <CountDownView targetISOString={targetISOString} />
      {isNotYet?'기다리세요.':'이미 지났습니다.'}
    </div>
  );
}

이 코드들이 포함된 데모는 여기서 확인할 수 있습니다.

여기까지 읽어주셔서 감사합니다.
잘못된 내용이나 다른 질문 다른 의견 있으시면 언제든지 알려주세요.

그럼 오늘도 즐거운 코딩 하세요~