레이아웃 컴포넌트가 상태를 갖기 시작하면 거기서부터 무너진다. isOpen을 컴포넌트 안에서 useState로 들고, 탭 선택을 컴포넌트가 기억하면, 페이지는 그 상태를 다시 끌어내려고 props를 뚫고 제어권이 흩어진다.
그래서 desktop/components/layout/의 세 컴포넌트(PageHeader, SideDetailPanel, PageTableCard)는 한 규칙을 공유한다. 상태는 페이지 훅이 소유하고, 컴포넌트는 받아서 표시만 한다. 아래는 그 규칙을 슬롯, Controlled 패널, Compound Component로 구현한 방식이다.
1. PageHeader — 슬롯 패턴
interface PageHeaderProps { title: string; titleSuffix?: string; // 제목 오른쪽 보조 타이틀 subtitle?: string; actions?: ReactNode; // 우측 버튼 영역 className?: string;}items-baseline
title은 text-[30px], titleSuffix는 text-[22px]로 크기가 다르다.
<h1 className="flex flex-wrap items-baseline gap-x-2 ..."> {title} {titleSuffix && <span className="text-[22px] ...">{titleSuffix}</span>}</h1>items-center를 쓰면 두 텍스트가 중앙 정렬되어 어색하다. items-baseline은 폰트 베이스라인을 맞춰 자연스럽게 나란히 놓는다.
actions?: ReactNode — 슬롯 패턴
PageHeader가 버튼의 종류나 동작을 알아야 할 이유가 없다. ReactNode로 뚫어두면 사용처에서 자유롭게 채운다:
<PageHeader title="직원 관리" titleSuffix="Employees" actions={<Button onClick={openCreateDrawer}>직원 추가</Button>}/>2. SideDetailPanel — Controlled 슬라이드 패널
interface SideDetailPanelProps { isOpen: boolean; onClose: () => void; width?: string; // 기본값 'w-[560px]' ariaLabel?: string; children?: React.ReactNode;}열기/닫기: CSS transform
className={`fixed top-0 right-0 z-50 flex h-full ${width} flex-col bg-white shadow-2xl transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'}`}isOpen이 false일 때 translate-x-full로 화면 오른쪽으로 완전히 밀어낸다. DOM에서 제거하지 않고 위치만 이동시키기 때문에 transition-transform duration-300이 적용돼 슬라이드 효과가 나온다.
display: none이나 visibility: hidden으로 숨기면 CSS transition이 작동하지 않는다. 트랜지션은 DOM에 존재하는 요소의 상태 변화에만 적용된다.
백드롭과 패널의 관계
<> {isOpen && ( <div className="fixed inset-0 z-40 bg-black/20" onClick={onClose} aria-hidden /> )} <div className="fixed top-0 right-0 z-50 ..."> {children} </div></>백드롭(z-40)과 패널(z-50)이 형제로 나란히 있다. 백드롭은 isOpen일 때만 DOM에 존재하고, 패널은 항상 DOM에 있되 translate-x-full로 숨겨진다.
z-index 계층: 페이지 내용 < 백드롭(z-40) < 패널(z-50)
Escape 키 처리
useEffect(() => { if (!isOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey);}, [isOpen, onClose]);세 포인트: (1) 패널이 닫혀 있을 때는 리스너를 등록하지 않는다. (2) cleanup으로 리스너를 반드시 제거한다. 제거하지 않으면 패널이 닫힌 후에도 Escape가 계속 작동한다. (3) [isOpen, onClose]가 의존성에 있어야 isOpen이 변할 때 effect가 재실행된다.
상태를 직접 소유하지 않는다
isOpen을 useState로 내부에서 관리하지 않는다. 부모(훅)에서 관리하고, 이 컴포넌트는 표시와 닫기 이벤트만 담당한다. Controlled Component 원칙 그대로다.
3. PageTableCard — Compound Component 패턴
왜 단일 컴포넌트가 아닌가
테이블 페이지는 탭, 툴바, 테이블 헤더/바디, 페이지네이션 등 여러 구획으로 이루어진다. 하나의 컴포넌트로 만들면 props가 폭발한다:
// ❌ 하나의 컴포넌트로 만들었다면<BigTableComponent tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} toolbarRight={...} columns={columns} data={data} isLoading={isLoading} page={page} total={total} onPageChange={onPageChange} // .../>유연성도 없다. 어떤 페이지는 탭이 없고, 어떤 페이지는 툴바에 특별한 요소가 들어간다.
Compound Component 패턴
const PageTableCard = Object.assign(PageTableCardRoot, { TabsRow, TabsWrap, Tabs, Tab, TabsDivider, TabsExtra, Toolbar, ToolbarRight, SearchWrap, TableWrap, Table, Thead, Th, Tbody, Tr, Td, Loading, Empty, Pagination,});Object.assign으로 루트 컴포넌트에 서브 컴포넌트들을 프로퍼티로 붙인다. 사용처에서는 필요한 구획만 골라 조합한다:
<PageTableCard> <PageTableCard.TabsRow> <PageTableCard.TabsWrap> <PageTableCard.Tabs> <PageTableCard.Tab active={activeTab === 'all'} onClick={() => setActiveTab('all')}> 전체 </PageTableCard.Tab> </PageTableCard.Tabs> </PageTableCard.TabsWrap> </PageTableCard.TabsRow>
<PageTableCard.Toolbar> <PageTableCard.SearchWrap> <input placeholder="검색..." /> </PageTableCard.SearchWrap> <PageTableCard.ToolbarRight> <Button>새로 만들기</Button> </PageTableCard.ToolbarRight> </PageTableCard.Toolbar>
<PageTableCard.TableWrap> <PageTableCard.Table> <PageTableCard.Thead> <PageTableCard.Th>이름</PageTableCard.Th> </PageTableCard.Thead> <PageTableCard.Tbody> {items.map(item => ( <PageTableCard.Tr key={item.id} selected={item.id === selectedId} onClick={() => openDetail(item)}> <PageTableCard.Td>{item.name}</PageTableCard.Td> </PageTableCard.Tr> ))} </PageTableCard.Tbody> </PageTableCard.Table> </PageTableCard.TableWrap></PageTableCard>탭이 필요 없으면 PageTableCard.TabsRow를 생략하면 된다.
pageTableCardStyles — 디자인 토큰 분리
export const pageTableCardStyles = { card: 'bg-white border border-gray-200 rounded-[14px] ...', th: 'text-left px-6 py-3 text-xs font-medium ... uppercase bg-gray-50', td: 'px-6 py-4 text-sm leading-5 text-[#0a0a0a] align-middle border-b border-gray-200', // ...};Tailwind 클래스를 객체로 묶어 컴포넌트와 같은 PageTableCard.tsx에 두고 index.ts에서 함께 export한다. 다른 페이지에서 pageTableCardStyles.td를 가져와 동일한 셀 스타일을 재사용하고, 수정은 한 곳에서만 이루어진다.
Tr — selected + hover 상태 조합
function Tr({ selected, children, className, ...rest }) { const selectedCls = 'bg-[rgba(21,93,252,0.06)]'; const baseCls = 'border-b border-gray-200 transition-[background] duration-150 cursor-pointer hover:bg-gray-50'; const cls = selected ? `${baseCls} ${selectedCls} ${className ?? ''}` : `${baseCls} ${className ?? ''}`; return <tr className={cls.trim() || undefined} {...rest}>{children}</tr>;}selected prop 하나로 기본/선택 상태를 전환한다. 선택 배경은 rgba(21,93,252,0.06)이다. 탭 활성 색상(#155dfc)과 동일한 계열의 연한 파란색이다.
...rest 패턴
function Tr({ selected, children, className, ...rest } : { selected?: boolean; children: ReactNode; className?: string } & React.HTMLAttributes<HTMLTableRowElement>) { return <tr className={cls} {...rest}>{children}</tr>;}Tr이 onClick을 명시적으로 선언할 필요가 없다. React.HTMLAttributes<HTMLTableRowElement>에 이미 포함되어 있으므로 ...rest로 자동으로 통과된다:
<PageTableCard.Tr onClick={() => openDetail(item)} data-testid="employee-row">세 컴포넌트의 공통 원칙
| 원칙 | PageHeader | SideDetailPanel | PageTableCard |
|---|---|---|---|
| 상태를 직접 소유하지 않음 | ✓ (actions는 부모가 정의) | ✓ (isOpen은 부모가 관리) | ✓ (탭/선택은 부모가 관리) |
| 슬롯(ReactNode)으로 유연성 확보 | actions | children | 각 구획 자체가 children |
| 스타일만 담당, 데이터 로직 없음 | ✓ | ✓ | ✓ |
껍데기 컴포넌트가 상태를 갖기 시작하는 순간, props drilling과 제어권 분산이 시작된다. 상태는 페이지 훅에, 레이아웃은 컴포넌트에 둔다. 이 분리가 유지될 때 각 레이어가 단순하게 남는다.