Next.js & React HTML Nesting 완전 정복 가이드 - Hydration Mismatch
중년개발자
@loxo
25일 전
Next.js와 React 환경에서 HTML nesting(중첩) 규칙으로 인한 Hydration 오류는 많은 개발자들이 경험하는 문제입니다.
📘 Next.js & React HTML Nesting 완전 정복 가이드
1. 💡 왜 HTML Nesting 오류가 Hydration Mismatch를 일으킬까?
Next.js(SSR/SSG)는 Node.js 서버에서 먼저 HTML 문자열을 만들어서 브라우저로 전송합니다.
<!-- 서버가 생성한 raw HTML 예시 -->
<p>
<div>안녕하세요</div>
</p>하지만 브라우저의 HTML Parser는 HTML 표준 명세에 따라 다음과 같이 동작합니다:
⚠️ "표준 규칙:
<p>(문단) 태그 안에<div>(블록)가 들어오면, 브라우저는<p>가 끝난 것으로 판단하고 즉시</p>를 자동으로 닫아버린다."
결국 브라우저 DOM은 다음과 같이 변형됩니다:
<!-- 브라우저가 자동 정정한 DOM -->
<p></p>
<div>안녕하세요</div>
<p></p>이후 클라이언트에서 React가 JavaScript를 실행(Hydration)하며 자신이 기억하는 Virtual DOM (<p> 안에 <div>)과 브라우저 DOM (<p> 뒤에 <div>)을 비교할 때 구조가 달라졌음을 감지하고 경고를 발생시킵니다:
❌ Hydration failed because the initial UI does not match what was rendered on the server.
Warning: In HTML, <div> cannot appear as a descendant of <p>.2. 🧱 잊지 않는 HTML 태그 계급도 (3대 직업군)
복잡한 HTML 명세를 모두 외울 필요 없이, 모든 태그를 3가지 직업군으로 나누어 생각하면 자연스럽게 몸에 익습니다.
① 상자형 (Block / Container)
- 대표 태그:
<div>,<section>,<article>,<main>,<header>,<footer>,<aside> - 수용 범위: 거의 모든 태그(상자, 문장, 글자)를 담을 수 있습니다.
- 규칙: 구조를 잡는 최외각 뼈대로 사용합니다.
② 문장형 (Paragraph / Inline)
- 대표 태그:
<p>,<h1>~<h6>,<span>,<strong>,<em>,<code> - 핵심 규칙 (Golden Rule):
<p>태그는 "오직 순수한 글자(Inline)와 텍스트만 담는 프린터"입니다.<p>태그 안에는 절대로 상자형(<div>,<ul>,<form>)이나 다른 문장형(<p>,<h1>)을 넣을 수 없습니다.
③ 상호작용형 (Interactive Element)
- 대표 태그:
<button>,<a>,<input>,<select> - 핵심 규칙 (Golden Rule):
- "클릭 유전자끼리는 중첩 금지!"
<button>안에<a>를 넣거나,<a>안에 또 다른<a>나<button>을 넣을 수 없습니다.
👑 엄격한 가문 (Direct Parent-Child Requirements)
<ul>,<ol>: 직속 자식은 무조건<li>만 허용!<table>:<table>→<thead>/<tbody>/<tfoot>→<tr>→<th>/<td>순서를 엄격히 준수.<table>바로 아래에<div>나<span>배치 금지!
3. ⚠️ 자주 실수하는 TOP 5 사례 & 올바른 코드
❌ 1위: Typography/Card 컴포넌트의 <p> 내부 <div>
UI 라이브러리(Shadcn UI, MUI 등)나 직접 만든 <Text> 컴포넌트를 쓸 때 가장 흔한 실수입니다.
// ❌ 잘못된 코드: <p> 안에 <div> (Flex box나 Icon 래퍼)
<p className="description">
설명 텍스트입니다.
<div className="badge">NEW</div> {/* 💥 Hydration Error! */}
</p>
// ⭕ 올바른 코드: 바깥을 <div>로 바꾸거나 내부를 <span>으로 변경
<div className="description">
설명 텍스트입니다.
<span className="badge">NEW</span>
</div>❌ 2위: 카드 전체 클릭 링크 (<a>) 안에 버튼/태그 링크
카드 전체를 <Link>로 감싸고, 카드 내부의 '프로필 버튼'이나 '태그 링크'를 또 클릭 가능하게 만든 경우입니다.
// ❌ 잘못된 코드: <a> 안에 <button> 또는 <a>
<Link href="/posts/1">
<div className="card">
<h3>포스트 제목</h3>
<button onClick={handleLike}>좋아요</button> {/* 💥 <a> 안에 <button> 중첩! */}
</div>
</Link>
// ⭕ 올바른 코드: 클릭 이벤트를 분리하거나 CSS absolute/Event Delegation 활용
<div className="card-wrapper relative">
<Link href="/posts/1" className="absolute inset-0">
<span className="sr-only">포스트 읽기</span>
</Link>
<h3>포스트 제목</h3>
<button onClick={handleLike} className="relative z-10">좋아요</button>
</div>❌ 3위: 드롭다운/리스트 UI 구현 시 <ul> 바로 아래 <div>
리스트 정렬이나 애니메이션 래퍼를 <ul> 바로 밑에 둘 때 발생합니다.
// ❌ 잘못된 코드: <ul> 직속 자식으로 <div> 사용
<ul>
<div className="scroll-wrapper"> {/* 💥 <ul> 직속 자식은 <li>만 가능! */}
<li>아이템 1</li>
<li>아이템 2</li>
</div>
</ul>
// ⭕ 올바른 코드: <div>를 <ul> 바깥으로 이동
<div className="scroll-wrapper">
<ul>
<li>아이템 1</li>
<li>아이템 2</li>
</ul>
</div>❌ 4위: <table> 구조 단순화 시도
테이블을 만들 때 <tbody>를 생략하거나 <tr> 없이 <td>를 넣는 경우입니다.
// ❌ 잘못된 코드
<table>
<tr>
<div> {/* 💥 <tr> 안에는 <th> 또는 <td>만 가능 */}
<td>내용</td>
</div>
</tr>
</table>
// ⭕ 올바른 코드
<table>
<tbody>
<tr>
<td>
<div>내용</div> {/* <td> 내부에는 <div> 가능 */}
</td>
</tr>
</tbody>
</table>❌ 5위: <p> 안에 <p> (부모-자식 컴포넌트 조합)
부모 컴포넌트가 <p> 태그로 감싸고 있는데, 자식 컴포넌트가 내부에서 또 <p>를 반환할 때 발생합니다.
// SubText.tsx
const SubText = () => <p>부연 설명입니다.</p>;
// Page.tsx
// ❌ 잘못된 코드: <p> 안에 <p>가 중첩됨
<p>
기본 문장입니다.
<SubText /> {/* 💥 <p><p>부연 설명입니다.</p></p> */}
</p>
// ⭕ 올바른 코드: 부모 요소를 <div>로 변경
<div>
<p>기본 문장입니다.</p>
<SubText />
</div>4. 🔍 에러 발생 시 3초 만에 원인 찾는 디버깅 노하우
Hydration 오류가 났을 때 다음 단계로 원인을 직관적으로 파악할 수 있습니다.
1️⃣ Next.js Dev Overlay / Console Log 읽기
오류 메시지에서 명시하는 태그 조합을 먼저 확인합니다.
In HTML, <p> cannot appear as a descendant of <p>.Expected server HTML to contain a matching <div> in <p>.
2️⃣ 브라우저 F12 (Elements 탭) vs 소스 보기 (Ctrl + U) 비교
Ctrl + U(서버 응답): 서버가 보낸 원본 HTML을 확인합니다.F12 Elements(브라우저 DOM): 브라우저가 파싱 후 자동 정정한 DOM을 확인합니다.- 꿀팁: Elements 탭에서
<p></p>처럼 내용 없이 빈 상태로 바로 닫혀있는<p>태그가 있다면, 100% 그 지점에<div>나 다른 블록 태그가 잘못 들어가 브라우저가 강제로 닫은 것입니다.
5. 🧠 자연스럽게 몸에 익히는 「3초 코딩 체크 공식」
코딩할 때 매번 명세서를 찾아보지 않고 자연스럽게 몸으로 체득하는 3단계 자가 점검법입니다.
[1초] 🏷️ "내가 지금 여는 태그가 <p> 인가?"
└─ YES ──> "내부에 div, ul, p, h1~6이 들어가는가?"
└─ YES ──> 즉시 outer 태그를 <div>로 교체!
[2초] 👆 "내가 지금 만드는 요소가 클릭 가능한 버튼/링크인가?"
└─ YES ──> "이 안이나 밖에 또 다른 button/Link가 있는가?"
└─ YES ──> 이벤트 구조 분리! (절대 겹치지 않기)
[3초] 👪 "이 태그가 <ul>, <ol>, <table> 가문인가?"
└─ YES ──> "직속 자식이 li, tr/tbody 인가?"
└─ NO ──> 직속 자식 규칙 재정립!💡 요약 한 줄 리마인더
"문장을 쓸 때는
<p>, 상자를 만들 때는<div>, 클릭 요소(a/button)끼리는 절대 겹치지 않는다!"