이번 편의 결과물: Layout으로 헤더·본문 구조를 감싸고, Modal로 책 상세 정보를 팝업으로 볼 수 있습니다. · 다루는 개념: children prop, 컴포넌트 합성(composition), 재사용 가능한 레이아웃 패턴
이 편에서 만드는 파일
bookshelf/src/
├── components/
│ ├── Layout.jsx + 신규
│ ├── Modal.jsx + 신규
│ └── BookCard.jsx ~ 수정 (클릭 시 상세 열기)
└── App.jsx ~ 수정 (Layout·Modal 배치)개념 정리
5편에서 props로 데이터를 전달하는 법을 배웠습니다. children은 조금 특별한 props입니다 — 컴포넌트를 여는 태그와 닫는 태그 사이에 넣은 JSX가 자동으로 children이라는 이름의 prop이 됩니다.
<Card>
<p>이 문단이 Card의 children이 됩니다.</p>
</Card>function Card({ children }) {
return <div className="card">{children}</div>;
}children이 중요한 이유는 컴포넌트를 미리 정해진 props 목록이 아니라, 감쌀 내용으로 설계할 수 있게 해주기 때문입니다. 이 방식을 합성(composition, 컴포지션)이라고 부릅니다.
| 방식 | 예시 | 특징 |
|---|---|---|
| props 나열 | <Card title="..." body="..." footer="..." /> | 내용이 정해진 형태에 고정됨 |
| children 합성 | <Card><h2>...</h2><p>...</p></Card> | 내용이 자유로운 JSX — 무엇이든 감쌀 수 있음 |
합성이 유리한 대표적인 두 패턴이 있습니다.
- 레이아웃 패턴: 헤더·여백·정렬 같은 “껍데기” 구조를 컴포넌트로 만들고, 안에 무엇을 넣을지는 사용하는 쪽이 결정합니다(
Layout). - 오버레이 패턴: 화면 위에 뜨는 배경·닫기 버튼 같은 “틀”을 컴포넌트가 담당하고, 안의 내용은 상황마다 다릅니다(
Modal).
두 경우 모두 Layout이나 Modal은 안에 책 카드가 들어갈지 폼이 들어갈지 전혀 몰라도 됩니다. 이것이 children이 props 나열보다 유연한 이유입니다.
실습
1. Layout 컴포넌트 만들기
// src/components/Layout.jsx
function Layout({ children }) {
return (
<div className="layout">
<main className="layout-main">{children}</main>
</div>
);
}
export default Layout;2. Modal 컴포넌트 만들기
// src/components/Modal.jsx
function Modal({ isOpen, onClose, children }) {
if (!isOpen) {
return null;
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={(event) => event.stopPropagation()}>
<button type="button" className="modal-close" onClick={onClose}>
닫기
</button>
{children}
</div>
</div>
);
}
export default Modal;isOpen이 false면 null을 반환해 아무것도 렌더링하지 않습니다. 배경(modal-backdrop) 클릭 시 onClose를 부르되, 내용(modal-content) 클릭은 stopPropagation으로 막아 배경 클릭과 구분합니다. Modal은 안에 무엇이 들어올지 전혀 모릅니다 — 책 상세든 다른 무엇이든 children으로 받을 뿐입니다.
3. BookCard에서 상세 보기 연결하기
// src/components/BookCard.jsx
import StatusBadge from './StatusBadge.jsx';
import StarRating from './StarRating.jsx';
function BookCard({ book, onDelete, onRate, onToggleStatus, onOpenDetail }) {
return (
<div className="book-card" style={{ borderTop: `6px solid ${book.coverColor}` }}>
<StatusBadge status={book.status} onToggle={(nextStatus) => onToggleStatus(book.id, nextStatus)} />
<button type="button" className="book-card-title" onClick={() => onOpenDetail(book)}>
{book.title}
</button>
<p>{book.author}</p>
<p className="book-pages">{book.pages}쪽</p>
<StarRating rating={book.rating} onChange={(rating) => onRate(book.id, rating)} />
<button type="button" onClick={() => onDelete(book.id)}>
삭제
</button>
</div>
);
}
export default BookCard;제목을 버튼으로 바꿔 클릭하면 onOpenDetail(book)을 호출합니다. BookCard의 나머지 props(onDelete, onRate, onToggleStatus)와 루트 요소(<div className="book-card">)는 10편 그대로입니다. BookList도 이 콜백을 그대로 한 층 아래로 전달해야 합니다(BookList에 onOpenDetail prop을 추가하고 BookCard로 넘기는 한 줄만 더합니다).
4. App.jsx에서 Layout·Modal 배치하기
// src/App.jsx (관련 부분만 발췌 — 나머지 useReducer 로직은 14편 그대로 유지)
import { useState } from 'react';
import Layout from './components/Layout';
import Modal from './components/Modal';
// ...기존 import 유지
function App() {
// ...기존 useReducer·useEffect·useRef 유지
const [selectedBook, setSelectedBook] = useState(null);
function handleOpenDetail(book) {
setSelectedBook(book);
}
function handleCloseDetail() {
setSelectedBook(null);
}
return (
<Layout>
<Header />
<p>총 {books.length}권</p>
<BookList
ref={scrollTargetRef}
books={books}
onDelete={handleDeleteBook}
onRate={handleRateBook}
onToggleStatus={handleToggleStatus}
onOpenDetail={handleOpenDetail}
/>
<BookForm onAddBook={handleAddBook} />
<Modal isOpen={selectedBook !== null} onClose={handleCloseDetail}>
{selectedBook && (
<div className="book-detail">
<h2>{selectedBook.title}</h2>
<p>{selectedBook.author}</p>
<p>{selectedBook.pages}쪽</p>
<p>{selectedBook.memo || '메모가 없습니다.'}</p>
</div>
)}
</Modal>
</Layout>
);
}
export default App;Layout은 Header, BookForm, BookList가 무엇인지 모른 채 그대로 감싸고, Modal은 책 상세 div가 무엇인지 모른 채 그대로 감쌉니다. 두 컴포넌트 모두 구조만 제공하고 내용은 관여하지 않는 합성의 전형입니다.
5. 실행
npm run dev6. 확인
- 책 카드의 제목을 클릭하면 화면 중앙에 모달이 뜨고, 제목·저자·쪽수·메모가 보입니다.
- “닫기” 버튼이나 모달 바깥(배경)을 클릭하면 모달이 닫힙니다.
- 모달 내용(흰 박스) 안을 클릭해도 모달이 닫히지 않습니다(
stopPropagation확인). - React DevTools Components 탭에서
Modal을 선택하면children이book-detaildiv 트리로 전달된 것을 확인할 수 있습니다.
직접 해보기
Layout에titleprop을 추가로 받아,children위에 페이지 제목을 표시해 보세요(props와 children을 함께 받는 컴포넌트 연습).Modal이 열려 있을 때Escape키를 누르면 닫히도록useEffect로keydown이벤트를 등록해 보세요.
정답 보기
// src/components/Layout.jsx
function Layout({ title, children }) {
return (
<div className="layout">
{title && <h1 className="layout-title">{title}</h1>}
<main className="layout-main">{children}</main>
</div>
);
}// src/components/Modal.jsx (useEffect 추가)
import { useEffect } from 'react';
function Modal({ isOpen, onClose, children }) {
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event) {
if (event.key === 'Escape') onClose();
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
// ...나머지 동일
}자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 모달 안을 클릭해도 모달이 닫힘 | modal-content의 클릭 이벤트가 modal-backdrop까지 전파됨 | event.stopPropagation() 호출 확인 |
children이 항상 undefined | 컴포넌트를 자기 닫힘 태그(<Layout />)로 사용 | 여는 태그와 닫는 태그 사이에 내용을 넣기(<Layout>...</Layout>) |
isOpen이 false인데도 모달이 잠깐 보임 | null 반환 전에 다른 조건부 렌더링과 얽힘 | 컴포넌트 최상단에서 if (!isOpen) return null;을 가장 먼저 처리 |
| props와 children을 같이 쓰다가 구조 분해를 빠뜨림 | function Layout(props)로 받고 props.children 대신 props를 그대로 렌더링 | { title, children }으로 구조 분해 |