Skip to Content
WebReactReact 기초15. 컴포넌트 설계: children과 합성

이번 편의 결과물: 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;

isOpenfalsenull을 반환해 아무것도 렌더링하지 않습니다. 배경(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도 이 콜백을 그대로 한 층 아래로 전달해야 합니다(BookListonOpenDetail 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;

LayoutHeader, BookForm, BookList가 무엇인지 모른 채 그대로 감싸고, Modal은 책 상세 div가 무엇인지 모른 채 그대로 감쌉니다. 두 컴포넌트 모두 구조만 제공하고 내용은 관여하지 않는 합성의 전형입니다.

5. 실행

npm run dev

6. 확인

  • 책 카드의 제목을 클릭하면 화면 중앙에 모달이 뜨고, 제목·저자·쪽수·메모가 보입니다.
  • “닫기” 버튼이나 모달 바깥(배경)을 클릭하면 모달이 닫힙니다.
  • 모달 내용(흰 박스) 안을 클릭해도 모달이 닫히지 않습니다(stopPropagation 확인).
  • React DevTools Components 탭에서 Modal을 선택하면 childrenbook-detail div 트리로 전달된 것을 확인할 수 있습니다.

직접 해보기

  1. Layouttitle prop을 추가로 받아, children 위에 페이지 제목을 표시해 보세요(props와 children을 함께 받는 컴포넌트 연습).
  2. Modal이 열려 있을 때 Escape 키를 누르면 닫히도록 useEffectkeydown 이벤트를 등록해 보세요.

정답 보기

// 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>)
isOpenfalse인데도 모달이 잠깐 보임null 반환 전에 다른 조건부 렌더링과 얽힘컴포넌트 최상단에서 if (!isOpen) return null;을 가장 먼저 처리
props와 children을 같이 쓰다가 구조 분해를 빠뜨림function Layout(props)로 받고 props.children 대신 props를 그대로 렌더링{ title, children }으로 구조 분해

확인 문제

문제 14지선다
React에서 children prop이 채워지는 방식은?
문제 24지선다
props 나열 방식 대비 children 합성 방식의 장점으로 가장 알맞은 것은?
문제 34지선다
Modal 컴포넌트에서 modal-content에 event.stopPropagation()을 호출하는 이유는?
문제 44지선다
Layout, Modal 컴포넌트가 안에 어떤 내용이 들어갈지 몰라도 되는 이유는?
문제 54지선다
Modal 컴포넌트에서 isOpen이 false일 때 if (!isOpen) return null;로 처리하는 이유는?

참고 자료

Last updated on