테마 전환

shadcn/ui 컴포지션 패턴: 여러 컴포넌트를 함께 사용하는 모범 사례

Easton editorial illustration: performance tuning console

사용자 관리 페이지의 코드를 열어 봤습니다.

DataTable은 사용자 목록을 보여 주고, 각 행에는 DropdownMenu 작업 메뉴가 있습니다. 여기서 ‘편집’을 누르면 Dialog가 열리고 그 안에 Form이 들어갑니다. 간단한 기능처럼 들리지 않나요?

하지만 실제 코드는 이랬습니다. 상태가 이곳저곳을 오가며 prop drilling은 다섯 번째 계층까지 이어졌고, Dialog의 open 상태는 부모 컴포넌트에, Form 데이터는 자식 컴포넌트에 있었습니다. 제출 후 DataTable을 갱신하는 콜백은 다시 부모로 전달해야 했고요.

그때는 shadcn/ui가 의심스러웠습니다. ‘컴포넌트 하나씩 쓸 때는 정말 편한데, 함께 쓰면 왜 이렇게 엉망이 되지?’

나중에 shadcn/ui 설계 문서를 살펴보고서야 문제는 컴포넌트 라이브러리가 아니라 컴포지션 패턴을 몰랐던 데 있다는 것을 알았습니다. shadcn/ui의 핵심 철학은 ‘상속보다 조합’이며 각 컴포넌트에는 일관되고 예측 가능한 인터페이스가 있습니다. 하지만 이 인터페이스 뒤의 설계 철학을 모르면 여러 컴포넌트를 조합했을 때 코드가 금세 뒤엉킵니다.

이번 글에서는 제가 겪었던 시행착오와 그 뒤에 익힌 컴포지션 패턴의 모범 사례를 정리해 보겠습니다.


먼저 shadcn/ui의 설계 철학 이해하기

구체적인 조합을 살펴보기 전에 shadcn/ui의 설계 방식을 이해해야 합니다. 그렇지 않으면 똑같은 React 컴포넌트 라이브러리를 쓰는데도 다른 사람의 코드는 깔끔한 반면 내 코드는 왜 스파게티처럼 꼬이는지 의문이 들 수 있습니다.

shadcn/ui가 전통적인 UI 라이브러리와 가장 크게 다른 점은 npm 패키지가 아니라는 것입니다. package.json에서 @shadcn/ui라는 의존성을 볼 수 없습니다. 모든 컴포넌트 코드를 프로젝트로 직접 복사해 사용합니다.

꽤 원시적으로 들릴 수도 있습니다. 하지만 바로 이것이 shadcn/ui의 설계 철학입니다.

Open Code: 컴포넌트 코드가 완전히 공개되어 있어 버전 충돌을 걱정하지 않고 원하는 대로 수정할 수 있습니다. 예를 들어 Button 컴포넌트의 특정 스타일이 마음에 들지 않으면 공식 새 버전을 기다리지 않고 소스 코드를 직접 고칠 수 있습니다.

Composition: 모든 컴포넌트가 일관된 조합형 인터페이스를 사용합니다. 즉, 각 컴포넌트의 구조를 예측할 수 있습니다. 예를 들어 Card는 항상 <Card>&lt;CardHeader>&lt;CardTitle>&lt;CardContent>와 같은 중첩 구조이고, Dialog는 <Dialog>&lt;DialogContent>&lt;DialogHeader>&lt;DialogTitle> 구조입니다.

이런 일관된 인터페이스 덕분에 여러 컴포넌트를 조합할 때 무엇을 중첩하고 무엇을 나란히 둘지 알 수 있습니다. ‘이 컴포넌트는 저 컴포넌트 안에 있어야 하는데, 저 컴포넌트는 다시 바깥에 있어야 한다’ 같은 모순도 피할 수 있습니다.


기본 조합: Dialog + Form

가장 흔한 조합은 팝업 안에 폼을 넣는 것입니다.

사용자가 ‘편집’ 버튼을 누르면 Dialog가 열리고, 그 안의 Form을 작성해 제출하면 Dialog가 닫힙니다. 간단해 보이지만 처음 구현했을 때 저는 Dialog와 Form의 상태를 한데 뒤섞는 실수를 했습니다.

잘못된 예

// ❌ 제가 시행착오를 겪었던 버전
function EditUserDialog() {
  const [open, setOpen] = useState(false)
  const [formData, setFormData] = useState&#123;&#123;&#125;&#125;

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button onClick={() => fetchUserData()}>편집</Button>
      </DialogTrigger>
      <DialogContent>
        <form onSubmit={(e) => {
          e.preventDefault()
          submitForm(formData)
          setOpen(false)
        }}>
          <Input
            value={formData.username}
            onChange={(e) => setFormData(&#123;...formData, username: e.target.value&#125;)}
          />
          <Button type="submit">저장</Button>
        </form>
      </DialogContent>
    </Dialog>
  )
}

무엇이 문제일까요? Dialog의 open 상태와 Form의 데이터 상태가 한 컴포넌트에 섞여 있습니다. 게다가 React Hook Form 없이 form 상태를 직접 관리해 검증과 오류 표시가 모두 복잡해졌습니다.

올바른 방법

shadcn/ui의 Form 컴포넌트는 React Hook Form + Zod를 기반으로 합니다. 이 조합을 사용하면 코드가 훨씬 깔끔해집니다.

// ✅ 올바른 조합 방식
import &#123; useForm &#125; from "react-hook-form"
import &#123; zodResolver &#125; from "@hookform/resolvers/zod"
import * as z from "zod"

// 1. 먼저 컴포넌트 밖에서 Schema 정의
const userSchema = z.object({
  username: z.string().min(3, "사용자 이름은 3자 이상이어야 합니다"),
  email: z.string().email("이메일 형식이 올바르지 않습니다")
})

function EditUserDialog(&#123; user, onSubmit &#125;) {
  const [open, setOpen] = useState(false)
  const form = useForm(&#123;
    resolver: zodResolver(userSchema),
    defaultValues: user // 사용자 데이터를 바로 전달
  &#125;)

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="outline">편집</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>사용자 정보 편집</DialogTitle>
        </DialogHeader>

        {/* shadcn의 Form 컴포넌트를 바로 사용 */}
        <Form &#123;...form&#125;>
          <form onSubmit={form.handleSubmit((data) => &#123;
            onSubmit(data)      // 데이터 제출
            setOpen(false)      // Dialog 닫기
          &#125;)}>
            <FormField
              name="username"
              render=&#123;(&#123; field &#125;) => (
                <FormItem>
                  <FormLabel>사용자 이름</FormLabel>
                  <FormControl>&lt;Input &#123;...field&#125; /></FormControl>
                  <FormMessage /> {/* 오류 자동 표시 */}
                </FormItem>
              )&#125;
            />
            <Button type="submit">저장</Button>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  )
}

핵심은 다음과 같습니다.

  1. Dialog는 컨테이너, Form은 콘텐츠: Dialog는 ‘열기/닫기’만, Form은 ‘데이터/검증/제출’만 담당해 책임을 분리합니다.
  2. Form에는 React Hook Form + Zod 사용: form 상태를 직접 관리하지 않습니다. form.handleSubmit이 검증과 제출을 자동으로 처리합니다.
  3. FormMessage로 오류 자동 표시: 오류 처리 로직을 직접 작성하지 않아도 Zod 검증에 실패하면 오류 메시지가 자동으로 나타납니다.

이렇게 하면 Dialog와 Form의 상태가 명확해집니다. Dialog의 open은 부모 컴포넌트에 있고, Form 데이터는 Form 컴포넌트 내부에서 React Hook Form을 통해 관리합니다.


DataTable + DropdownMenu: 테이블 행 작업

또 하나의 대표적인 조합은 테이블의 각 행에 작업 메뉴를 두고 ‘편집’을 누르면 Dialog를 여는 것입니다.

제가 겪은 문제는 행 데이터를 Dialog에 전달하는 방법을 몰랐다는 점이었습니다. DataTable의 column 정의에서는 row.original, 즉 현재 행 데이터에 접근할 수 있지만 Dialog는 DataTable 밖에 있습니다. 데이터를 어떻게 전달해야 할까요?

잘못된 예

// ❌ 처음 시도한 방식: Dialog를 cell 안에 넣음
const columns = [
  &#123;
    id: "actions",
    cell: &#123; row &#125; => (
      <Dialog>
        <DialogTrigger asChild>
          <Button>편집</Button>
        </DialogTrigger>
        <DialogContent>
          {/* 문제: cell을 렌더링할 때마다 Dialog 인스턴스를 생성 */}
          <EditForm user={row.original} />
        </DialogContent>
      </Dialog>
    )
  &#125;
]

이 방식은 행마다 Dialog 인스턴스를 만듭니다. 데이터가 100행이면 Dialog도 100개가 생겨 성능이 나빠지고, Dialog 상태를 일관되게 관리하기도 매우 어렵습니다.

올바른 방법

전역 Dialog 하나를 두고 Hook으로 상태를 관리합니다.

// 1. Dialog 상태를 관리하는 Hook을 먼저 정의
const useEditDialog = () => &#123;
  const [open, setOpen] = useState(false)
  const [editingUser, setEditingUser] = useState(null)

  const openEdit = (user) => &#123;
    setEditingUser(user)
    setOpen(true)
  &#125;

  const closeEdit = () => &#123;
    setOpen(false)
    setEditingUser(null)
  &#125;

  return &#123; open, editingUser, openEdit, closeEdit &#125;
&#125;

// 2. DataTable 열 정의에는 트리거 버튼만 배치
function UserDataTable(&#123; users &#125;) &#123;
  const &#123; open, editingUser, openEdit, closeEdit &#125; = useEditDialog()

  const columns = [
    &#123;
      id: "actions",
      cell: &#123; row &#125; => (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" size="icon">
              <MoreHorizontal />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuItem onClick={() => openEdit(row.original)}>
              편집
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => deleteUser(row.original.id)}>
              삭제
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    &#125;
  ]

  return (
    <>
      <DataTable columns={columns} data={users} />
      {/* 전역에서 유일한 Dialog */}
      <Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
        <DialogContent>
          <EditUserForm
            user={editingUser}
            onSubmit={(data) => &#123;
              updateUser(data)
              closeEdit()
              refreshTable() // 테이블 데이터 새로고침
            &#125;}
          />
        </DialogContent>
      </Dialog>
    </>
  )
&#125;

핵심은 다음과 같습니다.

  1. 전역 Dialog: 행마다 만들지 않고 DataTable 밖에 Dialog 하나만 둡니다.
  2. Hook으로 상태 관리: openEdit는 데이터를 전달하며 Dialog를 열고, closeEdit는 Dialog를 닫고 데이터를 비웁니다.
  3. DropdownMenu에서 트리거: cell에는 트리거 버튼만 두고 onClick에서 openEdit(row.original)을 호출합니다.

이렇게 하면 구조가 명확해집니다. DataTable은 ‘데이터 표시’, DropdownMenu는 ‘작업 실행’, Dialog는 ‘폼 표시’, Hook은 ‘상태 흐름 관리’를 담당합니다.


고급 패턴: Context로 Prop Drilling 피하기

여러 컴포넌트를 조합할 때 가장 빠지기 쉬운 함정은 prop drilling입니다. 상태를 여러 계층 아래로 전달하다 보면 다섯 번째 계층에서는 이 prop이 어디에서 왔는지조차 알기 어려워집니다.

shadcn/ui의 여러 컴포넌트는 그 자체로 Compound Components(복합 컴포넌트) 패턴을 사용합니다. Card가 대표적입니다.

<Card>
  <CardHeader>
    <CardTitle>제목</CardTitle>
    <CardDescription>설명</CardDescription>
  </CardHeader>
  <CardContent>콘텐츠</CardContent>
  <CardFooter>푸터</CardFooter>
</Card>

이런 중첩 구조를 보면 ‘CardTitle은 자신이 어느 Card에 속하는지 어떻게 알까? cardId를 전달해야 하나?‘라는 생각이 들 수 있습니다.

그럴 필요는 없습니다. Compound Components의 핵심은 Context로 상태를 공유해 자식 컴포넌트가 자신이 어느 부모 컴포넌트 안에 있는지 자동으로 ‘알게’ 하는 것입니다.

접을 수 있는 Card 직접 구현하기

shadcn/ui의 Card는 기본적으로 접을 수 없습니다. 접을 수 있는 버전을 확장해 만들면서 Context 패턴을 배워 보겠습니다.

// 1. Context 생성
import &#123; createContext, useContext, useState &#125; from "react"

type CardContextValue = &#123;
  isCollapsed: boolean
  toggle: () => void
&#125;

const CardContext = createContext&lt;CardContextValue | null>(null)

// 2. Root 컴포넌트: 상태를 관리하고 Context 제공
CollapsibleCard.Root = &#123; children, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardContext.Provider value=&#123;&#123;
      isCollapsed,
      toggle: () => setIsCollapsed(!isCollapsed)
    &#125;}>
      <Card className="border rounded-lg">&#123;children&#125;</Card>
    </CardContext.Provider>
  )
&#125;

// 3. Header 컴포넌트: 제목 + 접기 버튼 표시
CollapsibleCard.Header = &#123; title &#125; => &#123;
  const ctx = useContext(CardContext)
  if (!ctx) throw new Error("Header는 CollapsibleCard.Root 안에 있어야 합니다")

  return (
    <CardHeader className="cursor-pointer" onClick={ctx.toggle}>
      <div className="flex items-center justify-between">
        <CardTitle>&#123;title&#125;</CardTitle>
        &#123;ctx.isCollapsed ? <ChevronDown /> : <ChevronUp />&#125;
      </div>
    </CardHeader>
  )
&#125;

// 4. Content 컴포넌트: 접기 상태에 반응
CollapsibleCard.Content = &#123; children &#125; => &#123;
  const ctx = useContext(CardContext)
  if (!ctx) throw new Error("Content는 CollapsibleCard.Root 안에 있어야 합니다")

  if (ctx.isCollapsed) return null // 접힌 상태에서는 표시하지 않음

  return <CardContent>&#123;children&#125;</CardContent>
&#125;

사용 방법은 다음과 같습니다.

<CollapsibleCard.Root defaultCollapsed={false}>
  <CollapsibleCard.Header title="사용자 정보" />
  <CollapsibleCard.Content>
    <p>이름: 홍길동</p>
    <p>이메일: [email protected]</p>
  </CollapsibleCard.Content>
</CollapsibleCard.Root>

핵심은 다음과 같습니다.

  1. Context로 상태 공유: Root 컴포넌트가 Context를 만들고 자식 컴포넌트는 useContext로 상태를 자동으로 가져옵니다. prop drilling이 필요 없습니다.
  2. 자식 컴포넌트의 자동 반응: Header를 클릭하면 상태가 바뀌고 Content는 자동으로 표시되거나 숨겨집니다. 두 컴포넌트가 직접 통신할 필요가 없습니다.
  3. 부모 컴포넌트 제약 강제: 자식 컴포넌트가 Root 안에 없으면 오류를 던져 잘못된 사용을 알려 줍니다.

이 패턴을 사용하면 여러 컴포넌트를 조합할 때 상태 전달을 걱정할 필요가 없습니다. 자식 컴포넌트가 부모 안에 있기만 하면 상태를 자동으로 가져올 수 있습니다.


전체 예제: DataTable + Dialog + Form

앞서 배운 내용을 종합해 완전한 사용자 관리 페이지를 만들어 봅시다. DataTable은 목록을 보여 주고, ‘편집’을 누르면 Dialog가 열리며, Dialog 안에는 Form이 있습니다. Form 제출 후에는 테이블을 갱신합니다.

전체 코드 예제는 앞 절에서 확인할 수 있습니다. 여기서는 핵심 흐름을 정리하겠습니다.

  1. Schema 정의: Zod로 사용자 데이터 구조와 검증 규칙을 정의합니다.
  2. Dialog 상태 Hook: Dialog 열기/닫기와 데이터 전달을 한곳에서 관리합니다.
  3. DataTable 열 정의: DropdownMenu 작업 열을 포함합니다.
  4. 편집 폼 컴포넌트: Form + FormField + 여러 Input을 구성합니다.
  5. 메인 페이지 컴포넌트: DataTable과 Dialog를 조합합니다.

이 전체 예제에서 다음 컴포지션 패턴을 모두 확인할 수 있습니다.

  • DataTable은 데이터를 표시합니다.
  • DropdownMenu는 작업을 실행합니다.
  • Dialog는 폼을 표시합니다.
  • Form은 검증하고 제출합니다.
  • Hook은 상태 흐름을 관리합니다.

각 컴포넌트의 책임이 명확하고 상태는 Hook과 Context로 관리되므로 prop drilling을 피할 수 있습니다.


고급 기법: 성능 최적화와 타입 안전성

Context로 인한 재렌더링 피하기

Compound Components에 Context를 사용하면 편리하지만 주의할 점도 있습니다. Context 값이 바뀌면 useContext를 사용하는 모든 컴포넌트가 다시 렌더링됩니다.

예를 들어 CollapsibleCard의 접기 상태가 바뀌면 Header와 Content가 모두 다시 렌더링됩니다. Content 안에 복잡한 목록이 있다면 재렌더링 속도가 느려질 수 있습니다.

해결 방법은 Context를 분리하는 것입니다.

// 상태 Context(자주 변경됨)
const CardStateContext = createContext&lt;&#123; isCollapsed: boolean &#125;>()

// 설정 Context(변경되지 않음)
const CardConfigContext = createContext&lt;&#123; collapsible: boolean &#125;>()

// Root 컴포넌트가 두 Context를 제공
CollapsibleCard.Root = &#123; children, collapsible = true, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardConfigContext.Provider value=&#123;&#123; collapsible &#125;}>
      <CardStateContext.Provider value=&#123;&#123; isCollapsed &#125;}>
        <Card>
          &#123;children&#125;
          {/* Context 변경이 자식 컴포넌트에 영향을 주지 않도록 Toggle 버튼을 별도로 배치 */}
          &#123;collapsible && (
            <button onClick={() => setIsCollapsed(!isCollapsed)}>
              &#123;isCollapsed ? "펼치기" : "접기"&#125;
            </button>
          )&#125;
        </Card>
      </CardStateContext.Provider>
    </CardConfigContext.Provider>
  )
&#125;

Header는 변경되지 않는 CardConfigContext만 읽으므로 재렌더링되지 않고, Content는 CardStateContext만 읽어 접기 상태가 바뀔 때만 다시 렌더링됩니다.

TypeScript 타입 안전성

Compound Components의 타입은 신중하게 정의해야 합니다. 그렇지 않으면 TypeScript가 ‘자식 컴포넌트가 부모 컴포넌트 안에 있지 않을 수 있다’는 오류를 표시할 수 있습니다.

// 완전한 타입 정의
type CollapsibleCardProps = &#123;
  children: React.ReactNode
  defaultCollapsed?: boolean
  collapsible?: boolean
&#125;

type CollapsibleCardComponents = &#123;
  Root: FC&lt;CollapsibleCardProps>
  Header: FC&lt;&#123; title: string &#125;>
  Content: FC&lt;&#123; children: React.ReactNode &#125;>
&#125;

const CollapsibleCard: CollapsibleCardComponents = &#123;
  Root: &#123; children, defaultCollapsed = false, collapsible = true &#125; => &#123;
    // ...
  &#125;,
  Header: &#123; title &#125; => &#123;
    // ...
  &#125;,
  Content: &#123; children &#125; => &#123;
    // ...
  &#125;
&#125;

이렇게 작성하면 TypeScript가 전달한 props가 올바른지 검사합니다.

// ✅ 올바른 사용
<CollapsibleCard.Root defaultCollapsed={true}>
  <CollapsibleCard.Header title="제목" />
  <CollapsibleCard.Content>콘텐츠</CollapsibleCard.Content>
</CollapsibleCard.Root>

// ❌ TypeScript 오류: title은 필수 항목
<CollapsibleCard.Header />

정리: 컴포지션 패턴 체크리스트

지금까지 살펴본 핵심 원칙을 정리해 보겠습니다.

기본 조합

  • Dialog + Form: Dialog는 컨테이너, Form은 콘텐츠로 책임을 분리합니다.
  • DataTable + DropdownMenu: DropdownMenu가 작업을 실행하고 row.original로 데이터를 전달합니다.
  • Tabs + Form: Tabs는 탐색을 담당하고 TabsContent에 서로 다른 폼을 배치합니다.

고급 기법

  • Context 패턴: prop drilling을 피하고 자식 컴포넌트가 상태를 자동으로 가져오게 합니다.
  • Hook으로 상태 관리: Dialog 상태를 한곳에서 관리해 행마다 인스턴스가 생기지 않게 합니다.
  • Form + Zod: 검증 Schema를 통합하고 FormMessage로 오류를 자동 표시합니다.

성능 및 타입 최적화

  • Context 분리: 자주 바뀌는 상태 때문에 모든 자식 컴포넌트가 재렌더링되는 일을 막습니다.
  • TypeScript 타입: 완전한 타입을 정의해 잘못된 props 전달을 방지합니다.
  • Server/Client 분리: Next.js App Router에서는 Server가 데이터를 가져오고 Client가 UI를 담당하게 합니다.

마지막으로 한 가지 조언을 덧붙이자면, shadcn/ui 컴포넌트 파일을 직접 수정하지 않는 편이 좋습니다. 스타일을 맞추고 싶다면 래퍼 컴포넌트를 만들거나 variants를 사용하거나 theme으로 조정하세요. 소스 코드를 직접 고치면 나중에 업그레이드하기 어려워집니다.

솔직히 shadcn/ui의 컴포지션 패턴을 익힌 뒤 제 코드는 훨씬 깔끔해졌습니다. 300줄이 넘던 사용자 관리 페이지는 150줄 미만으로 줄었고 상태 관리도 명확해졌습니다. 물론 Context 패턴과 성능 최적화 같은 고급 기법은 처음에는 조금 복잡하게 느껴질 수 있지만 여러 번 작성하다 보면 익숙해집니다.

비슷한 컴포넌트 조합 문제를 겪고 있다면 이번 패턴을 적용해 보세요. 코드의 흐름을 정리하는 데 도움이 될 것입니다.


DataTable + Dialog + Form 조합 구현하기

완전한 사용자 관리 페이지를 구현하는 과정

⏱️ Estimated time: 45 min

  1. 1

    Step 1: Zod Schema 정의

    컴포넌트 밖에서 데이터 구조 검증을 정의합니다:

    • z.object()로 필드를 정의합니다.
    • 검증 규칙(min, email, enum)을 추가합니다.
    • schema와 type을 export합니다.
  2. 2

    Step 2: Dialog 상태 Hook 만들기

    Dialog 상태를 한곳에서 관리합니다:

    • useState로 open과 editingUser를 관리합니다.
    • openDialog로 Dialog를 열고 데이터를 전달합니다.
    • closeDialog로 Dialog를 닫고 데이터를 비웁니다.
  3. 3

    Step 3: DataTable 열 정의

    columns에 작업 열을 추가합니다:

    • cell 안에 DropdownMenu를 배치합니다.
    • onClick에서 openDialog(row.original)를 호출합니다.
    • Dialog를 cell 안에 넣지 않습니다.
  4. 4

    Step 4: 편집 폼 컴포넌트 만들기

    shadcn Form 컴포넌트를 사용합니다:

    • useForm + zodResolver
    • FormField + FormControl
    • FormMessage로 오류를 자동 표시합니다.
  5. 5

    Step 5: 메인 페이지 조합

    모든 컴포넌트를 조합합니다:

    • DataTable + 전역 Dialog
    • Dialog 안에 Form을 배치합니다.
    • 제출 후 목록 데이터를 갱신합니다.

FAQ

Dialog를 DataTable cell 안에 넣으면 안 되는 이유는 무엇인가요?
행마다 Dialog 인스턴스를 만들면 성능 문제가 생깁니다. 데이터가 100행이면 Dialog도 100개가 생기고 상태도 일관되게 관리하기 어렵습니다. 올바른 방법은 전역 Dialog 하나를 두고 Hook으로 상태를 관리하는 것입니다.
Form은 React Hook Form을 사용해야 하나요, 아니면 직접 관리해야 하나요?
React Hook Form + Zod 조합을 강력히 권장합니다:

• 자동 검증 및 오류 표시
• 타입 안전성(z.infer로 자동 추론)
• 더 나은 성능(재렌더링 감소)
• FormMessage의 자동 오류 메시지 표시
Context 패턴이 성능 문제를 일으킬 수 있나요?
그럴 수 있습니다. Context 값이 바뀌면 useContext를 사용하는 모든 컴포넌트가 다시 렌더링됩니다. 해결 방법은 다음과 같습니다:

• Context 분리(상태 Context + 설정 Context)
• 상태에 반응해야 하는 컴포넌트만 상태 Context를 읽게 합니다.
• 바뀌지 않는 설정은 설정 Context에 둡니다.
prop drilling을 피하려면 어떻게 해야 하나요?
Context 패턴을 사용합니다. Root 컴포넌트가 Context를 만들고 자식 컴포넌트는 useContext로 상태를 자동으로 가져옵니다. 자식은 props를 여러 단계에 걸쳐 전달받을 필요 없이 부모 컴포넌트 안에 있기만 하면 상태에 접근할 수 있습니다.
shadcn/ui 컴포넌트 소스 코드를 직접 수정해도 되나요?
권장하지 않습니다. 올바른 방법은 다음과 같습니다:

• 래퍼 컴포넌트를 만듭니다.
• variants로 변형을 정의합니다.
• theme으로 스타일을 맞춥니다.

소스 코드를 직접 수정하면 나중에 업그레이드하기 어려워집니다.
Compound Components의 TypeScript 타입은 어떻게 정의하나요?
완전한 타입 정의를 사용합니다:

• Root/Header/Content의 Props 타입을 정의합니다.
• FC<Props>로 컴포넌트 타입을 제한합니다.
• 자식 컴포넌트가 Root 밖에 있으면 오류를 던집니다.

이렇게 하면 TypeScript가 props의 올바른 사용 여부를 검사합니다.

5분 읽기 · 게시일: 2026년 4월 1일 · 수정일: 2026년 9월 8일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog