모던 UI 개발에서 로직과 프레젠테이션 분리하기
Min-jun Kim
Dev Intern · Leapcell

UI 개발에서 분리의 힘
빠르게 진화하는 프론트엔드 개발 환경에서 강력하고 접근 가능하며 고도로 맞춤 설정 가능한 사용자 인터페이스를 구축하는 것은 종종 상당한 과제를 안겨줍니다. 개발자들은 미학적 디자인, 기능적 로직, 다양한 애플리케이션 컨텍스트 전반에 걸친 컴포넌트의 적응성 사이의 섬세한 균형을 맞추기 위해 종종 씨름합니다. 이러한 노력은 핵심적인 긴장감을 강조합니다. 유용성을 제한하는 가정을 내장하지 않으면서 강력하고 유연한 UI 컴포넌트를 어떻게 만들 수 있을까요? 이 중요한 질문은 Radix UI, Headless UI, TanStack Table과 같은 선구적인 라이브러리에서 채택한 강력한 디자인 철학의 출현으로 이어졌습니다. 바로 로직과 뷰의 신중한 분리입니다. 이 접근 방식은 개발을 간소화할 뿐만 아니라 전례 없는 수준의 유연성, 접근성 및 유지보수성을 제공합니다.
헤드리스 접근 방식 이해
Radix UI, Headless UI, TanStack Table의 핵심에는 "헤드리스" 컴포넌트 패턴이 있습니다. 더 깊이 들어가기 전에 몇 가지 핵심 개념을 살펴보겠습니다.
- 헤드리스 컴포넌트(Headless Component): UI 요소에 대한 모든 로직, 상태 관리 및 접근성 기능을 제공하지만 자체적으로 시각적 출력을 렌더링하지는 않습니다. 개발자가 "자신만의" UI를 가져올 수 있도록 훅(hook) 또는 렌더 프롭(render prop) (또는 유사한 메커니즘)을 노출합니다.
 - 로직(Logic) 또는 동작(Behavior): 드롭다운이 열려 있는지 닫혀 있는지와 같은 상태 관리, 체크박스 토글, 데이터 필터링과 같은 상호 작용 처리, 키보드 탐색 및 접근성 속성과 같은 컴포넌트의 기능적 측면을 참조합니다.
 - 뷰(View) 또는 프레젠테이션(Presentation): 컴포넌트의 시각적 표현, 즉 스타일, 구조 및 전반적인 모양을 참조합니다. 여기에는 HTML 요소, CSS 및 시각적 피드백이 포함됩니다.
 - 접근성(Accessibility, A11Y): 웹 콘텐츠와 기능이 장애인을 포함한 모든 사람이 사용할 수 있고 작동할 수 있도록 보장합니다. 헤드리스 컴포넌트는 종종 모범 사례 접근성 기능을 기본적으로 포함합니다.
 
헤드리스 접근 방식의 기본 전제는 "뷰"를 강요하지 않고 UI 컴포넌트의 "두뇌"를 제공하는 것입니다. 이는 미리 정의된 스타일과 구조를 함께 제공하여 복잡한 재정의 없이는 심층적인 사용자 지정이 번거롭거나 불가능한 기존 컴포넌트 라이브러리와 대조됩니다.
헤드리스 원칙 실제 적용
주요 라이브러리의 예시를 통해 이를 설명해 보겠습니다.
Headless UI: 비의존적인 컴포넌트의 기초
Tailwind Labs의 Headless UI는 좋은 예입니다. Dropdown 컴포넌트를 생각해 보세요. 기존 라이브러리는 특정 버튼, 특정 목록 및 특정 스타일을 렌더링하는 <Dropdown> 컴포넌트를 제공할 수 있습니다. 다른 버튼 스타일이나 목록에 대한 사용자 지정 애니메이션을 원하면 컴포넌트와 충돌하게 됩니다.
Headless UI는 렌더 프롭 또는 훅을 통해 Transition, Menu, Dialog와 같은 기본 요소를 제공합니다. Headless UI를 사용한 Menu(드롭다운) 작동 방식은 다음과 같습니다.
import { Menu } from '@headlessui/react'; function MyCustomDropdown() { return ( <Menu> {({ open }) => ( <> <Menu.Button className="my-custom-button"> Options <span aria-hidden="true">{open ? '▲' : '▼'}</span> </Menu.Button> <Menu.Items className="my-custom-menu-items"> <Menu.Item as="a" href="/account"> {({ active }) => ( <div className={`${active ? 'bg-blue-500 text-white' : 'text-gray-900'} group flex w-full items-center px-4 py-2 text-sm`}> Account settings </div> )} </Menu.Item> <Menu.Item as="button" onClick={() => console.log('Signed out!')}> {({ active }) => ( <div className={`${active ? 'bg-blue-500 text-white' : 'text-gray-900'} group flex w-full items-center px-4 py-2 text-sm`}> Sign out </div> )} </Menu.Item> </Menu.Items> </> )} </Menu> ); }
Menu.Button, Menu.Items, Menu.Item이 로직(예: 클릭, 키보드 탐색, aria-* 속성, open 상태 관리)을 담당하지만, 스타일링과 정확한 HTML 구조(div, a, button)는 사용자가 제공한다는 점에 주목하세요. open 및 active 상태가 노출되어 조건부 스타일링이 가능합니다.
Radix UI: 포괄적인 컴포넌트 기본 요소
Radix UI는 접근 가능한 컴포넌트 기본 요소를 구축하는 데 유사하지만 종종 더 포괄적인 접근 방식을 취합니다. AlertDialog, DropdownMenu, Popover, RadioGroup, Slider 등과 같은 저수준 기본 요소에 중점을 둡니다.
다음은 Radix UI AlertDialog 스니펫입니다.
import * as AlertDialog from '@radix-ui/react-alert-dialog'; function DeleteConfirmationDialog() { return ( <AlertDialog.Root> <AlertDialog.Trigger asChild> <button className="text-red-500">Delete Account</button> </AlertDialog.Trigger> <AlertDialog.Portal> <AlertDialog.Overlay className="bg-blackA6 data-[state=open]:animate-overlayShow fixed inset-0" /> <AlertDialog.Content className="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-[6px] bg-white p-[25px] shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none"> <AlertDialog.Title className="text-mauve12 m-0 text-[17px] font-medium">Are you absolutely sure?</AlertDialog.Title> <AlertDialog.Description className="text-mauve11 mt-4 mb-5 text-[15px] leading-normal"> This action cannot be undone. This will permanently delete your account and remove your data from our servers. </AlertDialog.Description> <div className="flex justify-end gap-[25px]"> <AlertDialog.Cancel asChild> <button className="text-mauve11 bg-mauve4 hover:bg-mauve5 focus:shadow-mauve7 inline-flex h-[35px] items-center justify-center rounded-[4px] px-[15px] font-medium leading-none outline-none focus:shadow-[0_0_0_2px]">Cancel</button> </AlertDialog.Cancel> <AlertDialog.Action asChild> <button className="text-red-600 bg-red-100 hover:bg-red-200 focus:shadow-red7 inline-flex h-[35px] items-center justify-center rounded-[4px] px-[15px] font-medium leading-none outline-none focus:shadow-[0_0_0_2px]">Yes, delete account</button> </AlertDialog.Action> </div> </AlertDialog.Content> </AlertDialog.Portal> </AlertDialog.Root> ); }
Radix UI 컴포넌트는 상태, 포커스, 키보드 탐색 및 aria-* 속성(예: aria-modal, aria-labelledby, aria-describedby)을 자동으로 관리합니다. AlertDialog.Root, AlertDialog.Trigger, AlertDialog.Portal, AlertDialog.Overlay, AlertDialog.Content, AlertDialog.Title, AlertDialog.Description, AlertDialog.Cancel, AlertDialog.Action 구조는 대화 상자에 포함되는 요소와 각 요소의 역할에 대한 선언적 API를 제공합니다. 즉, 원하는 스타일만 적용하면 됩니다(예: className="...").
TanStack Table: 궁극의 데이터 그리드 엔진
TanStack Table(이전 React Table)은 이 헤드리스 철학을 복잡한 UI 컴포넌트에 적용한 가장 명시적인 예시일 수 있습니다. <table>, <tr>, <td> 요소를 전혀 렌더링하지 않습니다. 대신, 열 정의, 행 데이터, 필터링, 정렬, 페이징, 그룹화, 확장/축소, 행 선택, 가상화된 행 등 필요한 모든 테이블 상태를 계산하기 위한 강력한 API(useReactTable과 같은 훅을 통해)를 제공합니다.
계산된 모든 테이블 상태와 도우미 함수가 포함된 개체를 반환합니다. 실제 HTML 테이블 마크업을 렌더링하는 것은 전적으로 귀하에게 달려 있습니다.
import { useReactTable, getCoreRowModel } from '@tanstack/react-table'; function MyDataTable({ data, columns }) { const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), // 여기에 정렬, 필터링, 페이징과 같은 다른 플러그인을 추가하세요 }); return ( <table> <thead> {table.getHeaderGroups().map(headerGroup => ( <tr key={headerGroup.id}> {headerGroup.headers.map(header => ( <th key={header.id} colSpan={header.colSpan}> {header.isPlaceholder ? null : ( <div> {header.column.columnDef.header} {/* 여기에 정렬 표시기를 추가하세요 */} </div> )} </th> ))} </tr> ))} </thead> <tbody> {table.getRowModel().rows.map(row => ( <tr key={row.id}> {row.getVisibleCells().map(cell => ( <td key={cell.id}> {cell.getValue()} </td> ))} </tr> ))} </tbody> </table> ); }
이 단순화된 예시에서 table.getHeaderGroups(), table.getRowModel().rows, header.column.columnDef.header, cell.getValue()는 모든 데이터와 계산된 상태를 제공합니다. <table>, <thead>, <tbody>, <th>, <td> 요소와 그 스타일을 제공합니다. 즉, 일반 HTML 테이블, 수천 개의 행이 있는 가상화된 테이블, 사용자 지정 셀 렌더러가 있는 그리드 또는 데이터를 시각적으로 완전히 다르게 표현하는 것까지 모두 동일한 TanStack Table 로직으로 구동할 수 있습니다.
이 접근 방식의 이점
- 최대의 유연성과 사용자 지정: 개발자는 UI를 완벽하게 제어할 수 있어 컴포넌트 라이브러리의 기본값과 충돌하지 않고 모든 브랜드 또는 테마에 맞는 픽셀 단위 디자인이 가능합니다.
 - 비구속적 스타일링: CSS 모듈, Styled Components, Tailwind CSS, 일반 CSS 등 어떤 스타일링 솔루션과도 원활하게 작동합니다.
 - 향상된 접근성: 강력하고 검증된 접근성 기능(키보드 상호 작용, ARIA 속성)을 기본 제공함으로써 이러한 라이브러리는 개발자가 이를 올바르게 구현해야 하는 부담을 크게 줄여줍니다.
 - 성능 향상(잠재적): 기본 DOM 요소를 렌더링하지 않음으로써 이러한 라이브러리는 종종 더 작은 번들과 잠재적으로 더 빠른 렌더링으로 이어지며, 필요한 마크업만 생성됩니다.
 - 장기적인 유지보수성: 로직과 프레젠테이션을 분리하면 시각적 테마 또는 프레임워크의 변경이 기본 컴포넌트 로직에 미치는 영향을 줄이고 그 반대도 마찬가지입니다. 이는 더 안정적이고 유지보수하기 쉬운 코드베이스로 이어집니다.
 - 더 나은 사용자 경험: 시각적 변형에 관계없이 애플리케이션 전반에 걸쳐 일관되고 접근 가능한 컴포넌트 동작.
 
결론
Radix UI, Headless UI, TanStack Table의 디자인 철학은 프론트엔드 개발의 중요한 진화를 보여줍니다. "무엇(로직 및 동작)"과 "어떻게(시각적 프레젠테이션)"를 세심하게 분리함으로써 이러한 라이브러리는 개발자가 개발 속도 또는 유지보수성을 희생하지 않고도 매우 유연하고 접근 가능하며 사용자 지정 가능한 사용자 인터페이스를 구축할 수 있도록 지원합니다. 이 헤드리스 패러다임은 진정한 컴포넌트 재사용성과 적응성을 옹호하며 미래 지향적인 웹 애플리케이션을 위한 강력한 기반을 제공합니다. 이 접근 방식을 채택한다는 것은 진정으로 강력하고 보편적으로 적응 가능한 컴포넌트를 제작하는 것을 의미합니다.

