React SPA 구조: index.html에서 App까지

SPA는 하나의 HTML 문서를 유지하면서 JavaScript로 화면을 바꾸는 웹 애플리케이션 방식이다. MPA와의 차이와 클라이언트 라우팅을 살펴보고, Vite 기반 React 앱이 index.html에서 main.jsx와 App으로 이어져 화면을 그리는 과정을 따라간다.

React

출발점: JS는 DOM을 바꿀 수 있다

  1. 웹 페이지는 HTML, CSS, JavaScript로 구성된다.
  2. 이 중 JS가 화면에 동작을 만들 수 있는 이유는, 브라우저가 HTML을 DOM이라는 객체 트리로 바꿔 두고 JS가 이 트리를 읽고 수정할 수 있게 해주기 때문이다.
  3. 여기서 포인트는 “JS로 DOM을 수정할 수 있다”는 것이다.
  4. 서버에 새 문서를 요청하지 않아도 화면을 바꿀 수 있다는 뜻이며, 아래에서 다루는 SPA와 React는 여기서 출발한다.

SPA 이전의 구조: MPA (Multi Page Application)

  1. SPA 이전에는 MPA(Multi Page Application) 방식이 일반적이었다.
  2. MPA에서는 페이지마다 별도의 HTML 문서가 있고, 서버가 요청을 받을 때마다 완성된 HTML을 만들어 보내준다.
  3. 따라서 링크를 클릭하면 브라우저는 서버에 새 HTML을 요청하고, 받은 문서로 화면 전체를 다시 그린다.
  4. 이 과정에서 헤더나 메뉴처럼 바뀌지 않는 부분까지 매번 다시 받아오게 된다.
  5. 그 결과 페이지를 이동할 때 화면이 잠깐 깜빡이고, 이동마다 대기 시간이 생기는 편이다.
  6. 또한 문서가 통째로 교체되므로, 입력 중이던 값처럼 JS가 기억하던 상태도 함께 사라진다.
  7. 다만 MPA에서 인터랙션이 불가능했던 것은 아니다.
  8. MPA에서도 JS로 DOM을 직접 조작해 인터랙션을 구현할 수 있었고, jQuery가 그 대표적인 도구였다.
  9. 문제는 인터랙션이 많아질수록 어떤 데이터가 바뀌면 어떤 DOM을 고쳐야 하는지를 개발자가 직접 추적해야 했다는 점이다.
  10. 정리하면 MPA의 한계는 인터랙션 불가보다는, 페이지 이동 시의 전체 새로고침과 복잡해지는 DOM 관리에 가까웠다.

SPA: 문서는 한 번만 받고 화면은 JS가 바꾼다

  1. 이러한 한계에서 "HTML 문서는 한 번만 받고, 이후에는 JS가 필요한 부분만 바꾸면 어떨까"라는 접근이 나왔다.
  2. 이 접근을 따르는 방식이 SPA(Single Page Application)이다.
  3. SPA는 이름 그대로 하나의 HTML 페이지만 사용한다.
  4. 처음 접속할 때 거의 비어 있는 HTML과 JS 묶음을 받고, 그 JS가 화면 내용을 직접 만들어 DOM에 넣는다.
  5. 이후 페이지 이동처럼 보이는 동작은 문서 교체가 아니라, JS가 DOM의 일부를 바꾸는 것이다.
  6. 이때 새로 필요한 정보는 HTML이 아닌 데이터(주로 JSON)만 서버에서 받아온다.
  7. 문서가 교체되지 않으므로 화면 깜빡임이 줄고, JS가 가진 상태도 유지된다.
  8. 대신 모든 화면을 JS가 만들기 때문에, 첫 로딩 때 받는 JS가 커져 초기 화면 표시가 늦어질 수 있다.
  9. 처음 받은 HTML이 거의 비어 있어 검색 엔진이 내용을 읽기 어려울 수 있다는 점도 단점으로 거론된다.
  10. React는 이렇게 JS로 화면을 만드는 작업을 체계적으로 할 수 있게 도와주는 라이브러리이다.

SPA의 페이지 이동: 주소만 바꾸고 화면은 JS가 그린다

  1. 문서가 하나뿐이라면, 주소창의 주소가 /about으로 바뀌는 것은 어떻게 가능한지 의문이 생긴다.

  2. 브라우저는 문서를 새로 받지 않고 주소만 바꾸는 history.pushState()라는 API를 제공한다.

  3. SPA는 링크 클릭을 가로채서 이 API로 주소를 바꾸고, 바뀐 주소에 맞는 화면을 JS로 DOM에 그린다.

    link.addEventListener('click', (event) => {
      event.preventDefault();               // 브라우저의 기본 이동(새 문서 요청)을 막는다
      history.pushState({}, '', '/about');  // 주소만 바꾼다
      renderAboutPage();                    // 화면은 JS가 직접 바꾼다
    });
  4. 뒤로 가기를 누르면 브라우저가 popstate 이벤트로 알려주므로, SPA는 이때도 주소에 맞는 화면을 다시 그린다.

  5. React 자체에는 이 기능이 들어 있지 않아서, 보통 React Router 같은 라이브러리를 추가해 사용한다.

SPA의 대가: 새로고침하면 404가 나는 이유

  1. 주소를 JS가 바꾸는 구조에서는, 서버가 그 주소를 알지 못한다는 문제가 따라온다.
  2. /about에서 새로고침을 하면 브라우저는 서버에 실제로 /about 문서를 요청하는데, 서버에는 index.html만 있고 about이라는 파일은 없다.
  3. 그래서 별도 설정이 없는 서버는 404를 응답한다.
  4. 이를 해결하려면 서버가 파일을 찾지 못한 주소에 대해 index.html을 대신 보내도록 설정해야 한다.
  5. Vite의 개발 서버와 preview는 이 처리를 기본으로 해 주기 때문에, 개발 중에는 문제가 드러나지 않다가 배포한 뒤에 처음 발견되는 경우가 많다.

React와 SPA의 관계

  1. "React는 SPA이다"라고 줄여 말하기도 하지만, 조금 더 정확히는 React는 화면을 만드는 라이브러리이고 SPA는 그것으로 만들 수 있는 앱의 한 형태이다.
  2. React로 서버에서 HTML을 미리 만들어 보내는 방식도 가능하며, Next.js 같은 프레임워크가 그 예이다.
  3. 이 실습에서 Vite로 만드는 프로젝트는 그중 SPA 형태에 해당한다.
  4. 아래에서는 그 프로젝트가 어떤 파일에서 시작해 화면을 그리기까지 이어지는지를 순서대로 따라간다.

index.html: 하나뿐인 문서이자 출발점

  1. index.html은 SPA에서 말하는 "하나의 페이지"에 해당하는 파일이다.

  2. 브라우저가 가장 먼저 받는 파일이므로, 앱의 진입점 역할을 한다.

  3. 이 파일의 <body> 안에는 실제 화면 내용이 거의 없고, 두 가지 요소만 들어 있다.

    <body>
      <div id="root"></div>
      <script type="module" src="/src/main.jsx"></script>
    </body>
  4. 첫 번째인 <div id="root">는 React가 만든 화면이 들어갈 빈 자리이다.

  5. 두 번째인 <script> 태그는 브라우저에게 src/main.jsx를 불러와 실행하라고 알려준다.

  6. 즉 이 <script> 태그가 HTML과 React 코드를 잇는 연결 고리가 된다.

  7. 그런데 브라우저는 JSX 문법을 직접 이해하지 못한다.

  8. 그래서 Vite가 중간에서 main.jsx를 일반 JS로 변환해서 브라우저에 전달한다.

  9. 이 변환이 개발 서버와 빌드에서 각각 어떻게 일어나는지는 Vite 빌드 도구의 원리 글에서 다뤘다.

  10. 여기서 눈여겨볼 점은 Vite가 이 <script> 태그를 보고 앱의 코드가 어디에서 시작하는지를 알아낸다는 것이다.

  11. Vite가 index.html을 public 폴더가 아닌 프로젝트 최상위에 두는 것도, 이 파일을 단순한 정적 파일이 아니라 앱의 출발점으로 다루기 때문이다.

  12. 한편 type="module"은 이 스크립트가 import를 쓸 수 있는 ES 모듈임을 알리는 표시이다.

  13. 모듈 스크립트는 HTML을 끝까지 읽은 뒤에 실행되므로, main.jsx가 실행되는 시점에는 <div id="root">가 이미 DOM에 존재한다.

src/main.jsx: React를 DOM에 연결한다

  1. index.html의 <script> 태그에 의해 가장 먼저 실행되는 JS 파일이 src/main.jsx이다.

  2. 이 파일의 역할은 React와 실제 DOM을 연결하고, 첫 렌더링을 시작하는 것이다.

    import { StrictMode } from 'react'
    import { createRoot } from 'react-dom/client'
    import App from './App.jsx'
    
    createRoot(document.getElementById('root')).render(
      <StrictMode>
        <App />
      </StrictMode>,
    )
  3. 먼저 document.getElementById('root')로 index.html에 있던 빈 <div>를 찾는다.

    • document.getElementById('root')는 DOM에서 id가 root인 요소를 찾아 그 요소 객체를 돌려주는 DOM API이다.
    • 즉 React도 화면에 손을 대려면 먼저 일반 DOM API로 실제 DOM 노드 하나를 건네받아야 한다.
    • 해당 id를 가진 요소가 없으면 null이 반환되고, createRoot(null)은 Target container is not a DOM element라는 오류를 낸다.
    • 그래서 이 오류를 만나면 index.html의 id와 main.jsx에 적은 문자열이 같은지부터 확인해 볼 수 있다.
  4. 찾은 <div>를 createRoot()에 넘기면, React가 이 요소의 내부를 관리하는 루트를 만든다.

  5. createRoot가 react가 아닌 react-dom/client에서 오는 것은 React가 역할을 두 패키지로 나누어 두었기 때문이다.

  6. react는 컴포넌트와 상태 같은 공통 개념을 담당하고, react-dom은 그 결과를 브라우저의 DOM에 반영하는 일을 담당한다.

  7. 이렇게 나눈 덕분에 같은 react를 쓰면서 그리는 대상만 바꿀 수 있으며, 모바일 앱 화면을 그리는 React Native가 그 예이다.

  8. 이어서 루트의 render()에 <App />을 넘기면, React가 App 컴포넌트를 실행해 그 결과를 <div id="root"> 안에 그린다.

  9. 이때 <div id="root"> 안에 원래 들어 있던 내용이 있다면 첫 render()에서 모두 지워진다.

  10. 그 뒤로는 화면이 바뀔 때마다 React가 이전 결과와 비교해서 달라진 DOM 노드만 고친다.

StrictMode: 개발 중에 두 번 실행되는 이유

  1. 코드에서 <App />을 감싸고 있는 <StrictMode>는 화면에는 아무것도 그리지 않고, 개발 중에 실수를 일찍 찾도록 돕는 컴포넌트이다.
  2. <StrictMode> 안에서는 개발 모드에 한해 컴포넌트 함수가 한 번 더 호출되고, effect도 한 번 더 실행된다.
  3. 그래서 App 안에 console.log를 넣으면 로그가 두 번 찍히는데, 이는 오류가 아니라 의도된 동작이다.
  4. 같은 입력에 다른 결과를 내는 컴포넌트나 정리(cleanup)를 빠뜨린 effect는 두 번 실행했을 때 문제가 드러나기 쉽기 때문이다.
  5. 이 추가 실행은 npm run build로 만든 결과물에서는 일어나지 않는다.
  6. 여기까지가 main.jsx가 맡는 일이며, 실제 화면 내용은 App에게 맡긴다.

src/App.jsx: 화면의 내용은 컴포넌트가 만든다

!image.png

  1. main.jsx가 렌더링을 맡긴 App은 src/App.jsx에 정의되어 있다.

  2. App은 앱의 최상위 컴포넌트로, 화면에 보이는 요소들은 이 컴포넌트에서 시작해 아래로 뻗어 나간다.

  3. 여기서 컴포넌트란 화면의 한 조각을 반환하는 JS 함수이다.

    function Header() {
      return <h1>My App</h1>
    }
    
    function App() {
      return (
        <div>
          <Header />
          <p>Hello</p>
        </div>
      )
    }
    
    export default App
  4. 함수가 반환하는 HTML처럼 생긴 문법이 JSX이며, 그려질 화면의 모습을 JS 안에서 작성할 수 있게 해준다.

  5. JSX는 DOM 노드를 직접 만들지 않고, "어떤 태그를 어떤 속성으로 그릴지"를 적은 일반 JS 객체를 만든다.

  6. React는 이 객체를 설계도처럼 읽어서 실제 DOM 노드를 만들거나 고친다.

컴포넌트를 태그로 쓰는 규칙

  1. 이렇게 만든 컴포넌트는 <Header />처럼 태그 형태로 다른 컴포넌트 안에서 사용할 수 있다.

  2. Header()처럼 직접 호출하지 않고 태그로 적어 두는 것은, 언제 이 함수를 호출할지를 React가 정하도록 맡기기 위해서이다.

  3. 이때 React는 이름이 대문자로 시작하면 컴포넌트로, 소문자로 시작하면 일반 HTML 태그로 구분한다.

  4. 이 구분은 JSX가 JS로 변환되는 단계에서 정해지는데, 소문자 태그는 문자열로, 대문자 태그는 변수로 바뀐다.

    <div />     // jsx('div', {})   : 'div'라는 이름의 HTML 요소
    <Header />  // jsx(Header, {})  : Header라는 변수에 담긴 함수
  5. 그래서 컴포넌트 이름을 header처럼 소문자로 지으면, 내가 만든 함수가 아니라 HTML의 <header> 요소가 그려진다.

컴포넌트 트리: 화면 전체는 App에서 뻗어 나간다

  1. 컴포넌트 안에 컴포넌트를 넣을 수 있으므로, 화면 전체는 App을 뿌리로 하는 컴포넌트 트리가 된다.
  2. 이 구조 덕분에 화면을 작은 단위로 나누어 만들고, 같은 컴포넌트를 여러 곳에서 재사용할 수 있다.
  3. 마지막 줄의 export default App은 main.jsx가 import로 이 컴포넌트를 가져올 수 있도록 내보내는 부분이다.

댓글