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

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

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

좋아요 2

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

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

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

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

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

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

const useIntervalValue = (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 useIntervalValue = (calculator, delay) => {
  const [result, setResult] = useState(calculator());

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

  return result;
}

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

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

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

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

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

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

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

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

안녕하세요… 유튜브 보고 들어왔어요

친절하신 설명 감사합니다. 두가지 의견 드리고 싶어요.

  1. useIntervalValue를 강조하시려고 그러신 것 같긴 한데 App과 CountDownView 두 곳에서 사실상 같은 계산을 계속 하고 있는 것은 낭비같아요. App에서 isNotYet을 state로 두고 해당 state를 바꾸는 callback을 CountDownView에 prop으로 전달하는 게 어떨까요?

  2. useIntervalValue 구현에서, 어차피 값이 동일(Object.is)하면 re-render가 되지 않는 것으로 알고 있거든요. 그래서 비교하지 않고 그냥 쓰는 경우도 많더라구요. 그럼에도 미리 비교하는 것이 약간이라도 잇점이 있는 것일까요?

@rarira 반갑습니다. ㅎㅎ 두가지 의견 남겨주셔서 너무 감사합니다. 서로 의견을 나누는 것을 너무 기다리고 있습니다. 제 생각은 이렇습니다.

1 :thinking:

말씀하신 것 처럼 CountDownView에서 아래와 같이 별도의 prop을 전달 받는 것도 좋은 방법입니다.

<CountDownView onFinish={()=>{ setIsNotYet(false)}} />

영상에서 그렇게 하지 않은 이유는 짚어주신 것 처럼 useIntervalValue를 강조하기 위함도 있습니다. 그리고 한가지 이유가 더있는데 별도의 custom Hook이 있을 경우에는 두 View(App, CountDonwView)간의 의존성이 생기지 않도록 각자 해당 hook을 사용하는게 유지보수 측면에서 좋다고 생각해서 저렇게 작성했습니다.

2 :thinking:

정확히 지적입니다. 아래 코드에서 if(newResult !== result) setResult(newResult); 이부분을 말씀하신거죠? 저도 평상시 코딩할 때 비교하지 않고 set합니다. 그런데 여기에 이 것을 넣은 이유는 이 예제를 만들다보니 발견한게 있는데 이상하게도 값이 바뀌면 한번이 아니라 두번씩 더 컴포넌트 함수가 실행되더라고요. 이게 <React.StrictMode> 때문도 아니였어요. (비교 하지 않고 로그 찍는 것을 확인 할 수 있는 codsandbox)

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

예를 들어 타이머에서 계산을 1초에 5번씩하고 계속 setState하게 되면 렌더링이 되는 것이 아래와 같이 됩니다. 버튼 클릭으로하면 안그러는데 렌더링으로 하면 그렇게 되더군요. (관련 예제)

0 (렌더링, 최초)
0
0
0
0
1 (렌더링)
1 (렌더링)
1
1
1
2 (렌더링)
2 (렌더링)
2
2
2

그래서 너무 이상해서 비교하는 코드를 일단 넣었었어요.

2-1 :thinking: :bulb:

너무 이상하잖아요.ㅎㅎ 영상 만들때 살짝 찾아봤을 때는 그 이유에 대해서 못찾았었는 @rarira님이 다시 질문하셔서 다시 한번 더 검색해보다가 관련된 github 이슈를 찾았네요. ! :tada:

  • comment 1 : 컨커런트 모드를 구현하면서 생긴 quirk 다.
  • comment 2 : 렌더링은 한번 더 되지만 useEffect는 한번만 호출되고 렌더링 더 된것도 돔에 반영 안된다.
    • 이건 제가 이 예제에서도 log를 찍어 봤어요

덕분에 이상하다 생각한 부분의 원인에 대해서 정확히 알게 되었습니다.ㅎ 감사합니다. 앞으로도 이런 의견 많이 부탁해요! :pray:

@rarira 님, 이 것도 별도로 영상으로 만들어 볼만한가요?ㅎㅎ

좋아요 1

친절한 설명 감사합니다.

  1. 컴포넌트 재활용성을 높이기 위해 decoupling이 중요하다는 것은 알고 있는데 항상 지킨다는 것이 어려운 것 같습니다. global state 같은 것을 사용하게 되면 더 복잡해지는 것도 같구요.

  2. 재미있는 내용이네요. 덕분에 concurrent 모드도 공부했네요.
    그리고 re-evalutation 과 re-render의 차이에 대해서도 전혀 생각 못했어요.
    지금껏 저렇게 콘솔 찍어봐서 두번찍히면 당연히 리렌더가 되고 그래서 dom이 업데이트되니까 안좋아 라고 생각했는데 …개념이 조금 달랐네요.

    그런데 저걸 영상을 만드시려면 concurrent(스펠도 어렵네요) 모드를 다 설명해야 납득이 될 것 같아요.

처음에 hook 이 도입될 때 만해도 천국이 열렸다 생각했는데, 요즘에 프로젝트에서 특히 useEffect와 관련된 race condition 문제 때문에 골치를 썩이다 보니 react 슬슬 피곤해지네요 ㅎㅎ concurrent 모드가 들어오면 나아진다고 Dan 은 얘기하던데… 외려 더 복잡해지진 않을런지요 ㅎㅎ

이전에 다른 프로젝트에서 svelte를 접해 보고는 신세계 같았네요

좋아요 1

진짜 도움이 되는 내용이였습니다

좋아요 1