공통 컴포넌트 구현를 구현하면서

공통 컴포넌트를 구현해보며 배운 것

# React# Design System
/ Contents

개요: Shadcn을 참고하면서 공통 컴포넌트 구현하기

대표적인 컴포넌트 ButtonModal을 직접 구현해보았다.

최근 많은 프로젝트에서 Shadcn UIRadix UI가 표준처럼 자리 잡고 있다. 단순히 라이브러리를 가져다 쓰는 것을 넘어, 그들이 어떠한 철학으로 컴포넌트를 설계했는지 깊이 있게 이해하고자 대표적인 컴포넌트인 ButtonModal을 직접 구현해 보았다.

비교적 단순해 보였던 Button에서는 asChildSlot 컴포넌트를 통한 합성(Composition)의 복잡함을 경험했고, Modal 구현 과정에서는 Compound Pattern과 웹 접근성(ARIA)의 중요성을 체감할 수 있었다.

1. Slot 컴포넌트와 asChild 패턴

공통 컴포넌트를 설계할 때 가장 까다로운 부분 중 하나는 "사용자가 컴포넌트의 HTML 태그를 직접 결정하고 싶을 때"이다.

일반적으로 Button 컴포넌트는 <button> 태그로 고정되어 있다. 하지만 사용자가 버튼 모양의 링크를 만들기 위해 Next.js<Link> 컴포넌트를 자식으로 넣어야 할 때, <button><a>...</a></button>와 같은 유효하지 않은 HTML 구조가 발생하게 된다.

이를 해결하기 위해 asChild 프롭을 도입한다. 이 프롭이 true일 경우, 부모 컴포넌트는 자신의 태그를 렌더링하지 않고 자식 요소와 하나로 합쳐지게 된다.

내부 동작 원리

Slot 컴포넌트는 단순히 자식을 렌더링하는 것을 넘어, 부모가 가진 props와 ref를 자식 요소에 병합(Merge)하는 핵심 역할을 수행한다.

  • Props Merging: 부모의 className, style, event handler 등을 자식의 속성과 충돌 없이 결합한다.
  • Compose Ref: 부모에게 전달된 ref가 최종적으로 렌더링되는 자식 DOM 요소에 정확히 꽂히도록 처리한다.

2. 웹 접근성(Accessibility)과 ARIA 패턴

"최고의 ARIA는 ARIA를 쓰지 않는 것"이라고 문서에 적혀있다. 즉, 시맨틱 태그를 잘 활용하는 것이 우선이다. 하지만 모달이나 아코디언처럼 화면 구조가 동적으로 변하는 컴포넌트에서는 ARIA 패턴이 필수적이다.

상태 관리와 비주얼 피드백

  • data-* Attribute: HTML의 data-state 속성(예: open, closed)을 활용해 컴포넌트의 현재 상태를 관리하면, CSS에서 상태에 따른 스타일을 선언적으로 정의하기 매우 편리하다.
  • ARIA 속성: aria-expanded, aria-haspopup, aria-controls 등을 적재적소에 배치하여, 스크린 리더 사용자에게도 현재 컴포넌트의 상태(열림/닫힘 여부 등)를 정확히 전달할 수 있다.

고민

추가적으로 공통 컴포넌트를 디자인할 때 사용하는 유저에게 얼만큼의 확장성 또는 자유도를 부여하는 것이 좋을까 라는 고민을 계속하게 되었다.

대표적으로 compound pattern을 사용할 경우 내부 컴포넌트들을 사용자가 자유롭게 조합하고 변형하여 사용할 수 있어서 다양한 상황에 유연하게 사용이 가능하다.

하지만 높은 자유도와 함께 특정 컴포넌트들 간의 부모-자식 관계가 유지됨을 파악하고 있어야 하고 각 컴포넌트가 요구하는 프롭 또한 파악하고 있어야하므로 초기 러닝커브가 존재한다.

이러한 높은 자유도를 조금 줄이는 대신 초기의 러닝커브를 낮추기 위해 외부에 노출되는 컴포넌트를 줄이는 방법도 생각해보았다.

예를 들어서 모달 컴포넌트에서 Portal, Overlay,Body컴포넌트를 하나로 묶어서 외부에 Main이라는 이름으로 노출시키는 방법

<Modal>
 <Modal.Portal>
   <Modal.Overlay />
   <Modal.Body>
     ...
   </Modal.Body>
 </Modal.Portal>
</Modal>
<Modal>
 <Modal.Main>
   ...
 </Modal.Main>
</Modal>

한계: 이 경우 Overlay만 따로 스타일을 고치고 싶거나, 특정 조건에서 Portal의 타겟을 변경하고 싶을 때 다시금 확장성 문제에 부딪히게 된다.

결국 "자주 사용되는 기본 조합(Preset)"을 제공하되, 세밀한 제어가 필요한 경우를 위해 "개별 원자 컴포넌트"를 모두 노출하는 방식이 안전한 방법이라고 느꼈다.

사용자 입장에서 어떤 컴포넌트가 원자 컴포넌트이고 프리셋 컴포넌트인지 구분하기 어렵기 때문에 이를 구분하는 네이밍 컨벤션의 도입은 필요하다고 생각한다.

마무리하며

직접 공통 컴포넌트를 작성해 보며 Radix UI나 Shadcn UI가 왜 그토록 많은 사랑을 받는지 다시 한번 느낄 수 있었다.

접근성과 확장성이라는 두 마리 토끼를 잡기 위해 그들이 얼마나 많은 고민을 거쳤는지 알 수 있었기 때문이다.

학습의 목적이라면 직접 구현해 보는 것이 더할 나위 없이 좋은 경험이지만, 실제 프로덕션에서는 잘 검증된 라이브러리 위에 서비스 특성에 맞는 디자인과 로직을 입히는 것이 가장 효율적이고 안전한 선택임을 배웠다.

참고자료

radix-slot

composeRef

radix-dialog