이번 편의 결과물: BookCard에 compound component 패턴이 적용되어 BookCard.Cover, BookCard.Title, BookCard.Status로 조립할 수 있습니다. · 다루는 개념: compound component, Context 기반 암묵적 상태 공유, render props와 커스텀 훅의 차이
이 편에서 만드는 파일
bookshelf/src/
├── components/
│ └── BookCard.jsx ~ (compound component로 재작성)
└── pages/
└── BookListPage.jsx ~ (BookCard.* 조립 방식으로 사용)개념 정리
compound component는 부모 컴포넌트가 Context로 상태를 들고, 자식 컴포넌트들이 그 Context를 각자 읽어 조각을 렌더하는 패턴입니다. select/option처럼 부모와 자식이 한 세트로 동작하지만, 내부 상태 전달을 props 대신 Context로 숨깁니다.
| 패턴 | 상태 전달 방식 | 장점 | 단점 |
|---|---|---|---|
| props 조합 | 부모가 모든 자식에 값을 직접 전달 | 데이터 흐름이 명시적 | 자식이 늘수록 prop이 늘어남 |
| compound component | Context로 암묵적 공유, 자식은 BookCard.Cover처럼 이름으로 배치 | 사용부에서 필요한 조각만 원하는 순서로 배치 | Context를 몰래 쓰므로 자식을 BookCard 밖에서 쓰면 오류 |
| render props | 함수를 자식으로 전달해 렌더 로직을 위임 | 로직과 렌더를 분리 | 중첩되면 코드가 계단식으로 깊어짐 |
| 커스텀 훅 | 로직만 훅으로 추출, 렌더는 각자 작성 | 로직 재사용과 렌더 자유도를 동시에 얻음 | 상태 공유가 아니라 로직 공유만 가능 |
render props는 클래스 컴포넌트 시절 로직 재사용 수단으로 널리 쓰였습니다. 지금은 같은 문제를 커스텀 훅으로 더 평평한 코드로 풀 수 있어 새 코드에서는 거의 쓰지 않습니다. 이 과목 06~07편에서 만든 useLocalStorage, useDebounce가 바로 그 대체 사례입니다.
언제 compound를 고르는가: 여러 자식이 한 부모의 상태(현재 선택된 항목, 열림 여부 등)를 함께 봐야 하고, 사용하는 쪽에서 자식의 구성·순서를 자유롭게 바꾸고 싶을 때 씁니다. 자식이 하나뿐이거나 상태 공유가 필요 없으면 일반 props 조합으로 충분합니다.
실습
1. 파일 열기
src/components/BookCard.jsx를 엽니다. 지금은 book, onToggle, onDelete props를 받아 표지·제목·상태 버튼을 한 번에 렌더하는 단일 컴포넌트입니다.
2. 코드 작성
// src/components/BookCard.jsx
import { createContext, useContext } from 'react';
const BookCardContext = createContext(null);
function useBookCardContext(componentName) {
const context = useContext(BookCardContext);
if (!context) {
throw new Error(componentName + '는 BookCard 안에서만 사용할 수 있습니다');
}
return context;
}
function BookCard({ book, onToggle, onDelete, children }) {
return (
<BookCardContext.Provider value={{ book, onToggle, onDelete }}>
<li className="book-card">{children}</li>
</BookCardContext.Provider>
);
}
function Cover({ ref }) {
const { book } = useBookCardContext('BookCard.Cover');
// React 19부터 ref를 일반 prop으로 받는다. forwardRef가 필요 없다.
return <div ref={ref} className="book-card__cover" style={{ backgroundColor: book.coverColor }} aria-hidden="true" />;
}
function Title() {
const { book } = useBookCardContext('BookCard.Title');
return <h3 className="book-card__title">{book.title}</h3>;
}
function Status() {
const { book, onToggle } = useBookCardContext('BookCard.Status');
return (
<button type="button" onClick={onToggle} className="book-card__status">
{book.status === 'done' ? '완독' : '읽는 중'}
</button>
);
}
function DeleteButton() {
const { onDelete } = useBookCardContext('BookCard.DeleteButton');
return (
<button type="button" onClick={onDelete} aria-label="삭제">
삭제
</button>
);
}
BookCard.Cover = Cover;
BookCard.Title = Title;
BookCard.Status = Status;
BookCard.DeleteButton = DeleteButton;
export default BookCard;BookCard 자신은 <li>와 Context.Provider만 렌더하고, 실제 화면 조각은 Cover·Title·Status·DeleteButton이 각자 Context를 읽어 그립니다. 사용하는 쪽은 필요한 조각만, 원하는 순서로 배치할 수 있습니다.
// src/pages/BookListPage.jsx
import { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useBooks } from '../hooks/useBooks';
import { useDebounce } from '../hooks/useDebounce';
import BookCard from '../components/BookCard';
export default function BookListPage() {
const { booksById, bookIds, toggleBook, deleteBook } = useBooks();
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 300);
const visibleIds = useMemo(() => {
const term = debouncedKeyword.trim().toLowerCase();
if (!term) return bookIds;
return bookIds.filter((id) => {
const book = booksById[id];
return book.title.toLowerCase().includes(term) || book.author.toLowerCase().includes(term);
});
}, [bookIds, booksById, debouncedKeyword]);
return (
<section>
<h1>내 책장</h1>
{/* ...기존 ThemeToggle 유지 */}
<input
type="search"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="제목 또는 저자 검색"
aria-label="책 검색"
/>
<p>{visibleIds.length}권 표시 중 (전체 {bookIds.length}권)</p>
<ul>
{visibleIds.map((id) => (
<BookCard key={id} book={booksById[id]} onToggle={() => toggleBook(id)} onDelete={() => deleteBook(id)}>
<BookCard.Cover />
<BookCard.Title />
<BookCard.Status />
<BookCard.DeleteButton />
</BookCard>
))}
</ul>
<Link to="/books/new">새 책 추가</Link>
</section>
);
}3. 실행
npm run dev4. 확인
- 목록 화면이 이전과 같은 모습으로 보이지만,
BookListPage.jsx에서BookCard.Cover·BookCard.Title·BookCard.Status·BookCard.DeleteButton순서를 바꾸면 화면 배치도 바로 바뀝니다. BookCard.Title을BookCard밖에서 단독으로 렌더해보면 “BookCard.Title는 BookCard 안에서만 사용할 수 있습니다” 오류가 콘솔에 표시됩니다.
직접 해보기
BookCard.Author를 추가해 저자 이름을 표시하는 조각을 만들어보세요.Title과 같은 방식으로 Context에서book을 읽습니다.
답 보기
function Author() {
const { book } = useBookCardContext('BookCard.Author');
return <p className="book-card__author">{book.author}</p>;
}
BookCard.Author = Author;BookListPage.jsx의 자식 목록에 <BookCard.Author />를 추가하면 됩니다.
- 상세 페이지(
BookDetailPage.jsx)에서는BookCard.Cover와BookCard.Title만 크게 배치하고Status·DeleteButton은 빼보세요. 같은 컴포넌트 세트를 화면마다 다르게 조립할 수 있음을 확인합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| ”BookCard.Cover는 BookCard 안에서만 사용할 수 있습니다” 오류 | 자식 컴포넌트를 BookCard 태그 바깥에서 렌더 | 항상 BookCard 자식으로만 배치 |
| props로 book을 또 넘겨줌 | compound 패턴 도입 후에도 이전 습관대로 props 전달 | Context가 이미 값을 갖고 있으므로 자식은 props 없이 호출 |
| Cover에 넘긴 ref가 동작 안 함 | React 18 이하 습관으로 forwardRef를 씌움 | React 19부터는 함수 컴포넌트가 ref를 일반 prop으로 받으므로 forwardRef를 없앤다 |
| 자식 순서를 바꿔도 화면이 그대로 | 빌드 캐시나 HMR 문제 | 개발 서버를 재시작해 확인 |