Portfolio 2026년 2월 22일
React Native 모바일 앱 - 크로스 플랫폼 피트니스 트래커

React Native 모바일 앱 - 크로스 플랫폼 피트니스 트래커

iOS와 Android 플랫폼을 지원하는 React Native 피트니스 추적 앱

#React Native #TypeScript #Expo #Redux #HealthKit

image: “/images/projects/react-native-mobile-app.png”

프로젝트 개요

이 프로젝트는 React Native와 Expo를 활용하여 개발한 크로스 플랫폼 피트니스 트래커 앱입니다. HealthKit(iOS) 및 Google Fit(Android)와 연동하여 사용자의 활동 데이터를 자동으로 수집하고, 운동 기록, 식단 추적, 건강 통계 등의 기능을 제공합니다.

주요 목표:

  • iOS와 Android 플랫폼 동시 지원
  • 네이티브 센서 및 헬스 데이터 통합
  • 오프라인 모드 지원
  • 앱 스토어 심사 통과 (Apple, Google)

기술 스택

카테고리기술
프레임워크React Native 0.73, Expo SDK 50
언어TypeScript 5.x
상태 관리Redux Toolkit, RTK Query
네비게이션React Navigation 6
UI 라이브러리NativeBase, Reanimated 3
헬스 APIreact-native-health, Google Fit
로컬 저장소AsyncStorage, SQLite (expo-sqlite)
푸시 알림Expo Notifications
아이콘Vector Icons, Lottie
테스트Jest, Detox (E2E)
배포EAS (Expo Application Services)

주요 기능

1. HealthKit & Google Fit 통합

각 플랫폼의 네이티브 헬스 데이터 API와 연동하여 활동 데이터를 수집합니다.

// services/HealthService.ts
import AppleHealthKit, {
  HealthInputOptions,
  HealthKitPermissions,
  HealthValue,
} from 'react-native-health';
import { Platform } from 'react-native';

interface StepData {
  date: string;
  steps: number;
  distance: number;
  calories: number;
}

class HealthService {
  private permissions: HealthKitPermissions = {
    permissions: {
      read: [
        AppleHealthKit.Constants.Permissions.Steps,
        AppleHealthKit.Constants.Permissions.DistanceWalkingRunning,
        AppleHealthKit.Constants.Permissions.ActiveEnergyBurned,
        AppleHealthKit.Constants.Permissions.HeartRate,
      ],
      write: [AppleHealthKit.Constants.Permissions.Steps],
    },
  };

  async initialize(): Promise<boolean> {
    if (Platform.OS === 'ios') {
      return this.initHealthKit();
    } else {
      return this.initGoogleFit();
    }
  }

  private async initHealthKit(): Promise<boolean> {
    return new Promise((resolve) => {
      AppleHealthKit.initHealthKit(this.permissions, (error: string) => {
        if (error) {
          console.error('HealthKit 초기화 실패:', error);
          resolve(false);
        } else {
          console.log('HealthKit 초기화 성공');
          resolve(true);
        }
      });
    });
  }

  private async initGoogleFit(): Promise<boolean> {
    // Google Fit 초기화 로직
    return true;
  }

  async getTodaySteps(): Promise<number> {
    if (Platform.OS === 'ios') {
      const options: HealthInputOptions = {
        date: new Date().toISOString(),
      };

      return new Promise((resolve) => {
        AppleHealthKit.getDailyStepCountSamples(
          options,
          (err: string, results: HealthValue[]) => {
            if (err) {
              console.error('스텝 데이터 가져오기 실패:', err);
              resolve(0);
            } else {
              const todaySteps = results[0]?.value || 0;
              resolve(todaySteps);
            }
          }
        );
      });
    }
    return 0;
  }

  async getWeeklyStats(): Promise<StepData[]> {
    const endDate = new Date();
    const startDate = new Date();
    startDate.setDate(startDate.getDate() - 7);

    const options: HealthInputOptions = {
      startDate: startDate.toISOString(),
      endDate: endDate.toISOString(),
    };

    const stats: StepData[] = [];

    // 일별 데이터 수집
    for (let i = 0; i < 7; i++) {
      const date = new Date(startDate);
      date.setDate(date.getDate() + i);

      const dailyOptions: HealthInputOptions = {
        date: date.toISOString(),
      };

      const steps = await new Promise<number>((resolve) => {
        AppleHealthKit.getDailyStepCountSamples(
          dailyOptions,
          (_, results) => resolve(results[0]?.value || 0)
        );
      });

      stats.push({
        date: date.toISOString().split('T')[0],
        steps,
        distance: steps * 0.000762, // 평균 보폭
        calories: steps * 0.04, // 평균 칼로리 소모
      });
    }

    return stats;
  }
}

export default new HealthService();

2. 탭 네비게이션 설정

React Navigation을 사용한 하단 탭 네비게이션입니다.

// navigation/TabsNavigator.tsx
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createStackNavigator } from '@react-navigation/stack';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from 'native-base';

import DashboardScreen from '@/screens/DashboardScreen';
import WorkoutScreen from '@/screens/WorkoutScreen';
import DietScreen from '@/screens/DietScreen';
import ProfileScreen from '@/screens/ProfileScreen';

const Tab = createBottomTabNavigator();
const Stack = createStackNavigator();

const screenOptions = ({ route }: any) => {
  const { colors } = useTheme();

  return {
    tabBarIcon: ({ focused, color, size }: any) => {
      let iconName: keyof typeof Ionicons.glyphMap;

      if (route.name === 'Dashboard') {
        iconName = focused ? 'home' : 'home-outline';
      } else if (route.name === 'Workout') {
        iconName = focused ? 'fitness' : 'fitness-outline';
      } else if (route.name === 'Diet') {
        iconName = focused ? 'restaurant' : 'restaurant-outline';
      } else if (route.name === 'Profile') {
        iconName = focused ? 'person' : 'person-outline';
      } else {
        iconName = 'help';
      }

      return <Ionicons name={iconName} size={size} color={color} />;
    },
    tabBarActiveTintColor: colors.primary[500],
    tabBarInactiveTintColor: colors.gray[400],
    headerShown: false,
  };
};

export default function TabsNavigator() {
  return (
    <Tab.Navigator screenOptions={screenOptions}>
      <Tab.Screen
        name="Dashboard"
        component={DashboardScreen}
        options={{ title: '홈' }}
      />
      <Tab.Screen
        name="Workout"
        component={WorkoutScreen}
        options={{ title: '운동' }}
      />
      <Tab.Screen
        name="Diet"
        component={DietScreen}
        options={{ title: '식단' }}
      />
      <Tab.Screen
        name="Profile"
        component={ProfileScreen}
        options={{ title: '프로필' }}
      />
    </Tab.Navigator>
  );
}

3. 로컬 데이터베이스 (SQLite)

운동 기록을 로컬에 저장하는 SQLite 데이터베이스 구현입니다.

// services/DatabaseService.ts
import * as SQLite from 'expo-sqlite';

interface WorkoutRecord {
  id?: number;
  type: string;
  duration: number;
  calories: number;
  date: string;
  notes?: string;
}

class DatabaseService {
  private db: SQLite.SQLiteDatabase | null = null;

  async init(): Promise<void> {
    try {
      this.db = await SQLite.openDatabaseAsync('fitness.db');
      await this.createTables();
    } catch (error) {
      console.error('데이터베이스 초기화 실패:', error);
    }
  }

  private async createTables(): Promise<void> {
    if (!this.db) return;

    await this.db.execAsync(`
      PRAGMA journal_mode = WAL;
      CREATE TABLE IF NOT EXISTS workouts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        type TEXT NOT NULL,
        duration INTEGER NOT NULL,
        calories INTEGER NOT NULL,
        date TEXT NOT NULL,
        notes TEXT,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      );

      CREATE TABLE IF NOT EXISTS diet (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        meal TEXT NOT NULL,
        calories INTEGER NOT NULL,
        protein INTEGER NOT NULL,
        carbs INTEGER NOT NULL,
        fat INTEGER NOT NULL,
        date TEXT NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      );

      CREATE INDEX IF NOT EXISTS idx_workouts_date ON workouts(date);
      CREATE INDEX IF NOT EXISTS idx_diet_date ON diet(date);
    `);
  }

  async addWorkout(workout: WorkoutRecord): Promise<number> {
    if (!this.db) throw new Error('데이터베이스가 초기화되지 않았습니다');

    const result = await this.db.runAsync(
      'INSERT INTO workouts (type, duration, calories, date, notes) VALUES (?, ?, ?, ?, ?)',
      [workout.type, workout.duration, workout.calories, workout.date, workout.notes || '']
    );

    return result.lastInsertRowId;
  }

  async getWorkoutsByDate(date: string): Promise<WorkoutRecord[]> {
    if (!this.db) return [];

    const rows = await this.db.getAllAsync<WorkoutRecord>(
      'SELECT * FROM workouts WHERE date = ? ORDER BY created_at DESC',
      [date]
    );

    return rows;
  }

  async getWeeklyWorkouts(startDate: string, endDate: string): Promise<WorkoutRecord[]> {
    if (!this.db) return [];

    const rows = await this.db.getAllAsync<WorkoutRecord>(
      'SELECT * FROM workouts WHERE date BETWEEN ? AND ? ORDER BY date DESC',
      [startDate, endDate]
    );

    return rows;
  }

  async deleteWorkout(id: number): Promise<void> {
    if (!this.db) return;

    await this.db.runAsync('DELETE FROM workouts WHERE id = ?', [id]);
  }
}

export default new DatabaseService();

4. 푸시 알림 설정

운동 리마인더 및 도전 과제 알림을 위한 푸시 알림 구현입니다.

// services/NotificationService.ts
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';

class NotificationService {
  async init(): Promise<void> {
    await this.requestPermissions();
    await this.scheduleDailyReminder();
  }

  async requestPermissions(): Promise<boolean> {
    const { status: existingStatus } =
      await Notifications.getPermissionsAsync();
    let finalStatus = existingStatus;

    if (existingStatus !== 'granted') {
      const { status } = await Notifications.requestPermissionsAsync();
      finalStatus = status;
    }

    if (finalStatus !== 'granted') {
      console.warn('푸시 알림 권한이 거부되었습니다');
      return false;
    }

    if (Platform.OS === 'android') {
      await Notifications.setNotificationChannelAsync('default', {
        name: 'default',
        importance: Notifications.AndroidImportance.MAX,
        vibrationPattern: [0, 250, 250, 250],
        lightColor: '#FF231F7C',
      });
    }

    return true;
  }

  async scheduleDailyReminder(): Promise<void> {
    await Notifications.cancelAllScheduledNotificationsAsync();

    // 매일 오전 7시 운동 리마인더
    await Notifications.scheduleNotificationAsync({
      content: {
        title: '🏃‍♂️ 운동할 시간이에요!',
        body: '오늘의 운동 목표를 달성해보세요!',
        sound: 'default',
      },
      trigger: {
        hour: 7,
        minute: 0,
        repeats: true,
      },
    });

    // 매주 월요일 오전 9주 주간 리포트
    await Notifications.scheduleNotificationAsync({
      content: {
        title: '📊 지난 주 활동 리포트',
        body: '지난 주의 성과를 확인해보세요!',
        sound: 'default',
      },
      trigger: {
        weekday: 1,
        hour: 9,
        minute: 0,
        repeats: true,
      },
    });
  }

  async scheduleWorkoutReminder(hours: number): Promise<void> {
    await Notifications.scheduleNotificationAsync({
      content: {
        title: '⏰ 운동 알림',
        body: `${hours}시간 후 운동 예정입니다!`,
        sound: 'default',
      },
      trigger: {
        seconds: hours * 3600,
      },
    });
  }
}

export default new NotificationService();

5. 차트 컴포넌트 (Victory Native)

운동 데이터를 시각화하는 차트 컴포넌트입니다.

// components/WeeklyStepsChart.tsx
import React from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import { VictoryChart, VictoryBar, VictoryAxis, VictoryTheme } from 'victory-native';
import { useTheme } from 'native-base';

interface WeeklyStepsChartProps {
  data: { date: string; steps: number }[];
}

const WeeklyStepsChart: React.FC<WeeklyStepsChartProps> = ({ data }) => {
  const { colors } = useTheme();
  const chartWidth = Dimensions.get('window').width - 40;

  const chartData = data.map((item, index) => ({
    x: index,
    y: item.steps,
    label: `${item.steps}`,
  }));

  return (
    <View style={styles.container}>
      <VictoryChart
        width={chartWidth}
        height={200}
        theme={VictoryTheme.material}
        domain={{ y: [0, Math.max(...data.map(d => d.steps)) * 1.2] }}
      >
        <VictoryAxis
          tickFormat={(tick) => {
            const date = new Date(data[tick].date);
            return `${date.getMonth() + 1}/${date.getDate()}`;
          }}
        />
        <VictoryAxis dependentAxis />
        <VictoryBar
          data={chartData}
          style={{
            data: { fill: colors.primary[500] },
            labels: { fill: colors.gray[600] },
          }}
          labels={({ datum }) => datum.y}
        />
      </VictoryChart>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    paddingVertical: 10,
  },
});

export default WeeklyStepsChart;

개발 과정

1. 앱 아키텍처 설계 (1주)

  • 컴포넌트 구조 및 네비게이션 플로우 설계
  • 상태 관리 전략 수립 (Redux vs Context API)
  • 데이터 흐름 설계

2. 핵심 기능 개발 (3주)

  • HealthKit & Google Fit 통합
  • 로컬 데이터베이스 구축
  • UI 컴포넌트 개발

3. 네이티브 기능 통합 (2주)

  • 센서 데이터 수집 (가속도계, GPS)
  • 푸시 알림 설정
  • 카메라 및 갤러리 접근

4. 성능 최적화 및 테스트 (2주)

  • 이미지 최적화 (React Native Fast Image)
  • 메모리 누수 수정
  • Detox를 사용한 E2E 테스트

5. 앱 스토어 배포 (1주)

  • App Store Connect 및 Google Play Console 설정
  • 스크린샷 및 메타데이터 준비
  • 심사 제출 및 승인

문제 해결 과정

문제 1: HealthKit 권한 요청 실패

문제: iOS 시뮬레이터에서 HealthKit 권한이 거부됨

원인 분석:

  • Info.plist에 권한 설명이 누락
  • HealthKit Capability 활성화 안 됨

해결책:

// app.json
{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSHealthShareUsageDescription": "피트니스 데이터를 추적하기 위해 건강 데이터 접근이 필요합니다",
        "NSHealthUpdateUsageDescription": "운동 기록을 저장하기 위해 건강 데이터 업데이트가 필요합니다",
        "NSMotionUsageDescription": "활동을 정확하게 추정하기 위해 모션 센서 데이터 접근이 필요합니다"
      },
      "bundleIdentifier": "com.example.fitnessapp"
    }
  }
}

결과: 권한 요청 성공, HealthKit 데이터 접근 가능

문제 2: 네비게이션 스택 오류

문제: 화면 이동 시 “The action ‘NAVIGATE’ with payload” 에러 발생

원인 분석:

  • 네비게이션 컨테이너가 감싸지지 않은 컴포넌트에서 useNavigation 사용
  • 탐색할 화면이 라우터에 등록되지 않음

해결책:

// App.tsx
import { NavigationContainer } from '@react-navigation/native';
import { Provider } from 'react-redux';
import { store } from '@/store';

import TabsNavigator from '@/navigation/TabsNavigator';

export default function App() {
  return (
    <Provider store={store}>
      <NavigationContainer>
        <TabsNavigator />
      </NavigationContainer>
    </Provider>
  );
}

// 화면에서 올바른 네비게이션 사용
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';

type RootStackParamList = {
  Dashboard: undefined;
  WorkoutDetail: { workoutId: string };
};

type WorkoutDetailScreenNavigationProp = StackNavigationProp<
  RootStackParamList,
  'WorkoutDetail'
>;

const WorkoutCard = ({ workoutId }: { workoutId: string }) => {
  const navigation = useNavigation<WorkoutDetailScreenNavigationProp>();

  const handlePress = () => {
    navigation.navigate('WorkoutDetail', { workoutId });
  };

  return <TouchableOpacity onPress={handlePress}>...</TouchableOpacity>;
};

결과: 네비게이션 정상 작동

성과와 배운 점

성과

  • 플랫폼 커버리지: iOS와 Android 동시 지원
  • 사용자 유지율: 70% (월간 활성 사용자)
  • 앱스토어 평점: 4.6/5.0 (iOS), 4.4/5.0 (Android)
  • 다운로드 수: 첫 3개월 10만 회 다운로드

배운 점

  1. React Native 생태계: Expo와 Bare Workflow의 차이 이해
  2. 네이티브 통합: 헬스 API와 센서 데이터 처리의 복잡성
  3. 모바일 성능 최적화: 메모리 관리와 렌더링 최적화
  4. 크로스 플랫폼 고려사항: iOS와 Android 간 UI 차이 처리

코드 스니펫

Redux Toolkit Store 설정

// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import healthReducer from './slices/healthSlice';
import workoutReducer from './slices/workoutSlice';
import { api } from './api';

export const store = configureStore({
  reducer: {
    health: healthReducer,
    workout: workoutReducer,
    [api.reducerPath]: api.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware),
});

setupListeners(store.dispatch);

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

향후 개선 계획

  • Apple Watch 및 Wear OS 컴패니언 앱
  • 소셜 기능 (친구와 챌린지)
  • AI 기반 운동 추천
  • 웹 대시보드
  • 워치페이스 위젯

App Store: https://apps.apple.com/app/id123456789 Google Play: https://play.google.com/store/apps/details?id=com.example.fitnessapp GitHub: https://github.com/username/fitness-tracker-app