모바일 웹에서 기기의 기울기, 회전, 흔들기 등 모션을 활용하면 스크롤 없이도 풍부한 인터랙션을 제공할 수 있습니다. React에서 DeviceMotionEvent와 DeviceOrientationEvent를 안전하게 사용하고, iOS 권한 처리, 성능/접근성까지 고려하는 실전 구현 방법을 정리합니다.
1. 지원 현황과 권한, 보안
HTTPS 환경에서만 동작하는 보안 제약이 있습니다. 로컬 개발은 https 또는 localhost에서 테스트합니다. iOS 13+ Safari는 사용자 제스처로 권한을 직접 요청해야 합니다. Android Chrome은 HTTPS에서 기본 허용되는 경우가 많습니다. 데스크톱 브라우저는 대부분 제한적입니다.
핵심 체크포인트입니다. 첫째, window.isSecureContext가 true인지 확인합니다. 둘째, 'DeviceMotionEvent' 또는 'DeviceOrientationEvent' 지원 여부를 검사합니다. 셋째, iOS에서 DeviceMotionEvent.requestPermission이 함수인지 확인하고 버튼 클릭 등 사용자 제스처에서 권한을 요청합니다.
2. React 훅으로 안전하게 추상화하기
권한 요청, 리스너 등록/해제, requestAnimationFrame 기반 스로틀링, 부드러운 보간(smoothing), SSR 가드, prefers-reduced-motion 대응을 하나의 훅으로 캡슐화합니다.
// useMotion.js
import { useEffect, useRef, useState } from "react";
export function useMotion({ enabled = true, smooth = 0.15 } = {}) {
const [supported, setSupported] = useState(false);
const [permissionNeeded, setPermissionNeeded] = useState(false);
const [permissionGranted, setPermissionGranted] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
const [error, setError] = useState(null);
const [orientation, setOrientation] = useState({ alpha: 0, beta: 0, gamma: 0 });
const [accel, setAccel] = useState({ x: 0, y: 0, z: 0 });
const latestRef = useRef({ o: null, a: null });
const rafRef = useRef(null);
const mqlRef = useRef(null);
// 1) 환경/권한 플래그 설정
useEffect(() => {
if (typeof window === "undefined") return;
const secure = window.isSecureContext === true;
const hasMotion = "DeviceMotionEvent" in window || "DeviceOrientationEvent" in window;
const needsPerm = typeof window.DeviceMotionEvent !== "undefined" &&
typeof window.DeviceMotionEvent.requestPermission === "function";
setSupported(secure && hasMotion);
setPermissionNeeded(needsPerm);
if (window.matchMedia) {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = () => setReducedMotion(!!mql.matches);
mqlRef.current = mql;
handler();
mql.addEventListener?.("change", handler);
return () => mql.removeEventListener?.("change", handler);
}
}, []);
// 2) 센서 값 업데이트를 rAF로 스로틀링하고 스무딩
const schedule = () => {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
setOrientation(prev => {
const o = latestRef.current.o || {};
const a = prev.alpha + ((o.alpha ?? prev.alpha) - prev.alpha) * smooth;
const b = prev.beta + ((o.beta ?? prev.beta) - prev.beta) * smooth;
const g = prev.gamma + ((o.gamma ?? prev.gamma) - prev.gamma) * smooth;
return { alpha: a, beta: b, gamma: g };
});
setAccel(prev => {
const v = latestRef.current.a || {};
const x = prev.x + ((v.x ?? prev.x) - prev.x) * smooth;
const y = prev.y + ((v.y ?? prev.y) - prev.y) * smooth;
const z = prev.z + ((v.z ?? prev.z) - prev.z) * smooth;
return { x, y, z };
});
});
};
// 3) 리스너 등록/해제
useEffect(() => {
if (typeof window === "undefined") return;
if (!enabled || !supported || reducedMotion) return;
if (permissionNeeded && !permissionGranted) return;
const onMotion = (e) => {
const a = e.accelerationIncludingGravity || e.acceleration || null;
if (a) latestRef.current.a = { x: a.x || 0, y: a.y || 0, z: a.z || 0 };
schedule();
};
const onOrientation = (e) => {
latestRef.current.o = { alpha: e.alpha || 0, beta: e.beta || 0, gamma: e.gamma || 0 };
schedule();
};
window.addEventListener("devicemotion", onMotion, { passive: true });
window.addEventListener("deviceorientation", onOrientation, { passive: true });
return () => {
cancelAnimationFrame(rafRef.current);
window.removeEventListener("devicemotion", onMotion);
window.removeEventListener("deviceorientation", onOrientation);
};
}, [enabled, supported, reducedMotion, permissionNeeded, permissionGranted, smooth]);
// 4) iOS 권한 요청 함수
const requestPermission = async () => {
try {
if (typeof window === "undefined") return false;
const DM = window.DeviceMotionEvent;
const DO = window.DeviceOrientationEvent;
const canRequest = DM && typeof DM.requestPermission === "function";
if (!canRequest) {
setPermissionGranted(true);
return true;
}
const p1 = await DM.requestPermission();
const p2 = DO && typeof DO.requestPermission === "function" ? await DO.requestPermission() : "granted";
const granted = p1 === "granted" && p2 === "granted";
setPermissionGranted(granted);
setPermissionNeeded(!granted);
return granted;
} catch (err) {
setError(String(err));
return false;
}
};
return {
supported,
permissionNeeded,
permissionGranted,
reducedMotion,
orientation,
accel,
error,
requestPermission,
};
}
3. 예제: 기울기에 따라 카드 틸트
장치 기울기(beta, gamma)를 카드 회전에 매핑합니다. iOS에서는 버튼으로 권한을 요청합니다. prefers-reduced-motion이 설정된 사용자는 변환을 비활성화합니다.
// TiltCard.jsx
import React, { useMemo } from "react";
import { useMotion } from "./useMotion";
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
export default function TiltCard() {
const { supported, permissionNeeded, permissionGranted, reducedMotion, orientation, requestPermission } = useMotion();
const { rotateX, rotateY } = useMemo(() => {
const beta = clamp(orientation.beta, -30, 30); // 앞뒤 기울기
const gamma = clamp(orientation.gamma, -30, 30); // 좌우 기울기
return { rotateX: -beta, rotateY: gamma };
}, [orientation]);
const transform = reducedMotion ? "none" : `perspective(800px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
return (
<div style={{ display: "grid", placeItems: "center", minHeight: "50vh" }}>
{supported ? (
<div style={{ width: 260 }}>
{permissionNeeded && !permissionGranted ? (
<button onClick={requestPermission} style={{ padding: "10px 14px", width: "100%" }}>
센서 접근 허용하기 (iOS)
</button>
) : (
<div style={{
height: 160,
borderRadius: 16,
background: "linear-gradient(135deg,#6EE7F9,#A78BFA)",
boxShadow: "0 20px 40px rgba(0,0,0,0.2)",
transform,
transition: "transform 60ms linear",
transformStyle: "preserve-3d"
}} />
)}
</div>
) : (
<p>이 브라우저에서는 모션 센서가 지원되지 않거나 HTTPS가 아닙니다.</p>
)}
</div>
);
}
권장 사항입니다. 값 범위를 clamp로 제한하여 과도한 회전을 방지하고, transition을 짧게 적용해 자연스럽게 보이도록 합니다. 콘텐츠 가독성이 중요한 경우 텍스트 레이어는 transform 영향을 받지 않도록 분리합니다.
4. 흔들기 제스처 감지
가속도 변화량의 크기를 임계값으로 판정해 흔들기 동작을 감지할 수 있습니다. 네트워크 요청 재시도, 토스트 열기 등 경량 피드백에 활용합니다.
// useShake.js
import { useEffect, useRef } from "react";
export function useShake(onShake, { threshold = 14, interval = 800 } = {}) {
const last = useRef({ x: 0, y: 0, z: 0, t: 0 });
useEffect(() => {
if (typeof window === "undefined") return;
if (!("DeviceMotionEvent" in window)) return;
if (!window.isSecureContext) return;
let ticking = false;
const handler = (e) => {
const a = e.accelerationIncludingGravity || e.acceleration;
if (!a) return;
const now = Date.now();
const dx = (a.x || 0) - last.current.x;
const dy = (a.y || 0) - last.current.y;
const dz = (a.z || 0) - last.current.z;
const delta = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
ticking = false;
if (delta > threshold && now - last.current.t > interval) {
last.current.t = now;
onShake?.();
}
last.current.x = a.x || 0;
last.current.y = a.y || 0;
last.current.z = a.z || 0;
});
}
};
window.addEventListener("devicemotion", handler, { passive: true });
return () => window.removeEventListener("devicemotion", handler);
}, [onShake, threshold, interval]);
}
threshold는 기기마다 튜닝이 필요합니다. 12~18 사이를 시작점으로 A/B 테스트하며, 과민 반응을 막기 위해 최소 간격(interval)을 둡니다.
5. 접근성, 성능, 보안 체크
접근성입니다. prefers-reduced-motion을 존중해 애니메이션을 비활성화합니다. 시각적 멀미를 유발할 수 있으므로 강한 회전 값은 피합니다. 성능입니다. requestAnimationFrame으로 샘플링하고, smoothing으로 잔떨림을 줄입니다. 이벤트 해제(cleanup)로 배터리 소모를 방지합니다. 보안입니다. 반드시 HTTPS에서 제공하고, iOS에서는 사용자 제스처로 권한을 요청합니다. 데이터는 민감할 수 있으므로 분석 이벤트로 과도하게 전송하지 않습니다.
6. 테스트와 디버깅
Chrome DevTools에서 More tools > Sensors > Orientation을 사용해 기울기/회전을 시뮬레이션합니다. Safari에서는 Develop 메뉴에서 시뮬레이터를 활용합니다. 실제 기기 테스트를 병행해 센서 노이즈와 성능을 확인합니다.
7. 실전 적용 아이디어
히어로 섹션의 패럴랙스 효과, 카드/제품 3D 틸트, 흔들어서 새로고침 또는 취소, 작은 파티클 레이어 반응, AR 보조 UI 정렬 등으로 UX를 개선할 수 있습니다. 중요한 상호작용은 터치 제스처와 병행해 대체 경로를 반드시 제공합니다.
정리하면, 모션 센서는 적절한 권한 처리와 성능/접근성 설계를 전제로 할 때 React 앱의 체감 품질을 크게 끌어올릴 수 있는 도구입니다. 위의 훅과 패턴을 기반으로 안전하게 확장해 보시기 바랍니다.
/* 접근성 CSS 예시 (전역 스타일에 추가)
@media (prefers-reduced-motion: reduce) {
.tilt, [data-tilt] { transform: none !important; transition: none !important; }
}
*/
'React' 카테고리의 다른 글
| React로 차트 애니메이션 직접 구현하기 (0) | 2026.05.28 |
|---|---|
| React 앱에서 브라우저 Speech Recognition API 사용하기 (0) | 2026.05.28 |
| React에서 Intersection Observer로 비디오 자동 재생 제어하기 (0) | 2026.05.27 |
| React 앱에서 로컬 개발 환경과 프로덕션 환경 분리하기 (0) | 2026.05.27 |
| React에서 CSS-in-JS 라이브러리 비교 및 선택 가이드 (1) | 2026.05.27 |