기록의 습관화
article thumbnail

이번에는 Webview 환경에서 Background Timer를 구현하는 방법에 대해서 적어볼까 합니다.

바로 인증 코드 기능에 사용될 타이머인데요

이 글을 보시는 분들께서

 

setTimeout을 이용해 시간초를 줄이기만 하면 되는 거 아닌가요?

 

하시고 의문이 드실 수 있는데 요번에 구현해 볼 타이머는 조금 특이한? 조건을 가졌습니다.

 

  • 앱의 Background Mode에서도 동작할 것

 

아주 간단한 조건이긴 하지만 하면서 좀 까다로운 점들이 있어서 정리를 해두려고 합니다.

그럼 시작해 볼까요?


타이머를 만드는 여러 가지 방법들

타이머를 JS를 이용해서 만든다면 어떤 방법들이 있을까요?

가장 간단하게는 앞서 말했듯이 setTimeout을 이용한 방법이 있을 겁니다.

만약 그러면 창을 벗어나도 계속 타이머가 흐르게 하기 위해서는요?

 

이런 선택지들이 있을 수 있습니다.

 

  1. Web Workers를 이용
    웹 페이지의 메인 스레드와 별개로 동작하여 웹 페이지가 비활성 상태일 때도 코드가 계속해서 동작 가능
  2. Service Workers를 이용
    웹 앱의 프록시 서버 같이 동작
    백그라운드에서 동작하며 네트워크 동작을 가로채서 다양한 작업을 수행 가능
    주로 오프라인 환경에서의 사용을 위해 설계되었지만 백그라운드 코드를 실행하는데도 활용될 수 있음
  3. Background Sync를 이용
    주로 Service Worker와 연동하여 사용하는 API
    인터넷 연결이 끊어진 상태에서도 작업을 백그라운드에서 실행이 가능함
  4. Page Visibility API 
    페이지가 현재 보이는 상태인지 숨겨진 상태인지 알 수 있음
    페이지가 숨겨진 상태와 보이는 상태에 따라 시간을 조작

Web Workers, Service Workers를 이용한 구현

첫 번째로 Background 상태에도 분리된 스레드를 가지는 Web Workers를 이용해 타이머를 구현할 수 있겠다고 처음에 생각했습니다.

만약 Web Workers에 대한 자세한 설명이 필요하신 분 께서는 아래의 MDN 문서를 참고해 주시면 될 것 같습니다.

https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API

 

Web Workers API - Web APIs | MDN

Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application. The advantage of this is that laborious processing can be performed in a separate thread, allowing the main (usuall

developer.mozilla.org

간단하게 구현을 해보자면 다음과 같습니다.

 

// 타이머 WebWorker

self.addEventListener('message', (event: MessageEvent) => {
    const duration = event.data; // ms

    setTimeout(() => {
        (self as any).postMessage('Timeout finished!');
    }, duration);
});
import { useEffect, useState } from 'react';

const formatTime = (milliseconds: number) => {
    const totalSeconds = Math.floor(milliseconds / 1000);
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;

    return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};

const useTimer = (duration: number) => {
    const [time, setTime] = useState(formatTime(duration));
    const [isFinished, setIsFinished] = useState(false);

    useEffect(() => {
        const worker = new Worker(new URL('./timerWorker.ts', import.meta.url));

        worker.onmessage = (e: MessageEvent) => {
            switch (e.data.type) {
                case 'tick':
                    setTime(formatTime(e.data.timeLeft));
                    break;
                case 'finished':
                    setIsFinished(true);
                    break;
            }
        };

        worker.postMessage(duration);

        return () => {
            worker.terminate();
        };
    }, [duration]);

    return { time, isFinished };
};

export default useTimer;

하지만 직접 실행을 해보니 놓친 점이 있습니다.

React Native Webview의 환경에서는 스레드와 관계없이
앱이 BackgroundMode에 진입 시 BackgroundModes에 대한 다른 설정을 해두지 않는다면
브라우저가 자체가 정지 상태가 됩니다.

이를 위해서 Headless JS를 지원하거나 다른 라이브러리들이 존재합니다. 

 

https://reactnative.dev/docs/headless-js-android

 

Headless JS · React Native

Headless JS is a way to run tasks in JavaScript while your app is in the background. It can be used, for example, to sync fresh data, handle push notifications, or play music.

reactnative.dev

  • 앱이 Background Mode에 있는 동안 JS로 코드를 실행하는 방법을 제공하는 API
    • 주로 새로운 데이터 동기화, 푸시 알림 처리, 음악을 재생

https://github.com/transistorsoft/react-native-background-fetch

 

GitHub - transistorsoft/react-native-background-fetch: Periodic callbacks in the background for both IOS and Android

Periodic callbacks in the background for both IOS and Android - GitHub - transistorsoft/react-native-background-fetch: Periodic callbacks in the background for both IOS and Android

github.com

 

  • Background Mode에서 실행시키고 싶은 동작들을 좀 더 쉽게 사용할 수 있도록 추상화된 라이브러리

 

 

하지만 여기서 저희는 생각해 볼 점이 있습니다.

단순한 타이머를 위해서 Background Mode에서 계속 타이머를 실행시킬 필요가 있을까요?
또한 Webview 환경이 아닌 React Native에서 타이머를 동작시키고 이를 Webview에게 계속 전달해 줄 필요가 있을까요?

일단 저의 대답은 NO였습니다.

타이머 하나를 구동시키는데 배보다 배꼽이 더 큰 경우라고 생각이 들어서 다른 방법이 없을까 생각하던 도중

 

타이머를 시작한 시간을 기점으로 시간을 저장하고 Foreground에 왔을 때 차이를 계산하여 빼주면 되지 않을까?

를 생각해 보게 되었습니다.

웹뷰에서 실행될 수 있는 점과 BackgroundMode에서도 문제가 없다는 점에서 이를 구현해 보기로 하였습니다.


저장된 시간을 기반으로 타이머 구현

코드에 대한 개요는 이렇습니다.

 

  • 타이머 시작 시 현재 시간 저장: 사용자가 타이머를 시작할 때의 현재 시간을 저장
  • 앱 복귀 시 시간 차이 계산: 사용자가 앱을 백그라운드에서 복귀하였을 때 현재 시간과 저장된 시작 시간과의 차이를 계산하여 실제로 얼마나 많은 시간이 지났는지 계산
  • 타이머 조정: 계산된 시간 차이를 바탕으로 타이머를 조정 

코드로 구현하면 다음과 같습니다.

 

또한 초기 시작에 대한 setInterval에 대한 지연이 존재하기 때문에 이를 처리해 주었습니다.

const useTimer = (initialMilliseconds: number = 5 * 60 * 1000): TimerResponse => {
  const [startTime, setStartTime] = useState<number>(Date.now());
  const [remainingTime, setRemainingTime] = useState<number>(initialMilliseconds);
  const [isFinished, setIsFinished] = useState<boolean>(false);
  const [intervalId, setIntervalId] = useState<number | null>(null);

  const calculateRemaining = () => {
    const elapsedMilliseconds = Date.now() - startTime;
    const newRemainingTime = initialMilliseconds - elapsedMilliseconds;

    if (newRemainingTime <= 0) {
      setIsFinished(true);
      setRemainingTime(0);
      if (intervalId) {
        clearInterval(intervalId);
      }
    } else {
      setRemainingTime(newRemainingTime);
    }
  };

  useEffect(() => {
    const initiateTimer = () => {
      const id = window.setInterval(() => {
        calculateRemaining();
      }, 1000);
      setIntervalId(id);
    };

    // 초기 실행 시의 지연 계산
    const delayForNextSecond = 1000 - (Date.now() - startTime) % 1000;
    const initialTimeout = setTimeout(() => {
      calculateRemaining();
      initiateTimer();
    }, delayForNextSecond);

    return () => {
      clearTimeout(initialTimeout);
      if (intervalId) {
        clearInterval(intervalId);
      }
    };
  }, [startTime, initialMilliseconds]);

  const resetTimer = () => {
    if (intervalId) {
      clearInterval(intervalId);
    }
    setStartTime(Date.now());
    setIsFinished(false);
  };

  return {
    formattedTime: formatTime(remainingTime),
    isFinished,
    resetTimer,
  };
};

export default useTimer;

결과물

이제 다음과 같이 앱이 Background Mode에 전환되었다가 다시 돌아와도 시간이 정상적으로 흘러 있는 모습을 확인할 수 있습니다.

어떻게 보면 하나의 트릭?처럼 보일 수 있지만

앱이 자원을 계속 점유하지 않아도 된다는 점과 구현하려던 기능을 정확하게 만족하니 괜찮아 보입니다.

이걸 만들어보면서 문제를 좀 다른 시각으로 바라봐야 한다는 점이 기억에 남았던거 같네요 😃

profile

기록의 습관화

@ww8007

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!