React 애플리케이션에서 오프라인 모드 UI를 제공하면 네트워크가 불안정한 환경에서도 안정적인 사용자 경험을 유지할 수 있습니다. 실무에서는 실제 네트워크 연결 상태와 사용자가 직접 선택한 오프라인 모드를 함께 고려해 UI를 설계하는 것이 핵심입니다.
1. 오프라인 UI 요구사항 정의
오프라인 UI는 다음 두 가지 상태를 분리해서 관리합니다.
- 실제 네트워크 상태: 브라우저 연결 이벤트(online/offline)로 감지합니다.
- 사용자 오프라인 모드: 사용자가 강제로 오프라인으로 전환해 네트워크 요청을 보류합니다.
UI는 상태 배지/배너 표시, 토글 스위치 제공, 요청 가드, 폴백 메시지를 포함합니다. 사용자 선택은 localStorage에 저장해 재방문 시 유지합니다.
2. 네트워크 상태 감지 훅 만들기
브라우저의 navigator.onLine과 online/offline 이벤트로 기본 연결 상태를 추적합니다. 필요하면 핑(ping) URL로 실제 서버 응답을 주기적으로 확인합니다.
import { useEffect, useState } from 'react';
export function useNetworkStatus(pingUrl, pingInterval = 0) {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const update = () => setIsOnline(navigator.onLine);
window.addEventListener('online', update);
window.addEventListener('offline', update);
update();
let timer;
let abortCtrl;
const ping = async () => {
if (!pingUrl || pingInterval <= 0) return;
if (!navigator.onLine) { setIsOnline(false); return; }
abortCtrl?.abort();
abortCtrl = new AbortController();
try {
const res = await fetch(pingUrl, {
method: 'HEAD',
cache: 'no-store',
signal: abortCtrl.signal,
});
setIsOnline(res.ok);
} catch (e) {
setIsOnline(false);
}
};
if (pingInterval > 0) {
timer = setInterval(ping, pingInterval);
ping();
}
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
if (timer) clearInterval(timer);
abortCtrl?.abort();
};
}, [pingUrl, pingInterval]);
return isOnline;
}
3. 오프라인 모드 컨텍스트 구성
사용자 오프라인 모드 상태를 컨텍스트로 제공하고 localStorage에 동기화합니다. 실제 온라인 여부와 합쳐 "효과적 오프라인" 상태를 계산합니다.
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useNetworkStatus } from './useNetworkStatus';
const OfflineContext = createContext({
offlineMode: false,
setOfflineMode: () => {},
isOnline: true,
isOffline: false,
});
export function OfflineProvider({ children, pingUrl, pingInterval = 0 }) {
const isOnline = useNetworkStatus(pingUrl, pingInterval);
const [offlineMode, setOfflineMode] = useState(() => localStorage.getItem('offlineMode') === '1');
useEffect(() => {
localStorage.setItem('offlineMode', offlineMode ? '1' : '0');
}, [offlineMode]);
const isOffline = offlineMode || !isOnline;
const value = useMemo(() => ({ offlineMode, setOfflineMode, isOnline, isOffline }), [offlineMode, isOnline]);
return (
<OfflineContext.Provider value={value}>
{children}
</OfflineContext.Provider>
);
}
export const useOffline = () => useContext(OfflineContext);
4. 오프라인 배너와 토글 스위치 UI
오프라인일 때만 배너를 보여주고, 스위치로 사용자 오프라인 모드를 제어합니다. 접근성을 위해 aria-live와 aria-pressed를 설정합니다.
import React from 'react';
import { useOffline } from './OfflineProvider';
export function OfflineBanner() {
const { isOffline, offlineMode } = useOffline();
if (!isOffline) return null;
return (
<div role="status" aria-live="polite" style={{
background: '#fffbe6',
color: '#614700',
padding: 8,
textAlign: 'center',
borderBottom: '1px solid #ffe58f',
}}>
{offlineMode
? '오프라인 모드를 사용 중입니다. 네트워크 요청이 보류됩니다.'
: '네트워크 연결이 끊겼습니다. 오프라인으로 동작합니다.'}
</div>
);
}
export function OfflineToggle() {
const { offlineMode, setOfflineMode, isOffline } = useOffline();
const onClick = () => setOfflineMode(!offlineMode);
return (
<button
onClick={onClick}
aria-pressed={offlineMode}
title="오프라인 모드 전환"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 16,
background: offlineMode ? '#fde68a' : '#f5f5f5',
cursor: 'pointer',
}}>
<span style={{
width: 10,
height: 10,
borderRadius: '50%',
background: isOffline ? '#f59e0b' : '#22c55e',
}} />
{offlineMode ? '오프라인 모드 ON' : '오프라인 모드 OFF'}
</button>
);
}
5. 오프라인 가드 컴포넌트
오프라인일 때 특정 액션을 차단하거나 안내 메시지를 제공합니다.
import React from 'react';
import { useOffline } from './OfflineProvider';
export function GuardedButton({ onClick, children, ...rest }) {
const { isOffline } = useOffline();
return (
<button
{...rest}
onClick={isOffline ? undefined : onClick}
disabled={isOffline}
title={isOffline ? '오프라인 상태에서는 실행할 수 없습니다' : undefined}>
{children}
</button>
);
}
6. 앱에 통합하기
배너, 토글, 가드 컴포넌트를 앱에 배치합니다. pingUrl에는 서버 헬스 체크 엔드포인트를 설정합니다.
import React from 'react';
import { OfflineProvider } from './OfflineProvider';
import { OfflineBanner, OfflineToggle } from './OfflineUI';
import { GuardedButton } from './GuardedButton';
export default function App() {
return (
<OfflineProvider pingUrl="/healthz" pingInterval={15000}>
<OfflineBanner />
<header style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}>
<h1>My App</h1>
<OfflineToggle />
</header>
<main>
<GuardedButton onClick={() => alert('데이터 요청 실행')}>데이터 가져오기</GuardedButton>
</main>
</OfflineProvider>
);
}
7. 오프라인 요청 큐(axios)로 안전하게 처리
오프라인이면 요청을 큐에 저장하고 온라인 복귀 시 재시도합니다. 큐 크기를 제한하고, 온라인으로 돌아오면 즉시 flush합니다.
import axios from 'axios';
export function createQueuedClient(getIsOffline, { maxQueue = 50 } = {}) {
const client = axios.create();
const queue = [];
const flush = async () => {
if (getIsOffline() || queue.length === 0) return;
const items = [...queue];
queue.length = 0;
for (const item of items) {
try {
const res = await client.request(item.config);
item.resolve(res);
} catch (err) {
item.reject(err);
}
}
};
client.interceptors.request.use(config => {
if (getIsOffline()) {
if (queue.length >= maxQueue) {
return Promise.reject(new Error('오프라인 큐가 가득 찼습니다'));
}
return new Promise((resolve, reject) => {
queue.push({ config, resolve, reject });
});
}
return config;
});
window.addEventListener('online', flush);
return { client, flush };
}
// 폼 컴포넌트 예시
import React, { useEffect, useRef, useState } from 'react';
import { useOffline } from './OfflineProvider';
import { createQueuedClient } from './queuedClient';
export function PostForm() {
const { isOffline } = useOffline();
const [text, setText] = useState('');
const apiRef = useRef();
if (!apiRef.current) {
const { client, flush } = createQueuedClient(() => isOffline);
apiRef.current = { client, flush };
}
useEffect(() => {
if (!isOffline) apiRef.current.flush();
}, [isOffline]);
const submit = async () => {
try {
const res = await apiRef.current.client.post('/api/posts', { text });
console.log('saved', res.data);
} catch (err) {
console.error(err.message);
}
};
return (
<div>
<textarea value={text} onChange={e => setText(e.target.value)} />
<button onClick={submit} disabled={!text.trim()}>게시하기</button>
{isOffline && <p>오프라인 상태입니다. 제출은 큐에 저장되고 온라인에서 처리됩니다.</p>}
</div>
);
}
8. 접근성·UX 체크포인트
- 배너는 aria-live="polite"로 상태 변화를 알립니다.
- 토글은 aria-pressed로 현재 모드를 노출합니다.
- 오프라인 시 버튼 disabled + title로 이유를 전달합니다.
- 저장되지 않은 입력은 localStorage/IndexedDB에 임시 저장해 데이터 유실을 방지합니다.
9. PWA 서비스 워커 연계
정적 에셋 캐싱과 오프라인 폴백 페이지를 제공하면 UX가 개선됩니다.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(console.error);
});
}
서비스 워커에서는 HTML 폴백(/offline.html)과 API 응답 캐싱 전략을 설정합니다. 네트워크 우선 또는 캐시 우선 정책을 화면/데이터 성격에 맞게 선택합니다.
10. 테스트·운영 팁
- Chrome DevTools > Network에서 "Offline" 및 속도 제한으로 시뮬레이션합니다.
- 비행기 모드, 캡티브 포털 같은 실제 환경에서도 점검합니다.
- 큐 최대 크기, 재시도 백오프, 중복 요청 방지 로직을 설정합니다.
- 헤더에 오프라인 배너를 고정해 시각적 일관성을 유지합니다.
- 서버 헬스 체크 엔드포인트(/healthz)를 구성해 핑 정확도를 높입니다.
위 구성으로 React 앱에 오프라인 모드 전환 UI를 더하면 불안정한 네트워크에서도 신뢰감 있는 UX를 제공할 수 있습니다. 컨텍스트와 훅, 배너·토글·요청 큐를 함께 설계하는 것이 핵심입니다.
'React' 카테고리의 다른 글
| React에서 브라우저 Speech Synthesis API 활용하여 음성 출력하기 (0) | 2026.07.09 |
|---|---|
| React 앱에서 블루투스 디바이스 연동하기 (0) | 2026.07.09 |
| React 앱에서 지도 경로 탐색(Route Finding) 기능 추가하기 (0) | 2026.07.08 |
| React에서 스크롤 기반 콘텐츠 페이드 인/아웃 구현하기 (0) | 2026.07.08 |
| React 앱에서 RFID 리더기 데이터 처리하기 (0) | 2026.07.07 |