ReactNative로 러닝어플 만들기 - 실시간 타이머 구현

이은지·2022년 5월 14일
0
post-thumbnail

러닝 어플인 만큼 정보가 실시간으로 바뀌어야 한다. 실시간이라기 보다는 초 단위로 바뀌어야 한다는 말이 더 적합하긴 하겠다. 아무튼 대표적인 실시간 정보는 '사용자가 달린 시간'이다. 시간을 기준으로 계산해주어야 하는 값들이 존재하기 때문에(ex 페이스, 칼로리) 이 값을 잘 관리해줘야 한다 😊 ...

구현 목표 ⏰

아래 이미지의 타이머를 구현하고자 했다.

  1. 1초씩 증가해야 하고, 바뀐 값이 화면에도 바로 반영되어야 한다.
  2. 사용자가 일시정지 버튼을 누르면 시간 증가도 멈춰야 한다.
  3. 러닝을 재개하면 일시정지 버튼 누르기 전 시간에서 다시 1초씩 증가해야 한다.

기본 타이머 구현

useInterval이라는 custom Hook을 사용했다.
리액트의 기본 Hook인 setInterval을 활용해 타이머를 구현할 경우, 타이머의 시간 흐름과 실제 시간 흐름 사이에 괴리가 생길 수 있다고 한다. 이를 보완해 만든 게 useInterval인 것 같다.

setInterval의 기본 동작은 설정한 시간마다 한 번씩 콜백함수를 호출하는 것이다. 따라서 useInterval을 사용해 1000ms마다 시간을 증가시키는 함수를 호출했다.

자세한 설명은 요기 Making setInterval Declarative with React Hooks

moment.js 라이브러리의 duration object를 사용했다. moment.js는 이 플젝하면서 처음으로 알게 된 라이브러린데, 자바스크립트에서 날짜와 시간을 다룰 때 많이 사용하는 라이브러리라고 한다. 포매팅이라던지, 단위간 변환 등의 작업을 손쉽게 할 수 있도록 도와준다. duration은 말그대로 a length of time을 뜻하는 객체이다. 콘솔에 duration 객체를 찍어보면 PT1S PT2M30S 이런 식으로 생겼음을 확인할 수 있다.

const [time, setTime] = useState(moment.duration(0,'seconds'));
const tick = () => {
  setTime(prevTime => prevTime.clone().add(1, 'seconds'));
};
const timer = useInterval(() => {
  tick();
}, 1000);

시간이 1초 증가할 때마다 화면에 반영되어야 하므로 state로 정의했다.

일시정지 버튼을 누르면 타이머도 정지

이걸 다르게 이야기하면, 화면에 포커스가 잡혔을 때에만 타이머를 동작시켜야 한다는 뜻이다.
해당 컴포넌트가 사용자의 화면에 떠있는지를 알아내야 했다.

화면에 포커스가 잡힘 -> 타이머 시작/재개
화면에 포커스 사라짐 -> 타이머 중지

화면에 포커스가 잡히는 것과 사라지는 것을 모두 캐치해야 했다.

focus라는 state를 둬서 화면이 포커싱되면 값을 true, 포커스 아웃되면 false로 값을 업데이트 해주고자 했다.
focus값이 true일 경우 타이머를 증가시키고, false로 바뀔 경우 clearInterval로 타이머를 제거했다.

const timer = useInterval(() => {
  if (focus) {
    tick();
  }
}, 1000);

if (!focus) {
  clearInterval(timer);
}

1트: useEffect

결과: ❌
useEffect를 이용하면 화면이 렌더링된 후 마운트될 때 특정 작업을 수행할 수 있다.
문제는 리액트 내비게이션으로 화면 이동시 컴포넌트의 마운트/언마운트가 이뤄지지 않는다는 점이었다.

RunningScreen 컴포넌트에서 navigation.push를 통해 PauseScreen 컴포넌트로 이동한다고 가정해보자. 이때 RunningScreen은 언마운트 되는 게 아니라, 즉 화면에서 사라지는 게 아니라 그대로 있고 그 위에 PauseScreen이 쌓이는 것이다.

따라서 useEffect로는 화면의 포커싱을 감지할 수 없었다.

2트: useFocusEffect

결과: ❌
위의 문제를 해결하기 위해 사용하는 Hook이다. 용도는 적합했는데, 타이머의 존재가 문제가 됐다.
타이머 때문에 1초 마다 RunningScreen의 리렌더링이 발생하는데, 리렌더링될 때마다 useFocusEffect안에 작성한 함수가 호출됐다. 1초가 증가할 때마다 useFocusEffect에 작성한 콜백함수와 리턴함수가 연달아 호출되어 타이머가 제대로 동작하지 않았다.

(지금 다시 생각해보니 타이머를 별도의 컴포넌트로 분리해 해당 컴포넌트 내에서만 리렌더링이 일어나게 하거나, focus state를 업데이트하는 로직을 useCallback으로 작성해도 됐을 것 같다. 그땐 이 방법을 생각지 못했다.)

3트: navigation.addListener

결과: ✅

navigation.addListener를 사용하니 딱 화면에 포커스와 잡혔을 때, 포커스 아웃 됐을 때만 원하는 코드를 실행할 수 있었다. 상태 업데이트로 인한 리렌더링 시에는 코드가 실행되지 않았다.

useEffect를 사용해 컴포넌트 마운트 시에 addListener를 붙여줬다.

useEffect(() => {
  navigation.addListener('focus', e => {
    setFocus(true);
    getLocationUpdates();
  });
  navigation.addListener('blur', e => {
    setFocus(false);
    removeLocationUpdates();
  });
}, [navigation]);

코드 전문

  const [time, setTime] = useState(moment.duration(0, 'seconds'));
  const [focus, setFocus] = useState(true);
  const tick = () => {
    setTime(prevTime => prevTime.clone().add(1, 'seconds'));
  };

  const timer = useInterval(() => {
    if (focus) {
      tick();
    }
  }, 1000);

  if (!focus) {
    clearInterval(timer);
  }

  useEffect(() => {
    navigation.addListener('focus', e => {
      setFocus(true);
      getLocationUpdates();
    });
    navigation.addListener('blur', e => {
      setFocus(false);
      removeLocationUpdates();
    });
  }, [navigation]);

리팩토링에 대한 니즈도, 할만한 여유도 없다는 게 이런 플젝의 아쉬움인 것 같다.
그래도 이렇게 정리하는 과정에서 더 나은 방안을 떠올려볼 수 있는 게 좋은듯 ㅎㅎ

profile
교육학과 출신 서타터업 프론트 개발자 👩🏻‍🏫

0개의 댓글