서비스 학습
SOURCE · fe/src/main.tsx · 2026-09-06

빈 HTML 한 칸에
React를 심는 열 줄

브라우저가 가장 먼저 실행하는 우리 코드다. index.html의 빈 div를 찾아 그 안을 React에게 넘긴다. 이 파일 이후로는 화면의 모든 것이 React의 계산 결과다.

이 파일을 만든 이유

브라우저는 React를 모른다. 브라우저가 아는 것은 HTML 문서와 DOM뿐이다. 그래서 “어느 DOM 요소를 React에게 맡길 것인가”를 누군가 한 번은 말해 줘야 한다. 그 한 번이 이 파일이다.

fe/index.html에는 <div id="root"></div>라는 빈 칸이 하나 있다. 이 파일은 그 칸을 찾아 React 애플리케이션을 붙인다. 이후 화면에 보이는 사이드바·입력창·메시지는 전부 HTML 파일이 아니라 JavaScript가 실행되며 만들어진 DOM이다.

전체 구조에서의 위치

HTMLindex.html빈 div#root 한 칸
진입점src/main.tsx여기 — root를 찾아 React 연결
화면src/App.tsx실제 UI와 state
브라우저DOMReact가 계산한 결과가 그려짐

화살표가 한 방향이라는 점이 중요하다. App.tsx는 자기가 어느 DOM에 붙는지 모른다. 그 결정은 오직 이 파일에 있으며, 그래서 테스트 환경처럼 #root가 없는 곳에서도 App만 따로 가져다 쓸 수 있다.

전체 코드

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

한 줄씩 해석

import { StrictMode } from 'react'

React가 제공하는 개발용 검사 도구다. 화면에는 아무것도 그리지 않는다. 대신 개발 모드에서 의심스러운 패턴을 찾아 경고한다. 자세한 동작은 아래 별도 절에서 다룬다.

import { createRoot } from 'react-dom/client'

reactreact-dom다른 package라는 점이 핵심이다. react는 “무엇을 그릴지” 계산하는 부분이고, react-dom은 그 계산 결과를 브라우저 DOM에 반영하는 부분이다. 둘이 나뉘어 있어서 같은 React 코드가 React Native처럼 DOM이 없는 환경에서도 쓰일 수 있다.

import './index.css'

변수를 가져오지 않고 파일만 가리킨다. 이것은 JavaScript 표준 동작이 아니라 Vite가 제공하는 기능이다. 개발 중에는 CSS를 즉시 주입하고, build 때는 dist/assets/의 CSS 파일로 뽑아 index.html<link>로 연결한다. import 순서가 CSS 우선순위에 영향을 주므로 위치에 의미가 있다.

import App from './App.tsx'

App.tsxexport default App으로 내보낸 함수를 가져온다. default export는 이름을 마음대로 붙일 수 있다 — 여기서 App이라 부르는 것은 관례일 뿐이다. 확장자 .tsx를 명시한 것은 TypeScript 설정에 따른 스타일이다.

createRoot 한 줄을 쪼개서 보기

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
document.getElementById('root')

브라우저 표준 API다. index.html 안의 id="root"인 요소를 찾는다. 없으면 null을 돌려준다.

! — non-null assertion

TypeScript 문법이며 브라우저에는 남지 않는다. build 단계에서 사라진다. 뜻은 “null일 수 있다는 것은 알지만, 없을 리 없으니 그렇게 취급하라”다. getElementById의 반환 type이 HTMLElement | null이라 이것이 없으면 type 오류가 난다.

위험한 약속이기도 하다. 실제로 #root가 없으면 type 검사는 통과하고 실행 시점에 TypeError가 난다. 즉 이 !는 “index.html을 고칠 때 #root를 지우지 않겠다”는 사람의 약속이다.

createRoot(...)

그 DOM 요소를 React가 관리하는 root로 만든다. 이 시점에는 아직 화면이 바뀌지 않는다. React 18에서 도입된 방식이며, 이전의 ReactDOM.render와 달리 root 객체를 먼저 만들고 그리기를 따로 요청한다.

.render(...)

실제로 그리기를 요청한다. 여기서 App이 처음 실행되고, 그 결과가 #root 안의 DOM으로 만들어진다. 이 호출이 끝나야 화면에 무언가 나타난다.

<App />App()이 아닌 이유

JSX로 쓰면 React에게 “이 함수를 컴포넌트로 다뤄라”고 알리는 것이다. React가 직접 호출 시점을 정하고, state가 바뀌면 다시 호출한다. App()이라고 직접 부르면 그냥 한 번 실행된 결과값이 되어 state 변경에 반응하지 못한다.

StrictMode가 실제로 하는 일

이름 때문에 “엄격한 문법 검사”로 오해하기 쉽지만 그렇지 않다. 화면에 요소를 추가하지도 않는다. 개발 모드에서만 다음을 한다.

동작목적
컴포넌트 함수를 두 번 호출같은 입력에 같은 결과가 나오는지 확인. 두 번 실행하면 깨지는 코드는 부작용이 섞인 코드다
effect를 붙였다 떼었다 다시 붙임정리(cleanup) 코드를 빠뜨렸는지 드러낸다
더 이상 권장하지 않는 API에 경고오래된 패턴을 미리 알려 준다

production build에서는 이 모든 것이 사라진다. 그래서 개발 중에 console.log가 두 번 찍히는 것은 버그가 아니라 의도된 동작이다. 현재 App.tsxuseEffect를 쓰지 않아 두 번 호출되어도 화면 결과가 같다. 하지만 4단계에서 streaming 요청을 useEffect로 붙이면 요청이 두 번 나가는 것처럼 보이는 상황을 여기서 처음 만나게 된다.

실제 실행 순서

1. index.html 을 받는다        → 화면은 아직 비어 있다 (div#root 는 빈 칸)
2. <script type=module src=/assets/index-해시.js> 를 만나 JS 를 내려받는다
3. main.tsx 에서 나온 코드가 실행된다
     document.getElementById('root')      → 빈 div 를 찾음
     createRoot(...)                      → React 가 관리 시작
     .render(<StrictMode><App /></StrictMode>)
4. App() 이 처음 실행된다
     useState 세 개의 초기값이 정해진다
     반환된 JSX 로부터 DOM 이 만들어진다
5. div#root 안이 채워지고 화면이 보인다

1번과 5번 사이의 시간이 흰 화면이다. 지금은 파일이 작아 눈에 띄지 않지만, 이것이 SPA의 구조적 특징이다. nginx.conf가 모든 경로에 index.html을 돌려주는 이유도 여기에 있다 — 화면을 만드는 것은 서버가 아니라 이 JavaScript다.

관찰하는 방법

cd fe
npm install
npm run dev          # http://localhost:5173

브라우저 개발자 도구에서 두 가지를 확인해 본다.

확인방법보이는 것
React가 DOM을 만들었다Elements 탭에서 div#root를 펼친다index.html 원본에는 없던 div.app-shell 이하가 들어 있다
원본 HTML은 비어 있다페이지 소스 보기 (view-source:)<div id="root"></div>만 있고 채팅 UI는 없다
StrictMode 이중 호출App 함수 첫 줄에 console.log('render')를 넣는다개발 모드에서 두 번 찍힌다

자주 발생하는 오류

증상원인진단
흰 화면 + Cannot read properties of nullindex.htmlid="root"가 없거나 이름이 다름!가 감춘 상황이 실제로 일어난 것이다. HTML의 id를 확인한다
흰 화면, 콘솔은 조용함index.html<script type="module" src="/src/main.tsx">가 없음JS 자체가 실행되지 않은 것이다
스타일이 하나도 없음import './index.css' 누락CSS import는 부작용 import라 지워도 type 오류가 나지 않는다
개발 중 요청이 두 번 나감StrictMode의 의도된 이중 실행production build에서는 한 번이다. cleanup을 제대로 썼는지 점검하는 신호로 받아들인다
Failed to resolve import경로나 확장자 오타Vite는 이 오류를 브라우저 화면에 직접 표시한다

설계 선택과 대안

App.tsx가 스스로 DOM에 붙지 않는가?

붙는 위치를 아는 코드가 하나뿐이어야 재사용이 가능하다. App이 자기 자신을 #root에 심으면, 테스트나 Storybook처럼 다른 컨테이너에 그리고 싶은 상황에서 손댈 수 없다. 이 열 줄이 그 결정을 독점한다.

! 대신 안전하게 쓰려면?

const root = document.getElementById('root'); if (!root) throw new Error('#root not found');처럼 명시적으로 검사하면 실패 원인이 메시지에 남는다. 지금은 index.html과 이 파일이 같은 저장소에 있고 함께 관리되므로 짧은 쪽을 택했다. 두 파일의 관리 주체가 갈라지면 바꿀 만하다.

StrictMode를 빼면 안 되나?

개발 중 이중 실행이 거슬린다는 이유로 빼는 경우가 있지만, 그러면 정리 코드를 빠뜨린 버그가 production에서야 드러난다. 특히 streaming 연결을 다루는 4단계에서는 연결을 제대로 닫는지가 중요해지므로 유지하는 편이 이득이다.

이전 단계와 다음 파일

Frontend 구조에서 “TSX는 브라우저가 그대로 실행할 수 없다”고 했다. 그 변환을 담당하는 설정이 vite.config.ts다. 이 파일이 실행시키는 화면 자체는 App.tsx에서 읽는다.

← 이전HTML 진입점다음 →채팅 화면과 state