[번역] Building Type-Safe Compound Components

[번역] Building Type-Safe Compound Components

2026-01-03

TkDodoBuilding Type-Safe Compound Components를 번역한 글입니다.

1

저는 Compound 컴포넌트가 컴포넌트 라이브러리를 구축할 때 정말 좋은 디자인 패턴이라고 생각합니다. Compound 컴포넌트는 소비자에게 컴포넌트가 어떻게 구성되는지에 대한 유연성을 제공하며, 모든 변형을 단일의 속성 중심 API에 강제로 넣을 필요가 없습니다. 또한, 마크업에서 컴포넌트 간의 관계를 명확하게 만듭니다. Compound 컴포넌트가 항상 적합한 것은 아닙니다. 가끔은 속성을 사용하는 것이 더 나은 경우도 있습니다.

나쁜 예시

복합 구성 요소와 관련하여 우리가 볼 수 있는 일반적인 예는 옵션이 있는 Select입니다. 이는 HTML에서도 작동하는 방식이기 때문입니다.

// Compound Select
import { Select, Option } from "@/components/select";

function ThemeSwitcher({ value, onChange }) {
  return (
    <Select value={value} onChange={onChange}>
      <Option value="system">🤖</Option>
      <Option value="light">☀️</Option>
      <Option value="dark">🌑</Option>
    </Select>
  );
}

이 예제가 복합 컴포넌트가 무엇에 적합한지를 보여주기에 이상적이지 않다고 생각하는 몇 가지 이유가 있습니다.

  1. Fixed Layout

Compound 컴포넌트는 사용자가 children을 원하는 대로 배치할 수 있도록 뛰어난 성능을 발휘합니다. Selects의 경우, 우리는 아마도 그것이 필요하지 않을 것입니다. 옵션은 메뉴에 들어가며, 우리는 각 옵션을 차례로 보여주고자 합니다. 이것이 바로 많은 사람들이 타입 수준에서 children에게 전달할 수 있는 것을 제한하고자 하는 이유입니다. 예를 들어, OptionSelect에 전달할 수 있도록 하는 것이죠.

이러한 제한은 현재 불가능하며(문제가 2018년부터 열려 있음), 또한 바람직하지도 않습니다. 당신이 이 아티클에서 복합 컴포넌트를 타입 안전하게 만드는 방법을 말해주길 바랐던 것을 알고 있습니다. 그리고 저는 그렇게 할 것입니다. 하지만 이는 children과는 전혀 관련이 없습니다. 제 생각은 children을 특정 유형으로 제한하고 싶다면, 복합 컴포넌트는 잘못된 추상화라는 것입니다.

  1. Dynamic Content

Compound 컴포넌트는 콘텐츠가 대부분 정적일 때 정말 좋습니다. 위의 예제에는 세 개의 하드코딩된 옵션이 있으므로, 이는 해당됩니다, 맞죠?

실제로는 선택할 수 있는 옵션이 세 개뿐인 Selects는 없을 것이며, 콘텐츠는 동적 결과 집합을 가진 API 호출에서 주로 올 가능성이 큽니다. 또한 대부분의 디자인 가이드에서는 옵션이 다섯 개 미만일 경우 선택 요소를 사용하지 말라고 합니다. 적은 선택을 드롭다운에 숨기면 불필요한 클릭과 인지 부하가 추가되기 때문입니다.

사실, Adverity에서는 Compound 컴포넌트 Select로 시작했지만, 이후에는 대부분의 사용을 위해 이 매핑 코드를 작성해야 했습니다:

// Select-Usage
import { Select, Option } from "@/components/select";

function UserSelect({ value, onChange }) {
  const userQuery = useSuspenseQuery(userOptions);

  return (
    <Select value={value} onChange={onChange}>
      {userQuery.data.map((option) => (
        <Option value={option.value}>{option.label}</Option>
      ))}
    </Select>
  );
}

그 시점에서 우리는 children 대신 props를 사용하는 Select를 노출하는 것으로 전환했습니다.

// Select-With-Options
import { Select } from "@/components/select";

function UserSelect({ value, onChange }) {
  const userQuery = useSuspenseQuery(userOptions);

  return <Select value={value} onChange={onChange} options={userQuery.data} />;
}

이것은 우리가 어디에서나 해야 했던 지루한 매핑을 없앨 수 있게 해주었을 뿐만 아니라, 우리가 제한해야 할 children이 없기 때문에 우리가 원하던 타입 안전성을 제공했습니다. 또한 Selectvalue, onChangeoptions가 모두 동일한 타입을 얻도록 쉽게 제네릭으로 만들 수 있습니다.

// SelectProps
type SelectValue = string | number;
type SelectOption<T> = { value: T; label: string };
type SelectProps<T extends SelectValue> = {
  value: T;
  onChange: (value: T) => void;
  options: ReadonlyArray<SelectOption<T>>;
};

Slots

ModalDialog 구성 요소는 사용자에게 복합 구성 요소의 전체 권한을 주고 싶지 않은 또 다른 예입니다. 즉, DialogFooterDialogHeader 위에 렌더링하는 것은 의미가 없습니다. 우리는 또한 누군가가 실수로 DialogBackdrop을 빼먹거나 DialogBodyDialogFooter 사이에 서로 다른 간격을 만들기를 원하지 않습니다. 일관성과 순서가 중요한 경우, 슬롯은 일반적으로 더 나은 추상화입니다.

// ModalDialog
function ModalDialog({ header, body, footer }) {
  return (
    <DialogRoot>
      <DialogBackdrop />
      <DialogContent>
        <DialogHeader>{header}</DialogHeader>
        <DialogBody>{body}</DialogBody>
        <DialogFooter>{footer}</DialogFooter>
      </DialogContent>
    </DialogRoot>
  );
}

// usage:

<ModalDialog header="Hello" body="World" />;

그들은 여전히 특정 위치에 임의의 React 구성 요소를 주입하여 어느 누구도 그 보일러플레이트를 어디에서나 복사 및 붙여넣어야 하지 않도록 하면서 일정한 형태의 유연성을 허용합니다. 물론 이러한 Dialog primitives 요소가 디자인 시스템 내에 있는 것은 훌륭하지만, 소비자에게는 노출하지 않을 것입니다.


그래서 그것들이 제가 정말로 Compound 컴포넌트를 원하는지 의문을 갖게 만드는 두 가지 지표입니다 - 고정 레이아웃과 대부분 동적 콘텐츠입니다. 그럼 언제가 정말 좋은 적합일까요? 그리고 타입 안전성과는 어떤 관련이 있나요?

좋은 예시

동적으로 레이아웃된 자식 요소가 대부분 고정 요소로 구성된 좋은 사용 사례는 <ButtonGroup>, <TabBar> 또는 <RadioGroup>일 것입니다:

// RadioGroup
import { RadioGroup, RadioGroupItem } from "@/components/radio";
import { Flex } from "@/components/layout";

function ThemeSwitcher({ value, onChange }) {
  return (
    <RadioGroup value={value} onChange={onChange}>
      <Flex direction={["row", "column"]} gap="sm">
        <RadioGroupItem value="system">🤖</RadioGroupItem>
        <RadioGroupItem value="light">☀️</RadioGroupItem>
        <RadioGroupItem value="dark">🌑</RadioGroupItem>
      </Flex>
    </RadioGroup>
  );
}

Select와의 주요 차이점은 RadioGroupItem 타입이 아닌 children 요소를 명시적으로 원한다는 것입니다. 우리가 원하는 대로 레이아웃을 구성하는 것이 필수적이며, 추가적인 도움말 텍스트를 포함할 수도 있습니다. 물론, RadioGroup이 동적인 옵션이 필요한 경우도 있을 수 있지만, 그 경우 제가 이전에 보여준 것처럼 루프를 만들더라도 세상은 끝나지 않습니다.

여전히 ThemeSwitcher에 전달되는 이 단순한 문자열이 아닌, 아마도 문자열 리터럴일 것이라는 타입 안전성의 문제는 남아 있습니다.

// ThemeValue
type ThemeValue = "system" | "light" | "dark";

Type Safety

물론 RadioGroupItem을 일반화할 수도 있습니다. 그러나 이 접근법의 문제는 RadioGroup의 타입이 항목에 자동으로 적용되지 않는다는 점입니다. 왜냐하면 JSX 자식 요소는 부모 컴포넌트로부터 타입 매개변수를 ‘상속받지’ 않기 때문입니다. 따라서 RadioGroup이 완벽하게 타입이 지정되어 <ThemeValue>를 추론하더라도, 여전히 각 RadioGroupItem을 매개변수화해야 합니다.

// Generic-RadioGroupItem
import { RadioGroup, RadioGroupItem } from "@/components/radio";
import { Flex } from "@/components/layout";

type ThemeValue = "system" | "light" | "dark";

type ThemeSwitcherProps = {
  value: ThemeValue;
  onChange: (value: ThemeValue) => void;
};

function ThemeSwitcher({ value, onChange }) {
  return (
    <RadioGroup value={value} onChange={onChange}>
      <Flex direction={["row", "column"]} gap="sm">
        <RadioGroupItem<ThemeValue> value="system">🤖</RadioGroupItem>
        <RadioGroupItem<ThemeValue> value="light">☀️</RadioGroupItem>
        <RadioGroupItem<ThemeValue> value="dark">🌑</RadioGroupItem>
      </Flex>
    </RadioGroup>
  );
}

그것은 좋은 API가 아닙니다. 왜냐하면 모든 수동 타입 주석은 쉽게 잊혀질 수 있기 때문입니다. 나를 잘 아는 사람은 내가 가능한 한 타입이 완전히 추론되는 것을 좋아한다는 것을 알 것입니다. Compound 컴포넌트에 대해 이를 수행하는 가장 좋은 방법은 해당 컴포넌트를 직접 노출하는 것이 아니라, 사용자에게 호출할 수 있는 방법만 제공하는 것입니다.

Component Factory Pattern

이 패턴의 정확한 이름인지는 잘 모르겠지만, 개념을 전달하기에는 충분하다고 생각합니다. 기본적으로, 수동 타입 주석을 완전히 없앨 수는 없지만, 이를 숨기고 명시적으로 만들 수는 있습니다. RadioGroupRadioGroupItem을 노출하는 대신, 한 번 타입 매개변수와 함께 호출해야 하는 createRadioGroup이라는 함수만 내보냅니다. 이 함수는 그러면 정적 타입이 지정된 RadioGroup과 그에 연결된 타입의 RadioGroupItem을 반환합니다.

// createRadioGroup
import { RadioGroup, RadioGroupItem } from "./internal/radio";

export const createRadioGroup = <T extends GroupValue = never>(): {
  RadioGroup: (props: RadioGroupProps<T>) => JSX.Element;
  RadioGroupItem: (props: Item<T>) => JSX.Element;
} => ({ RadioGroup, RadioGroupItem });

이것은 런타임에서 아무런 작업을 수행하지 않으며, 내부 RadioGroupRadioGroupItem을 객체로 감싸는 것만 합니다. 그러나 타입 레벨에서, 이는 타입 매개변수를 서로 연결합니다. 그리고 우리의 제네릭을 never로 기본값 설정한 사실은 사용자가 결과로 의미 있는 작업을 수행할 수 있도록 이를 전달해야 함을 의미합니다. 이를 통해 다음과 같이 사용할 수 있습니다:

// Typed-RadioGroup
import { createRadioGroup } from "@/components/radio";
import { Flex } from "@/components/layout";

type ThemeValue = "system" | "light" | "dark";

type ThemeSwitcherProps = {
  value: ThemeValue;
  onChange: (value: ThemeValue) => void;
};

const Theme = createRadioGroup<ThemeValue>();

function ThemeSwitcher({ value, onChange }) {
  return (
    <Theme.RadioGroup value={value} onChange={onChange}>
      <Flex direction={["row", "column"]} gap="sm">
        <Theme.RadioGroupItem value="system">🤖</Theme.RadioGroupItem>
        <Theme.RadioGroupItem value="light">☀️</Theme.RadioGroupItem>
        <Theme.RadioGroupItem value="dark">🌑</Theme.RadioGroupItem>
      </Flex>
    </Theme.RadioGroup>
  );
}

물론, 이 버전은 완벽하지 않습니다. 우리는 여전히 다른 유형의 RadioGroup을 만들고 그 항목들을 우리의 Theme.RadioGroup에 전달할 수 있지만, 이런 일이 우연히 발생할 가능성은 훨씬 낮습니다.

전반적으로, 이 접근 방식은 Compound 컴포넌트를 훌륭하게 만드는 유연성을 유지하면서 강력한 유형 보증을 추가합니다. 유일한 실제 비용은 소비자가 컴포넌트를 직접 가져오는 것이 아니라 함수 호출을 통해 컴포넌트 패밀리의 typed instance를 생성한다는 점입니다. 나는 이것이 가치 있는 거래라고 생각하며, 디자인 시스템의 사용자에게 가능한 한 유형 안전하게 Compound 컴포넌트를 만드는 가장 좋은 방법입니다.