스마트폰의 가속도계, 자이로, 기기 방향, 위치 데이터를 React 앱에서 동시에 쓰려면 권한, 브라우저 제약, 성능, 데이터 품질을 한 번에 고려해야 합니다. 이 글은 실무에서 바로 적용 가능한 최소 코드로 센서 통합 처리 패턴을 정리합니다.
1. 지원과 제약 요약
- HTTPS 필수: 센서/지오로케이션은 보안 컨텍스트에서만 동작합니다.
- iOS 사파리: devicemotion/deviceorientation은 사용자 제스처 후 권한 요청이 필요합니다.
- 이벤트 주기: 기기마다 주파수·정밀도가 다릅니다. 통합 시 타임스탬프 기준으로 병합하세요.
- SSR: 센서는 브라우저 전용입니다. React 서버 렌더링 시 안전가드를 둡니다.
2. 권한 유틸과 SSR 가드
// ssrGuard.js
export const isClient = typeof window !== 'undefined' && typeof navigator !== 'undefined';
export async function requestMotionPermission() {
if (!isClient) return false;
const DM = typeof DeviceMotionEvent !== 'undefined' ? DeviceMotionEvent : null;
const DO = typeof DeviceOrientationEvent !== 'undefined' ? DeviceOrientationEvent : null;
try {
if (DM && typeof DM.requestPermission === 'function') {
const res = await DM.requestPermission();
if (res !== 'granted') return false;
}
if (DO && typeof DO.requestPermission === 'function') {
const res = await DO.requestPermission();
if (res !== 'granted') return false;
}
return true;
} catch (e) {
return false;
}
}
export async function ensureGeoPermission() {
if (!isClient || !navigator.permissions) return true; // 일부 브라우저 호환
try {
const status = await navigator.permissions.query({ name: 'geolocation' });
return status.state === 'granted' || status.state === 'prompt';
} catch {
return true;
}
}
3. 가속도/자이로 훅 (저지연 + 저소음)
// useDeviceMotion.js
import { useEffect, useRef, useState } from 'react';
import { isClient } from './ssrGuard';
function lowPass(prev, curr, alpha) {
if (prev == null) return curr;
return prev + alpha * (curr - prev);
}
export function useDeviceMotion({ smoothing = 0.2 } = {}) {
const [data, setData] = useState(null);
const rafRef = useRef(0);
const bufRef = useRef(null);
useEffect(() => {
if (!isClient) return;
const onMotion = (e) => {
const a = e.accelerationIncludingGravity || e.acceleration || {};
const r = e.rotationRate || {};
const sample = {
ax: a.x ?? 0,
ay: a.y ?? 0,
az: a.z ?? 0,
alpha: r.alpha ?? 0, // deg/s
beta: r.beta ?? 0,
gamma: r.gamma ?? 0,
interval: e.interval ?? 16,
t: performance.now(),
};
bufRef.current = sample;
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
if (bufRef.current) {
setData((prev) => {
if (!prev) return bufRef.current;
return {
...bufRef.current,
ax: lowPass(prev.ax, bufRef.current.ax, smoothing),
ay: lowPass(prev.ay, bufRef.current.ay, smoothing),
az: lowPass(prev.az, bufRef.current.az, smoothing),
alpha: lowPass(prev.alpha, bufRef.current.alpha, smoothing),
beta: lowPass(prev.beta, bufRef.current.beta, smoothing),
gamma: lowPass(prev.gamma, bufRef.current.gamma, smoothing),
};
});
}
});
}
};
window.addEventListener('devicemotion', onMotion, { passive: true });
return () => {
window.removeEventListener('devicemotion', onMotion);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [smoothing]);
return data; // { ax, ay, az, alpha, beta, gamma, interval, t }
}
4. 기기 방향 훅 (자이로/자기계 보조)
// useDeviceOrientation.js
import { useEffect, useState } from 'react';
import { isClient } from './ssrGuard';
export function useDeviceOrientation() {
const [ori, setOri] = useState(null);
useEffect(() => {
if (!isClient) return;
const onOrientation = (e) => {
setOri({
alpha: e.alpha ?? 0, // 0~360, z축
beta: e.beta ?? 0, // -180~180, x축 기울기
gamma: e.gamma ?? 0, // -90~90, y축 기울기
absolute: !!e.absolute,
t: performance.now(),
});
};
window.addEventListener('deviceorientation', onOrientation, { passive: true });
return () => window.removeEventListener('deviceorientation', onOrientation);
}, []);
return ori;
}
5. 지오로케이션 훅 (워처 + 정밀도 옵션)
// useGeolocation.js
import { useEffect, useRef, useState } from 'react';
import { isClient } from './ssrGuard';
export function useGeolocation(options = { enableHighAccuracy: true, maximumAge: 1000, timeout: 10000 }) {
const [pos, setPos] = useState(null);
const watchId = useRef(null);
useEffect(() => {
if (!isClient || !('geolocation' in navigator)) return;
watchId.current = navigator.geolocation.watchPosition(
(p) => {
setPos({
lat: p.coords.latitude,
lng: p.coords.longitude,
accuracy: p.coords.accuracy,
speed: p.coords.speed,
heading: p.coords.heading,
t: p.timestamp,
});
},
(err) => {
console.warn('Geolocation error', err);
},
options
);
return () => {
if (watchId.current != null) navigator.geolocation.clearWatch(watchId.current);
};
}, [options?.enableHighAccuracy, options?.maximumAge, options?.timeout]);
return pos;
}
6. 센서 허브: 시간 기준 병합과 파생값
센서마다 주기가 다르므로 마지막 샘플을 보관해 requestAnimationFrame 주기에 맞춰 일관된 스냅샷을 제공합니다. 또한 간단한 파생값(기울기, 중력 크기)을 계산합니다.
// useSensorHub.js
import { useEffect, useRef, useState } from 'react';
import { useDeviceMotion } from './useDeviceMotion';
import { useDeviceOrientation } from './useDeviceOrientation';
import { useGeolocation } from './useGeolocation';
export function useSensorHub() {
const motion = useDeviceMotion();
const orientation = useDeviceOrientation();
const geo = useGeolocation();
const [snap, setSnap] = useState(null);
const rafRef = useRef(0);
useEffect(() => {
const loop = () => {
setSnap((prev) => {
const m = motion || {};
const o = orientation || {};
const g = geo || {};
const gravityMag = m.ax != null ? Math.sqrt(m.ax*m.ax + m.ay*m.ay + m.az*m.az) : null;
const tilt = o.beta != null ? { pitch: o.beta, roll: o.gamma } : null;
return {
t: performance.now(),
motion: m,
orientation: o,
geo: g,
derived: { gravityMag, tilt },
};
});
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [motion, orientation, geo]);
return snap;
}
7. 권한 요청 + 시각화 컴포넌트
// SensorsDashboard.jsx
import React, { useState } from 'react';
import { requestMotionPermission, ensureGeoPermission } from './ssrGuard';
import { useSensorHub } from './useSensorHub';
export default function SensorsDashboard() {
const [granted, setGranted] = useState(false);
const snap = useSensorHub();
const onEnable = async () => {
const ok1 = await requestMotionPermission();
const ok2 = await ensureGeoPermission();
if (!ok2 && 'geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(() => {}, () => {});
}
setGranted(ok1);
};
return (
<div style={{ padding: 16 }}>
{!granted && (
<button onClick={onEnable}>센서 활성화</button>
)}
{granted && snap && (
<div>
<h4>Motion</h4>
<div>ax: {snap.motion?.ax?.toFixed?.(3)} ay: {snap.motion?.ay?.toFixed?.(3)} az: {snap.motion?.az?.toFixed?.(3)}</div>
<div>gyro αβγ: {snap.motion?.alpha?.toFixed?.(1)} / {snap.motion?.beta?.toFixed?.(1)} / {snap.motion?.gamma?.toFixed?.(1)}</div>
<h4>Orientation</h4>
<div>α: {snap.orientation?.alpha?.toFixed?.(1)} β: {snap.orientation?.beta?.toFixed?.(1)} γ: {snap.orientation?.gamma?.toFixed?.(1)} abs: {String(snap.orientation?.absolute)}</div>
<h4>Geo</h4>
<div>lat: {snap.geo?.lat} lng: {snap.geo?.lng} ±{snap.geo?.accuracy}m</div>
<h4>Derived</h4>
<div>|g|≈ {snap.derived?.gravityMag ? snap.derived.gravityMag.toFixed(3) : '—'} m/s²</div>
<div>tilt: pitch {snap.derived?.tilt?.pitch?.toFixed?.(1)} roll {snap.derived?.tilt?.roll?.toFixed?.(1)}</div>
</div>
)}
</div>
);
}
8. 성능 팁
- requestAnimationFrame으로 센서 이벤트를 합쳐 렌더 빈도를 제한합니다.
- 이벤트 리스너는 passive: true로 등록하고, 컴포넌트 언마운트 시 반드시 해제합니다.
- 리스트/차트로 그릴 때는 memoization과 windowing을 적용합니다.
- 필요한 센서만 구독하도록 훅을 분리하고 페이지 전환 시 구독을 해제합니다.
9. 데이터 품질과 보정
- 저역통과(중력 추정)와 고역통과(진동/충격 분리)를 목적에 맞게 조합합니다.
- 기기 방향 좌표계는 화면 회전에 따라 달라집니다. 화면 방향(window.screen.orientation.angle)을 고려해 회전 보정하세요.
- 자기장 간섭이 있는 환경에서는 절대 방향(absolute)이 불안정할 수 있습니다.
10. 테스트와 디버깅
- 크롬 DevTools의 Sensors 패널로 위치/가속 모킹을 활용합니다.
- iOS는 실제 기기 테스트가 필수입니다. 권한 요청은 사용자 제스처(클릭)에서만 허용됩니다.
11. 배포 체크리스트
- HTTPS 및 PWA 설정
- 첫 진입 튜토리얼/권한 요청 UI
- 지원 불가 브라우저 안내 및 폴백
- 개인정보 처리방침 및 위치 데이터 저장 정책
위 코드 조합만으로도 React 앱에서 센서 데이터를 안전하게 통합·시각화할 수 있습니다. 이후에는 Kalman/Complementary Filter, AHRS 라이브러리 등을 추가해 자세 추정까지 확장해 보시길 권합니다.
'React' 카테고리의 다른 글
| React 앱에서 파일 시스템 접근 API 활용하기 (0) | 2026.07.13 |
|---|---|
| React에서 컴포넌트 렌더링 조건 최적화하기 (0) | 2026.07.12 |
| React에서 애플리케이션 Configuration 파일 동적 로드하기 (0) | 2026.07.10 |
| React 앱에서 사용자 활동 로그 시각화하기 (1) | 2026.07.10 |
| React에서 브라우저 Speech Synthesis API 활용하여 음성 출력하기 (0) | 2026.07.09 |