2025.03.13 - [Programming Language/React] - [React] Design Pattern in React (5) - Singleton
[React] Design Pattern in React (5) - Singleton
2025.03.12 - [Programming Language/React] - [React] Design Pattern in React (4) - Proxy 이전 글에 이어,이번엔 싱글톤(Singleton) 패턴의 자주 쓰이는 용례들을 적어보고자 한다. 싱글톤 패턴(Singleton)싱글톤 패턴은
juniortunar.tistory.com
이전 글에 이어,
이번엔 컴파운드(Compound) 패턴의 자주 쓰이는 용례들을 적어보고자 한다.
컴파운드 패턴(Compound)
컴파운드 패턴은 여러 컴포넌트가 함께 작동하여 하나의 복합적인 기능을 제공하는 디자인 패턴입니다. 각 컴포넌트는 독립적이지만 서로 관련되어 있으며, 부모 컴포넌트가 상태와 로직을 관리하고 자식 컴포넌트들이 이를 활용합니다. React에서는 UI 요소의 구성과 재사용성을 높이는 데 유용하게 활용됩니다.
1. 기본 구조
React에서의 컴파운드 패턴 기본 구현:
// app/components/Accordion/index.tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
// 컨텍스트 타입 정의
type AccordionContextType = {
expandedIndex: number | null;
toggleItem: (index: number) => void;
};
// 컨텍스트 생성
const AccordionContext = createContext<AccordionContextType | undefined>(undefined);
// 컨텍스트 훅
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion compound components must be used within an Accordion');
}
return context;
}
// 메인 컴포넌트
interface AccordionProps {
children: ReactNode;
defaultIndex?: number | null;
}
function Accordion({ children, defaultIndex = null }: AccordionProps) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(defaultIndex);
const toggleItem = (index: number) => {
setExpandedIndex(prevIndex => (prevIndex === index ? null : index));
};
const value = { expandedIndex, toggleItem };
return (
<AccordionContext.Provider value={value}>
<div className="border rounded-lg divide-y">{children}</div>
</AccordionContext.Provider>
);
}
// 아이템 컴포넌트
interface AccordionItemProps {
children: ReactNode;
index: number;
}
function AccordionItem({ children, index }: AccordionItemProps) {
return (
<div className="accordion-item">
{children}
</div>
);
}
// 헤더 컴포넌트
interface AccordionHeaderProps {
children: ReactNode;
index: number;
}
function AccordionHeader({ children, index }: AccordionHeaderProps) {
const { expandedIndex, toggleItem } = useAccordion();
const isExpanded = expandedIndex === index;
return (
<button
className="w-full p-4 text-left flex justify-between items-center hover:bg-gray-50"
onClick={() => toggleItem(index)}
aria-expanded={isExpanded}
>
<span className="font-medium">{children}</span>
<span className="transform transition-transform duration-200">
{isExpanded ? '▲' : '▼'}
</span>
</button>
);
}
// 패널 컴포넌트
interface AccordionPanelProps {
children: ReactNode;
index: number;
}
function AccordionPanel({ children, index }: AccordionPanelProps) {
const { expandedIndex } = useAccordion();
const isExpanded = expandedIndex === index;
if (!isExpanded) return null;
return (
<div className="p-4 bg-gray-50">
{children}
</div>
);
}
// 컴파운드 컴포넌트 구성
Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Panel = AccordionPanel;
export default Accordion;
// app/components/AccordionExample.tsx
'use client';
import Accordion from './Accordion';
export default function AccordionExample() {
return (
<div className="max-w-md mx-auto my-8">
<h2 className="text-2xl font-bold mb-4">FAQ</h2>
<Accordion defaultIndex={0}>
<Accordion.Item index={0}>
<Accordion.Header index={0}>
What is React 19?
</Accordion.Header>
<Accordion.Panel index={0}>
React 19 is the latest version of React that introduces new features like
improved server components, automatic memoization, and better performance.
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item index={1}>
<Accordion.Header index={1}>
What is Next.js 15?
</Accordion.Header>
<Accordion.Panel index={1}>
Next.js 15 is a React framework that provides features like server-side
rendering, static site generation, and API routes with improved performance
and developer experience.
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item index={2}>
<Accordion.Header index={2}>
What is the Compound Pattern?
</Accordion.Header>
<Accordion.Panel index={2}>
The Compound Pattern is a design pattern where multiple components work
together to create a cohesive UI element. It provides flexibility and
maintains semantic structure while sharing state between components.
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</div>
);
}
2. 탭 컴포넌트
컴파운드 패턴을 활용한 탭 컴포넌트:
// app/components/Tabs/index.tsx
'use client';
import { createContext, useContext, useState, ReactNode, useId } from 'react';
// 컨텍스트 타입 정의
type TabsContextType = {
activeTab: string;
setActiveTab: (id: string) => void;
};
// 컨텍스트 생성
const TabsContext = createContext<TabsContextType | undefined>(undefined);
// 컨텍스트 훅
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs compound components must be used within a Tabs component');
}
return context;
}
// 메인 컴포넌트
interface TabsProps {
children: ReactNode;
defaultTab?: string;
}
function Tabs({ children, defaultTab }: TabsProps) {
// 첫 번째 탭을 기본값으로 사용하기 위한 상태 초기화 지연
const [activeTab, setActiveTab] = useState<string>(defaultTab || '');
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs-container">{children}</div>
</TabsContext.Provider>
);
}
// 탭 목록 컴포넌트
interface TabListProps {
children: ReactNode;
}
function TabList({ children }: TabListProps) {
return (
<div className="flex border-b">
{children}
</div>
);
}
// 탭 버튼 컴포넌트
interface TabProps {
children: ReactNode;
id: string;
}
function Tab({ children, id }: TabProps) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === id;
return (
<button
className={`py-2 px-4 font-medium focus:outline-none ${
isActive
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab(id)}
role="tab"
aria-selected={isActive}
id={`tab-${id}`}
aria-controls={`panel-${id}`}
>
{children}
</button>
);
}
// 탭 패널 컨테이너
interface TabPanelsProps {
children: ReactNode;
}
function TabPanels({ children }: TabPanelsProps) {
return <div className="py-4">{children}</div>;
}
// 탭 패널 컴포넌트
interface TabPanelProps {
children: ReactNode;
id: string;
}
function TabPanel({ children, id }: TabPanelProps) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return (
<div
role="tabpanel"
id={`panel-${id}`}
aria-labelledby={`tab-${id}`}
>
{children}
</div>
);
}
// 컴파운드 컴포넌트 구성
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
export default Tabs;
// app/components/TabsExample.tsx
'use client';
import Tabs from './Tabs';
export default function TabsExample() {
return (
<div className="max-w-2xl mx-auto my-8">
<h2 className="text-2xl font-bold mb-4">Product Information</h2>
<Tabs defaultTab="description">
<Tabs.List>
<Tabs.Tab id="description">Description</Tabs.Tab>
<Tabs.Tab id="specifications">Specifications</Tabs.Tab>
<Tabs.Tab id="reviews">Reviews</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel id="description">
<div className="prose">
<h3 className="text-lg font-medium mb-2">Product Description</h3>
<p>
This is an amazing product that will revolutionize the way you work.
Built with the latest technology and designed with user experience in mind,
it's the perfect addition to your toolkit.
</p>
<p className="mt-2">
Features include seamless integration with your existing workflow,
customizable settings, and robust performance even under heavy loads.
</p>
</div>
</Tabs.Panel>
<Tabs.Panel id="specifications">
<div className="prose">
<h3 className="text-lg font-medium mb-2">Technical Specifications</h3>
<ul className="list-disc pl-5 space-y-1">
<li>Dimensions: 10" x 8" x 2"</li>
<li>Weight: 1.5 lbs</li>
<li>Battery Life: 12 hours</li>
<li>Connectivity: Bluetooth 5.0, Wi-Fi 6</li>
<li>Storage: 256GB SSD</li>
<li>Processor: Quad-core 2.4GHz</li>
</ul>
</div>
</Tabs.Panel>
<Tabs.Panel id="reviews">
<div className="space-y-4">
<h3 className="text-lg font-medium mb-2">Customer Reviews</h3>
<div className="border-b pb-4">
<div className="flex items-center mb-1">
<div className="text-yellow-400">★★★★★</div>
<span className="ml-2 font-medium">Amazing product!</span>
</div>
<p className="text-gray-600">
I've been using this for a month now and it has completely transformed my workflow.
Highly recommended!
</p>
</div>
<div className="border-b pb-4">
<div className="flex items-center mb-1">
<div className="text-yellow-400">★★★★☆</div>
<span className="ml-2 font-medium">Great, but could be better</span>
</div>
<p className="text-gray-600">
The product works well for most tasks, but I found the battery life to be slightly
less than advertised. Otherwise, it's a solid purchase.
</p>
</div>
</div>
</Tabs.Panel>
</Tabs.Panels>
</Tabs>
</div>
);
}
3. 드롭다운 메뉴
컴파운드 패턴을 활용한 드롭다운 메뉴:
// app/components/Dropdown/index.tsx
'use client';
import { createContext, useContext, useState, useRef, useEffect, ReactNode } from 'react';
// 컨텍스트 타입 정의
type DropdownContextType = {
isOpen: boolean;
toggleDropdown: () => void;
closeDropdown: () => void;
};
// 컨텍스트 생성
const DropdownContext = createContext<DropdownContextType | undefined>(undefined);
// 컨텍스트 훅
function useDropdown() {
const context = useContext(DropdownContext);
if (!context) {
throw new Error('Dropdown compound components must be used within a Dropdown');
}
return context;
}
// 메인 컴포넌트
interface DropdownProps {
children: ReactNode;
}
function Dropdown({ children }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const toggleDropdown = () => setIsOpen(prev => !prev);
const closeDropdown = () => setIsOpen(false);
// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
closeDropdown();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<DropdownContext.Provider value={{ isOpen, toggleDropdown, closeDropdown }}>
<div className="relative inline-block text-left" ref={dropdownRef}>
{children}
</div>
</DropdownContext.Provider>
);
}
// 트리거 컴포넌트
interface DropdownTriggerProps {
children: ReactNode;
}
function DropdownTrigger({ children }: DropdownTriggerProps) {
const { toggleDropdown, isOpen } = useDropdown();
return (
<div onClick={toggleDropdown} aria-expanded={isOpen} aria-haspopup="true">
{children}
</div>
);
}
// 메뉴 컴포넌트
interface DropdownMenuProps {
children: ReactNode;
}
function DropdownMenu({ children }: DropdownMenuProps) {
const { isOpen } = useDropdown();
if (!isOpen) return null;
return (
<div className="absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div className="py-1" role="menu" aria-orientation="vertical">
{children}
</div>
</div>
);
}
// 아이템 컴포넌트
interface DropdownItemProps {
children: ReactNode;
onClick?: () => void;
}
function DropdownItem({ children, onClick }: DropdownItemProps) {
const { closeDropdown } = useDropdown();
const handleClick = () => {
if (onClick) onClick();
closeDropdown();
};
return (
<button
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
role="menuitem"
onClick={handleClick}
>
{children}
</button>
);
}
// 구분선 컴포넌트
function DropdownDivider() {
return <hr className="my-1 border-gray-200" />;
}
// 컴파운드 컴포넌트 구성
Dropdown.Trigger = DropdownTrigger;
Dropdown.Menu = DropdownMenu;
Dropdown.Item = DropdownItem;
Dropdown.Divider = DropdownDivider;
export default Dropdown;
// app/components/DropdownExample.tsx
'use client';
import Dropdown from './Dropdown';
export default function DropdownExample() {
const handleEdit = () => {
console.log('Edit clicked');
};
const handleDuplicate = () => {
console.log('Duplicate clicked');
};
const handleArchive = () => {
console.log('Archive clicked');
};
const handleDelete = () => {
console.log('Delete clicked');
};
return (
<div className="p-8 flex justify-end">
<Dropdown>
<Dropdown.Trigger>
<button className="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Options
<svg className="-mr-1 ml-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item onClick={handleEdit}>
<div className="flex items-center">
<svg className="mr-2 h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
Edit
</div>
</Dropdown.Item>
<Dropdown.Item onClick={handleDuplicate}>
<div className="flex items-center">
<svg className="mr-2 h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 9a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9z" />
<path d="M5 3a2 2 0 00-2 2v6a2 2 0 002 2V5h8a2 2 0 00-2-2H5z" />
</svg>
Duplicate
</div>
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item onClick={handleArchive}>
<div className="flex items-center">
<svg className="mr-2 h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M4 3a2 2 0 100 4h12a2 2 0 100-4H4z" />
<path fillRule="evenodd" d="M3 8h14v7a2 2 0 01-2 2H5a2 2 0 01-2-2V8zm5 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
Archive
</div>
</Dropdown.Item>
<Dropdown.Item onClick={handleDelete}>
<div className="flex items-center text-red-600">
<svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Delete
</div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
);
}
4. 폼 컴포넌트
컴파운드 패턴을 활용한 폼 컴포넌트:
'use client';
import { z } from 'zod';
import Form from './Form';
export default function FormExample() {
const handleSubmit = (values: Record<string, any>) => {
console.log('Form submitted:', values);
// 여기서 API 호출 등의 작업 수행
alert(`Form submitted successfully!\n\n${JSON.stringify(values, null, 2)}`);
};
return (
<div className="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6">Registration Form</h2>
<Form
initialValues={{
name: '',
email: '',
password: '',
confirmPassword: '',
role: 'user'
}}
onSubmit={handleSubmit}
>
<Form.Field
name="name"
label="Full Name"
validator={z.string().min(2, 'Name must be at least 2 characters')}
>
{({ value, onChange, onBlur, error }) => (
<input
type="text"
value={value}
onChange={onChange}
onBlur={onBlur}
className={`mt-1 block w-full rounded-md border p-2 ${error ? 'border-red-500' : 'border-gray-300'}`}
/>
)}
</Form.Field>
<Form.Field
name="email"
label="Email Address"
validator={z.string().email('Please enter a valid email address')}
>
{({ value, onChange, onBlur, error }) => (
<input
type="email"
value={value}
onChange={onChange}
onBlur={onBlur}
className={`mt-1 block w-full rounded-md border p-2 ${error ? 'border-red-500' : 'border-gray-300'}`}
/>
)}
</Form.Field>
<Form.Field
name="password"
label="Password"
validator={z.string().min(8, 'Password must be at least 8 characters')}
>
{({ value, onChange, onBlur, error }) => (
<input
type="password"
value={value}
onChange={onChange}
onBlur={onBlur}
className={`mt-1 block w-full rounded-md border p-2 ${error ? 'border-red-500' : 'border-gray-300'}`}
/>
)}
</Form.Field>
<Form.Field
name="confirmPassword"
label="Confirm Password"
validator={z.string().min(1, 'Please confirm your password')}
>
{({ value, onChange, onBlur, error }) => (
<input
type="password"
value={value}
onChange={onChange}
onBlur={onBlur}
className={`mt-1 block w-full rounded-md border p-2 ${error ? 'border-red-500' : 'border-gray-300'}`}
/>
)}
</Form.Field>
<Form.Field
name="role"
label="Role"
>
{({ value, onChange, onBlur }) => (
<select
value={value}
onChange={onChange}
onBlur={onBlur}
className="mt-1 block w-full rounded-md border border-gray-300 p-2"
>
<option value="user">User</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
)}
</Form.Field>
<div className="mt-6">
<Form.SubmitButton>
Register
</Form.SubmitButton>
</div>
</Form>
</div>
);
}
5. 모달 컴포넌트
컴파운드 패턴을 활용한 모달 컴포넌트:
'use client';
import { createContext, useContext, useState, useRef, useEffect, ReactNode } from 'react';
import { createPortal } from 'react-dom';
// 컨텍스트 타입 정의
type ModalContextType = {
isOpen: boolean;
openModal: () => void;
closeModal: () => void;
};
// 컨텍스트 생성
const ModalContext = createContext<ModalContextType | undefined>(undefined);
// 컨텍스트 훅
function useModal() {
const context = useContext(ModalContext);
if (!context) {
throw new Error('Modal compound components must be used within a Modal');
}
return context;
}
// 메인 컴포넌트
interface ModalProps {
children: ReactNode;
}
function Modal({ children }: ModalProps) {
const [isOpen, setIsOpen] = useState(false);
const openModal = () => setIsOpen(true);
const closeModal = () => setIsOpen(false);
return (
<ModalContext.Provider value={{ isOpen, openModal, closeModal }}>
{children}
</ModalContext.Provider>
);
}
// 트리거 컴포넌트
interface ModalTriggerProps {
children: ReactNode;
}
function ModalTrigger({ children }: ModalTriggerProps) {
const { openModal } = useModal();
return (
<div onClick={openModal}>
{children}
</div>
);
}
// 컨텐츠 컴포넌트
interface ModalContentProps {
children: ReactNode;
}
function ModalContent({ children }: ModalContentProps) {
const { isOpen, closeModal } = useModal();
const [mounted, setMounted] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
// 클라이언트 사이드에서만 마운트
useEffect(() => {
setMounted(true);
// 모달이 열릴 때 스크롤 방지
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// ESC 키로 모달 닫기
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeModal();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeModal]);
// 오버레이 클릭 시 모달 닫기
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
closeModal();
}
};
if (!mounted || !isOpen) return null;
return createPortal(
<div
ref={overlayRef}
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={handleOverlayClick}
>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
{children}
</div>
</div>,
document.body
);
}
// 헤더 컴포넌트
interface ModalHeaderProps {
children: ReactNode;
}
function ModalHeader({ children }: ModalHeaderProps) {
const { closeModal } = useModal();
return (
<div className="flex justify-between items-center p-4 border-b">
<h3 className="text-lg font-medium">{children}</h3>
<button
onClick={closeModal}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
aria-label="Close"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
// 바디 컴포넌트
interface ModalBodyProps {
children: ReactNode;
}
function ModalBody({ children }: ModalBodyProps) {
return (
<div className="p-4">
{children}
</div>
);
}
// 푸터 컴포넌트
interface ModalFooterProps {
children: ReactNode;
}
function ModalFooter({ children }: ModalFooterProps) {
return (
<div className="p-4 border-t flex justify-end space-x-2">
{children}
</div>
);
}
// 컴파운드 컴포넌트 구성
Modal.Trigger = ModalTrigger;
Modal.Content = ModalContent;
Modal.Header = ModalHeader;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
export default Modal;
'use client';
import Modal from './Modal';
export default function ModalExample() {
const handleConfirm = () => {
console.log('Confirmed!');
// 확인 로직 처리
};
return (
<div className="p-8">
<h2 className="text-2xl font-bold mb-4">Modal Example</h2>
<Modal>
<Modal.Trigger>
<button className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
Open Modal
</button>
</Modal.Trigger>
<Modal.Content>
<Modal.Header>Confirmation</Modal.Header>
<Modal.Body>
<p>Are you sure you want to perform this action? This cannot be undone.</p>
</Modal.Body>
<Modal.Footer>
<Modal.Trigger>
<button className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50">
Cancel
</button>
</Modal.Trigger>
<button
onClick={handleConfirm}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Confirm
</button>
</Modal.Footer>
</Modal.Content>
</Modal>
</div>
);
}
장점
- 선언적 API: 컴파운드 패턴은 선언적이고 직관적인 API를 제공하여 컴포넌트의 사용 방법을 명확하게 함
- 유연성: 사용자가 컴포넌트의 구조와 레이아웃을 자유롭게 구성할 수 있어 다양한 상황에 적응할 수 있음
- 상태 공유: 관련 컴포넌트 간에 상태를 효율적으로 공유할 수 있어 prop drilling을 방지
- 관심사 분리: 각 하위 컴포넌트는 자신의 역할에만 집중하므로 코드가 더 모듈화되고 유지보수가 쉬워짐
- 의미적 구조: 컴포넌트 구조가 의미적으로 명확해져 코드의 가독성이 향상됨
- 재사용성: 개별 하위 컴포넌트를 독립적으로 재사용하거나 확장할 수 있음
- 타입 안전성: TypeScript와 함께 사용하면 컴파운드 컴포넌트의 타입 안전성을 보장할 수 있음
- 테스트 용이성: 각 하위 컴포넌트를 독립적으로 테스트할 수 있어 테스트가 더 간단해짐
단점
- 복잡성 증가: 여러 컴포넌트와 컨텍스트를 관리해야 하므로 단일 컴포넌트보다 구현이 복잡할 수 있음
- 학습 곡선: 컴파운드 패턴의 개념과 구현 방법을 이해하는 데 시간이 필요할 수 있음
- 컨텍스트 의존성: 컨텍스트 API에 의존하므로 컨텍스트의 한계(예: 중첩된 컨텍스트의 성능 문제)를 상속받음
- 과도한 추상화: 간단한 UI 요소에 컴파운드 패턴을 적용하면 불필요한 추상화가 될 수 있음
- 디버깅 어려움: 여러 컴포넌트와 컨텍스트가 상호작용하므로 문제를 디버깅하기 어려울 수 있음
- 서버 컴포넌트 제한: React 19의 서버 컴포넌트에서는 컨텍스트를 사용할 수 없으므로, 컴파운드 패턴을 클라이언트 컴포넌트로 제한해야 함
- 번들 크기: 여러 하위 컴포넌트를 포함하므로 번들 크기가 커질 수 있음
어느 때에 쓰는게 적합할까
- 복잡한 UI 컴포넌트: 탭, 아코디언, 드롭다운, 모달 등 여러 하위 요소로 구성된 복잡한 UI 컴포넌트를 구현할 때 적합
- 유연한 레이아웃 필요: 사용자가 컴포넌트의 구조와 레이아웃을 자유롭게 구성해야 하는 경우에 유용
- 관련 컴포넌트 간 상태 공유: 여러 관련 컴포넌트가 동일한 상태를 공유해야 할 때 적합
- 의미적 구조 중요: 컴포넌트의 의미적 구조가 중요한 경우(예: 접근성 요구사항)에 유용
- 재사용 가능한 컴포넌트 라이브러리: 다양한 프로젝트에서 재사용할 수 있는 컴포넌트 라이브러리를 구축할 때 적합
- 폼 요소: 복잡한 폼 요소와 유효성 검사를 구현할 때 컴파운드 패턴이 유용
- 데이터 테이블: 정렬, 필터링, 페이지네이션 등 다양한 기능을 가진 데이터 테이블을 구현할 때 적합
- 네비게이션 컴포넌트: 메뉴, 내비게이션 바, 사이드바 등의 네비게이션 컴포넌트를 구현할 때 유용
- 멀티스텝 프로세스: 마법사, 체크아웃 프로세스 등 여러 단계로 구성된 UI 흐름을 구현할 때 적합
- 컴포넌트 API 설계: 직관적이고 선언적인 API를 가진 컴포넌트를 설계하고자 할 때 유용
React를 사용하면 기본적으로 다루게 되는 흔한 패턴이지만,
용례를 적으면서 어느 때에 써야할지 명확해지는 것 같다.