디바이스 기능 활용 — 카메라, 위치, 푸시 알림




“앱이 웹과 다른 이유, 바로 디바이스 기능입니다.”
이 글에서는 Expo SDK를 활용해 카메라, 위치, 푸시 알림을 구현하는 방법을 코드와 함께 살펴봅니다.


디바이스 기능 사용 전 알아야 할 것

디바이스 기능(카메라, 위치 등)은 사용자 권한 허가가 필요합니다.
Expo SDK가 권한 요청을 간단하게 처리해줍니다.

기능 사용 요청
    ↓
권한 요청 다이얼로그 표시
    ↓
사용자 허가 / 거부
    ↓
허가된 경우에만 기능 사용

1. 카메라 & 이미지 선택

패키지 설치

npx expo install expo-image-picker expo-camera

갤러리에서 이미지 선택

import { View, Image, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { useState } from 'react';
import * as ImagePicker from 'expo-image-picker';

export default function ImagePickerScreen() {
  const [image, setImage] = useState<string | null>(null);

  const pickImage = async () => {
    // 갤러리 접근 권한 요청
    const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
    if (status !== 'granted') {
      alert('갤러리 접근 권한이 필요합니다.');
      return;
    }

    // 이미지 선택
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Images,
      allowsEditing: true,   // 편집 허용
      aspect: [1, 1],        // 1:1 비율로 크롭
      quality: 0.8,          // 품질 (0~1)
    });

    if (!result.canceled) {
      setImage(result.assets[0].uri);
    }
  };

  return (
    <View style={styles.container}>
      {image ? (
        <Image source={{ uri: image }} style={styles.image} />
      ) : (
        <View style={styles.placeholder}>
          <Text style={styles.placeholderText}>이미지를 선택하세요</Text>
        </View>
      )}

      <TouchableOpacity style={styles.button} onPress={pickImage}>
        <Text style={styles.buttonText}>갤러리에서 선택</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16, alignItems: 'center', gap: 16 },
  image: { width: 300, height: 300, borderRadius: 12 },
  placeholder: {
    width: 300,
    height: 300,
    backgroundColor: '#f0f0f0',
    borderRadius: 12,
    alignItems: 'center',
    justifyContent: 'center',
  },
  placeholderText: { color: '#999', fontSize: 16 },
  button: {
    backgroundColor: '#007AFF',
    paddingHorizontal: 24,
    paddingVertical: 12,
    borderRadius: 8,
  },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

카메라로 촬영

import * as ImagePicker from 'expo-image-picker';

const takePhoto = async () => {
  // 카메라 권한 요청
  const { status } = await ImagePicker.requestCameraPermissionsAsync();
  if (status !== 'granted') {
    alert('카메라 접근 권한이 필요합니다.');
    return;
  }

  // 카메라 실행
  const result = await ImagePicker.launchCameraAsync({
    allowsEditing: true,
    aspect: [4, 3],
    quality: 0.8,
  });

  if (!result.canceled) {
    setImage(result.assets[0].uri);
  }
};

카메라 화면 직접 구현 (expo-camera)

import { CameraView, useCameraPermissions } from 'expo-camera';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useRef } from 'react';

export default function CameraScreen() {
  const [permission, requestPermission] = useCameraPermissions();
  const cameraRef = useRef(null);

  if (!permission) return <View />;

  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text style={styles.message}>카메라 권한이 필요합니다.</Text>
        <TouchableOpacity style={styles.button} onPress={requestPermission}>
          <Text style={styles.buttonText}>권한 허용</Text>
        </TouchableOpacity>
      </View>
    );
  }

  const takePicture = async () => {
    if (cameraRef.current) {
      const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 });
      console.log('촬영된 사진:', photo.uri);
    }
  };

  return (
    <View style={styles.container}>
      <CameraView style={styles.camera} ref={cameraRef} facing="back">
        <View style={styles.controls}>
          <TouchableOpacity style={styles.captureButton} onPress={takePicture} />
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  controls: {
    flex: 1,
    justifyContent: 'flex-end',
    alignItems: 'center',
    paddingBottom: 40,
  },
  captureButton: {
    width: 70,
    height: 70,
    borderRadius: 35,
    backgroundColor: '#fff',
    borderWidth: 4,
    borderColor: '#ddd',
  },
  message: { fontSize: 16, textAlign: 'center', marginBottom: 16 },
  button: { backgroundColor: '#007AFF', padding: 12, borderRadius: 8 },
  buttonText: { color: '#fff', fontWeight: '600' },
});

2. 위치 (GPS)

패키지 설치

npx expo install expo-location

현재 위치 가져오기

import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useState } from 'react';
import * as Location from 'expo-location';

type LocationData = {
  latitude: number;
  longitude: number;
  accuracy: number | null;
} | null;

export default function LocationScreen() {
  const [location, setLocation] = useState<LocationData>(null);
  const [isLoading, setIsLoading] = useState(false);

  const getLocation = async () => {
    setIsLoading(true);

    // 위치 권한 요청
    const { status } = await Location.requestForegroundPermissionsAsync();
    if (status !== 'granted') {
      alert('위치 접근 권한이 필요합니다.');
      setIsLoading(false);
      return;
    }

    // 현재 위치 가져오기
    const currentLocation = await Location.getCurrentPositionAsync({
      accuracy: Location.Accuracy.High,
    });

    setLocation({
      latitude: currentLocation.coords.latitude,
      longitude: currentLocation.coords.longitude,
      accuracy: currentLocation.coords.accuracy,
    });

    setIsLoading(false);
  };

  return (
    <View style={styles.container}>
      {location ? (
        <View style={styles.infoBox}>
          <Text style={styles.label}>위도</Text>
          <Text style={styles.value}>{location.latitude.toFixed(6)}</Text>
          <Text style={styles.label}>경도</Text>
          <Text style={styles.value}>{location.longitude.toFixed(6)}</Text>
          <Text style={styles.label}>정확도</Text>
          <Text style={styles.value}>{location.accuracy?.toFixed(0)}m</Text>
        </View>
      ) : (
        <Text style={styles.placeholder}>위치 정보가 없습니다.</Text>
      )}

      <TouchableOpacity
        style={[styles.button, isLoading && styles.buttonDisabled]}
        onPress={getLocation}
        disabled={isLoading}
      >
        <Text style={styles.buttonText}>
          {isLoading ? '위치 확인 중...' : '현재 위치 가져오기'}
        </Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16, alignItems: 'center', justifyContent: 'center', gap: 16 },
  infoBox: {
    backgroundColor: '#f5f5f5',
    borderRadius: 12,
    padding: 20,
    width: '100%',
    gap: 4,
  },
  label: { fontSize: 12, color: '#999', marginTop: 8 },
  value: { fontSize: 18, fontWeight: '600' },
  placeholder: { fontSize: 16, color: '#999' },
  button: { backgroundColor: '#007AFF', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8 },
  buttonDisabled: { backgroundColor: '#aaa' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

주소로 변환 (역지오코딩)

import * as Location from 'expo-location';

const getAddress = async (latitude: number, longitude: number) => {
  const addresses = await Location.reverseGeocodeAsync({ latitude, longitude });

  if (addresses.length > 0) {
    const addr = addresses[0];
    return `${addr.city} ${addr.district} ${addr.street}`;
  }
  return '주소를 찾을 수 없습니다.';
};

실시간 위치 추적

import { useEffect, useRef } from 'react';
import * as Location from 'expo-location';

const startTracking = async () => {
  const { status } = await Location.requestForegroundPermissionsAsync();
  if (status !== 'granted') return;

  // 위치 변경 시마다 콜백 실행
  const subscription = await Location.watchPositionAsync(
    {
      accuracy: Location.Accuracy.High,
      timeInterval: 3000,      // 3초마다 업데이트
      distanceInterval: 10,    // 10m 이동 시 업데이트
    },
    (newLocation) => {
      console.log('새 위치:', newLocation.coords);
    }
  );

  // 컴포넌트 언마운트 시 추적 중지
  return () => subscription.remove();
};

3. 푸시 알림

패키지 설치

npx expo install expo-notifications expo-device

알림 권한 요청 & 토큰 발급

import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';

// 알림 표시 방식 설정
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

export async function registerForPushNotifications() {
  // 실기기에서만 동작
  if (!Device.isDevice) {
    alert('푸시 알림은 실기기에서만 동작합니다.');
    return;
  }

  // 권한 확인
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  // 권한이 없으면 요청
  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

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

  // Android 채널 설정
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: '기본 알림',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
    });
  }

  // Expo Push Token 발급
  const token = await Notifications.getExpoPushTokenAsync();
  console.log('Push Token:', token.data);
  return token.data;
}

로컬 알림 발송 (즉시 & 예약)

import * as Notifications from 'expo-notifications';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';

export default function NotificationScreen() {

  // 즉시 알림
  const sendImmediateNotification = async () => {
    await Notifications.scheduleNotificationAsync({
      content: {
        title: '새로운 메시지 📬',
        body: '안녕하세요! 테스트 알림입니다.',
        data: { screen: 'home', postId: '123' }, // 알림 클릭 시 전달할 데이터
      },
      trigger: null, // null = 즉시 발송
    });
  };

  // 5초 후 알림
  const sendDelayedNotification = async () => {
    await Notifications.scheduleNotificationAsync({
      content: {
        title: '5초 후 알림 ⏰',
        body: '5초가 지났습니다!',
      },
      trigger: { seconds: 5 },
    });
    alert('5초 후에 알림이 옵니다!');
  };

  // 매일 오전 9시 알림
  const scheduleDailyNotification = async () => {
    await Notifications.scheduleNotificationAsync({
      content: {
        title: '오늘의 알림 ☀️',
        body: '좋은 아침입니다!',
      },
      trigger: {
        hour: 9,
        minute: 0,
        repeats: true, // 매일 반복
      },
    });
    alert('매일 오전 9시에 알림이 설정되었습니다!');
  };

  // 예약된 알림 모두 취소
  const cancelAllNotifications = async () => {
    await Notifications.cancelAllScheduledNotificationsAsync();
    alert('모든 예약 알림이 취소되었습니다.');
  };

  return (
    <View style={styles.container}>
      <TouchableOpacity style={styles.button} onPress={sendImmediateNotification}>
        <Text style={styles.buttonText}>즉시 알림 보내기</Text>
      </TouchableOpacity>

      <TouchableOpacity style={styles.button} onPress={sendDelayedNotification}>
        <Text style={styles.buttonText}>5초 후 알림</Text>
      </TouchableOpacity>

      <TouchableOpacity style={styles.button} onPress={scheduleDailyNotification}>
        <Text style={styles.buttonText}>매일 오전 9시 알림 설정</Text>
      </TouchableOpacity>

      <TouchableOpacity
        style={[styles.button, styles.cancelButton]}
        onPress={cancelAllNotifications}
      >
        <Text style={styles.buttonText}>모든 알림 취소</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16, gap: 12 },
  button: { backgroundColor: '#007AFF', padding: 14, borderRadius: 8, alignItems: 'center' },
  cancelButton: { backgroundColor: '#FF3B30' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

알림 클릭 이벤트 처리

// app/_layout.tsx
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';

export default function RootLayout() {
  const router = useRouter();
  const notificationListener = useRef<any>();
  const responseListener = useRef<any>();

  useEffect(() => {
    // 앱이 열린 상태에서 알림 수신
    notificationListener.current =
      Notifications.addNotificationReceivedListener((notification) => {
        console.log('알림 수신:', notification);
      });

    // 알림 클릭 시
    responseListener.current =
      Notifications.addNotificationResponseReceivedListener((response) => {
        const data = response.notification.request.content.data;
        // 알림 데이터에 따라 특정 화면으로 이동
        if (data?.screen) {
          router.push(`/${data.screen}`);
        }
      });

    return () => {
      notificationListener.current?.remove();
      responseListener.current?.remove();
    };
  }, []);

  return <Stack />;
}

app.json 권한 설정

디바이스 기능을 사용하려면 app.json에 권한을 명시해야 합니다.

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSCameraUsageDescription": "프로필 사진 촬영을 위해 카메라가 필요합니다.",
        "NSPhotoLibraryUsageDescription": "프로필 사진 선택을 위해 갤러리 접근이 필요합니다.",
        "NSLocationWhenInUseUsageDescription": "주변 매장 검색을 위해 위치 정보가 필요합니다."
      }
    },
    "android": {
      "permissions": [
        "CAMERA",
        "READ_EXTERNAL_STORAGE",
        "ACCESS_FINE_LOCATION",
        "ACCESS_COARSE_LOCATION"
      ]
    }
  }
}

정리

기능패키지주요 함수
갤러리 선택expo-image-pickerlaunchImageLibraryAsync()
카메라 촬영expo-image-pickerlaunchCameraAsync()
카메라 뷰expo-camera<CameraView>
현재 위치expo-locationgetCurrentPositionAsync()
위치 추적expo-locationwatchPositionAsync()
역지오코딩expo-locationreverseGeocodeAsync()
로컬 알림expo-notificationsscheduleNotificationAsync()
푸시 토큰expo-notificationsgetExpoPushTokenAsync()

다음 편에서는 Firebase를 연동해 로그인과 데이터 저장을 구현하는 방법을 살펴보겠습니다.


시리즈 목차

  • 1편 Expo 개발 환경 세팅 & 첫 앱 실행
  • 2편 화면 구성 & 핵심 컴포넌트
  • 3편 화면 이동 구현 (Expo Router)
  • 4편 상태 관리 & API 연동
  • 5편 디바이스 기능 활용 (카메라, 위치, 알림) ← 현재 글
  • 6편 Firebase 연동
  • 7편 빌드 & 앱스토어 배포



댓글 남기기