열두 줄짜리 빈 문서가
전체의 시작점이다
채팅 화면은 이 파일 어디에도 없다. 빈 div 하나와 script 태그 하나뿐이며, 나머지는 전부 JavaScript가 만들어 낸다.
이 파일을 만든 이유
브라우저는 주소를 받으면 가장 먼저 HTML 문서를 요청한다. React 애플리케이션이라도 예외가 없다. 그 첫 문서가 이 파일이며, 하는 일은 두 가지다. React가 붙을 자리를 하나 만들고, 애플리케이션 코드를 불러오는 것.
Vite에게도 특별한 파일이다. Vite는 이 index.html을 build의 진입점으로 삼아 <script>가 가리키는 파일부터 의존성을 따라간다. 그래서 위치가 src/가 아니라 프로젝트 최상단이다.
전체 구조에서의 위치
①과 ④ 사이가 빈 화면이다. 서버가 완성된 HTML을 주는 방식(SSR)과 달리, 화면을 만드는 책임이 전부 브라우저 쪽 JavaScript에 있다.
전체 코드
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
한 줄씩 해석
<!doctype html>
브라우저에게 표준 모드로 해석하라고 알린다. 없으면 옛 호환 모드로 동작해 CSS 크기 계산이 달라진다. 첫 줄에 있어야 한다.
<html lang="en">
문서의 기본 언어다. 화면 글자는 대부분 한국어이므로 실제 내용과 어긋나 있다. Vite 기본 템플릿 값이 그대로 남은 것이다. 스크린 리더의 발음과 브라우저 번역 제안에 영향을 주므로 lang="ko"가 더 맞는다.
<meta charset="UTF-8" />
글자 인코딩 선언이다. 한국어를 다루므로 반드시 필요하고, head의 맨 앞에 있어야 한다. 브라우저는 이 선언 전까지 읽은 바이트를 다시 해석해야 하기 때문이다.
<link rel="icon" … href="/favicon.svg" />
탭에 표시할 아이콘이다. 경로가 /로 시작하므로 사이트 root 기준이며, 실제 파일은 fe/public/favicon.svg다. public/ 폴더의 파일은 build 때 변환 없이 dist/ 최상단으로 복사된다.
<meta name="viewport" …>
모바일에서 화면 폭을 실제 기기 폭으로 맞춘다. 이 줄이 없으면 iPhone·iPad Safari가 데스크톱 폭으로 그린 뒤 축소해 글자가 아주 작아진다. iPad에서 쓰겠다는 이 프로젝트의 목표에 직접 관계된다.
<title>fe</title>
탭 제목이다. 폴더 이름 그대로인 기본값이 남아 있다. 브라우저 탭·북마크·기록에 그대로 보이므로 서비스 이름으로 바꾸는 편이 낫다.
<div id="root"></div>
이 파일의 핵심이다. 비어 있는 것이 정상이다. main.tsx의 document.getElementById('root')가 이 요소를 찾는다. id를 바꾸면 그쪽도 함께 바꿔야 하며, 그렇지 않으면 흰 화면이 된다.
<script type="module" src="/src/main.tsx">
type="module"은 ES module로 다루라는 뜻이며, 이것이 있으면 브라우저가 HTML 파싱을 멈추지 않고 문서를 다 읽은 뒤 실행한다. 그래서 #root보다 뒤에 있어도, 앞에 있어도 동작한다.
.tsx를 직접 가리키는 점이 특이하다. 브라우저는 TSX를 실행하지 못한다. 개발 중에는 Vite가 요청을 가로채 변환해 주고, build 후에는 이 줄이 변환된 JS 파일을 가리키도록 바뀐다.
build 전후 비교
<div id=root></div>
<script type=module src=/src/main.tsx></script>
↑ Vite 개발 서버가 요청을 가로채 TSX 를 즉시 변환해 준다
<div id=root></div>
<script type=module crossorigin src=/assets/index-a1b2c3d4.js></script>
<link rel=stylesheet crossorigin href=/assets/index-e5f6g7h8.css>
↑ Vite 가 자동으로 바꿔 넣는다. CSS link 도 새로 추가된다
이 파일을 직접 고쳐 script 경로를 바꾸는 일은 없다. Vite가 build 때 대신 해 준다. 해시가 붙은 파일 이름 덕분에 Nginx가 자산을 1년 동안 캐시해도 안전하다.
실제로 확인하는 방법
| 확인 | 방법 | 보이는 것 |
|---|---|---|
| 원본은 비어 있다 | 브라우저에서 view-source:로 열기 | div#root가 빈 채로 있고 채팅 UI가 없다 |
| JS가 채운다 | 개발자 도구 Elements 탭 | 같은 div#root 안에 div.app-shell 이하가 들어 있다 |
| build 결과 | npm run build 후 cat dist/index.html | script 경로가 해시 파일로 바뀌고 CSS link가 추가돼 있다 |
| JS를 끄면 | 브라우저 설정에서 JavaScript 차단 | 완전히 빈 화면이 된다. SPA의 구조적 한계다 |
마지막 항목이 이 구조의 대가다. 검색 엔진과 JavaScript를 실행하지 않는 환경에서는 아무 내용도 볼 수 없다. 로그인 뒤에 쓰는 채팅 서비스라 지금은 문제가 되지 않는다.
자주 발생하는 오류
| 증상 | 원인 | 진단 |
|---|---|---|
흰 화면 + Cannot read properties of null | id="root"를 바꾸거나 지움 | main.tsx의 getElementById 인수와 맞춘다 |
| 흰 화면, 콘솔도 조용함 | <script> 줄이 없거나 경로 오타 | Network 탭에 JS 요청 자체가 없다 |
| 한글이 깨짐 | charset 선언이 없거나 head 뒤쪽에 있음 | 반드시 head 맨 앞이어야 한다 |
| 모바일에서 글자가 아주 작음 | viewport meta 누락 | iPad Safari에서 특히 두드러진다 |
| build 후 404 자산 | base 설정과 배포 경로 불일치 | vite.config.ts의 base를 확인한다 |
| 아이콘이 안 보임 | public/favicon.svg가 없음 | public/의 파일만 root로 복사된다 |
설계 선택과 대안
왜 HTML에 화면을 미리 그려 두지 않는가?
화면이 messages 같은 state에 따라 달라지기 때문이다. HTML에 초기 모습을 적어 두면 JavaScript가 그린 결과와 어긋나 깜빡임이 생기고, 두 곳을 함께 고쳐야 한다. React를 쓰는 이상 화면의 근거는 한 곳이어야 한다.
SSR로 첫 화면을 서버가 그리면?
빈 화면 시간이 줄고 검색 노출에도 유리하다. 대신 Node 서버가 하나 더 필요해지고, 현재 구조의 “Nginx가 정적 파일만 제공한다”는 단순함이 사라진다. 8GB 환경에서 container를 더 늘리는 선택이므로 지금은 하지 않는다.
lang과 title은 지금 고쳐야 하나?
동작에는 영향이 없지만 둘 다 사용자에게 보이는 값이다. title은 탭에 그대로 fe라고 뜨고, lang은 보조 기술의 판단에 쓰인다. 인증 화면을 붙이는 8단계에서 서비스 이름이 정해지면 함께 정리할 만한 항목이다.
이전 단계와 다음 파일
이 파일이 불러오는 코드는 main.tsx이고, 그 코드가 그리는 화면은 App.tsx에 있다. 여기에 적용되는 전역 스타일은 index.css, build 규칙은 vite.config.ts, 배포 시 이 문서를 내보내는 설정은 nginx.conf에서 이어진다.