From 3f1287f54880ccbcfe977d933eeb6e9790127b0b Mon Sep 17 00:00:00 2001 From: monam2 Date: Sat, 2 May 2026 00:07:41 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vitepress/config.mts | 3 + docs/component-design/compound-components.md | 118 ++++++++++++++++ docs/component-design/headless-ui.md | 124 +++++++++++++++++ docs/component-design/index.md | 20 ++- .../component-design/single-responsibility.md | 130 ++++++++++++++++++ 5 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 docs/component-design/compound-components.md create mode 100644 docs/component-design/headless-ui.md create mode 100644 docs/component-design/single-responsibility.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 074b9ce..7c6a1f5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -38,6 +38,9 @@ export default defineConfig({ text: '컴포넌트 설계', items: [ { text: '개요', link: '/component-design/' }, + { text: '단일 책임 원칙으로 컴포넌트 경계 세우기', link: '/component-design/single-responsibility' }, + { text: 'Compound Components로 복합 UI 설계하기', link: '/component-design/compound-components' }, + { text: 'Headless UI 패턴으로 로직과 표현 분리하기', link: '/component-design/headless-ui' }, ], }, ], diff --git a/docs/component-design/compound-components.md b/docs/component-design/compound-components.md new file mode 100644 index 0000000..b0d6a9d --- /dev/null +++ b/docs/component-design/compound-components.md @@ -0,0 +1,118 @@ +# Compound Components로 복합 UI 설계하기 + +Compound Components는 여러 하위 컴포넌트를 하나의 UI 단위로 묶는 패턴이다. 부모 컴포넌트는 함께 쓰는 상태와 동작을 관리하고, 하위 컴포넌트는 필요한 값만 꺼내 각자의 역할을 수행한다. + +## 규칙: 관련된 하위 컴포넌트를 맥락으로 단위로 묶으세요 + +`Tabs`, `Accordion`, `Select`, `Menu`, `Modal`처럼 여러 파트가 하나의 상태를 기준으로 함께 움직이는 UI는 하나의 거대한 컴포넌트보다 역할이 나뉜 하위 컴포넌트 구조가 더 적합하다. 호출하는 쪽은 구조를 선언적으로 배치하고, 내부 구현은 파트 간 협력을 책임진다. + +:::tabs +== Bad + +```tsx +function Tabs({ + items, + activeValue, + layout = "horizontal", + showDivider = false, + renderTrigger, + renderPanel, +}: TabsProps) { + return ( +
+ {items.map((item) => ( +
+ {renderTrigger ? renderTrigger(item) : item.label} + {activeValue === item.value && ( +
{renderPanel ? renderPanel(item) : item.content}
+ )} +
+ ))} +
+ ); +} +``` + +== Good + +```tsx + + + 프로필 + 보안 + + + 프로필 내용 + 보안 내용 + +``` + +::: + +Bad 예시는 `Tabs` 하나가 데이터, 배치 옵션, 트리거 렌더링, 패널 렌더링을 모두 받는다. Good 예시는 `Tabs.List`, `Tabs.Trigger`, `Tabs.Panel`이 각각 자기 역할을 드러내고, 사용하는 쪽이 필요한 구조를 직접 조립한다. + +## 규칙: 함께 쓰는 상태는 부모에서 관리하세요 + +Compound Components의 핵심은 단순히 `children`을 받는 것이 아니라 관련 하위 컴포넌트들이 같은 상태를 함께 쓴다는 점이다. 부모가 선택된 값 같은 상태를 관리하고, 하위 컴포넌트는 context를 통해 필요한 값과 변경 함수를 꺼내 쓴다. + +```tsx +const TabsContext = createContext(null); + +function Tabs({ defaultValue, children }: TabsProps) { + const [value, setValue] = useState(defaultValue); + + return ( + + {children} + + ); +} + +function TabsTrigger({ value, children }: TabsTriggerProps) { + const tabs = useTabsContext(); + const selected = tabs.value === value; + + return ( + + ); +} +``` + +중간 컴포넌트가 `activeValue`, `onChange`를 계속 전달하지 않아도 되므로 각 파트의 역할이 선명해진다. + +## 규칙: 자식 요소를 강제로 변형하지 마세요 + +`Children`과 `cloneElement`로 자식에게 props를 주입하는 방식도 가능하지만 데이터 흐름을 추적하기 어렵고 구조 변화에 약하다. 기본 구현은 context 기반으로 두고, 자식 요소를 직접 변형하는 방식은 예외적으로만 사용한다. + +## 빠른 참조 + +| 상황 | 권장 설계 | +| ----------------------------------------- | ---------------------------------------------------------------- | +| 여러 파트가 하나의 상태를 공유함 | Compound Components + context | +| 호출자가 UI 구조를 선언적으로 배치해야 함 | `Tabs.List`, `Tabs.Trigger`, `Tabs.Panel` 같은 하위 컴포넌트 API | +| 중간 컴포넌트가 props를 전달만 함 | 부모 context로 공유 상태 제공 | +| 자식에게 props를 자동 주입하고 싶음 | `cloneElement`보다 context 우선 | + +## 주의사항 + +- 관련 파트가 실제로 협력할 때만 적용한다. +- 독립적인 컴포넌트를 억지로 하나의 사용 단위로 묶지 않는다. +- 이 문서는 generic `children`, render props, slot 선택 기준을 다루지 않는다. +- 상태를 어디에 둘지에 대한 일반 원칙은 상태 관리 문서에서 다룬다. +- `Tabs`, `Accordion`, `Menu`, `Modal`에는 ARIA 역할과 키보드 상호작용이 함께 따라와야 한다. + +## 참고 자료 + +- [React 공식 문서 - Passing Props to a Component](https://react.dev/learn/passing-props-to-a-component) +- [React 공식 문서 - Passing Data Deeply with Context](https://react.dev/learn/passing-data-deeply-with-context) +- [React 공식 문서 - Children](https://react.dev/reference/react/Children) +- [React 공식 문서 - cloneElement](https://react.dev/reference/react/cloneElement) +- [Patterns.dev - Compound Pattern](https://www.patterns.dev/react/compound-pattern/) +- [Epic React - Compound Components: Truly Flexible React APIs](https://www.epicreact.dev/compound-components-truly-flexible-react-apis-5nu15) +- [WAI-ARIA APG Patterns](https://www.w3.org/WAI/ARIA/apg/patterns/) diff --git a/docs/component-design/headless-ui.md b/docs/component-design/headless-ui.md new file mode 100644 index 0000000..28311d9 --- /dev/null +++ b/docs/component-design/headless-ui.md @@ -0,0 +1,124 @@ +# Headless UI 패턴으로 로직과 표현 분리하기 + +Headless UI는 동작, 접근성, 상태 관리는 컴포넌트가 맡고 마크업과 스타일은 사용하는 쪽이 결정하게 하는 설계다. 같은 동작을 여러 디자인으로 재사용해야 할 때, 컴포넌트를 "기능이 있는 뼈대"와 "표현"으로 나누어 확장성을 확보한다. + +## 규칙: 동작과 마크업을 한 컴포넌트에 묶지 마세요 + +컴포넌트 내부에 색상, 간격, 레이아웃, 그림자 같은 시각적 결정이 고정되면 다른 화면에서 같은 동작을 재사용하기 어렵다. Headless UI는 열림/닫힘, 선택, 포커스, 키보드 조작 같은 동작만 제공하고 표현은 사용하는 쪽에 맡긴다. + +:::tabs == Bad + +```tsx +function UserMenu() { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( +
+ + 내 정보 + + +
+ )} +
+ ); +} +``` + +== Good + +```tsx +function UserMenu() { + return ( + + + 프로필 + + + + + + 내 정보 + + + + 로그아웃 + + + + ); +} +``` + +::: + +Bad 예시는 메뉴의 동작과 디자인이 `UserMenu` 안에 강하게 묶여 있다. Good 예시는 `Menu` 계열 컴포넌트가 열림/닫힘, 포커스, 키보드 조작, ARIA 계약을 담당하고, 사용하는 쪽이 `className`과 마크업을 결정한다. + +## 규칙: 상태는 스타일링 가능한 레이어로 노출하세요 + +Headless UI는 보이지 않는 컴포넌트가 아니다. 상태를 외부에서 스타일링할 수 있도록 `data-state`, `data-selected`, `data-disabled` 같은 표면을 제공해야 한다. 상태가 밖으로 드러나야 사용하는 쪽에서 디자인만 바꿔도 동작은 그대로 유지된다. + +```tsx +function MenuTrigger({ open, ...props }: MenuTriggerProps) { + return ( + + + ); +} +``` + +== Good + +```tsx +function ProductCard({ product }: { product: Product }) { + return ( +
+ + + + +
+ ); +} + +function ProductThumbnail({ product }: { product: Product }) { + return {product.name}; +} + +function ProductSummary({ product }: { product: Product }) { + return ( + <> +

{product.name}

+

{product.description}

+ + ); +} + +function ProductPrice({ + price, + discountRate, +}: { + price: number; + discountRate: number; +}) { + const discountedPrice = price * (1 - discountRate); + + return ( +
+ {discountRate > 0 && {discountRate * 100}% 할인} +

{discountedPrice.toLocaleString()}원

+
+ ); +} + +function ProductAction({ stock }: { stock: number }) { + const isSoldOut = stock === 0; + + return ( + + ); +} +``` + +::: + +`ProductCard`는 상품 카드의 전체 배치만 담당한다. 썸네일 표시 방식이 바뀌면 `ProductThumbnail`, 가격 정책이 바뀌면 `ProductPrice`, 품절 버튼 문구가 바뀌면 `ProductAction`만 수정하면 된다. + +## 규칙: 컴포넌트 설명에 "그리고"가 많아지면 분리하세요 + +컴포넌트를 설명할 때 "썸네일을 그리고, 가격을 계산하고, 버튼 상태를 정하고, 카드 레이아웃을 렌더링한다"처럼 `그리고(and)`가 반복된다면 책임이 섞였을 가능성이 크다. 다만 무조건 파일을 많이 나누는 것이 목표는 아니다. 책임 분리는 변경 이유를 기준으로 판단한다. + +- **같이 두어도 되는 경우**: 같은 UI 조각을 표현하기 위한 마크업, 스타일, 간단한 조건부 렌더링 +- **분리해야 하는 경우**: 카드 레이아웃과 가격 정책, 썸네일 표시와 액션 버튼, 리스트 배치와 아이템 표현 +- **아직 분리하지 않아도 되는 경우**: 컴포넌트가 작고 변경 이유가 하나이며, 분리했을 때 오히려 추적해야 할 파일만 늘어나는 경우 + +## 빠른 참조 + +| 코드 냄새 | 개선 방법 | +| ---------------------------------------- | -------------------------------------------- | +| 컴포넌트 이름보다 내부 역할이 훨씬 많음 | 변경 이유가 다른 영역을 하위 컴포넌트로 분리 | +| 한 영역 수정이 주변 JSX까지 함께 건드림 | 해당 영역을 독립 컴포넌트로 분리 | +| 특정 정책 계산이 마크업 사이에 섞여 있음 | 정책을 표현하는 작은 컴포넌트로 분리 | +| 컴포넌트를 한 문장으로 설명하기 어려움 | 변경 이유가 다른 책임을 별도 컴포넌트로 분리 | + +## 주의사항 + +- 상태를 어디에 둘지는 상태 관리 문서에서 다룬다. +- 커스텀 훅 추출 기준은 훅 설계 문서에서 다룬다. +- props 네이밍과 `children`, render props, slot 선택 기준은 Props/인터페이스 문서에서 다룬다. +- 이 문서에서는 컴포넌트의 변경 이유와 책임 경계만 다룬다. + +## 체크리스트 + +- 이 컴포넌트를 한 문장으로 설명할 수 있나요? +- 변경 요청자가 달라지는 책임이 섞여 있지 않나요? 예: 디자이너의 UI 변경, 백엔드의 API 변경, 기획자의 정책 변경 +- 컴포넌트 내부의 주요 영역들이 서로 다른 이유로 변경되지 않나요? +- 분리 후 각 컴포넌트 이름이 자기 책임을 명확히 드러내나요? +- 분리했을 때 오히려 파일 이동만 늘어나는 과한 추상화는 아닌가요? + +## 참고 자료 + +- [React 공식 문서 - Thinking in React](https://react.dev/learn/thinking-in-react) +- [Single Responsibility Principle in React: The Art of Component Focus](https://cekrem.github.io/posts/single-responsibility-principle-in-react/) +- [[번역] 리액트에서의 단일 책임 원칙: 컴포넌트 집중의 기술](https://velog.io/@eunbinn/single-responsibility-principle-in-react)