본문 바로가기
사이드 프로젝트

[React] recoil, framer-motion을 이용해 뽀모도로 타이머 만들기

by 반류연 2025. 3. 27.

✅ 프로젝트 소개

노마드코더의 'React JS 마스터 챌린지' 과제로, 다음 조건을 만족하는 뽀모도로 타이머를 구현하는 프로젝트다.

 

[구현 조건]

  • 25분짜리 뽀모도로 타이머 구현. 이때 시간은 MM:SS 형식을 써야 한다.
  • 25분은 1 ROUND, 4 ROUND는 1 GOAL 이 되며 각각의 상태가 하단에 표기된다. 이때 12 GOAL을 달성할 경우 ROUND, GOAL 모두 초기화 된다.
  • 타이머와 버튼은 framer-motion을 사용해 애니메이션 효과를 주어야 한다. 
  • 정지/재생 버튼을 통해 타이머를 조작할 수 있어야 한다.

 

✅ 결과물

 

[코드 확인]

https://stackblitz.com/edit/reactstudy-a17-blueprint-brchcr-td5elkjy?file=src%2Fcomponents%2FPomodoroState.jsx

 

ReactStudy-A17-Blueprint (forked) - StackBlitz

Next generation frontend tooling. It's fast!

stackblitz.com

※ 테스트 시, 'atom.js'와 'PomodoroState.jsx'의 숫자값을 [min: 0, sec: 10]으로 조절하면 빠른 확인이 가능합니다.

 

 

✅구현

  • 사용기술: recoil, styled-component, framer-motion
  • 타이머 구현: setInterval을 사용해 1초마다 min, sec 값이 바뀌도록 했다. 처음엔 정지/버튼을 클릭할 때마다 각각 다르게 함수를 짜야한다 생각했는데 useEffect로 한 번에 관리하는 방법을 찾아 한결 깔끔한 코드를 만들어 낼 수 있었다.
  • 애니메이션 효과
    • 타이머 숫자가 나타나는 효과는 Enter animation을, 버튼 클릭효과는 Gestures를 사용함.
    • motion의 key 값을 이용하여 초/분이 바뀔 때마다 카드가 깜빡이는 효과를 구현.
    • framer-motion 리엑트 효과 예시: https://examples.motion.dev/react
 

Motion Examples - Official Motion and Framer Motion components & examples

Framer Motion is now Motion. Official examples of how to use Motion, including scroll animations, layout animations, keyframes, springs and more. View and copy source code for components and tests. Learn which APIs are used and link through to the source d

examples.motion.dev

  • 조건 외 구현: 12 GOAL 달성 시 하단에 축하 메시지를 띄움. 이후 다시 타이머를 시작할 경우 메시지가 사라짐. 

 

✅ 이슈사항

1. 에러 메시지:  Invalid hook call. Hooks can only be called inside of the body of a function component.

 

시작하자마자 만난 에러. 근데 이건 내가 바보짓한 거였다😅

 const countdownFn = () => {
    const [lastSec, setLastSec] = useState(60);
    const [lastMin, setLastMin] = useState(60 * 25);

    setInterval(() => {
      setLastSec((prev) => prev - 1);
      setLastMin((prev) => prev - 1);
    }, 1000);

    console.log(`${lastMin} : ${lastSec}`);
  };
  
  function Clock(){
  	...
  }
  
  export default Clock;

 

원칙: useState는 React 컴포넌트 함수 또는 커스텀 훅 안에서만 사용해야 해야 한다.

 

위 코드를 보면 function Clock() 밖에서 useState를 호출하고 있다. 그래서 발생한 오류! 별 거 아니지만 이후로도 비슷한 실수를 몇 번이나 반복해 좀 더 신경 써야지, 하고 반성했다.

 

2. 에러 메시지: An atom update was triggered within the execution of a state updater function. State updater functions provided to Recoil must be pure functions

 

타이머 구현 중 atom으로 값을 받아오자 발생한 오류. 위 메시지의 핵심은 마지막 줄의 'Recoil must be pure functions'이다.

//Clock.jsx

import { useEffect, useState } from 'react';
import styled from 'styled-components';
import { motion } from 'framer-motion';
import State from './State';
import { atom, useRecoilState } from 'recoil';
import { minuteState, secondState } from '../atom';

// 스타일 생략

function Clock() {
  const [min, setMin] = useRecoilState(minuteState);
  const [sec, setSec] = useRecoilState(secondState);
  const formattedMin = String(min).padStart(2, '0');
  const formattedSec = String(sec).padStart(2, '0');

  const [isPause, setPause] = useState(true);

  useEffect(() => {
    // 정지상태면 -> 타이머 동작 x
    if (isPause) return;

    // 1초마다 타이머 가동
    const timer = setInterval(() => {
      setSec((prev) => {
        // 1. 초가 0일때
        if (prev === 0) {
          if (min === 0) {
            // 타이머 종료
            clearInterval(timer);
            return 0;
          } else {
            setMin((prev) => prev - 1);
            return 59;
          }
        } else {
          // 초가 0 아닐 때
          return prev - 1;
        }
      });

      return clearInterval(timer);
    }, 1000);
  }, [isPause, min, sec]);

  const ClickTimer = () => {
    if (isPause) {
      // 정지 상태면 -> 시작
      setPause(false);
    } else {
      setPause(true);
    }
  };

  return (
    ...
  );
}

export default Clock;

 

chatGPT에게 물어보니 Recoil의 set 함수 안에서 또 다른 atom을 업데이트하거나 부수효과(side effect)를 발생시키면 안 된다고 했다. 위 코드에선 setSec안에서 setMin을 호출한 게 문제였던 것.

 

근데....
그럼 시간에 따라 값 조절을 어케해....?

 

 

초가 0이 될 때, 분이 0이 될 때 등- 타이머를 만들기 위해선 시간의 상태에 따라 조건문을 나눠야 하는데 저걸 중첩하면 문제가 생긴다니;; 이 문제의 답은 min과 sec의 atom을 통합함으로써 해결했다.

// 기존의 atom.js

export const secState = atom({
  key: 'second',
  default: 0
});

export const minState = atom({
  key: 'minute',
  default: 0
})
// 수정한 atom.js

export const timeState = atom({
  key: 'timeState',
  default: {
    min: 25,
    sec: 0,
  },
});

 

이렇게 하면 set 함수 하나로 두 값을 모두 조절할 수 있다!

 

내 코드에서는 setTime이라는 함수를 이용해 이전의 min, sec값을 받아와 값을 수정하는데, 이때 주의할 것이 바로 return으로 값을 업데이트하는 것이다.

// Clock.jsx 일부

function Clock(){
  ...
  
  useEffect(() => {
    if (isPause) {
      // 정지 상태에선 타이머 작동 x
      return;
    }

    // 타이머
    const timer = setInterval(() => {
      setTime((prev) => {
        let { min, sec } = prev;

        if (min === 0 && sec === 0) {
          // 타이머 종료
          clearInterval(timer);
          return { min: 0, sec: 0 };
        }

        if (sec === 0) {
          return { min: min - 1, sec: 59 };
        }

        return { min, sec: sec - 1 };
      });
    }, 1000);

    return () => clearInterval(timer);
  }, [isPause, timeState]);
  
  return <>
    ...
  </>
}

 

if문의 조건들이 충족되면 setTime으로 값을 업데이트하는 게 아니라 return으로 값을 전달하는 걸 볼 수 있다. 처음 나타난 에러와 마찬가지로 퓨어 함수를 유지하기 위해서인데 set 함수 안에서는 '무엇을 리턴하느냐 = 새로운 상태로 뭘 바꿀 거냐'이다. 사실상 set 함수를 사용한다고 생각하면 된다.

 

이 에러는 recoil에서 자주 발생하는 에러라고 한다. 그런데 찾아보니 react 자체도 업데이트 함수는 퓨어 함수 쓰는 게 원칙이라 useState에서도 가급적 피하는 게 좋다고! (🤖chatGPT: 기술적으론 가능하지만 권장되진 않아~) 나처럼 상태 업데이트는 무조건 업데이트 함수 써야 한다고 생각해서 오류 겪는 사람들이 많을 것 같다.

 

3. 에러 메시지: useEffect received a final argument that is not an array (instead, received `object`). When specified, the final argument must be an array.

const [meNow, setMeNow] = useRecoilState(pomodoroState);

  useEffect(() => {
    console.log(meNow);
  }, meNow);
  
  // meNow: {round: 0, goal: 0} < 객체

 

이건 진짜 핵 멍청한 실수였다. useEffect는 배열이 넘어가야 하는데 객체를 넘긴 것. 두 번째 인자를 [meNow]로 고치니 해결됐다.

 

4. 에러 메시지: Encountered two children with the same key, `0`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.

타이머까지 다 구현하고 이제 정말 끝!이라고 생각했을 때 뙇 에러가 나타났다😂 [00:00] 이 되자 발생한 에런데 이는 두 Box 컴포넌트의 key값이 중복되어 발생한 거였다.

// 기존 코드(Clock.jsx)

<Wrap>
  <Box
    variants={BoxVars}
    initial="start"
    animate="end"
    key={time.min}
  >
   {formatMin}
  </Box>
  <Span>:</Span>
  <Box
    variants={BoxVars}
    initial="start"
    animate="end"
    key={time.sec}
   >
   {formatSec}
   </Box>
</Wrap>

 

그래서 위의 코드를

// key값 수정

<Wrap>
  <Box
    variants={BoxVars}
    initial="start"
    animate="end"
    key={`min-${time.min}`}
  >
   {formatMin}
  </Box>
  <Span>:</Span>
  <Box
    variants={BoxVars}
    initial="start"
    animate="end"
    key={`sec-${time.sec}`}
   >
   {formatSec}
   </Box>
</Wrap>

 

요렇게 바꿔주니 에러 해결! 0분 0초가 되더라도 앞의 텍스트 덕분에 둘의 key 값이 겹치지 않는다.

 

5. 축하 메세지 출력

12 GOAL을 달성했을 때 하단에 축하메세지를 띄운 다음 타이머를 재시작했을 땐 지우고 싶었다. 그래서 12 GOAL에 달성했는지 여부를 확인하는 상태값(isFullGoal)을 만들어 이 값이 변했을 때 useEffect를 동작시켜 값을 바꾸려 했다. 그런데 해당 코드가 실행되지 않는게 아닌가?

// PomodoroState.jsx

useEffect(() => {
    if (goal === 12) {
      setGoal(0);
      setRound(0);
      setFullGoal(true);
    }
    setTime({ min: 25, sec: 0 });
    setIsPause(true);
  }, [goal]);
  
useEffect(() => {
  if (!isFullGoal) {
    setFullGoal((prev) => !prev);
  }
}, [isFullGoal]);

 

console을 찍어 확인해보니 아예 마지막 useEffect가 실행되지 않고 있었다. 알고보니 해당 코드는 isFullGoal 값이 false일때 작동하는데 위에서 먼저 true로 변환되기 때문에 여기까지 내려오지 않는 거였다. 그래서 이 useEffect를 없애고 정지/재생 버튼을 클릭할 때 마다 실행하는 함수를 만들어 거기에 'setFullGoal(false)'를 넣었다. 

축하합니다~

 

사실 12 GOAL 달성할때만 바뀌면 되는 걸 클릭 할때마다 실행시키는게 맘에 들진 않는다. 더 나은 개선방향이 있는지 고민해봐야겠다.

 

✅ 후기

 

재밌다...!!!!!

 

역시 나는 프론트엔드를 해야겠다. 화면 효과 주는 게 세상에서 제일 재밌음 ㅋㅋㅋ

 

똑같이 코드가 어려워도 배워가며 흥분될 때가 있고 동태눈깔로 키보드만 타닥타닥 칠 때가 있는데 이번 프로젝트는 압도적으로 전자였다. 중간에 과제가 날아가 3시간이 공중분해 됐는데 복구하는 과정마저 재밌었다면 알만하지 않은가? 심지어 이번 과제 기한 5일인데 이틀 만에 끝냈음 ㅋㅋㅋㅋ 다음 주 과제는 드디어 넷플릭스 클론 코딩! 너무너무 기대된다.