Part 2. 리액트 동작 원리와 상태 관리

React Router와 History API: SPA는 어떻게 페이지를 전환할까?

React Router와 History API: SPA는 어떻게 페이지를 전환할까?

크리스가 처음 리액트로 웹사이트를 만들 때 겪는 흔한 실수가 있다. 페이지를 이동시키기 위해 HTML의 정석대로 <a> 태그를 사용한 것이다.

// ❌ 이렇게 하면 안 된다
<a href="/about">소개 페이지로 이동</a>

링크를 클릭하는 순간, 화면이 하얗게 깜빡이더니 전체 페이지가 새로고침 된다. 열심히 관리하던 리액트의 state는 전부 초기화되어 날아갔다. SPA(Single Page Application)를 만든다면서, 전통적인 MPA(Multi Page Application)처럼 동작하게 만든 것이다.

우리가 원하는 건 "URL은 바뀌지만, 페이지는 새로고침 되지 않는" 마법이다. 이 마법을 가능하게 하는 브라우저의 핵심 기술, History APIReact Router의 원리를 파헤쳐 본다.

1. SPA의 딜레마: URL을 속여라

SPA는 이름 그대로 단 하나의 HTML 페이지(index.html)만 존재한다. 그런데 사용자는 /home, /about, /profile 등 여러 페이지를 오가고 싶어 한다.

여기서 모순이 생긴다.

  • 브라우저의 기본 동작: 주소창의 URL이 바뀌면, 브라우저는 해당 주소의 새로운 HTML 파일을 서버에 요청하고 화면을 갱신한다.
  • SPA의 목표: URL은 바꾸고 싶지만, 서버 요청은 막고 자바스크립트로 화면(컴포넌트)만 갈아 끼우고 싶다.
  • 이 문제를 해결하기 위해 HTML5에 등장한 것이 바로 History API다.

    2. 엔진: History API (pushState)

    브라우저의 window 객체에는 방문 기록을 관리하는 history 객체가 있다. 이 중 리액트 라우터가 사용하는 핵심 메서드는 pushState다.

    // browser-console.ts
    
    // 1. 현재 주소: /home
    // 2. pushState 실행 (데이터, 제목, 새로운 URL)
    window.history.pushState({ page: 'about' }, '', '/about');
    
    // 3. 결과: 주소창이 /about으로 바뀐다.
    // ✨ 중요: 브라우저는 서버에 요청을 보내지 않고, 새로고침도 하지 않는다!

    이 메서드는 브라우저에게 "주소창의 글자만 조용히 바꿔줘, 아무것도 로딩하지 말고"라고 명령하는 것과 같다.

    하지만 이것만으로는 부족하다. 주소창만 바뀌었지, 실제 화면(View)은 그대로이기 때문이다. 주소가 바뀌었음을 감지하고 컴포넌트를 바꿔줄 감시자가 필요하다.

    3. 운전사: React Router

  • React Router는 History API를 감싸서 리액트의 상태(State)와 연결해주는 라이브러리다. 작동 원리는 생각보다 단순하다.
  • 클릭 감지: <Link> 컴포넌트를 클릭한다.
  • 기본 동작 차단: 내부적으로 a 태그를 생성하지만, e.preventDefault()를 호출하여 브라우저의 새로고침을 막는다.
  • URL 변경: history.pushState()를 호출하여 주소창만 바꾼다.
  • 상태 업데이트: URL이 바뀌었다는 사실을 감지하고, 라우터 내부의 location 상태를 업데이트한다.
  • 리렌더링: 상태가 바뀌었으니 리액트가 리렌더링을 시작하고, 현재 URL에 맞는 컴포넌트(Route)만 화면에 보여준다.
  • 코드로 보는 원리

    // MiniRouter.tsx (개념적 구현)
    import { useState, useEffect } from 'react';
    
    function MiniRouter() {
      const [path, setPath] = useState(window.location.pathname);
    
      useEffect(() => {
        // 뒤로가기 버튼(popstate)을 감지하는 이벤트 리스너
        const onPopState = () => setPath(window.location.pathname);
        window.addEventListener('popstate', onPopState);
        return () => window.removeEventListener('popstate', onPopState);
      }, []);
    
      const navigate = (url: string) => {
        window.history.pushState({}, '', url); // 1. URL 변경
        setPath(url); // 2. 상태 변경 -> 리렌더링 유발
      };
    
      return (
        <div>
          <button onClick={() => navigate('/home')}>홈</button>
          <button onClick={() => navigate('/about')}>소개</button>
          
          {/* 3. 현재 path에 따라 컴포넌트 교체 */}
          {path === '/home' && <Home />}
          {path === '/about' && <About />}
        </div>
      );
    }

    4. 배포 시 주의할 점 (새로고침 문제)

    크리스가 개발 서버(localhost)에서는 잘 동작하던 앱을 실제 서버(S3, Nginx 등)에 배포했다. 그런데 /about 페이지에 직접 접속하거나 새로고침을 했더니 404 Not Found 에러가 떴다.

    왜일까?

    브라우저가 /about이라는 파일을 서버에 요청했기 때문이다. 하지만 서버에는 index.html 하나밖에 없다. /about이라는 폴더나 파일은 물리적으로 존재하지 않는다.

    해결책: Fallback 설정

    서버 설정을 통해 "어떤 경로로 들어오든 무조건 index.html을 돌려줘라"라고 해야 한다. 그러면 index.html이 로드되고, 자바스크립트가 실행되면서 현재 URL(/about)을 보고 알맞은 페이지를 렌더링해준다.

    # nginx.conf 예시
    location / {
      try_files $uri $uri/ /index.html;
    }

    핵심 정리

  • SPA의 핵심: 페이지 이동 시 새로고침 없이 내용만 교체한다.
  • history.pushState(): 브라우저의 주소를 바꾸지만 서버 요청은 보내지 않는 핵심 API다.
  • React Router: URL 변경을 감지하고, 그에 맞는 컴포넌트를 렌더링(State 변경)해주는 역할을 한다.
  • 배포 설정: 모든 요청을 index.html로 보내주는 Fallback 설정이 필수다.
  • 이제 페이지 이동의 원리를 알았다. 그런데 컴포넌트 안에서 데이터를 가져오거나 DOM을 건드리는 작업은 어디서 해야 할까? 가장 많이 쓰지만 가장 많이 틀리는 훅, useEffect를 제대로 다룰 시간이다.

    "useEffect의 의존성 배열 거짓말: Stale Closure 문제 해결과 useLayoutEffect가 필요한 순간" 편에서 계속된다.


    🔗 참고 링크

  • History API - MDN
  • React Router - Main Concepts
  • PopStateEvent