본문 바로가기
C.W.K.
Stream
Lesson 03 of 07 · published

상속 대신 조합하는 React의 방식

~14 min · composition, children, slot-props

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
React는 깊은 상속 계층보다 작은 컴포넌트를 조합해 화면을 만들어. children, 이름 붙은 슬롯, compound component를 언제 고를지 차례로 익혀 보자.

먼저 children으로 빈자리를 열어

Wrapper가 바깥 모양을 소유하고 호출자가 안쪽 내용을 정하면 children이 가장 단순한 계약이야. Card는 테두리와 여백만 렌더링하고, 제목이나 버튼은 Card 안에 둔 JSX가 결정하게 할 수 있어. 부모 컴포넌트는 자식의 구체적인 종류를 몰라도 되므로 새 콘텐츠가 생길 때마다 prop을 추가할 필요가 없어.

타입은 보통 children: React.ReactNode로 둬. ReactNode에는 JSX element뿐 아니라 문자열, 숫자, fragment, null처럼 React가 렌더링할 수 있는 값이 모두 들어가. 하나의 JSX element를 검사하거나 clone해야 하는 특별한 경우에만 더 좁은 ReactElement를 검토해.

자리가 여러 개면 이름 붙은 슬롯을 써

Dialog처럼 header, body, footer의 위치가 정해져 있다면 각각을 ReactNode prop으로 받는 편이 읽기 쉬워. 호출자는 header={...}footer={...}를 보며 어느 내용이 어디에 들어가는지 바로 알 수 있고, Dialog는 접근성 역할과 레이아웃 순서를 한곳에서 지켜.

슬롯은 자식끼리 상태를 공유하지 않아도 되는 화면에 잘 맞아. 선택 상태나 등록 순서까지 자식들이 알아야 하는 게 아니라면 Context를 추가할 이유가 없어. children 하나로 충분한지 먼저 보고, 서로 다른 위치가 실제로 필요할 때 이름 붙은 슬롯으로 확장해.

자식들이 같은 상태를 알아야 하면 compound component를 검토해

Tabs가 선택된 값을 소유하고 Tab 자식들이 자신이 선택됐는지 읽어야 한다면 좁은 Context를 사용한 compound component가 자연스러울 수 있어. 호출 지점은 <Tabs><Tab value="recent">최근</Tab></Tabs>처럼 도메인 구조를 그대로 보여 주고, 키보드 이동과 선택 규칙은 Tabs 가족 안에 모여.

대신 Tab이 Tabs 밖에서 렌더링됐을 때 이해하기 쉬운 오류를 내야 하고, 암묵적인 Context 의존성도 문서화해야 해. 라이브러리처럼 같은 사용법이 여러 화면에서 반복될 때는 가치가 크지만, 한 화면의 단순한 레이아웃에 쓰면 구조만 무거워질 수 있어.

asChild는 실제 DOM element를 호출자가 고르게 해

스타일이 있는 Button이 어떤 곳에서는 button이고 다른 곳에서는 Link여야 할 수 있어. Radix UI가 널리 알린 asChild와 Slot 패턴은 불필요한 wrapper를 만들지 않고 자식 element를 실제 DOM node로 사용해. 컴포넌트는 className, 이벤트 핸들러, ref를 자식과 안전하게 병합해야 하고, 호출자는 anchor 안에 button을 넣는 잘못된 중첩을 피할 수 있어.

이 병합 계약은 생각보다 까다로워. 이벤트 순서, ref 결합, disabled 의미가 모두 맞아야 하므로 앱마다 어설픈 cloneElement helper를 만들기보다 검증된 primitive를 쓰는 편이 좋아.

동작을 재사용하려면 상속보다 함수와 훅을 조합해

화면 구조는 children과 슬롯으로, 상태 로직은 커스텀 훅으로, 데이터 변환은 평범한 함수로 나누면 돼. HOC가 필요한 기존 라이브러리는 계속 사용할 수 있지만 새 앱 코드에서 wrapper 계층을 먼저 만들 이유는 드물어. 호출 지점과 데이터 흐름이 그대로 보이는 가장 작은 조합부터 시작해.

공유 상태가 없으면 Context도 필요 없어. children으로 시작하고, 위치가 여러 개일 때 슬롯을 쓰며, 자식들이 같은 상태 계약을 공유해야 할 때만 compound component로 올라가.

Code

단순한 자리는 children으로 열기·tsx
function Card({ children }: { children: React.ReactNode }) {
  return (
    <article className="rounded-card bg-bg-elevated p-6 shadow-sm">
      {children}
    </article>
  );
}

// 호출자가 안쪽 채움:
<Card>
  <h2>Hello</h2>
  <p>This is a card.</p>
</Card>
이름 붙은 slot prop으로 자리 나누기·tsx
type DialogProps = {
  title: React.ReactNode;
  body: React.ReactNode;
  actions?: React.ReactNode;
};

function Dialog({ title, body, actions }: DialogProps) {
  return (
    <div role="dialog" className="rounded-card bg-bg-elevated p-6 space-y-4">
      <header className="font-semibold text-fg">{title}</header>
      <div>{body}</div>
      {actions && (
        <footer className="flex justify-end gap-2">{actions}</footer>
      )}
    </div>
  );
}

// 호출자가 이름 붙은 자리에 composition:
<Dialog
  title="Delete conversation?"
  body={<p>This cannot be undone.</p>}
  actions={
    <>
      <button onClick={cancel}>Cancel</button>
      <button onClick={confirm}>Delete</button>
    </>
  }
/>
Compound component에서 상태 공유하기·tsx
import { createContext, useContext, useState } from "react";

const TabsCtx = createContext<{
  active: string;
  setActive: (id: string) => void;
} | null>(null);

function Tabs({ defaultActive, children }: {
  defaultActive: string;
  children: React.ReactNode;
}) {
  const [active, setActive] = useState(defaultActive);
  return (
    <TabsCtx.Provider value={{ active, setActive }}>
      <div className="flex gap-2 border-b">{children}</div>
    </TabsCtx.Provider>
  );
}

function Tab({ id, children }: { id: string; children: React.ReactNode }) {
  const ctx = useContext(TabsCtx);
  if (!ctx) throw new Error("<Tab>은 <Tabs> 안에서 사용해야 해");
  const isActive = ctx.active === id;
  return (
    <button
      onClick={() => ctx.setActive(id)}
      className={isActive ? "text-brand border-b-2 border-brand" : "text-muted"}
    >
      {children}
    </button>
  );
}

// 호출자는 둘을 함께 사용하고 공유 상태는 컴포넌트 안에 감춰.
<Tabs defaultActive="a">
  <Tab id="a">First</Tab>
  <Tab id="b">Second</Tab>
</Tabs>

External links

Exercise

header, body, footer 슬롯을 받는 Modal과 Modal.Header, Modal.Body, Modal.Footer 자식을 쓰는 compound 버전을 각각 만들어. 같은 삭제 확인 화면을 두 방식으로 렌더하고 호출 지점의 가독성, 구현 복잡도, 공유 상태가 생겼을 때의 확장성을 비교해.
Hint
Compound 버전은 좁은 Context로 공유 값을 전달하고 자식이 Modal 밖에 놓였을 때 이해하기 쉬운 오류를 내게 해. 단순한 슬롯 버전에는 Context를 넣지 마.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.