객체지향을 배우면 상속으로 기능을 물려받는 데 익숙해진다. 근데 리액트에서 컴포넌트끼리 상속하는 코드는 거의 볼 일이 없다. 대신 작은 컴포넌트를 레고처럼 끼워 맞춰 큰 화면을 만든다. 이걸 합성(composition, 여러 조각을 조립하는 것)이라고 부른다. 상속이 "A는 B의 한 종류다"라면, 합성은 "A 안에 B가 들어 있다"에 가깝다. 리액트가 공식적으로 상속보다 합성을 권하는 데는 이유가 있고, children이라는 특별한 prop이 그 중심에 있다.
기본이 되는 children부터 시작해서, 실무에서 자주 쓰는 합성 패턴 몇 가지를 상황과 코드로 보여주겠다.
children은 태그 사이에 넣은 내용이 자동으로 들어오는 prop이다. 컴포넌트를 여는 태그와 닫는 태그 사이에 뭘 적으면, 그게 props.children으로 전달된다. 이걸 이용하면 겉모양은 고정하고 속만 갈아끼우는 껍데기를 만들 수 있다.
function Card({ children }) {
return <div className="card">{children}</div>;
}
function App() {
return (
<Card>
<h2>제목</h2>
<p>본문 내용</p>
</Card>
);
}
Card는 자기가 뭘 감싸는지 몰라도 된다. <Card>와 </Card> 사이에 넣은 제목과 본문이 children으로 들어와 card 상자 안에 그대로 렌더된다. 카드의 테두리와 그림자 같은 껍데기는 한 번만 짜두고, 안에 들어갈 내용은 부르는 쪽이 매번 다르게 채운다. 같은 상자에 다른 내용을 넣고 싶을 때마다 새 컴포넌트를 만들 필요가 없어진다.
같은 껍데기를 복붙하고 있으면 합성하라는 신호다. 나는 초보 때 제목 달린 상자가 필요할 때마다 div에 클래스를 붙여 통째로 복사했다. 알림 상자, 프로필 상자, 설정 상자가 다 비슷한 테두리에 내용만 달랐다. 그러다 테두리 색을 바꾸라는 요청 하나에 파일 일곱 개를 열어 같은 값을 일곱 번 고쳤다.
// 고치기 전: 상자마다 같은 마크업이 복사돼 있다
<div className="box"><h3>알림</h3> ... </div>
<div className="box"><h3>프로필</h3> ... </div>
<div className="box"><h3>설정</h3> ... </div>
이걸 Box 하나로 빼고 제목은 prop으로, 안쪽 내용은 children으로 받게 바꾸니, 그 뒤로 테두리는 Box 한 곳만 고치면 전부 따라 바뀌었다. 비슷한 마크업을 두세 번 넘게 복사하고 있다면, 그건 대개 합성으로 묶으라는 신호다. 복붙한 조각은 언젠가 한 군데를 빠뜨리고 고쳐서 화면 하나만 어긋나게 만든다.
레이아웃 껍데기는 children으로 감싸는 게 정석이다. 여러 페이지가 같은 헤더와 여백을 공유해야 할 때, 그 틀을 컴포넌트 하나로 빼고 페이지 내용을 children으로 받는다.
function Layout({ children }) {
return (
<div>
<header>로고</header>
<main>{children}</main>
</div>
);
}
<Layout>
<HomePage />
</Layout>
헤더와 main 구조는 Layout이 고정하고, 그 안에 어떤 페이지를 넣을지는 부르는 쪽이 정한다. 홈이든 설정이든 같은 껍데기를 재활용하면서 내용만 갈아끼운다. 만약 이걸 상속으로 풀려고 HomePage가 Layout을 물려받게 짰다면, 페이지마다 껍데기를 다시 이어 붙이느라 훨씬 뻣뻣했을 것이다.
구멍이 여러 개면 children 대신 prop으로 자리를 나눈다. 한 곳이 아니라 위아래 여러 자리에 각각 다른 내용을 꽂아야 할 때가 있다. 이럴 땐 children 하나로는 부족하니, 자리마다 이름 붙인 prop으로 컴포넌트를 통째로 넘긴다.
function Modal({ header, footer, children }) {
return (
<div className="modal">
<div className="top">{header}</div>
<div className="body">{children}</div>
<div className="bottom">{footer}</div>
</div>
);
}
<Modal
header={<h3>확인</h3>}
footer={<button>닫기</button>}
>
정말 삭제할까요?
</Modal>
header와 footer에는 JSX를 통째로 값으로 넘기고, 가운데 본문은 태그 사이에 적어 children으로 받는다. 이렇게 자리를 여러 개로 나누면 모달의 뼈대는 그대로 두고 위, 가운데, 아래를 각각 다르게 채운 여러 모달을 찍어낼 수 있다. JSX가 그냥 값이라 prop으로 넘길 수 있다는 점이 이 패턴의 핵심이다.
특수한 컴포넌트는 일반 컴포넌트를 감싸서 만든다. 상속에서 "구체 클래스가 추상 클래스를 확장"하던 걸, 리액트에서는 감싸기로 대신한다. 일반적인 Card가 있으면, 경고용 카드는 그 Card를 안에서 불러 쓰면서 필요한 것만 채운다.
function AlertCard({ children }) {
return (
<Card>
<span>주의</span>
{children}
</Card>
);
}
AlertCard는 Card를 물려받은 게 아니라 그냥 안에서 쓴다. 카드의 겉모양은 Card에 한 번만 정의돼 있고, AlertCard는 거기에 "주의" 딱지만 얹는다. 나중에 카드 테두리 스타일을 바꿔도 Card 한 곳만 고치면 이걸 감싼 모든 특수 카드에 같이 반영된다. 상속이었으면 부모 클래스 손볼 때 자식마다 무슨 일이 생기나 신경 써야 했을 텐데, 감싸기는 관계가 눈에 보여서 훨씬 마음이 편하다.
조건에 따라 보이고 숨기는 껍데기도 합성으로 짠다. 로그인한 사람에게만 내용을 보여주는 것처럼, 조건이 맞을 때만 안쪽을 렌더하는 껍데기도 children으로 만든다.
function Show({ when, children }) {
if (!when) return null;
return <>{children}</>;
}
<Show when={isLoggedIn}>
<p>환영합니다</p>
</Show>
when이 거짓이면 null을 돌려줘 아무것도 안 그리고, 참이면 children을 그대로 내보낸다. 리액트에서 null을 반환하면 그 자리에 아무것도 렌더되지 않는다. 화면 곳곳에 흩어질 if 조건을 이 껍데기 하나에 모으면, 보이고 숨기는 판단을 한 군데에서 관리하게 된다.
합성은 "안에 무엇이 오든 상관없다"는 태도다. 합성이 잘 된 컴포넌트는 자기가 감싸는 내용이 뭔지 몰라도 제 역할을 한다. Card는 안에 든 게 글인지 그림인지 신경 쓰지 않고, Layout은 어떤 페이지가 들어올지 모른 채로 틀만 잡는다. 이 무심함 덕에 조각들을 자유롭게 갈아끼울 수 있다. 처음엔 상속이 더 정돈돼 보여도, 화면이 커질수록 레고처럼 끼우는 합성이 손이 덜 간다. 새 컴포넌트를 만들기 전에 "이거 기존 걸 감싸면 되지 않나?"부터 물어보는 습관이, 결국 안 꼬이는 코드로 이어진다.