“앱이 웹과 다른 이유, 바로 디바이스 기능입니다.”
이 글에서는 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-picker | launchImageLibraryAsync() |
| 카메라 촬영 | expo-image-picker | launchCameraAsync() |
| 카메라 뷰 | expo-camera | <CameraView> |
| 현재 위치 | expo-location | getCurrentPositionAsync() |
| 위치 추적 | expo-location | watchPositionAsync() |
| 역지오코딩 | expo-location | reverseGeocodeAsync() |
| 로컬 알림 | expo-notifications | scheduleNotificationAsync() |
| 푸시 토큰 | expo-notifications | getExpoPushTokenAsync() |
다음 편에서는 Firebase를 연동해 로그인과 데이터 저장을 구현하는 방법을 살펴보겠습니다.
시리즈 목차
- 1편 Expo 개발 환경 세팅 & 첫 앱 실행
- 2편 화면 구성 & 핵심 컴포넌트
- 3편 화면 이동 구현 (Expo Router)
- 4편 상태 관리 & API 연동
- 5편 디바이스 기능 활용 (카메라, 위치, 알림) ← 현재 글
- 6편 Firebase 연동
- 7편 빌드 & 앱스토어 배포