서비스가 커지며 복잡도도 같이 높아지는 Frontend 실무의 특성
Frontend 개발자로서 UI를 다루다 보면, 다음과 같은 순간이 찾아옵니다.
- 원래 한 가지 용도로 시작했는데, 용도에 대한 요구사항이 다양해질 때
- 기능은 같은데, 요구되는 스타일의 variants가 여러 개일 때
- 딱히 정해둔 UI 요소는 아닌데, 여러 도메인에서 자주 쓰일 때
조금 더 구체적인 케이스는 다음과 같습니다.
Case A. Button의 진화 — "버튼 하나가 링크가 되는 순간"
시작은 다음과 같은 간단한 버튼이었습니다.
<Button onClick={openModal}>장바구니에 담기</Button>
근데, 갑자기 요구사항으로 다음이 들어옵니다.
"이건 페이지 이동이 필요해요"
기존 onClick의 역할만 하던 버튼들 중 일부가 라우팅까지 책임지게 된 것이지요. 고려할 수 있는 옵션은 다음과 같습니다.
<Button onClick={() => router.push('/checkout')}>결제하기</Button>처럼 onClick 콜백 함수에 라우트 로직을 추가한다- Next.js나 기타 라우터 라이브러리에서 제공하는
Link로 감싼다 <Button as={Link} href="/checkout" variant="primary">결제하기</Button>처럼 as prop을 사용하여 wrapping 한다
그 외에도 정답이 없는 여러 가지 방식을 적용해 볼 수 있습니다. 하지만 1, 2, 3 모두 확장성에는 살짝 문제가 있을 것 같습니다.
1번의 문제 (onClick 콜백 함수에 라우트 로직을 추가한다)
- 우클릭 → 새 탭 열기 안 됨
- 크롤러 입장에선 그냥 <button> → SEO 손실
- Next.js prefetch 못 씀
등의 실제 a 태그를 렌더링 하지 못하기 때문에 나타나는 문제들이 있을 수 있어요.
2번의 문제 (라우터 라이브러리에서 제공하는 Link로 감싼다)
이 경우, 1번의 문제는 해결되지만 해당 버튼을 다양한 페이지에서 쓰고 있다면, 모든 파일을 전수 수정해야 하는 상황이 올 것 같습니다.
3번의 문제 (as prop을 사용하여 wrapping 한다)
이 경우, 코드 모양이 다음과 같이 나올 것 같습니다.
type ButtonProps<T extends ElementType> = {
as?: T;
variant?: 'primary' | 'ghost';
} & ComponentPropsWithoutRef<T>;
function Button<T extends ElementType = 'button'>({ as, ...props }: ButtonProps<T>) { ... }
<Button as={Link} href="/checkout" variant="primary">결제하기</Button> 정도의 요구사항까지는 동작합니다. 다만, 점점 다음과 같은 관리 비용이 슬금슬금 늘어납니다.
- 제네릭 시그니처가 호출부 자동완성을 망가뜨림 (href가 진짜 필수인지 IDE가 늦게 알려줌)
- forwardRef + 제네릭 조합이 타입 추론을 자주 놓침 →
as any가 한두 개씩 증가하기 시작 as={ThirdPartyComponent}가 들어오면 ref forwarding이 안 되어 런타임 경고
여기까지는 어느 정도 규모가 적당한 서비스라면 사람 드리븐의 규약과 팀원 간 약속으로 운영해 볼 만합니다. 하지만 Button 요소에 hover하면 나타나는 tooltip 기능까지 붙여달라는 요구가 온다면 어떨까요?
"Tooltip도 붙여줘요"
이 요구사항은 다음과 같은 코드 모양으로 해결할 수 있을 것 같습니다.
<Tooltip content="회원만 결제 가능">
<Button as={Link} href="/checkout">
결제하기
</Button>
</Tooltip>
근데 이 방식도 나름의 빈틈을 가지고 있습니다.
- Tooltip이 wrapper로
<span>을 두면 Stack의 gap이 생긴다 :first-child셀렉터가 깨진다<span>을 쓰지 않으면 ref, aria-describedby를 어디에 꽂아야 하나?
등 CSS와 접근성 측면에서 제약이 붙습니다.
이때 여러 상황을 조합해서 한 번에 풀어줄 수 있는 해결사가 등장합니다.
바로 asChild 패턴입니다.
asChild: 합성을 평평하게
앞서 다루었던 tooltip 문제를 asChild 패턴으로 풀어보겠습니다.
<Tooltip content="회원만 결제 가능" asChild>
<Button variant="primary" asChild>
<Link href="/checkout">결제하기</Link>
</Button>
</Tooltip>
코드 모양은 위와 같이 나옵니다. asChild를 도입함으로써 어떤 효과를 가져왔는지 알아볼까요?
- 실제 DOM은
<a>하나 - Tooltip의 aria-describedby + Button의 스타일 + Link의 prefetch가 같은 노드에 머지
- 타입 측면에선 각 컴포넌트가 자기 props만 책임짐 → 제네릭 폭발 없음
결국, 각 기능이 책임져야 할 요소가 분명해졌으며 그로 인해 타입 관리 복잡도가 낮아지고 코드의 투명도가 올라갔습니다.
Button의 경우 asChild 패턴이 가져다주는 핵심은 명확합니다.
"이 컴포넌트가 자기 태그를 갖느냐"가 아니라 "행동·스타일·a11y만 빌려주느냐"로 멘탈 모델이 바뀌는 순간 asChild가 ROI를 냅니다.
Case B. Card — "표시용에서 인터랙티브로"
실무에서 자주 사용하는 Card 컴포넌트입니다. 초기 카드 컴포넌트는 다음과 같다고 가정해 볼게요.
<Card>
<Card.Title>{product.name}</Card.Title>
<Card.Price>{product.price}</Card.Price>
</Card>
그런데 다음과 같은 요구사항이 들어왔습니다.
"카드 전체가 클릭되면 좋겠어요"
단순한 해결책으로는 다음을 떠올릴 수 있습니다.
<Card onClick={() => router.push(`/product/${id}`)}>...</Card>
하지만 이 경우 Button과 비슷한 문제가 나타나요.
- 시맨틱:
<div onClick>→ a11y 깨짐 (탭 포커스, Enter 키, role) - SEO 손실
- 카드 내부에 "찜하기 버튼"이 있으면 이벤트 버블링 처리 지옥 (
e.stopPropagation()코드가 늘어남)
그럼 위의 문제들을 해결하기 위해 카드 전체를 <a>로 감싸면 어떨까 하는 생각이 드네요.
<Link href={`/product/${id}`}>
<Card>
...
<FavoriteButton /> {/* ❌ <a> 안의 <button> — invalid nesting 경고 */}
</Card>
</Link>
위와 같은 방식도 단순한 해결책은 될 수 있지만 아래와 같은 문제가 생겨요.
- HTML 스펙상
<a>안에 인터랙티브 요소(button)를 넣으면 안 됨 - 하이드레이션 경고 + 스크린리더 동작 이상
그런데 또 다음과 같은 요구사항이 추가되었어요.
"어떤 카드는
<article>, 어떤 건<li>로 들어가야 해요"
검색 결과는 <li>로, 상세 추천은 <article>로.
Card에 tag prop을 만들기 시작하고, 다시 제네릭 지옥에 빠집니다.
asChild: 조합을 현명하게
Card 컴포넌트의 경우도, 복잡도를 현명하게 다루려면 asChild 패턴이 답이 됩니다.
// 검색 결과 — li
<Card asChild>
<li>
<Card.Title>...</Card.Title>
<FavoriteButton />
</li>
</Card>
// 카드 자체가 링크 — 단, 내부 인터랙션은 별도 영역
<Card asChild>
<article>
<Link href={`/product/${id}`} className="absolute inset-0" aria-label={name} />
<Card.Title>...</Card.Title>
<FavoriteButton /> {/* z-index로 위에 떠서 정상 동작 */}
</article>
</Card>
위와 같이 검색 결과 용도는 <li>로 감싸주고, 상세 추천 용도는 <article>로 감싸주면 됩니다.
그리고 Link가 필요하다면 약간의 CSS 트릭을 사용한 stretched link 패턴으로 렌더링도 가능해요.
각 요소가 자기 책임만 분명히 지면서도, 호출부에서 자유롭게 조합할 수 있게 되었어요.
Card의 경우 asChild 패턴이 가져다주는 핵심은 다음과 같아요.
컴포넌트가 처음엔 하나의 DOM을 책임지는 것으로 충분했지만, 도메인이 늘어나면서 "내가 누구로 렌더되어야 하는지"까지 호출자에게 넘겨줘야 하는 순간이 올 때, asChild(내부적으로는 cloneElement + ref merge + props merge)는 그 비용을 호출부로 평평하게 흘려보내는 테크닉입니다.
asChild의 내부 로직 — 결국은 cloneElement 한 줄
asChild는 라이브러리마다 이름이 다르지만(Radix는 Slot, Reach UI는 as, Headless UI는 Fragment, Expo Router는 Link) 본질은 같습니다.
"자기 자신을 렌더링하지 않고, 자식 요소에 props를 머지해서 그 자식을 대신 렌더링한다."
이걸 가능하게 하는 React API가 두 개 있습니다.
React.cloneElement(element, props)— 기존 element를 복제하면서 props를 덧붙임React.Children.only(children)— 자식이 단일 element인지 검증
가장 작게 구현하면 이렇습니다.
import { cloneElement, isValidElement, forwardRef } from 'react'
type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string
children: React.ReactNode
asChild?: boolean
}
const Link = forwardRef<HTMLAnchorElement, LinkProps>(
({ children, asChild, href, ...linkProps }, ref) => {
// 1) asChild가 아니면 평범한 <a> 로 렌더
if (!asChild) {
return (
<a href={href} ref={ref} {...linkProps}>
{children}
</a>
)
}
// 2) asChild면 자식이 단일 element인지 검증
if (!isValidElement(children)) {
if (process.env.NODE_ENV !== 'production') {
console.warn('Link: asChild를 쓰려면 단일 React element를 자식으로 넘겨야 합니다.')
}
return null
}
// 3) Link가 가진 href·props·ref를 자식 element에 머지해서 그 자식이 되도록 한다
return cloneElement(children, {
href,
...mergeProps(linkProps, children.props),
ref: composeRefs(ref, (children as any).ref),
})
},
)
핵심은 세 가지 책임입니다.
1. props 머지 — 그냥 spread 하면 안 되는 이유
단순히 { ...slotProps, ...childProps }로 합치면 이벤트 핸들러가 덮어써집니다. Tooltip이 onPointerEnter를 꽂으려는데, 자식 Button이 자기 onPointerEnter를 갖고 있으면 한쪽이 죽죠. 그래서 props 종류별로 머지 전략이 다릅니다.
function mergeProps(slotProps: any, childProps: any) {
const merged = { ...childProps }
for (const key in slotProps) {
const slotValue = slotProps[key]
const childValue = childProps[key]
if (/^on[A-Z]/.test(key)) {
// 이벤트 핸들러: 둘 다 호출 (child 먼저 → slot)
merged[key] =
childValue && slotValue
? (...args: unknown[]) => {
childValue(...args)
slotValue(...args)
}
: (slotValue ?? childValue)
} else if (key === 'className') {
// className: 합치기
merged[key] = [slotValue, childValue].filter(Boolean).join(' ')
} else if (key === 'style') {
// style: 객체 머지 (child가 더 구체적이므로 우선)
merged[key] = { ...slotValue, ...childValue }
} else if (childValue === undefined) {
// 그 외: child에 값이 없으면 slot 값 사용
merged[key] = slotValue
}
// child에 이미 값이 있으면 그대로 둠 (호출자의 의도 존중)
}
return merged
}
규칙을 한 줄로 줄이면:
| props 종류 | 머지 전략 | 이유 |
|---|---|---|
onXxx | 두 핸들러 모두 호출 (chain) | 행동을 빼앗으면 라이브러리 동작이 깨짐 |
className | 공백으로 join | 스타일은 누적이 자연스러움 |
style | spread, child 우선 | 호출자가 인라인으로 덮어쓸 권리 보장 |
| 그 외 | child 우선, 없으면 slot | 더 구체적인 의도(호출부)가 이김 |
2. ref 머지 — Slot도 ref가 필요하고, 자식도 ref가 필요할 때
Tooltip은 trigger 노드의 getBoundingClientRect()가 필요해서 ref를 잡아야 하고, 호출자도 자기 ref를 꽂고 싶을 수 있습니다. 한 노드에 ref 두 개를 동시에 거는 건 불가능하므로, 함수 ref 하나로 위장해서 둘 다 채워주는 합성 함수를 씁니다.
type PossibleRef<T> = React.Ref<T> | undefined
function composeRefs<T>(...refs: PossibleRef<T>[]) {
return (node: T | null) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(node)
} else if (ref != null) {
;(ref as React.MutableRefObject<T | null>).current = node
}
})
}
}
이게 있어야 <Tooltip asChild><Button ref={myRef} /></Tooltip> 같은 호출에서 Tooltip 내부 ref와 myRef가 둘 다 살아있게 됩니다.
3. 자식 검증 — 조용히 실패하지 않도록
cloneElement는 인자가 element가 아니면 런타임에서 폭발합니다. 그래서 진입부에서 한 번 막아주는 것이 좋습니다.
if (!isValidElement(children)) {
if (process.env.NODE_ENV !== 'production') {
console.warn('Slot: children must be a single React element')
}
return null
}
Radix는 Children.count(children) > 1인 경우(여러 자식)에도 명확한 에러를 던집니다. asChild는 "단일 element를 받아 그 element가 되는" 패턴이라, Fragment·문자열·배열은 의미가 정의되지 않습니다.
그래서 호출부에서 일어나는 일
<Tooltip content="결제하기" asChild>
<Button variant="primary" onClick={handleClick}>
결제
</Button>
</Tooltip>
위 코드의 실제 렌더 트리는 다음과 같이 평탄해집니다.
<button
className="tooltip-trigger primary" // 두 className 합쳐짐
onPointerEnter={chainedHandler} // Tooltip의 hover open + Button의 핸들러
onClick={handleClick} // child 우선
aria-describedby="tooltip-xxx" // Tooltip 주입
ref={composedRef} // Tooltip ref + 호출자 ref
>
결제
</button>
래퍼 div가 없습니다. Tooltip의 행동·a11y·ref가 전부 실제 <button> 한 노드에 머지됩니다. 이게 asChild가 주는 진짜 가치이고, cloneElement라는 React의 30줄짜리 API가 디자인시스템 합성 비용을 얼마나 줄이는지를 보여주는 지점입니다.
앞서 만든 Link의 경우 다음과 같이 호출부에서 사용할 수 있습니다.
// 평범한 링크 — 그냥 <a> 가 렌더됨
<Link href="/about">소개</Link>
// 디자인시스템 Button의 모양을 빌리고, 동작은 Link가 책임짐
<Link href="/checkout" asChild>
<Button variant="primary">결제하기</Button>
</Link>
// Next.js라면 next/link 와 합성해서 prefetch까지
<Link href="/products/123" asChild>
<NextLink prefetch>
<Card>...</Card>
</NextLink>
</Link>
마지막 호출의 실제 렌더 결과는 래퍼 없이 평탄합니다.
<a href="/products/123" class="card ..."> <!-- NextLink가 만든 <a>에 Link의 href가 머지 -->
...
</a>
cloneElement의 제약
cloneElement 기반이라 따라오는 제약이 있습니다.
- 자식이
forwardRef가 아니면 ref 머지가 무용지물 — 함수형 컴포넌트는 ref를 못 받음 - 자식이 단일 element여야 함 — 조건부 렌더(
{cond && <X/>})나 Fragment는 못 받음 - props 머지 규칙을 모르면 디버깅 난이도 상승 — "왜 내
onClick이 두 번 호출되지?", "왜 내className이 안 먹지?"의 절반은 이 규칙 때문
이 한계 때문에 React 19에서 추가된 ref-as-prop, 그리고 표준화 논의 중인 <Slot> 컴포넌트 패턴이 의미가 있는데, 이건 다른 글에서 다루는 게 좋겠습니다.