Tooltip에서 asChild

문제 상황
shadcn으로 커스텀 버튼을 제작하면서 위와 같은 문제가 발생했었다.
우선 shadcn은 내부적으로 Radix UI를 사용한다. 구글링을 진행한 결과 asChild 라는 프로퍼티를 사용해서 해결은 했지만 <button> 안에 왜 <button> 태그를 넣으면 경고가 발생하는지, 그리고 asChild 가 어떤 역할을 하길래 위 경고를 하는지 알아보기로 했다.
버튼 안에 버튼이 생기는 상황
TooltipTrigger는 기본적으로 <button>으로 렌더링된다
먼저 왜 버튼 중첩이 발생하는지 이해하려면, TooltipTrigger가 내부적으로 어떤 요소를 렌더링하는지 살펴봐야 한다.
// packages/react/tooltip/src/tooltip.tsx
type TooltipTriggerElement = React.ComponentRef<typeof Primitive.button>;
type PrimitiveButtonProps = React.ComponentPropsWithoutRef<typeof Primitive.button>;
interface TooltipTriggerProps extends PrimitiveButtonProps {}
const TooltipTrigger = React.forwardRef<TooltipTriggerElement, TooltipTriggerProps>(
(props, forwardedRef) => {
// ...
return (
<PopperPrimitive.Anchor asChild {...popperScope}>
<Primitive.button // ← 여기서 <button> 요소를 렌더링
aria-describedby={context.open ? context.contentId : undefined}
data-state={context.stateAttribute}
{...triggerProps}
ref={composedRefs}
onPointerMove={...}
onPointerLeave={...}
onPointerDown={...}
onFocus={...}
onBlur={...}
onClick={...}
/>
</PopperPrimitive.Anchor>
);
},
);
위 코드처럼 Primitive.button을 사용하고 있다. 이 Primitive.button이 바로 <button> HTML 요소를 감싼 Radix UI의 래퍼 형태이다. asChild를 넘기지 않으면 이게 그대로 <button>으로 렌더링된다.
그래서 우리가 그 안에 또 <button>을 넣으면 이런 잘못된 중첩 구조가 만들어진다.
// ❌ 문제가 있는 코드
<TooltipTrigger>
<button className="bg-primary-0 rounded-[5px] px-4 py-3 text-white">
호버해보세요
</button>
</TooltipTrigger>
<!-- 브라우저에 실제로 그려지는 구조 (잘못됨) -->
<button>
<button class="bg-primary-0 ...">
호버해보세요
</button>
</button>
<button> 안에 <button>이 중첩된 구조는 HTML 명세상 이는 허용되지 않는 구조다.
왜 문제가 되는가?
우선 WHATWG HTML Living Standard에서는 <button>의 Content Model을 다음과 같이 정의한다.
Content model: Phrasing content, but there must be no interactive content descendant and no descendant with the tabindex attribute. - WHATWG HTML Living Standard §4.10.6 The button element
<button>은 phrasing content를 자식으로 가질 수 있지만, interactive content를 자손으로 가질 수 없다.
<button>은 interactive content에 해당하므로, <button> 안에 <button>을 넣는 것은 명세 위반이다.
interactive content는 이름 그대로 유저 상호작용과 관련되어 있다.
접근성(Accessibility) 문제
스크린 리더는 DOM 구조를 기반으로 콘텐츠를 해석한다. 버튼이 중첩되면 스크린 리더가 어떤 요소에 포커스를 줘야 할지, 어떤 버튼의 레이블을 읽어야 할지 혼란스러워진다.클릭 이벤트 충돌
중첩된 버튼 구조에서는 클릭 이벤트가 두 버튼 모두에 버블링될 수 있어, 어떤 핸들러가 실제로 실행될지 예측하기 어렵다.브라우저마다 다른 해석
잘못된 HTML을 만났을 때 브라우저마다 오류를 복구하는 방식이 다르다. Chrome에서는 잘 동작하는데 Safari나 Firefox에서 이상하게 보이는 상황이 생길 수 있다.
asChild 의 역할
1단계 - Primitive
TooltipTrigger는 내부적으로 Primitive.button을 렌더링한다. Primitive.button은 @radix-ui/react-primitive에 정의된 래퍼이며, 이 안에서 asChild 분기가 일어난다.
// packages/react/primitive/src/primitive.tsx
const Primitive = NODES.reduce((primitive, node) => {
const Slot = createSlot(`Primitive.${node}`); // Slot 미리 생성해둠
const Node = React.forwardRef((props: PrimitivePropsWithRef<typeof node>, forwardedRef: any) => {
const { asChild, ...primitiveProps } = props;
const Comp: any = asChild ? Slot : node;
// asChild=false → 'button' (native HTML 요소로 직접 렌더링)
// asChild=true → Slot (렌더링을 자식에게 위임)
return <Comp {...primitiveProps} ref={forwardedRef} />;
// ↑ asChild=true면 이 시점에 Slot이 렌더링되며 다음 단계로 진입
});
// ...
}, {} as Primitives);
asChild가 true일 경우 Comp가 Slot으로 바뀌고, primitiveProps(TooltipTrigger의 이벤트 핸들러, aria 속성 등 모든 props)를 그대로 들고 Slot으로 넘어간다.
2단계 — SlotClone
Slot은 createSlot으로 만들어지며, 실제 작업은 내부의 SlotClone이 담당한다. 1단계에서 Slot으로 전달된 primitiveProps는 여기서 slotProps로 받아진다.
// packages/react/slot/src/slot.tsx
function createSlotClone(ownerName: string) {
const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
let { children, ...slotProps } = props;
// slotProps = 1단계에서 넘어온 primitiveProps
// (aria-describedby, data-state, onPointerMove, onClick 등)
if (isLazyComponent(children) && typeof use === 'function') {
children = use(children._payload);
}
if (React.isValidElement(children)) {
const childrenRef = getElementRef(children);
// ↓ slotProps와 children.props를 병합 → 3단계 mergeProps로 진입
const props = mergeProps(slotProps, children.props as AnyProps);
// ↓ ref 충돌 처리 → 3단계 composeRefs로 진입
if (children.type !== React.Fragment) {
props.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
}
// ↓ 병합된 props를 자식 버튼에 입혀서 반환 — 이것이 최종 DOM에 렌더링됨
return React.cloneElement(children, props);
}
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});
SlotClone.displayName = `${ownerName}.SlotClone`;
return SlotClone;
}
SlotClone은 두 가지 작업을 차례로 호출한 뒤 React.cloneElement로 마무리한다. mergeProps로 props를 병합하고, composeRefs로 ref를 합성한 다음, 그 결과물을 자식 <button>에 입혀 반환한다.
결국 TooltipTrigger의 <button>은 DOM에 나타나지 않고, 우리가 작성한 <button>이 Tooltip의 모든 기능을 흡수한 채 최종 DOM에 렌더링된다.
3단계
1. mergeProps
단순히 { ...slotProps, ...childProps }로 덮어쓰면 TooltipTrigger의 onClick이 커스텀된 onClick에 덮여 사라져버립니다. 반대로 slotProps를 우선하면 우리 핸들러가 무시됩니다. mergeProps는 이를 prop 유형별로 다른 전략을 써서 해결한다.
// packages/react/slot/src/slot.tsx
// SlotClone에서 mergeProps(slotProps, children.props)로 호출됨
function mergeProps(slotProps: AnyProps, childProps: AnyProps) {
const overrideProps = { ...childProps }; // 자식 props를 기본값으로
for (const propName in childProps) {
const slotPropValue = slotProps[propName]; // TooltipTrigger 쪽 prop
const childPropValue = childProps[propName]; // 우리가 작성한 자식의 prop
const isHandler = /^on[A-Z]/.test(propName); // onClick, onPointerMove 등 이벤트 핸들러 판별
if (isHandler) {
// 양쪽 모두 핸들러가 존재 → 자식 먼저, TooltipTrigger 나중에 실행하는 합성 함수
if (slotPropValue && childPropValue) {
overrideProps[propName] = (...args: unknown[]) => {
const result = childPropValue(...args); // 자식 핸들러 먼저 실행
slotPropValue(...args); // TooltipTrigger 핸들러 이후 실행
return result;
};
}
// TooltipTrigger에만 핸들러가 있으면 → TooltipTrigger 핸들러 그대로 사용
else if (slotPropValue) {
overrideProps[propName] = slotPropValue;
}
}
else if (propName === 'style') {
// style은 객체 병합 (자식이 우선)
overrideProps[propName] = { ...slotPropValue, ...childPropValue };
}
else if (propName === 'className') {
// className은 공백으로 이어 붙이기
overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' ');
}
}
return { ...slotProps, ...overrideProps };
}
이벤트 핸들러는 둘 다 실행하는 합성 함수로, style은 객체 병합으로, className은 공백으로 이어 붙이는 방식으로 처리한다. 이 결과물이 다시 SlotClone으로 돌아가 React.cloneElement의 두 번째 인자로 쓰인다.
2. composeRefs
mergeProps와 나란히, SlotClone은 ref도 합성한다.
TooltipTrigger는 tooltip 위치 계산을 위해 trigger DOM 노드의 ref를 내부적으로 보유해야 한다. 동시에 기존에 작성했던 코드에서도 같은 버튼에 ref를 붙일 수 있습니다. 두 ref가 하나의 DOM 노드를 공유해야 하는 상황입니다.
// packages/react/slot/src/slot.tsx
// SlotClone 내부 — mergeProps 호출 직후 실행됨
const childrenRef = getElementRef(children); // 자식(우리 버튼)이 가진 ref
// forwardedRef = TooltipTrigger가 내부적으로 필요한 ref
// childrenRef = 우리가 외부에서 넘긴 ref
props.ref = forwardedRef
? composeRefs(forwardedRef, childrenRef) // 두 ref를 하나의 콜백 ref로 합성
: childrenRef;
composeRefs는 전달받은 모든 ref를 하나의 콜백 ref로 묶어, 동일한 DOM 노드가 마운트될 때 각 ref를 순서대로 채워준다.이 결과도 마찬가지로 SlotClone으로 돌아가 React.cloneElement의 props에 포함된다.
이렇게 mergeProps와 composeRefs의 결과물이 모두 모이면, SlotClone은 React.cloneElement(children, props)로 자식 버튼에 모든 것을 입혀 반환한다.
asChild 적용 결과 — 비교
// ✅ asChild를 사용한 올바른 코드
<TooltipTrigger asChild>
<button className="bg-primary-0 rounded-[5px] px-4 py-3 text-white">
호버해보세요
</button>
</TooltipTrigger>
실제로 생성되는 HTML은 다음과 같다.
<!-- 단 하나의 버튼, 모든 props가 병합된 상태 -->
<button
class="bg-primary-0 rounded-[5px] px-4 py-3 text-white"
data-state="closed"
aria-describedby="tooltip-content-id"
>
호버해보세요
</button>
하나의 <button>이 Tooltip 트리거 역할도 하고, 기존 스타일도 사라지지 않았다.
// packages/react/slot/src/slot.tsx
// SlotClone에서 mergeProps(slotProps, children.props)로 호출됨
function mergeProps(slotProps: AnyProps, childProps: AnyProps) {
const overrideProps = { ...childProps }; // 자식 props를 기본값으로
for (const propName in childProps) {
const slotPropValue = slotProps[propName]; // TooltipTrigger 쪽 prop
const childPropValue = childProps[propName]; // 우리가 작성한 자식의 prop
const isHandler = /^on[A-Z]/.test(propName); // onClick, onPointerMove 등 이벤트 핸들러 판별
if (isHandler) {
// 양쪽 모두 핸들러가 존재 → 자식 먼저, TooltipTrigger 나중에 실행하는 합성 함수
if (slotPropValue && childPropValue) {
overrideProps[propName] = (...args: unknown[]) => {
const result = childPropValue(...args); // 자식 핸들러 먼저 실행
slotPropValue(...args); // TooltipTrigger 핸들러 이후 실행
return result;
};
}
// TooltipTrigger에만 핸들러가 있으면 → TooltipTrigger 핸들러 그대로 사용
else if (slotPropValue) {
overrideProps[propName] = slotPropValue;
}
}
else if (propName === 'style') {
// style은 객체 병합 (자식이 우선)
overrideProps[propName] = { ...slotPropValue, ...childPropValue };
}
else if (propName === 'className') {
// className은 공백으로 이어 붙이기
overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' ');
}
}
return { ...slotProps, ...overrideProps };
}
asChild가 쓰이는 다른 상황들
asChild 패턴은 TooltipTrigger에만 쓰이는 것은 아니다. Radix UI의 거의 모든 트리거/액티베이터 컴포넌트가 이를 지원한다.
DialogTrigger asChild— 모달을 여는 버튼을 커스텀 요소로 지정할 때PopoverTrigger asChild— 팝오버를 트리거하는 요소를<a>태그나 커스텀 컴포넌트로 쓸 때DropdownMenuTrigger asChild— 드롭다운을 아이콘 버튼 등으로 열 때AccordionTrigger asChild— 아코디언 헤더를 커스텀 요소로 구성할 때
예를 들어 링크(<a>)에 Tooltip을 붙이고 싶다면 이렇게 하면 된다.
<TooltipTrigger asChild>
<a href="/docs">문서 보기</a>
</TooltipTrigger>
표로 간략하게 정리하면 다음과 같다.
asChild는 단순한 편의 prop이 아니라 올바른 HTML 구조를 유지하고, 접근성을 지키고, 예측 가능한 이벤트 동작을 보장하기 위한 패턴이다.
| 구분 | asChild 없이 | asChild 사용 시 |
|---|---|---|
| DOM 구조 | 버튼 중첩 (❌) | 단일 버튼 (✅) |
| HTML 유효성 | 명세 위반 | 완전히 유효 |
| 접근성 | 스크린 리더 혼란 | 정상 동작 |
| 이벤트 처리 | 충돌 가능성 | mergeProps로 안전하게 합성 |
| ref 처리 | 충돌 가능성 | composeRefs로 안전하게 합성 |
Radix UI나 Shadcn UI를 사용하면서 트리거 컴포넌트 안에 커스텀 요소를 넣어야 하는 상황이라면, asChild를 반드시 선언해야 한다는 점을 배우게 됐고, 앞으로 개발에 있어서도 이 점을 유념해야겠다.



