본문 바로가기

React

React 앱에서 브라우저 히스토리 API 활용하기

SPA에서 URL은 상태 그 자체입니다. History API(pushState/replaceState/popstate)를 직접 다루면 라우팅, 쿼리 파라미터, 스크롤 복원, 애널리틱스까지 가볍게 제어할 수 있습니다. 이 글은 React 앱에서 History API를 실무적으로 활용하는 방법을 정리합니다.

1. 핵심 개념 요약

- pushState: URL 변경 + 기록 추가(뒤로가기 가능)입니다.
- replaceState: 현재 기록 교체입니다.
- popstate: 뒤/앞으로 이동 시 발생하는 이벤트입니다.
- 같은 오리진 내에서만 동작합니다. 서버는 모든 경로를 index.html로 리라이트해야 새로고침 404를 피할 수 있습니다.

2. 최소 예제: URL만 바꾸고 화면은 React가 렌더

// URL을 /posts/123 으로 바꾸고 state를 기록합니다.
window.history.pushState({ from: 'list' }, '', '/posts/123');

// 뒤/앞으로 이동 시 실행됩니다.
window.addEventListener('popstate', (e) => {
  console.log('popstate state:', e.state);
  // 여기서 현재 URL에 맞춰 React 상태를 갱신하거나 렌더를 트리거합니다.
});

주의: pushState/replaceState는 popstate를 발생시키지 않습니다. 사용자 네비게이션(back/forward)에서만 popstate가 발생합니다.

3. 실전: 경량 History 라우터 훅 + Provider

아래 코드는 History API 위에 얇은 라우팅 레이어를 올리는 패턴입니다. 클릭 가로채기, 쿼리/해시 반영, 스크롤 복원, 수동 리렌더까지 포함합니다.

import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';

function genKey() {
  return (crypto && crypto.randomUUID) ? crypto.randomUUID() : Math.random().toString(36).slice(2);
}

function getLocation() {
  const { pathname, search, hash } = window.location;
  return {
    pathname,
    search,
    hash,
    state: window.history.state || {},
    key: (window.history.state && window.history.state.__key) || 'root',
  };
}

function toURL(to) {
  if (to instanceof URL) return to;
  if (typeof to === 'string') return new URL(to, window.location.href);
  throw new Error('Unsupported "to" type');
}

const HistoryContext = createContext(null);

export function HistoryProvider({ children }) {
  const [location, setLocation] = useState(() => {
    // 초기 진입 시 state에 key가 없으면 주입합니다.
    if (!window.history.state || !window.history.state.__key) {
      window.history.replaceState({ ...(window.history.state || {}), __key: genKey() }, '', window.location.href);
    }
    return getLocation();
  });

  const scrollPositions = useRef(new Map()); // key -> {x,y}

  useEffect(() => {
    if ('scrollRestoration' in window.history) {
      window.history.scrollRestoration = 'manual';
    }
    const onPop = () => {
      // pop 전에 이전 위치 스크롤 저장
      const prevKey = location.key;
      scrollPositions.current.set(prevKey, { x: window.scrollX, y: window.scrollY });

      setLocation(getLocation());

      // pop 후 스크롤 복원
      const nextKey = (window.history.state && window.history.state.__key) || 'root';
      const pos = scrollPositions.current.get(nextKey);
      if (pos) {
        requestAnimationFrame(() => window.scrollTo(pos.x, pos.y));
      } else {
        requestAnimationFrame(() => window.scrollTo(0, 0));
      }
    };

    window.addEventListener('popstate', onPop);
    return () => window.removeEventListener('popstate', onPop);
  }, [location.key]);

  const navigate = useCallback((to, options = {}) => {
    const { replace = false, state } = options;
    const url = toURL(to);

    // 현재 위치 스크롤 저장
    scrollPositions.current.set(location.key, { x: window.scrollX, y: window.scrollY });

    const nextKey = genKey();
    const nextState = { ...(state || {}), __key: nextKey };
    const href = url.pathname + url.search + url.hash;

    if (replace) {
      window.history.replaceState(nextState, '', href);
    } else {
      window.history.pushState(nextState, '', href);
    }

    // React 렌더 트리거 및 스크롤 초기화(필요 시 수정)
    setLocation(getLocation());
    requestAnimationFrame(() => window.scrollTo(0, 0));
  }, [location.key]);

  const value = useMemo(() => ({ location, navigate }), [location, navigate]);
  return <HistoryContext.Provider value={value}>{children}</HistoryContext.Provider>;
}

export function useHistory() {
  const ctx = useContext(HistoryContext);
  if (!ctx) throw new Error('useHistory must be used within HistoryProvider');
  return ctx;
}

export function Link({ to, replace, state, onClick, children, ...rest }) {
  const { navigate } = useHistory();
  const handleClick = (e) => {
    if (onClick) onClick(e);
    // 새 창, 수정키, 기본 외부 동작은 통과
    if (
      e.defaultPrevented || e.button !== 0 || e.metaKey || e.altKey || e.ctrlKey || e.shiftKey || rest.target === '_blank'
    ) return;
    // 동일 오리진만 SPA 네비게이션
    const url = toURL(to);
    const sameOrigin = url.origin === window.location.origin;
    if (sameOrigin) {
      e.preventDefault();
      navigate(url, { replace, state });
    }
  };
  const href = toURL(to).pathname + toURL(to).search + toURL(to).hash;
  return <a href={href} onClick={handleClick} {...rest}>{children}</a>;
}

사용 방법: 앱 루트에 HistoryProvider를 감싸고 useHistory().location으로 현재 경로를 읽어 조건부 렌더링합니다.

function App() {
  return (
    <HistoryProvider>
      <Routes />
    </HistoryProvider>
  );
}

function Routes() {
  const { location } = useHistory();
  const { pathname } = location;
  if (pathname === '/') return <Home />;
  if (pathname.startsWith('/posts')) return <Posts />;
  return <NotFound />;
}

4. 검색 파라미터 읽기/갱신

URLSearchParams로 쿼리를 파싱하고, push/replace로 URL만 업데이트합니다.

import { useMemo, useCallback } from 'react';
import { useHistory } from './history';

export function useQuery() {
  const { location, navigate } = useHistory();
  const params = useMemo(() => new URLSearchParams(location.search), [location.search]);

  const setQuery = useCallback((key, value, { replace = true } = {}) => {
    const url = new URL(window.location.href);
    if (value == null || value === '') url.searchParams.delete(key);
    else url.searchParams.set(key, value);
    navigate(url, { replace });
  }, [navigate]);

  return [params, setQuery];
}

리스트 필터, 페이지네이션 등 UI 상태를 URL에 반영해 북마크/공유와 복원을 쉽게 합니다.

5. 스크롤 복원 전략

- 위 훅은 위치 key별로 스크롤을 저장/복원합니다.
- push/replace 시 상단으로 이동하고, popstate에서는 저장된 위치를 복원합니다.
- 긴 목록에서 디테일 페이지를 갔다가 뒤로가기를 눌렀을 때 목록 위치가 자연스럽게 복원됩니다.

6. 애널리틱스와 타이틀 동기화

네비게이션마다 페이지뷰를 전송하고 문서 타이틀을 갱신합니다.

import { useEffect } from 'react';
import { useHistory } from './history';

function usePageEffects() {
  const { location } = useHistory();

  useEffect(() => {
    // 타이틀 규칙 예시
    const title = (
      location.pathname === '/' ? '홈' :
      location.pathname.startsWith('/posts') ? '게시글' :
      '앱'
    );
    document.title = `${title} | MyApp`;

    // GA4 예시 (gtag 스니펫이 이미 로드되었다고 가정)
    if (window.gtag) {
      window.gtag('event', 'page_view', {
        page_location: window.location.href,
        page_path: location.pathname + location.search,
        page_title: document.title,
      });
    }
  }, [location.pathname, location.search]);
}

팁: push/replace는 popstate를 발생시키지 않으므로, 위처럼 location 상태를 직접 관찰하는 방식이 안정적입니다.

7. 서버/SEO 체크리스트

- 서버 리라이트: 모든 경로를 index.html로 리다이렉트/리라이트합니다. 정적 파일, API 경로는 예외 처리합니다.
- 오리진 제한: 다른 도메인/프로토콜로는 pushState가 불가합니다. 외부 링크는 기본 동작을 유지합니다.
- 상태 크기: history.state는 브라우저별 제한이 있습니다. 큰 객체/민감 정보 저장을 피합니다.
- SEO: History API로 URL을 잘게 나눠도 CSR만으로는 크롤러 친화성이 떨어질 수 있습니다. 필요한 페이지는 SSR/프리렌더를 고려합니다.

8. React Router와의 관계

React Router 등 라우팅 라이브러리는 내부적으로 History를 활용합니다. 위 패턴은 라이브러리 없이도 필요한 기능만 빠르게 구축할 때 유용합니다. 복잡한 중첩 라우팅, 데이터 라우팅이 필요하면 검증된 라우터를 사용합니다.

9. 마무리

History API를 직접 다루면 URL, 스크롤, 쿼리, 애널리틱스를 한 흐름으로 통제할 수 있습니다. 작은 앱은 위 훅/Provider만으로도 충분하며, 성장에 맞춰 라우터로 이행해도 무리가 없습니다.