본문 바로가기

React

React 앱에서 스마트폰 센서 데이터 통합 처리

스마트폰의 가속도계, 자이로, 기기 방향, 위치 데이터를 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 라이브러리 등을 추가해 자세 추정까지 확장해 보시길 권합니다.