백그라운드에서 React Native 타이머가 멈추는 것을 방지하는 완전 가이드

발행: (2025년 12월 26일 오후 12:06 GMT+9)
12 분 소요
원문: Dev.to

I’m happy to translate the article for you, but I’ll need the full text of the post (excluding any code blocks or URLs you’d like to keep unchanged). Could you please paste the content you’d like translated? Once I have it, I’ll provide a Korean translation while preserving the original formatting and markdown.

백그라운드 타이머가 있는 습관 추적 앱 만들기

도전 과제:
사용자가 폰을 잠그거나 다른 앱으로 전환하면 JavaScript 스레드가 일시 중지되고 타이머가 멈춥니다. 명상이나 운동 타이머의 경우 이는 기능이 깨진 상황이며, 사용자는 앱이 전경에 없더라도 타이머가 계속 실행되어 완료 시 알림을 받기를 기대합니다.

해결책: react-native-background-actions를 사용하여 Android Foreground Service를 활용하고, 최신 상태‑관리 솔루션인 Zustand와 동기화합니다.

왜 포그라운드 서비스인가?

모바일 OS는 배터리를 절약하기 위해 백그라운드 JavaScript를 적극적으로 일시 중지합니다. 포그라운드 서비스는 OS에 다음과 같이 알립니다:

“사용자가 알고 있는 중요한 작업을 수행하고 있으니—제거하지 마세요.”

지속적인 알림이 필요하여 사용자가 앱이 여전히 활성 상태임을 확인할 수 있습니다.

스택

구성 요소이유
React Native (Expo Dev Client 또는 bare workflow)UI 및 크로스‑플랫폼 코드
Zustand간단하고 훅이 없는 상태 저장소 (백그라운드 접근에 필수)
react-native-background-actionsAndroid 포그라운드 서비스를 처리

1️⃣ 라이브러리 설치

npm install react-native-background-actions

2️⃣ Android Manifest 구성

필요한 권한을 태그 내부에 추가하고, 서비스를 태그 내부에 선언하세요 (android/app/src/main/AndroidManifest.xml):

<!-- Add your permissions and service declaration here -->

Tip: 대부분의 개발자가 여기서 막힙니다. 권한이나 서비스 선언 중 하나라도 빠뜨리면 백그라운드 작업을 시작할 때 충돌이 발생합니다.

3️⃣ 백그라운드 스레드에서 Zustand 접근하기

Important: 백그라운드 작업 루프 안에서는 React 훅(useState, useEffect, useHabitStore() 같은 커스텀 훅)을 사용할 수 없습니다. 훅은 React 렌더 사이클에 의존하는데, 서비스에서는 해당 사이클이 실행되지 않기 때문입니다.

Zustand는 imperative access 라는 탈출구를 제공합니다: store.getState()를 통해. 이를 통해 백그라운드 작업이 상태를 직접 읽고 변경할 수 있습니다.

4️⃣ 백그라운드 작업 정의

작업 함수는 모든 React 컴포넌트 외부에 정의되어야 합니다.

// timerBackgroundTask.ts
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore'; // Direct import of the Zustand store

// Helper: pause execution without hogging the CPU
const sleep = (time: number) =>
  new Promise((resolve) => setTimeout(resolve, time));

/**
 * Background timer task.
 * @param taskDataArguments Optional data passed when starting the service.
 */
export const timerBackgroundTask = async (
  taskDataArguments?: {
    targetSeconds?: number;
    habitName?: string;
  }
): Promise<void> => {
  const target = taskDataArguments?.targetSeconds ?? 0;
  const habitName = taskDataArguments?.habitName ?? 'Timer';

  await new Promise((resolve) => {
    // Loop while the foreground service is alive
    const loop = async () => {
      while (BackgroundService.isRunning()) {
        // 1️⃣ IMPERATIVE STATE ACCESS (no hooks!)
        const state = useHabitStore.getState();

        // 2️⃣ Stop conditions – user paused or timer already finished
        if (!state.timerIsRunning || state.timerIsCompleted) {
          break;
        }

        // 3️⃣ Timer logic (robust against app pauses)
        const now = Date.now();
        const start = state.timerStartTime;

        // Initialise start time if it’s missing
        if (!start) {
          useHabitStore.getState().setTimerStartTime(now);
          await sleep(1000);
          continue;
        }

        const elapsed = Math.floor((now - start) / 1000); // seconds

        // 4️⃣ Target reached?
        if (target > 0 && elapsed >= target) {
          // Update Zustand state
          useHabitStore.getState().setTimerIsCompleted(true);
          useHabitStore.getState().setTimerIsRunning(false);
          useHabitStore.getState().setTimerTimeElapsed(target);

          // Final notification
          await BackgroundService.updateNotification({
            taskTitle: habitName,
            taskDesc: 'Timer finished!',
            progressBar: {
              max: target,
              value: target,
              indeterminate: false,
            },
          });

          // End the loop
          break;
        } else {
          // 5️⃣ Regular tick – update elapsed time & notification
          useHabitStore.getState().setTimerTimeElapsed(elapsed);

          const remaining = Math.max(0, target - elapsed);
          await BackgroundService.updateNotification({
            taskTitle: habitName,
            taskDesc: `Remaining: ${formatTime(remaining)}`, // helper defined elsewhere
            progressBar: {
              max: target,
              value: elapsed,
              indeterminate: false,
            },
          });
        }

        // 6️⃣ Sleep 1 s to save battery/CPU
        await sleep(1000);
      }

      // Loop exited – resolve the outer promise
      resolve();
    };

    // Kick‑off the async loop
    loop();
  });
};

핵심 포인트

  • 명령형 스토어 접근 (useHabitStore.getState())은 서비스 내부에서 상태를 읽고 쓰는唯一한 방법입니다.
  • 루프는 매 반복마다 BackgroundService.isRunning()을 확인하므로, 서비스가 중지될 때 작업이 깔끔하게 종료됩니다.
  • sleep(1000)은 루프가 CPU를 과도하게 사용하지 않도록 합니다.

5️⃣ UI를 백그라운드 작업에 연결하기

// HabitTimer.tsx
import { useEffect } from 'react';
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore';
import { timerBackgroundTask } from './timerBackgroundTask';

// Options for the foreground service notification
const serviceOptions = {
  taskName: 'HabitTimer',
  taskTitle: 'Habit Timer',
  taskDesc: 'Running...',
  // Android only – customize the notification appearance
  notification: {
    channelId: 'habit-timer',
    channelName: 'Habit Timer',
    color: '#ff0000',
    // icon: 'ic_launcher', // optional
  },
};

export const HabitTimer = () => {
  const {
    timerIsRunning,
    timerIsCompleted,
    timerTargetSeconds,
    habitName,
    setTimerIsRunning,
    setTimerIsCompleted,
    setTimerTimeElapsed,
    // any other needed actions...
  } = useHabitStore();

  // Start the foreground service when the timer becomes active
  useEffect(() => {
    const startService = async () => {
      if (timerIsRunning && !timerIsCompleted) {
        await BackgroundService.start(timerBackgroundTask, serviceOptions, {
          // Pass any data the task needs
          targetSeconds: timerTargetSeconds,
          habitName,
        });
      }
    };

    startService();

    // Cleanup – stop the service when the component unmounts or timer stops
    return () => {
      if (!timerIsRunning || timerIsCompleted) {
        BackgroundService.stop();
      }
    };
  }, [timerIsRunning, timerIsCompleted, timerTargetSeconds, habitName]);

  // UI rendering (simplified)
  return (
    <div>
      {habitName}
      {/* Render timer UI, start/pause buttons, etc. */}
    </div>
  );
};

설명

  1. **useEffect**는 Zustand 상태(timerIsRunning, timerIsCompleted 등)의 관련 조각들을 감시합니다.
  2. 타이머가 시작될 때 BackgroundService.start(...)를 호출하고 다음을 전달합니다:
    • 작업 함수(timerBackgroundTask)
    • 알림 옵션(serviceOptions)
    • 작업에 필요한 인수(targetSeconds, habitName).
  3. 정리 함수는 타이머가 일시 정지되거나 완료되었을 때, 혹은 컴포넌트가 언마운트될 때 서비스를 중지합니다.

6️⃣ 도우미: 시간 포맷팅 (선택사항)

// utils.ts
export const formatTime = (seconds: number): string => {
  const mins = Math.floor(seconds / 60);
  const secs = seconds % 60;
  return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};

🎉 요약

단계수행한 작업
1️⃣ 설치react-native-background-actions를 추가했습니다
2️⃣ 매니페스트포그라운드 서비스 권한 및 서비스를 선언했습니다
3️⃣ 스토어 접근백그라운드 루프 내부에서 훅스 대신 store.getState()를 사용하도록 전환했습니다
4️⃣ 작업Zustand를 업데이트하고 알림을 갱신하며 매 틱마다 1 초 대기하는 비동기 루프를 구현했습니다
5️⃣ UI 연결Zustand 상태에 따라 포그라운드 서비스를 시작/중지하기 위해 useEffect를 사용했습니다
6️⃣ 부가 기능보기 좋은 알림을 위한 작은 formatTime 헬퍼를 추가했습니다

이제 앱이 백그라운드에 있더라도 타이머가 계속 실행되고, 사용자는 지속적인 알림을 보며, Zustand의 명령형 API 덕분에 UI가 동기화된 상태를 유지합니다. 즐거운 습관 만들기! 🚀

백그라운드 서비스 통합

import { timerBackgroundTask } from './backgroundTasks'; // Import the function above
import { COLORS } from './theme';

// Inside your TimerComponent...
const {
  timerIsRunning,
  timerIsCompleted,
  selectedHabit,
  targetSeconds,
} = useHabitStore();

useEffect(() => {
  const manageBackgroundService = async () => {
    // -------------------------------------------------
    // Condition to **START**: timer is running, not finished,
    // and a habit is selected
    // -------------------------------------------------
    if (timerIsRunning && !timerIsCompleted && selectedHabit) {
      // Options for the native notification
      const options = {
        taskName: 'Habit Timer Task',
        taskTitle: selectedHabit.name,
        taskDesc: 'Timer Running...',
        taskIcon: {
          name: 'ic_launcher', // Ensure this icon exists in android/app/src/main/res/mipmap-*
          type: 'mipmap',
        },
        color: COLORS.primary,
        parameters: {
          // Pass initial data to the background‑task arguments
          targetSeconds,
          habitName: selectedHabit.name,
        },
      };

      try {
        // Only start if it's not already running
        if (!BackgroundService.isRunning()) {
          await BackgroundService.start(timerBackgroundTask, options);
          console.log('Background service started successfully');
        }
        // Optional: else { BackgroundService.updateNotification(...) } 
        // if you need UI‑only updates
      } catch (error) {
        console.error('Background Service Start failed:', error);
      }
    } else {
      // -------------------------------------------------
      // Logic to **STOP**: timer paused in UI or completed
      // -------------------------------------------------
      if (BackgroundService.isRunning()) {
        console.log('Stopping background service...');
        await BackgroundService.stop(); // Initiates teardown of the while loop
      }
    }
  };

  manageBackgroundService();

  // Re‑run this logic whenever the timer state changes
}, [timerIsRunning, timerIsCompleted, selectedHabit, targetSeconds]);

구현 팁

  • State‑Management Paradox
    백그라운드 작업은 일반 JavaScript 함수이며, React 컴포넌트가 아닙니다.
    여기에서는 훅을 사용할 수 없습니다. Redux, Zustand 또는 다른 스토어를 사용한다면, 루프 내부에서 데이터를 읽고 쓰기 위해 명령형 API(store.getState(), store.dispatch())를 사용하세요.

  • The Notification Loop
    BackgroundService.updateNotification을 사용하면 알림을 “활성” 상태로 유지할 수 있습니다.
    매 초마다 진행 바와 타이머 텍스트를 업데이트하면 사용자는 앱이 여전히 실행 중이며 주머니 안에 있음을 확인할 수 있습니다.

  • Clean‑Up Is Vital
    useEffect 의존성 배열이 핵심입니다. UI에서 timerIsRunningfalse 로 전환될 때 반드시 BackgroundService.stop()을 호출해야 합니다.
    이를 놓치면 배터리를 소모하고 사용자를 짜증나게 하는 “유령 프로세스”가 남게 됩니다.

  • Robust Timing
    백그라운드 작업이 단순히 elapsed++ 카운터 대신 Date.now() 차감을 사용하는 점에 주목하세요.
    시스템 시간 차이를 이용하면 OS가 일시적으로 실행을 멈추더라도 정확한 타이밍을 보장합니다.

이 접근 방식 덕분에 습관 추적기에 필요한 “설정하고 잊어버리기” 경험을 견고하게 구현할 수 있었으며, 스와이프 기반 UI의 세련됨과도 일치했습니다.


웹 또는 모바일 개발에 대한 자세한 내용은 언제든지 제 웹사이트를 방문해 주세요.

Back to Blog

관련 글

더 보기 »