React SPA 구조: index.html에서 App까지
SPA는 하나의 HTML 문서를 유지하면서 JavaScript로 화면을 바꾸는 웹 애플리케이션 방식이다. MPA와의 차이와 클라이언트 라우팅을 살펴보고, Vite 기반 React 앱이 index.html에서 main.jsx와 App으로 이어져 화면을 그리는 과정을 따라간다.
출발점: JS는 DOM을 바꿀 수 있다
- 웹 페이지는 HTML, CSS, JavaScript로 구성된다.
- 이 중 JS가 화면에 동작을 만들 수 있는 이유는, 브라우저가 HTML을 DOM이라는 객체 트리로 바꿔 두고 JS가 이 트리를 읽고 수정할 수 있게 해주기 때문이다.
- 여기서 포인트는 “JS로 DOM을 수정할 수 있다”는 것이다.
- 서버에 새 문서를 요청하지 않아도 화면을 바꿀 수 있다는 뜻이며, 아래에서 다루는 SPA와 React는 여기서 출발한다.
SPA 이전의 구조: MPA (Multi Page Application)
- SPA 이전에는 MPA(Multi Page Application) 방식이 일반적이었다.
- MPA에서는 페이지마다 별도의 HTML 문서가 있고, 서버가 요청을 받을 때마다 완성된 HTML을 만들어 보내준다.
- 따라서 링크를 클릭하면 브라우저는 서버에 새 HTML을 요청하고, 받은 문서로 화면 전체를 다시 그린다.
- 이 과정에서 헤더나 메뉴처럼 바뀌지 않는 부분까지 매번 다시 받아오게 된다.
- 그 결과 페이지를 이동할 때 화면이 잠깐 깜빡이고, 이동마다 대기 시간이 생기는 편이다.
- 또한 문서가 통째로 교체되므로, 입력 중이던 값처럼 JS가 기억하던 상태도 함께 사라진다.
- 다만 MPA에서 인터랙션이 불가능했던 것은 아니다.
- MPA에서도 JS로 DOM을 직접 조작해 인터랙션을 구현할 수 있었고, jQuery가 그 대표적인 도구였다.
- 문제는 인터랙션이 많아질수록 어떤 데이터가 바뀌면 어떤 DOM을 고쳐야 하는지를 개발자가 직접 추적해야 했다는 점이다.
- 정리하면 MPA의 한계는 인터랙션 불가보다는, 페이지 이동 시의 전체 새로고침과 복잡해지는 DOM 관리에 가까웠다.
SPA: 문서는 한 번만 받고 화면은 JS가 바꾼다
- 이러한 한계에서 "HTML 문서는 한 번만 받고, 이후에는 JS가 필요한 부분만 바꾸면 어떨까"라는 접근이 나왔다.
- 이 접근을 따르는 방식이 SPA(Single Page Application)이다.
- SPA는 이름 그대로 하나의 HTML 페이지만 사용한다.
- 처음 접속할 때 거의 비어 있는 HTML과 JS 묶음을 받고, 그 JS가 화면 내용을 직접 만들어 DOM에 넣는다.
- 이후 페이지 이동처럼 보이는 동작은 문서 교체가 아니라, JS가 DOM의 일부를 바꾸는 것이다.
- 이때 새로 필요한 정보는 HTML이 아닌 데이터(주로 JSON)만 서버에서 받아온다.
- 문서가 교체되지 않으므로 화면 깜빡임이 줄고, JS가 가진 상태도 유지된다.
- 대신 모든 화면을 JS가 만들기 때문에, 첫 로딩 때 받는 JS가 커져 초기 화면 표시가 늦어질 수 있다.
- 처음 받은 HTML이 거의 비어 있어 검색 엔진이 내용을 읽기 어려울 수 있다는 점도 단점으로 거론된다.
- React는 이렇게 JS로 화면을 만드는 작업을 체계적으로 할 수 있게 도와주는 라이브러리이다.
SPA의 페이지 이동: 주소만 바꾸고 화면은 JS가 그린다
-
문서가 하나뿐이라면, 주소창의 주소가
/about으로 바뀌는 것은 어떻게 가능한지 의문이 생긴다. -
브라우저는 문서를 새로 받지 않고 주소만 바꾸는
history.pushState()라는 API를 제공한다. -
SPA는 링크 클릭을 가로채서 이 API로 주소를 바꾸고, 바뀐 주소에 맞는 화면을 JS로 DOM에 그린다.
link.addEventListener('click', (event) => { event.preventDefault(); // 브라우저의 기본 이동(새 문서 요청)을 막는다 history.pushState({}, '', '/about'); // 주소만 바꾼다 renderAboutPage(); // 화면은 JS가 직접 바꾼다 }); -
뒤로 가기를 누르면 브라우저가
popstate이벤트로 알려주므로, SPA는 이때도 주소에 맞는 화면을 다시 그린다. -
React 자체에는 이 기능이 들어 있지 않아서, 보통 React Router 같은 라이브러리를 추가해 사용한다.
SPA의 대가: 새로고침하면 404가 나는 이유
- 주소를 JS가 바꾸는 구조에서는, 서버가 그 주소를 알지 못한다는 문제가 따라온다.
/about에서 새로고침을 하면 브라우저는 서버에 실제로/about문서를 요청하는데, 서버에는index.html만 있고about이라는 파일은 없다.- 그래서 별도 설정이 없는 서버는 404를 응답한다.
- 이를 해결하려면 서버가 파일을 찾지 못한 주소에 대해
index.html을 대신 보내도록 설정해야 한다. - Vite의 개발 서버와
preview는 이 처리를 기본으로 해 주기 때문에, 개발 중에는 문제가 드러나지 않다가 배포한 뒤에 처음 발견되는 경우가 많다.
React와 SPA의 관계
- "React는 SPA이다"라고 줄여 말하기도 하지만, 조금 더 정확히는 React는 화면을 만드는 라이브러리이고 SPA는 그것으로 만들 수 있는 앱의 한 형태이다.
- React로 서버에서 HTML을 미리 만들어 보내는 방식도 가능하며, Next.js 같은 프레임워크가 그 예이다.
- 이 실습에서 Vite로 만드는 프로젝트는 그중 SPA 형태에 해당한다.
- 아래에서는 그 프로젝트가 어떤 파일에서 시작해 화면을 그리기까지 이어지는지를 순서대로 따라간다.
index.html: 하나뿐인 문서이자 출발점
-
index.html은 SPA에서 말하는 "하나의 페이지"에 해당하는 파일이다. -
브라우저가 가장 먼저 받는 파일이므로, 앱의 진입점 역할을 한다.
-
이 파일의
<body>안에는 실제 화면 내용이 거의 없고, 두 가지 요소만 들어 있다.<body> <div id="root"></div> <script type="module" src="/src/main.jsx"></script> </body> -
첫 번째인
<div id="root">는 React가 만든 화면이 들어갈 빈 자리이다. -
두 번째인
<script>태그는 브라우저에게src/main.jsx를 불러와 실행하라고 알려준다. -
즉 이
<script>태그가 HTML과 React 코드를 잇는 연결 고리가 된다. -
그런데 브라우저는 JSX 문법을 직접 이해하지 못한다.
-
그래서 Vite가 중간에서
main.jsx를 일반 JS로 변환해서 브라우저에 전달한다. -
이 변환이 개발 서버와 빌드에서 각각 어떻게 일어나는지는 Vite 빌드 도구의 원리 글에서 다뤘다.
-
여기서 눈여겨볼 점은 Vite가 이
<script>태그를 보고 앱의 코드가 어디에서 시작하는지를 알아낸다는 것이다. -
Vite가
index.html을public폴더가 아닌 프로젝트 최상위에 두는 것도, 이 파일을 단순한 정적 파일이 아니라 앱의 출발점으로 다루기 때문이다. -
한편
type="module"은 이 스크립트가import를 쓸 수 있는 ES 모듈임을 알리는 표시이다. -
모듈 스크립트는 HTML을 끝까지 읽은 뒤에 실행되므로,
main.jsx가 실행되는 시점에는<div id="root">가 이미 DOM에 존재한다.
src/main.jsx: React를 DOM에 연결한다
-
index.html의<script>태그에 의해 가장 먼저 실행되는 JS 파일이src/main.jsx이다. -
이 파일의 역할은 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>, ) -
먼저
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에 적은 문자열이 같은지부터 확인해 볼 수 있다.
-
찾은
<div>를createRoot()에 넘기면, React가 이 요소의 내부를 관리하는 루트를 만든다. -
createRoot가react가 아닌react-dom/client에서 오는 것은 React가 역할을 두 패키지로 나누어 두었기 때문이다. -
react는 컴포넌트와 상태 같은 공통 개념을 담당하고,react-dom은 그 결과를 브라우저의 DOM에 반영하는 일을 담당한다. -
이렇게 나눈 덕분에 같은
react를 쓰면서 그리는 대상만 바꿀 수 있으며, 모바일 앱 화면을 그리는 React Native가 그 예이다. -
이어서 루트의
render()에<App />을 넘기면, React가App컴포넌트를 실행해 그 결과를<div id="root">안에 그린다. -
이때
<div id="root">안에 원래 들어 있던 내용이 있다면 첫render()에서 모두 지워진다. -
그 뒤로는 화면이 바뀔 때마다 React가 이전 결과와 비교해서 달라진 DOM 노드만 고친다.
StrictMode: 개발 중에 두 번 실행되는 이유
- 코드에서
<App />을 감싸고 있는<StrictMode>는 화면에는 아무것도 그리지 않고, 개발 중에 실수를 일찍 찾도록 돕는 컴포넌트이다. <StrictMode>안에서는 개발 모드에 한해 컴포넌트 함수가 한 번 더 호출되고, effect도 한 번 더 실행된다.- 그래서
App안에console.log를 넣으면 로그가 두 번 찍히는데, 이는 오류가 아니라 의도된 동작이다. - 같은 입력에 다른 결과를 내는 컴포넌트나 정리(cleanup)를 빠뜨린 effect는 두 번 실행했을 때 문제가 드러나기 쉽기 때문이다.
- 이 추가 실행은
npm run build로 만든 결과물에서는 일어나지 않는다. - 여기까지가
main.jsx가 맡는 일이며, 실제 화면 내용은App에게 맡긴다.
src/App.jsx: 화면의 내용은 컴포넌트가 만든다
!image.png
-
main.jsx가 렌더링을 맡긴App은src/App.jsx에 정의되어 있다. -
App은 앱의 최상위 컴포넌트로, 화면에 보이는 요소들은 이 컴포넌트에서 시작해 아래로 뻗어 나간다. -
여기서 컴포넌트란 화면의 한 조각을 반환하는 JS 함수이다.
function Header() { return <h1>My App</h1> } function App() { return ( <div> <Header /> <p>Hello</p> </div> ) } export default App -
함수가 반환하는 HTML처럼 생긴 문법이 JSX이며, 그려질 화면의 모습을 JS 안에서 작성할 수 있게 해준다.
-
JSX는 DOM 노드를 직접 만들지 않고, "어떤 태그를 어떤 속성으로 그릴지"를 적은 일반 JS 객체를 만든다.
-
React는 이 객체를 설계도처럼 읽어서 실제 DOM 노드를 만들거나 고친다.
컴포넌트를 태그로 쓰는 규칙
-
이렇게 만든 컴포넌트는
<Header />처럼 태그 형태로 다른 컴포넌트 안에서 사용할 수 있다. -
Header()처럼 직접 호출하지 않고 태그로 적어 두는 것은, 언제 이 함수를 호출할지를 React가 정하도록 맡기기 위해서이다. -
이때 React는 이름이 대문자로 시작하면 컴포넌트로, 소문자로 시작하면 일반 HTML 태그로 구분한다.
-
이 구분은 JSX가 JS로 변환되는 단계에서 정해지는데, 소문자 태그는 문자열로, 대문자 태그는 변수로 바뀐다.
<div /> // jsx('div', {}) : 'div'라는 이름의 HTML 요소 <Header /> // jsx(Header, {}) : Header라는 변수에 담긴 함수 -
그래서 컴포넌트 이름을
header처럼 소문자로 지으면, 내가 만든 함수가 아니라 HTML의<header>요소가 그려진다.
컴포넌트 트리: 화면 전체는 App에서 뻗어 나간다
- 컴포넌트 안에 컴포넌트를 넣을 수 있으므로, 화면 전체는
App을 뿌리로 하는 컴포넌트 트리가 된다. - 이 구조 덕분에 화면을 작은 단위로 나누어 만들고, 같은 컴포넌트를 여러 곳에서 재사용할 수 있다.
- 마지막 줄의
export default App은main.jsx가import로 이 컴포넌트를 가져올 수 있도록 내보내는 부분이다.