본문 바로가기

React

React에서 오프라인 모드 전환 UI 구성하기

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를 제공할 수 있습니다. 컨텍스트와 훅, 배너·토글·요청 큐를 함께 설계하는 것이 핵심입니다.