frontend

shadcn/ui의 핵심, asChild 속성 이해하기: 개념부터 실무 예제까지

shadcn/ui나 Radix UI 라이브러리를 사용하다 보면 버튼이나 링크를 구현할 때 필수적으로 마주치는 속성이 있습니다. 바로 **asChild**입니다. 처음 접하면 단순히 “자식 요소를 렌더링하나 보다”라고 생각하기 쉽지만, 이 속성 뒤에는 리액트의 합성(Composition) 패턴과 시맨틱 웹을 위한 깊은 고민이 담겨 있습니다. 오늘은 시니어 개발자의 시선으로 이 asChild가 왜 탄생했는지, 그리고 어떻게 사용하는지 심층적으로 살펴보겠습니다.

목차

  1. asChild란 무엇인가?
  2. 왜 asChild가 필요한가? (문제 의식)
  3. Slot 패턴의 이해
  4. 실무 코드 예제: Button과 Next.js Link 결합
  5. 개발자의 팁: ref 전달 문제 해결하기

asChild란 무엇인가?

**asChild**는 Radix UI에서 시작된 개념으로, 컴포넌트가 자기 자신의 HTML 태그를 생성하는 대신 자식으로 전달된 요소를 자신의 “뿌리(Root)“로 사용하도록 지시하는 속성입니다.

일반적으로 <Button> 컴포넌트는 내부적으로 <button> 태그를 렌더링합니다. 하지만 asChild 속성을 넘기면, 컴포넌트는 자신의 태그를 렌더링하지 않고 자식 요소에게 자신의 이벤트 핸들러, aria 속성, 스타일 클래스를 그대로 주입(Merge)합니다.

왜 asChild가 필요한가? (문제 의식)

전통적인 방식에서는 컴포넌트의 태그를 변경하기 위해 as prop을 사용하곤 했습니다. (예: <Button as="a" href="...">) 하지만 이 방식은 몇 가지 한계가 있습니다.

  1. 타입 추론의 어려움: as="a"일 때와 as="button"일 때 받아야 할 props(href 등)가 달라지는데, 이를 TypeScript로 완벽하게 처리하기가 복잡합니다.
  2. DOM 중첩 문제: 버튼 안에 링크를 넣고 싶은 경우, <button><a>...</a></button>와 같은 유효하지 않은 HTML 구조가 만들어지기 쉽습니다. 이는 SEO와 접근성 측면에서 치명적입니다.
  3. 컴포넌트 확장성: 커스텀 컴포넌트나 Next.js의 <Link>와 결합할 때, 스타일은 버튼의 것을 쓰면서 기능은 링크의 것을 써야 하는 상황에서 asChild는 가장 깔끔한 해결책이 됩니다.

Slot 패턴의 이해

asChild 기능을 실제로 구현하는 것은 Radix UI의 @radix-ui/react-slot 패키지에 포함된 Slot 컴포넌트입니다.

  • 동작 원리: Slot은 자신을 렌더링하지 않습니다. 대신 자신의 모든 props를 바로 아래 자식에게 복사합니다.
  • 속성 병합: 만약 부모(Button)에 onClick이 있고 자식(Link)에도 onClick이 있다면, Slot은 두 함수를 모두 실행할 수 있도록 스마트하게 병합합니다.

가장 흔한 사례는 shadcn/ui의 버튼 스타일을 유지하면서 Next.js의 클라이언트 사이드 라우팅 기능을 사용하는 것입니다.

import Link from 'next/link';
import { Button } from '@/components/ui/button';

export default function Home() {
  return (
    <div className="flex gap-4">
      {/* 1. 일반적인 버튼 사용 */}
      <Button onClick={() => console.log("clicked")}>
        기본 버튼
      </Button>

      {/* 2. asChild를 사용한 Next.js Link 결합 */}
      <Button asChild variant="outline" className="font-bold">
        {/* Button의 스타일과 variant 클래스가 아래 Link로 전달됩니다. */}
        {/* 실제 DOM에는 <button>이 생성되지 않고 <a href="...">만 렌더링됩니다. */}
        <Link href="/dashboard">
          대시보드로 이동
        </Link>
      </Button>
    </div>
  );
}

위 코드에서 asChild를 사용하지 않고 <Button><Link>...</Link></Button>로 작성했다면, 브라우저는 버튼 안에 앵커 태그가 들어있는 잘못된 구조를 경고(Warning)했을 것입니다. asChild는 이를 단일 <a> 태그로 합쳐줍니다.


개발자의 팁: ref 전달 문제 해결하기

asChild를 사용하는 자식 요소는 반드시 ref를 전달받을 수 있는 구조여야 합니다. Radix UI는 포커스 관리나 팝업 위치 계산을 위해 DOM 노드에 직접 접근해야 하기 때문입니다.

Warning: 만약 직접 만든 커스텀 컴포넌트를 asChild 아래에 넣는다면, 반드시 React.forwardRef를 사용하여 ref를 내부 DOM 요소로 전달해야 합니다. 그렇지 않으면 런타임 에러가 발생하거나 스타일이 깨질 수 있습니다.


자주 묻는 질문 (FAQ)

Q1. asChild를 사용할 때 자식 요소가 여러 개면 어떻게 되나요?

asChild오직 하나의 자식 요소만 가질 수 있습니다. 만약 두 개 이상의 자식을 넣으면 에러가 발생합니다. 여러 요소를 넣어야 한다면 <div><span> 등으로 감싼 뒤 그 감싼 요소 하나를 자식으로 두어야 합니다.

Q2. className은 어떻게 병합되나요?

부모 컴포넌트(예: Button)에 정의된 Tailwind 클래스와 자식 요소(예: Link)에 정의된 클래스가 합쳐집니다. shadcn/ui 내부에서는 보통 cn() 유틸리티를 사용하여 중복되는 클래스를 최적화하여 병합합니다.

Q3. asChild와 그냥 as의 차이점은 무엇인가요?

as는 문자열(태그 이름)이나 컴포넌트를 직접 넘겨서 타입을 변경하는 방식이고, asChild는 실제 리액트 엘리먼트를 자식으로 넘겨서 속성을 위임하는 방식입니다. 현대적인 UI 라이브러리들은 타입 안정성과 유연성 때문에 asChild 패턴을 선호합니다.

asChild 패턴을 이해하면 shadcn/ui를 훨씬 더 자유자재로 다룰 수 있게 됩니다. 이제 불필요한 DOM depth를 줄이고, 시맨틱한 마크업을 유지하며 더 견고한 UI를 구축해 보세요!

이 글이 마음에 드셨나요?

로딩 중...