Context API를 활용한 Compound Pattern 구현
React의 Context API와 Compound Pattern을 결합하면 강력하고 유연한 컴포넌트 시스템을 만들 수 있습니다.
들어가며: Prop Drilling의 고통
React로 복잡한 UI를 만들다 보면 이런 경험 한 번쯤 있으시죠?
function Dashboard({ currentUser }) {
return (
<Layout currentUser={currentUser}>
<Sidebar currentUser={currentUser}>
<Navigation currentUser={currentUser}>
<UserMenu currentUser={currentUser}>
<Avatar user={currentUser} />
<UserName user={currentUser} />
</UserMenu>
</Navigation>
</Sidebar>
</Layout>
);
}
currentUser를 5단계나 전달하는 이 코드… 보기만 해도 답답합니다. 이게 바로 Prop Drilling 문제입니다.
저도 처음 React를 배울 때 이런 코드를 작성했습니다. 컴포넌트가 10개, 20개로 늘어나면서 props를 전달하는 것만으로도 머리가 아팠죠. 그리고 중간에 prop 이름을 바꾸려면? 모든 컴포넌트를 하나씩 수정해야 했습니다.
이 문제를 해결하는 우아한 방법이 바로 Context API와 Compound Pattern의 조합입니다.
Context API란?
Context는 컴포넌트 트리에서 데이터를 “전역적으로” 공유할 수 있게 해주는 React의 기능입니다.
Context의 핵심 개념
Provider (데이터 제공자)
│
│ value={data}
▼
┌───────────────┐
│ Component A │
└───────┬───────┘
│
┌─────────┴─────────┐
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ Child1 │ │ Child2 │
└─────────┘ └────┬────┘
│
▼
┌──────────┐
│Grandchild│ ← useContext()로 데이터 접근
└──────────┘
Grandchild 컴포넌트는 중간 컴포넌트들을 거치지 않고도 Provider의 데이터에 직접 접근할 수 있습니다!
Context vs Provider 차이점
많은 개발자들이 Context와 Provider를 혼동하는데, 실제로는 Context와 Provider는 함께 동작하는 하나의 시스템입니다.
| 구분 | Context | Provider |
|---|---|---|
| 역할 | 데이터 저장소 정의 | 데이터 제공자 |
| 생성 | createContext() |
Context.Provider |
| 기능 | 타입과 기본값 정의 | 실제 값 전달 |
| 사용 | useContext(Context) |
JSX에서 컴포넌트 감싸기 |
| 비유 | 배달 앱의 주문 양식 | 실제 배달부 |
// Context: 데이터 구조 정의 (주문 양식)
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {}
});
// Provider: 실제 데이터 제공 (배달부)
const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{
theme,
toggleTheme: () => setTheme(prev => prev === 'light' ? 'dark' : 'light')
}}>
{children}
</ThemeContext.Provider>
);
};
Context만으로는 데이터 공유가 불가능하며, 반드시 Provider와 함께 사용해야 합니다. Context는 “무엇을 공유할지”를 정의하고, Provider는 “실제 값을 어떻게 제공할지”를 구현합니다.
왜 Context와 Compound Pattern을 함께 쓸까요?
Compound Pattern은 여러 서브컴포넌트가 협력해서 하나의 기능을 만드는 패턴입니다. 그런데 이 서브컴포넌트들이 서로 상태를 공유해야 한다면?
문제 상황: Tabs 컴포넌트
// ❌ Props로 상태 전달: 복잡하고 제한적
<Tabs activeTab={activeTab} onChange={setActiveTab}>
<TabList activeTab={activeTab} onChange={setActiveTab}>
<Tab id="1" activeTab={activeTab} onChange={setActiveTab}>탭 1</Tab>
<Tab id="2" activeTab={activeTab} onChange={setActiveTab}>탭 2</Tab>
</TabList>
<TabPanels activeTab={activeTab}>
<TabPanel id="1" activeTab={activeTab}>내용 1</TabPanel>
<TabPanel id="2" activeTab={activeTab}>내용 2</TabPanel>
</TabPanels>
</Tabs>
activeTab과 onChange를 모든 컴포넌트에 전달해야 합니다. 너무 반복적이고 실수하기 쉽죠.
해결책: Context + Compound Pattern
// ✅ Context로 상태 공유: 깔끔하고 직관적
<Tabs defaultTab="1">
<TabList>
<Tab id="1">탭 1</Tab>
<Tab id="2">탭 2</Tab>
</TabList>
<TabPanels>
<TabPanel id="1">내용 1</TabPanel>
<TabPanel id="2">내용 2</TabPanel>
</TabPanels>
</Tabs>
Context를 사용하면 서브컴포넌트들이 내부적으로 상태를 공유하므로 props를 반복해서 전달할 필요가 없습니다!
Context + Compound의 시너지
이 두 패턴을 결합하면 다음과 같은 이점이 있습니다.
1. 사용자 친화적인 API
// 사용자는 내부 구조를 몰라도 됩니다
<Accordion>
<AccordionItem id="1">
<AccordionHeader>질문 1</AccordionHeader>
<AccordionContent>답변 1</AccordionContent>
</AccordionItem>
<AccordionItem id="2">
<AccordionHeader>질문 2</AccordionHeader>
<AccordionContent>답변 2</AccordionContent>
</AccordionItem>
</Accordion>
2. 완벽한 캡슐화
┌─────────────────────────────────────┐
│ Accordion (Provider) │
│ ┌───────────────────────────────┐ │
│ │ Context (내부 상태) │ │
│ │ - openItems: Set<string> │ │
│ │ - toggle: (id) => void │ │
│ └───────────────────────────────┘ │
│ │ │
│ │ (Context를 통해 공유) │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ AccordionItem │ │
│ │ ┌───────────────┐ │ │
│ │ │ Header │──┼─ toggle() │
│ │ └───────────────┘ │ │
│ │ ┌───────────────┐ │ │
│ │ │ Content │──┼─ isOpen │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
└─────────────────────────────────────┘
3. 유연한 구조
// 순서와 구조를 자유롭게 변경 가능
<Tabs>
<TabPanels> {/* 순서 바뀜 */}
<TabPanel id="1">내용 1</TabPanel>
<TabPanel id="2">내용 2</TabPanel>
</TabPanels>
<TabList>
<Tab id="1">탭 1</Tab>
<Tab id="2">탭 2</Tab>
</TabList>
</Tabs>
단계별 구현 가이드
실제로 Context + Compound Pattern을 어떻게 구현하는지 단계별로 살펴보겠습니다.
Step 1: Context 정의
먼저 공유할 데이터의 타입과 Context를 정의합니다.
// types.ts
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
orientation: 'horizontal' | 'vertical';
}
// TabsContext.ts
import { createContext } from 'react';
const TabsContext = createContext<TabsContextValue | null>(null);
export default TabsContext;
Step 2: Provider 컴포넌트 구현
// Tabs.tsx
import { useState, ReactNode } from 'react';
import TabsContext from './TabsContext';
interface TabsProps {
defaultTab: string;
orientation?: 'horizontal' | 'vertical';
children: ReactNode;
className?: string;
}
const Tabs = ({
defaultTab,
orientation = 'horizontal',
children,
className
}: TabsProps) => {
const [activeTab, setActiveTab] = useState(defaultTab);
const contextValue: TabsContextValue = {
activeTab,
setActiveTab,
orientation
};
return (
<TabsContext.Provider value={contextValue}>
<div className={`tabs tabs--${orientation} ${className || ''}`}>
{children}
</div>
</TabsContext.Provider>
);
};
export default Tabs;
Step 3: Custom Hook 생성
Context를 안전하게 사용하기 위한 Hook을 만듭니다.
// useTabsContext.ts
import { useContext } from 'react';
import TabsContext from './TabsContext';
const useTabsContext = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error(
'Tabs 서브컴포넌트는 Tabs 컴포넌트 내부에서만 사용할 수 있습니다. ' +
'사용 예시:\n' +
'<Tabs>\n' +
' <TabList>\n' +
' <Tab>...</Tab>\n' +
' </TabList>\n' +
'</Tabs>'
);
}
return context;
};
export default useTabsContext;
Step 4: 서브컴포넌트 구현
이제 Context를 사용하는 서브컴포넌트들을 만듭니다.
// Tab.tsx
import { ReactNode } from 'react';
import useTabsContext from './useTabsContext';
interface TabProps {
id: string;
children: ReactNode;
disabled?: boolean;
}
const Tab = ({ id, children, disabled = false }: TabProps) => {
const { activeTab, setActiveTab } = useTabsContext();
const isActive = activeTab === id;
const handleClick = () => {
if (!disabled) {
setActiveTab(id);
}
};
return (
<button
role="tab"
aria-selected={isActive}
aria-controls={`panel-${id}`}
disabled={disabled}
className={`tab ${isActive ? 'tab--active' : ''} ${disabled ? 'tab--disabled' : ''}`}
onClick={handleClick}
>
{children}
</button>
);
};
export default Tab;
// TabPanel.tsx
import { ReactNode } from 'react';
import useTabsContext from './useTabsContext';
interface TabPanelProps {
id: string;
children: ReactNode;
}
const TabPanel = ({ id, children }: TabPanelProps) => {
const { activeTab } = useTabsContext();
const isActive = activeTab === id;
if (!isActive) return null;
return (
<div
role="tabpanel"
id={`panel-${id}`}
aria-labelledby={`tab-${id}`}
className="tab-panel"
>
{children}
</div>
);
};
export default TabPanel;
Step 5: 컴포넌트 조합
// index.ts
import Tabs from './Tabs';
import TabList from './TabList';
import Tab from './Tab';
import TabPanels from './TabPanels';
import TabPanel from './TabPanel';
// Compound Component 패턴으로 조합
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
export default Tabs;
Step 6: 사용하기
// App.tsx
import Tabs from './components/Tabs';
function App() {
return (
<Tabs defaultTab="home" orientation="horizontal">
<Tabs.List>
<Tabs.Tab id="home">홈</Tabs.Tab>
<Tabs.Tab id="profile">프로필</Tabs.Tab>
<Tabs.Tab id="settings">설정</Tabs.Tab>
<Tabs.Tab id="disabled" disabled>비활성</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel id="home">
<h2>홈 화면</h2>
<p>메인 콘텐츠가 여기 표시됩니다.</p>
</Tabs.Panel>
<Tabs.Panel id="profile">
<h2>프로필</h2>
<p>사용자 정보를 확인하세요.</p>
</Tabs.Panel>
<Tabs.Panel id="settings">
<h2>설정</h2>
<p>앱 설정을 변경할 수 있습니다.</p>
</Tabs.Panel>
</Tabs.Panels>
</Tabs>
);
}
풍부한 실전 예제
예제 1: Accordion (아코디언)
여러 아이템을 펼치고 접을 수 있는 컴포넌트입니다.
// AccordionContext.tsx
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
allowMultiple: boolean;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
// Accordion.tsx
interface AccordionProps {
children: ReactNode;
allowMultiple?: boolean;
defaultOpen?: string[];
}
const Accordion = ({
children,
allowMultiple = false,
defaultOpen = []
}: AccordionProps) => {
const [openItems, setOpenItems] = useState<Set<string>>(
new Set(defaultOpen)
);
const toggle = (id: string) => {
setOpenItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
if (!allowMultiple) {
newSet.clear();
}
newSet.add(id);
}
return newSet;
});
};
const value: AccordionContextValue = {
openItems,
toggle,
allowMultiple
};
return (
<AccordionContext.Provider value={value}>
<div className="accordion">
{children}
</div>
</AccordionContext.Provider>
);
};
// AccordionItem.tsx
interface AccordionItemProps {
id: string;
children: ReactNode;
}
const AccordionItem = ({ id, children }: AccordionItemProps) => {
const { openItems } = useAccordionContext();
const isOpen = openItems.has(id);
return (
<div
className={`accordion-item ${isOpen ? 'accordion-item--open' : ''}`}
data-item-id={id}
>
{children}
</div>
);
};
// AccordionHeader.tsx
const AccordionHeader = ({ children }: { children: ReactNode }) => {
const { toggle } = useAccordionContext();
const itemElement = document.querySelector(`[data-item-id]`);
const itemId = itemElement?.getAttribute('data-item-id') || '';
return (
<button
className="accordion-header"
onClick={() => toggle(itemId)}
>
{children}
<ChevronIcon />
</button>
);
};
// AccordionContent.tsx
const AccordionContent = ({ children }: { children: ReactNode }) => {
const { openItems } = useAccordionContext();
const itemElement = document.querySelector(`[data-item-id]`);
const itemId = itemElement?.getAttribute('data-item-id') || '';
const isOpen = openItems.has(itemId);
return (
<div
className="accordion-content"
style={{ display: isOpen ? 'block' : 'none' }}
>
{children}
</div>
);
};
// 사용 예시
<Accordion allowMultiple defaultOpen={['faq1']}>
<AccordionItem id="faq1">
<AccordionHeader>React란 무엇인가요?</AccordionHeader>
<AccordionContent>
React는 사용자 인터페이스를 만들기 위한 JavaScript 라이브러리입니다.
</AccordionContent>
</AccordionItem>
<AccordionItem id="faq2">
<AccordionHeader>Context API는 언제 사용하나요?</AccordionHeader>
<AccordionContent>
전역 상태를 공유하거나 Prop Drilling을 피하고 싶을 때 사용합니다.
</AccordionContent>
</AccordionItem>
</Accordion>
예제 2: Form with Steps (다단계 폼)
복잡한 폼을 여러 단계로 나누어 관리하는 컴포넌트입니다.
// FormContext.tsx
interface FormContextValue {
currentStep: number;
totalSteps: number;
formData: Record<string, any>;
goToStep: (step: number) => void;
nextStep: () => void;
prevStep: () => void;
updateFormData: (data: Record<string, any>) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
// Form.tsx
const Form = ({ children, onSubmit }: FormProps) => {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<Record<string, any>>({});
const steps = React.Children.toArray(children);
const totalSteps = steps.length;
const nextStep = () => {
if (currentStep < totalSteps - 1) {
setCurrentStep(prev => prev + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
};
const goToStep = (step: number) => {
if (step >= 0 && step < totalSteps) {
setCurrentStep(step);
}
};
const updateFormData = (data: Record<string, any>) => {
setFormData(prev => ({ ...prev, ...data }));
};
const handleSubmit = () => {
if (currentStep === totalSteps - 1) {
onSubmit?.(formData);
}
};
const value: FormContextValue = {
currentStep,
totalSteps,
formData,
goToStep,
nextStep,
prevStep,
updateFormData,
isFirstStep: currentStep === 0,
isLastStep: currentStep === totalSteps - 1
};
return (
<FormContext.Provider value={value}>
<div className="form-wizard">
{steps[currentStep]}
</div>
</FormContext.Provider>
);
};
// FormStep.tsx
const FormStep = ({ children, title }: FormStepProps) => {
return (
<div className="form-step">
<h2 className="form-step__title">{title}</h2>
<div className="form-step__content">
{children}
</div>
</div>
);
};
// FormNavigation.tsx
const FormNavigation = () => {
const {
isFirstStep,
isLastStep,
nextStep,
prevStep
} = useFormContext();
return (
<div className="form-navigation">
{!isFirstStep && (
<button onClick={prevStep}>이전</button>
)}
<button onClick={nextStep}>
{isLastStep ? '제출' : '다음'}
</button>
</div>
);
};
// FormProgress.tsx
const FormProgress = () => {
const { currentStep, totalSteps } = useFormContext();
const progress = ((currentStep + 1) / totalSteps) * 100;
return (
<div className="form-progress">
<div className="form-progress__bar">
<div
className="form-progress__fill"
style={{ width: `${progress}%` }}
/>
</div>
<span className="form-progress__text">
{currentStep + 1} / {totalSteps}
</span>
</div>
);
};
// 사용 예시
<Form onSubmit={handleFormSubmit}>
<FormProgress />
<FormStep title="개인 정보">
<input name="name" placeholder="이름" />
<input name="email" placeholder="이메일" />
</FormStep>
<FormStep title="주소">
<input name="address" placeholder="주소" />
<input name="city" placeholder="도시" />
</FormStep>
<FormStep title="확인">
<p>입력하신 정보를 확인해주세요.</p>
</FormStep>
<FormNavigation />
</Form>
예제 3: Carousel (캐러셀)
이미지나 콘텐츠를 슬라이드 형태로 보여주는 컴포넌트입니다.
// CarouselContext.tsx
interface CarouselContextValue {
currentIndex: number;
totalItems: number;
goToSlide: (index: number) => void;
nextSlide: () => void;
prevSlide: () => void;
isAutoPlaying: boolean;
toggleAutoPlay: () => void;
}
// Carousel.tsx
const Carousel = ({
children,
autoPlay = false,
interval = 3000
}: CarouselProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isAutoPlaying, setIsAutoPlaying] = useState(autoPlay);
const items = React.Children.toArray(children);
const totalItems = items.length;
const goToSlide = (index: number) => {
const newIndex = ((index % totalItems) + totalItems) % totalItems;
setCurrentIndex(newIndex);
};
const nextSlide = () => goToSlide(currentIndex + 1);
const prevSlide = () => goToSlide(currentIndex - 1);
const toggleAutoPlay = () => setIsAutoPlaying(prev => !prev);
// Auto play 기능
useEffect(() => {
if (!isAutoPlaying) return;
const timer = setInterval(nextSlide, interval);
return () => clearInterval(timer);
}, [isAutoPlaying, currentIndex]);
const value: CarouselContextValue = {
currentIndex,
totalItems,
goToSlide,
nextSlide,
prevSlide,
isAutoPlaying,
toggleAutoPlay
};
return (
<CarouselContext.Provider value={value}>
<div className="carousel">
{children}
</div>
</CarouselContext.Provider>
);
};
// CarouselSlide.tsx
const CarouselSlide = ({
children,
index
}: CarouselSlideProps) => {
const { currentIndex } = useCarouselContext();
const isActive = currentIndex === index;
return (
<div
className={`carousel-slide ${isActive ? 'carousel-slide--active' : ''}`}
style={{
transform: `translateX(${(index - currentIndex) * 100}%)`,
transition: 'transform 0.3s ease-in-out'
}}
>
{children}
</div>
);
};
// CarouselControls.tsx
const CarouselControls = () => {
const { nextSlide, prevSlide } = useCarouselContext();
return (
<div className="carousel-controls">
<button
className="carousel-controls__prev"
onClick={prevSlide}
>
◀
</button>
<button
className="carousel-controls__next"
onClick={nextSlide}
>
▶
</button>
</div>
);
};
// CarouselIndicators.tsx
const CarouselIndicators = () => {
const { currentIndex, totalItems, goToSlide } = useCarouselContext();
return (
<div className="carousel-indicators">
{Array.from({ length: totalItems }).map((_, index) => (
<button
key={index}
className={`carousel-indicator ${
currentIndex === index ? 'carousel-indicator--active' : ''
}`}
onClick={() => goToSlide(index)}
/>
))}
</div>
);
};
// 사용 예시
<Carousel autoPlay interval={5000}>
<CarouselSlide index={0}>
<img src="/slide1.jpg" alt="Slide 1" />
</CarouselSlide>
<CarouselSlide index={1}>
<img src="/slide2.jpg" alt="Slide 2" />
</CarouselSlide>
<CarouselSlide index={2}>
<img src="/slide3.jpg" alt="Slide 3" />
</CarouselSlide>
<CarouselControls />
<CarouselIndicators />
</Carousel>
예제 4: Modal (모달)
Context를 활용한 모달 시스템입니다.
// ModalContext.tsx
interface ModalContextValue {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
}
// Modal.tsx
const Modal = ({
children,
defaultOpen = false,
onClose
}: ModalProps) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const open = () => setIsOpen(true);
const close = () => {
setIsOpen(false);
onClose?.();
};
const toggle = () => setIsOpen(prev => !prev);
// ESC 키로 닫기
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
close();
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [isOpen]);
const value: ModalContextValue = { isOpen, open, close, toggle };
return (
<ModalContext.Provider value={value}>
{children}
</ModalContext.Provider>
);
};
// ModalTrigger.tsx
const ModalTrigger = ({ children, asChild }: ModalTriggerProps) => {
const { open } = useModalContext();
if (asChild) {
return React.cloneElement(children as React.ReactElement, {
onClick: open
});
}
return <button onClick={open}>{children}</button>;
};
// ModalContent.tsx
const ModalContent = ({ children }: ModalContentProps) => {
const { isOpen, close } = useModalContext();
const modalRef = useRef<HTMLDivElement>(null);
// 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
close();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, close]);
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal-content" ref={modalRef}>
{children}
</div>
</div>
);
};
// ModalClose.tsx
const ModalClose = ({ children }: ModalCloseProps) => {
const { close } = useModalContext();
return (
<button
className="modal-close"
onClick={close}
>
{children || '×'}
</button>
);
};
// 사용 예시
<Modal>
<ModalTrigger>
<button>모달 열기</button>
</ModalTrigger>
<ModalContent>
<h2>알림</h2>
<p>이것은 모달 내용입니다.</p>
<ModalClose />
</ModalContent>
</Modal>
예제 5: Menu (드롭다운 메뉴)
Context로 관리되는 드롭다운 메뉴입니다.
// MenuContext.tsx
interface MenuContextValue {
isOpen: boolean;
toggle: () => void;
close: () => void;
selectedValue: string | null;
selectItem: (value: string) => void;
}
// Menu.tsx
const Menu = ({ children, onSelect }: MenuProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const toggle = () => setIsOpen(prev => !prev);
const close = () => setIsOpen(false);
const selectItem = (value: string) => {
setSelectedValue(value);
setIsOpen(false);
onSelect?.(value);
};
// 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
close();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const value: MenuContextValue = {
isOpen,
toggle,
close,
selectedValue,
selectItem
};
return (
<MenuContext.Provider value={value}>
<div className="menu" ref={menuRef}>
{children}
</div>
</MenuContext.Provider>
);
};
// MenuButton.tsx
const MenuButton = ({ children }: MenuButtonProps) => {
const { toggle, isOpen } = useMenuContext();
return (
<button
className="menu-button"
onClick={toggle}
aria-expanded={isOpen}
>
{children}
<span className={`menu-button__icon ${isOpen ? 'menu-button__icon--open' : ''}`}>
▼
</span>
</button>
);
};
// MenuList.tsx
const MenuList = ({ children }: MenuListProps) => {
const { isOpen } = useMenuContext();
if (!isOpen) return null;
return (
<ul className="menu-list" role="menu">
{children}
</ul>
);
};
// MenuItem.tsx
const MenuItem = ({ value, children, disabled }: MenuItemProps) => {
const { selectItem, selectedValue } = useMenuContext();
const isSelected = selectedValue === value;
return (
<li
role="menuitem"
className={`menu-item ${isSelected ? 'menu-item--selected' : ''} ${disabled ? 'menu-item--disabled' : ''}`}
onClick={() => !disabled && selectItem(value)}
>
{children}
{isSelected && <span className="menu-item__check">✓</span>}
</li>
);
};
// 사용 예시
<Menu onSelect={(value) => console.log('Selected:', value)}>
<MenuButton>
언어 선택
</MenuButton>
<MenuList>
<MenuItem value="ko">한국어</MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja">日本語</MenuItem>
<MenuItem value="zh" disabled>中文 (곧 출시)</MenuItem>
</MenuList>
</Menu>
예제 6: Stepper (단계 표시기)
프로세스의 진행 상황을 시각적으로 보여주는 컴포넌트입니다.
// StepperContext.tsx
interface StepperContextValue {
currentStep: number;
totalSteps: number;
goToStep: (step: number) => void;
nextStep: () => void;
prevStep: () => void;
completedSteps: Set<number>;
markStepComplete: (step: number) => void;
isStepComplete: (step: number) => boolean;
isStepAccessible: (step: number) => boolean;
}
// Stepper.tsx
const Stepper = ({
children,
initialStep = 0,
allowSkip = false
}: StepperProps) => {
const [currentStep, setCurrentStep] = useState(initialStep);
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const steps = React.Children.toArray(children);
const totalSteps = steps.length;
const goToStep = (step: number) => {
if (step >= 0 && step < totalSteps) {
if (allowSkip || isStepAccessible(step)) {
setCurrentStep(step);
}
}
};
const nextStep = () => {
if (currentStep < totalSteps - 1) {
markStepComplete(currentStep);
setCurrentStep(prev => prev + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
};
const markStepComplete = (step: number) => {
setCompletedSteps(prev => new Set([...prev, step]));
};
const isStepComplete = (step: number) => {
return completedSteps.has(step);
};
const isStepAccessible = (step: number) => {
if (allowSkip) return true;
return step <= currentStep || isStepComplete(step - 1);
};
const value: StepperContextValue = {
currentStep,
totalSteps,
goToStep,
nextStep,
prevStep,
completedSteps,
markStepComplete,
isStepComplete,
isStepAccessible
};
return (
<StepperContext.Provider value={value}>
<div className="stepper">
{children}
</div>
</StepperContext.Provider>
);
};
// StepperHeader.tsx
const StepperHeader = () => {
const {
currentStep,
totalSteps,
goToStep,
isStepComplete,
isStepAccessible
} = useStepperContext();
return (
<div className="stepper-header">
{Array.from({ length: totalSteps }).map((_, index) => (
<div key={index} className="stepper-header__item">
<button
className={`stepper-header__step ${
currentStep === index ? 'stepper-header__step--active' : ''
} ${
isStepComplete(index) ? 'stepper-header__step--complete' : ''
}`}
onClick={() => goToStep(index)}
disabled={!isStepAccessible(index)}
>
{isStepComplete(index) ? '✓' : index + 1}
</button>
{index < totalSteps - 1 && (
<div
className={`stepper-header__line ${
isStepComplete(index) ? 'stepper-header__line--complete' : ''
}`}
/>
)}
</div>
))}
</div>
);
};
// Step.tsx
const Step = ({ children, title }: StepProps) => {
return (
<div className="step">
<h3 className="step__title">{title}</h3>
<div className="step__content">
{children}
</div>
</div>
);
};
// StepperActions.tsx
const StepperActions = () => {
const {
currentStep,
totalSteps,
nextStep,
prevStep
} = useStepperContext();
return (
<div className="stepper-actions">
<button
onClick={prevStep}
disabled={currentStep === 0}
>
이전
</button>
<button
onClick={nextStep}
disabled={currentStep === totalSteps - 1}
>
{currentStep === totalSteps - 1 ? '완료' : '다음'}
</button>
</div>
);
};
// 사용 예시
<Stepper initialStep={0} allowSkip={false}>
<StepperHeader />
<Step title="계정 정보">
<input placeholder="이메일" />
<input placeholder="비밀번호" type="password" />
</Step>
<Step title="개인 정보">
<input placeholder="이름" />
<input placeholder="전화번호" />
</Step>
<Step title="약관 동의">
<label>
<input type="checkbox" />
이용약관에 동의합니다
</label>
</Step>
<StepperActions />
</Stepper>
시각적 데이터 흐름 다이어그램
Context + Compound Pattern의 데이터 흐름을 시각화해보겠습니다.
기본 흐름
┌─────────────────────────────────────────────────────────┐
│ Tabs Component │
│ ┌────────────────────────────────────────────────┐ │
│ │ TabsContext.Provider │ │
│ │ │ │
│ │ value = { │ │
│ │ activeTab: "home", │ │
│ │ setActiveTab: (id) => {...} │ │
│ │ } │ │
│ │ │ │
│ │ ▼ ▼ │ │
│ │ ┌─────────┐ ┌─────────┐ │ │
│ │ │ TabList │ │ Panels │ │ │
│ │ └────┬────┘ └────┬────┘ │ │
│ │ │ │ │ │
│ │ ┌────┴────┬───────┐ │ │ │
│ │ ▼ ▼ ▼ ▼ │ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌─────┐ │ │
│ │ │Tab│ │Tab│ │Tab│ │Panel│ │ │
│ │ └─┬─┘ └─┬─┘ └─┬─┘ └──┬──┘ │ │
│ │ │ │ │ │ │ │
│ │ └────────┴───────┴───────┘ │ │
│ │ │ │ │
│ │ useContext(TabsContext) │ │
│ │ │ │ │
│ │ { activeTab, setActiveTab } │ │
│ └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
상태 변경 흐름
1. 사용자가 Tab 클릭
│
▼
2. Tab 컴포넌트에서 setActiveTab('profile') 호출
│
▼
3. Tabs 컴포넌트의 상태 업데이트
│
▼
4. Provider의 value가 변경됨
│
▼
5. Context를 구독하는 모든 컴포넌트 리렌더링
│
├─▶ Tab들이 isActive 상태 업데이트
│
└─▶ Panel들이 표시 여부 업데이트
Compound Pattern의 계층 구조
<Tabs> ← Provider (상태 관리)
│
├── <TabList> ← 그룹 컨테이너
│ ├── <Tab id="1"> ← Consumer (Context 사용)
│ ├── <Tab id="2"> ← Consumer
│ └── <Tab id="3"> ← Consumer
│
└── <TabPanels> ← 그룹 컨테이너
├── <TabPanel id="1"> ← Consumer (Context 사용)
├── <TabPanel id="2"> ← Consumer
└── <TabPanel id="3"> ← Consumer
모든 자식 컴포넌트가 동일한 Context를 공유
Props 전달 vs Context 비교
┌─────────────────────────────────────────────────────────┐
│ Props 전달 방식 │
├─────────────────────────────────────────────────────────┤
│ │
│ Parent { state, setState } │
│ │ │
│ ├─▶ Child1 { state, setState } │
│ │ └─▶ Grandchild { state, setState } │
│ │ │
│ ├─▶ Child2 { state, setState } │
│ │ └─▶ Grandchild { state, setState } │
│ │ │
│ └─▶ Child3 { state, setState } │
│ └─▶ Grandchild { state, setState } │
│ │
│ 문제: 모든 중간 컴포넌트가 props를 전달해야 함 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Context 방식 │
├─────────────────────────────────────────────────────────┤
│ │
│ Provider { value: { state, setState } } │
│ │ │
│ ├─▶ Child1 { } │
│ │ └─▶ Grandchild ← useContext() │
│ │ │
│ ├─▶ Child2 { } │
│ │ └─▶ Grandchild ← useContext() │
│ │ │
│ └─▶ Child3 { } │
│ └─▶ Grandchild ← useContext() │
│ │
│ 장점: 중간 컴포넌트는 props 전달 불필요 │
└─────────────────────────────────────────────────────────┘
성능 최적화
Context를 사용할 때 주의하지 않으면 불필요한 리렌더링이 발생할 수 있습니다.
문제 1: 매번 새로운 객체 생성
// ❌ 나쁜 예: 매 렌더링마다 새 객체 생성
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState('home');
// 이 객체는 매번 새로 생성됩니다!
const value = {
activeTab,
setActiveTab
};
return (
<TabsContext.Provider value={value}>
{children}
</TabsContext.Provider>
);
};
// 문제: value가 매번 달라지므로 모든 Consumer가 리렌더링됩니다
해결 1: useMemo 사용
// ✅ 좋은 예: useMemo로 최적화
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState('home');
// activeTab이 변경될 때만 새 객체 생성
const value = useMemo(() => ({
activeTab,
setActiveTab
}), [activeTab]);
return (
<TabsContext.Provider value={value}>
{children}
</TabsContext.Provider>
);
};
문제 2: 하나의 Context에 모든 상태
// ❌ 나쁜 예: 모든 상태를 하나의 Context에
const FormContext = createContext(null);
const Form = ({ children }) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [phone, setPhone] = useState('');
const value = {
name, setName,
email, setEmail,
address, setAddress,
phone, setPhone
};
return (
<FormContext.Provider value={value}>
{children}
</FormContext.Provider>
);
};
// 문제: name이 변경되면 email, address, phone을 사용하는 컴포넌트도 리렌더링
해결 2: Context 분리
// ✅ 좋은 예: 관심사별로 Context 분리
const NameContext = createContext(null);
const EmailContext = createContext(null);
const AddressContext = createContext(null);
const PhoneContext = createContext(null);
const Form = ({ children }) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [phone, setPhone] = useState('');
return (
<NameContext.Provider value={{ name, setName }}>
<EmailContext.Provider value={{ email, setEmail }}>
<AddressContext.Provider value={{ address, setAddress }}>
<PhoneContext.Provider value={{ phone, setPhone }}>
{children}
</PhoneContext.Provider>
</AddressContext.Provider>
</EmailContext.Provider>
</NameContext.Provider>
);
};
// 이제 각 컴포넌트는 필요한 Context만 구독합니다
문제 3: 불필요한 컴포넌트 리렌더링
// ❌ 나쁜 예: 모든 자식이 리렌더링됨
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState('home');
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">
{children}
</div>
</TabsContext.Provider>
);
};
해결 3: React.memo 사용
// ✅ 좋은 예: React.memo로 불필요한 리렌더링 방지
const Tab = React.memo(({ id, children }) => {
const { activeTab, setActiveTab } = useTabsContext();
const isActive = activeTab === id;
return (
<button
className={`tab ${isActive ? 'tab--active' : ''}`}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
});
Tab.displayName = 'Tab';
해결 4: Context Selector 패턴
// ✅ 더 나은 방법: 필요한 값만 선택해서 구독
const TabsStateContext = createContext(null);
const TabsActionsContext = createContext(null);
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState('home');
// 상태와 액션을 분리
const state = useMemo(() => ({ activeTab }), [activeTab]);
const actions = useMemo(() => ({ setActiveTab }), []);
return (
<TabsStateContext.Provider value={state}>
<TabsActionsContext.Provider value={actions}>
{children}
</TabsActionsContext.Provider>
</TabsStateContext.Provider>
);
};
// 상태만 필요한 컴포넌트
const TabPanel = ({ id, children }) => {
const { activeTab } = useContext(TabsStateContext);
// setActiveTab가 변경되어도 리렌더링 안 됨!
if (activeTab !== id) return null;
return <div>{children}</div>;
};
// 액션만 필요한 컴포넌트
const TabResetButton = () => {
const { setActiveTab } = useContext(TabsActionsContext);
// activeTab이 변경되어도 리렌더링 안 됨!
return <button onClick={() => setActiveTab('home')}>리셋</button>;
};
성능 측정 도구
// React DevTools Profiler로 성능 측정
import { Profiler } from 'react';
const onRenderCallback = (
id, // 프로파일러 ID
phase, // "mount" 또는 "update"
actualDuration, // 렌더링 시간
baseDuration, // 최적화 없이 렌더링했을 때 예상 시간
startTime, // React가 렌더링을 시작한 시간
commitTime, // React가 이 업데이트를 커밋한 시간
interactions // 이 업데이트와 관련된 상호작용
) => {
console.log(`${id} took ${actualDuration}ms to render`);
};
<Profiler id="Tabs" onRender={onRenderCallback}>
<Tabs>
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
</TabList>
</Tabs>
</Profiler>
함정과 주의사항 (Pitfalls)
Context + Compound Pattern을 사용하면서 자주 마주치는 문제들과 해결 방법입니다.
함정 1: Provider 밖에서 Context 사용
// ❌ 에러 발생!
function App() {
return (
<div>
<Tab id="1">탭 1</Tab> {/* TabsContext.Provider 밖에서 사용 */}
</div>
);
}
// Error: Tabs 서브컴포넌트는 Tabs 컴포넌트 내부에서만 사용할 수 있습니다.
해결책:
// ✅ Custom Hook에서 명확한 에러 메시지 제공
const useTabsContext = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error(
'Tabs 서브컴포넌트는 Tabs 컴포넌트 내부에서만 사용할 수 있습니다.\n\n' +
'올바른 사용법:\n' +
'<Tabs>\n' +
' <Tab>...</Tab>\n' +
'</Tabs>'
);
}
return context;
};
함정 2: Context 기본값의 오해
// ❌ 잘못된 이해: 기본값이 자동으로 사용될 것이라 생각
const TabsContext = createContext({
activeTab: 'home',
setActiveTab: () => {}
});
// 실제로는 Provider 없이 사용하면 기본값이 사용되지만,
// 이는 타입 에러를 숨기고 버그를 만들 수 있습니다!
해결책:
// ✅ null을 기본값으로 하고 타입 체크 강제
const TabsContext = createContext<TabsContextValue | null>(null);
const useTabsContext = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Provider 없이 사용됨');
}
return context;
};
함정 3: 순환 의존성
// ❌ 컴포넌트끼리 서로 Context를 참조
const AccordionItem = ({ children }) => {
const { openItems } = useAccordionContext();
return (
<div>
{children}
<AccordionHeader /> {/* 여기서 다시 Context 사용 */}
</div>
);
};
const AccordionHeader = () => {
const { toggle } = useAccordionContext();
const { itemId } = useAccordionItemContext(); // 또 다른 Context!
return <button onClick={() => toggle(itemId)}>...</button>;
};
// 문제: Context 구조가 복잡해지고 디버깅이 어려워집니다
해결책:
// ✅ Context 계층을 명확하게 설계
const Accordion = ({ children }) => {
// 최상위 Context
return (
<AccordionContext.Provider value={...}>
{children}
</AccordionContext.Provider>
);
};
const AccordionItem = ({ id, children }) => {
// 아이템별 Context (부모 Context 사용 가능)
return (
<AccordionItemContext.Provider value={{ id }}>
{children}
</AccordionItemContext.Provider>
);
};
함정 4: 과도한 리렌더링
// ❌ Context 값이 자주 변경되어 모든 자식이 리렌더링
const App = () => {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e) => {
setMousePosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return (
<MouseContext.Provider value={mousePosition}>
<ExpensiveComponent /> {/* 마우스 움직일 때마다 리렌더링! */}
</MouseContext.Provider>
);
};
해결책:
// ✅ 방법 1: 구독 범위 최소화
const MouseTracker = () => {
const { x, y } = useMouseContext(); // 여기서만 구독
return <div>X: {x}, Y: {y}</div>;
};
const App = () => {
return (
<MouseProvider>
<ExpensiveComponent /> {/* Context를 사용하지 않으므로 리렌더링 안 됨 */}
<MouseTracker />
</MouseProvider>
);
};
// ✅ 방법 2: 업데이트 throttle
const MouseProvider = ({ children }) => {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
let timeoutId;
const handleMouseMove = (e) => {
if (timeoutId) return;
timeoutId = setTimeout(() => {
setMousePosition({ x: e.clientX, y: e.clientY });
timeoutId = null;
}, 16); // 약 60fps
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return (
<MouseContext.Provider value={mousePosition}>
{children}
</MouseContext.Provider>
);
};
함정 5: TypeScript 타입 안정성 부족
// ❌ 타입이 느슨함
const TabsContext = createContext({} as any);
// 자동완성도 안 되고, 타입 에러도 잡지 못합니다
해결책:
// ✅ 엄격한 타입 정의
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
orientation: 'horizontal' | 'vertical';
disabled: boolean;
}
const TabsContext = createContext<TabsContextValue | null>(null);
// Custom Hook에서 타입 보장
const useTabsContext = (): TabsContextValue => {
const context = useContext(TabsContext);
if (!context) {
throw new Error('useTabsContext must be used within Tabs');
}
return context; // null이 제거된 타입 반환
};
// 사용할 때 자동완성 완벽하게 작동
const Tab = ({ id }) => {
const { activeTab, setActiveTab } = useTabsContext();
// ~~~~~~~~~ 자동완성 ✓
};
함정 6: 메모리 누수
// ❌ 이벤트 리스너가 정리되지 않음
const Modal = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
});
// cleanup 함수 없음!
}, []);
// ...
};
해결책:
// ✅ cleanup 함수로 이벤트 리스너 제거
const Modal = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
window.addEventListener('keydown', handleEscape);
// cleanup 함수
return () => {
window.removeEventListener('keydown', handleEscape);
};
}
}, [isOpen]);
// ...
};
실전 활용 사례
실제 프로젝트에서 Context + Compound Pattern이 어떻게 사용되는지 살펴보겠습니다.
사례 1: 대시보드 위젯 시스템
// 여러 위젯을 관리하는 대시보드
<Dashboard>
<Dashboard.Header>
<Dashboard.Title>분석 대시보드</Dashboard.Title>
<Dashboard.Actions>
<button>새로고침</button>
<button>설정</button>
</Dashboard.Actions>
</Dashboard.Header>
<Dashboard.Grid>
<Dashboard.Widget id="sales" span={2}>
<Dashboard.WidgetHeader>매출 현황</Dashboard.WidgetHeader>
<Dashboard.WidgetContent>
<SalesChart />
</Dashboard.WidgetContent>
</Dashboard.Widget>
<Dashboard.Widget id="users">
<Dashboard.WidgetHeader>사용자 통계</Dashboard.WidgetHeader>
<Dashboard.WidgetContent>
<UserStats />
</Dashboard.WidgetContent>
</Dashboard.Widget>
<Dashboard.Widget id="notifications">
<Dashboard.WidgetHeader>
알림
<Dashboard.WidgetBadge count={5} />
</Dashboard.WidgetHeader>
<Dashboard.WidgetContent>
<NotificationList />
</Dashboard.WidgetContent>
</Dashboard.Widget>
</Dashboard.Grid>
</Dashboard>
사례 2: 데이터 테이블
// 복잡한 데이터 테이블
<DataTable data={users} onSort={handleSort}>
<DataTable.Toolbar>
<DataTable.Search placeholder="검색..." />
<DataTable.Filter columns={['role', 'status']} />
<DataTable.Export formats={['csv', 'excel']} />
</DataTable.Toolbar>
<DataTable.Table>
<DataTable.Header>
<DataTable.Column sortable>이름</DataTable.Column>
<DataTable.Column sortable>이메일</DataTable.Column>
<DataTable.Column>역할</DataTable.Column>
<DataTable.Column>상태</DataTable.Column>
<DataTable.Column>액션</DataTable.Column>
</DataTable.Header>
<DataTable.Body>
{users.map(user => (
<DataTable.Row key={user.id}>
<DataTable.Cell>{user.name}</DataTable.Cell>
<DataTable.Cell>{user.email}</DataTable.Cell>
<DataTable.Cell>
<Badge>{user.role}</Badge>
</DataTable.Cell>
<DataTable.Cell>
<StatusIndicator status={user.status} />
</DataTable.Cell>
<DataTable.Cell>
<DataTable.Actions>
<button>편집</button>
<button>삭제</button>
</DataTable.Actions>
</DataTable.Cell>
</DataTable.Row>
))}
</DataTable.Body>
</DataTable.Table>
<DataTable.Pagination
totalItems={totalUsers}
itemsPerPage={20}
/>
</DataTable>
사례 3: 폼 빌더
// 동적 폼 생성기
<FormBuilder onSubmit={handleSubmit} validation={validationSchema}>
<FormBuilder.Section title="기본 정보">
<FormBuilder.Field
name="name"
label="이름"
required
>
<FormBuilder.Input />
<FormBuilder.ErrorMessage />
</FormBuilder.Field>
<FormBuilder.Field
name="email"
label="이메일"
required
>
<FormBuilder.Input type="email" />
<FormBuilder.HelperText>
업무용 이메일을 입력하세요
</FormBuilder.HelperText>
<FormBuilder.ErrorMessage />
</FormBuilder.Field>
</FormBuilder.Section>
<FormBuilder.Section title="추가 정보">
<FormBuilder.Field name="role" label="역할">
<FormBuilder.Select
options={[
{ value: 'admin', label: '관리자' },
{ value: 'user', label: '사용자' }
]}
/>
</FormBuilder.Field>
<FormBuilder.Field name="bio" label="소개">
<FormBuilder.Textarea rows={4} />
</FormBuilder.Field>
<FormBuilder.Field name="agree" label="약관 동의">
<FormBuilder.Checkbox>
이용약관에 동의합니다
</FormBuilder.Checkbox>
</FormBuilder.Field>
</FormBuilder.Section>
<FormBuilder.Actions>
<FormBuilder.ResetButton>초기화</FormBuilder.ResetButton>
<FormBuilder.SubmitButton>제출</FormBuilder.SubmitButton>
</FormBuilder.Actions>
</FormBuilder>
다른 상태 관리와의 비교
Context + Compound Pattern을 다른 상태 관리 방법과 비교해보겠습니다.
Context API vs Redux
| 특징 | Context API | Redux |
|---|---|---|
| 학습 곡선 | 낮음 | 높음 |
| 보일러플레이트 | 적음 | 많음 |
| DevTools | 제한적 | 강력함 |
| 미들웨어 | 없음 | 풍부함 |
| 시간여행 디버깅 | 없음 | 있음 |
| 적합한 규모 | 작은~중간 | 중간~큰 |
// Context API
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState('home');
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
};
// Redux
const tabsSlice = createSlice({
name: 'tabs',
initialState: { activeTab: 'home' },
reducers: {
setActiveTab: (state, action) => {
state.activeTab = action.payload;
}
}
});
// 사용
const Tab = ({ id }) => {
const dispatch = useDispatch();
const activeTab = useSelector(state => state.tabs.activeTab);
return (
<button onClick={() => dispatch(setActiveTab(id))}>
{id}
</button>
);
};
언제 Context를 쓸까?
- 컴포넌트 라이브러리 (Tabs, Modal 등)
- 테마, 언어 설정 같은 전역 설정
- 부모-자식 간 깊은 props 전달
언제 Redux를 쓸까?
- 복잡한 비즈니스 로직
- 여러 컴포넌트에서 공유되는 복잡한 상태
- 디버깅과 상태 추적이 중요한 경우
Context API vs Zustand
// Context API
const useTheme = () => {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return { theme, toggleTheme };
};
// Zustand
import create from 'zustand';
const useThemeStore = create((set) => ({
theme: 'light',
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
}))
}));
// 사용
const ThemeToggle = () => {
const { theme, toggleTheme } = useThemeStore();
return <button onClick={toggleTheme}>{theme}</button>;
};
Zustand의 장점:
- Provider 불필요
- 더 간결한 문법
- 성능 최적화가 쉬움
Context의 장점:
- React 내장 기능
- 추가 라이브러리 불필요
- Compound Pattern과 자연스럽게 통합
Context API vs React Query
// Context API (로컬 상태)
const UserProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const fetchUser = async () => {
setLoading(true);
const data = await api.getUser();
setUser(data);
setLoading(false);
};
return (
<UserContext.Provider value={{ user, loading, fetchUser }}>
{children}
</UserContext.Provider>
);
};
// React Query (서버 상태)
const useUser = () => {
return useQuery('user', api.getUser, {
staleTime: 5000,
refetchOnWindowFocus: true
});
};
const UserProfile = () => {
const { data: user, isLoading, refetch } = useUser();
if (isLoading) return <div>Loading...</div>;
return <div>{user.name}</div>;
};
언제 뭘 쓸까?
- Context: UI 상태 (열림/닫힘, 선택된 탭 등)
- React Query: 서버 데이터 (API 응답, 캐싱 등)
TypeScript 완벽 가이드
TypeScript와 함께 사용할 때의 베스트 프랙티스입니다.
제네릭을 활용한 재사용 가능한 Context
// 제네릭 Context Factory
function createGenericContext<T>() {
const Context = createContext<T | null>(null);
const useContext = (): T => {
const context = useContext(Context);
if (!context) {
throw new Error('Context must be used within Provider');
}
return context;
};
return [Context.Provider, useContext] as const;
}
// 사용 예시
interface TabsState {
activeTab: string;
setActiveTab: (id: string) => void;
}
const [TabsProvider, useTabsContext] = createGenericContext<TabsState>();
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState('home');
return (
<TabsProvider value={{ activeTab, setActiveTab }}>
{children}
</TabsProvider>
);
};
엄격한 Props 타입
// 서브컴포넌트 타입 정의
interface TabProps {
id: string;
children: ReactNode;
disabled?: boolean;
className?: string;
onClick?: (id: string) => void;
}
interface TabListProps {
children: ReactNode;
'aria-label'?: string;
className?: string;
}
// Compound Component 타입
interface TabsComponent extends FC<TabsProps> {
List: FC<TabListProps>;
Tab: FC<TabProps>;
Panels: FC<{ children: ReactNode }>;
Panel: FC<TabPanelProps>;
}
const Tabs: TabsComponent = ({ children, ...props }) => {
// ...
};
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
타입 안전한 Event Handler
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
registerItem: (id: string) => () => void;
}
const Accordion = ({ children }: { children: ReactNode }) => {
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
// 타입 안전한 toggle 함수
const toggle = useCallback((id: string) => {
setOpenItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
// cleanup을 위한 register 함수
const registerItem = useCallback((id: string) => {
return () => {
setOpenItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
};
}, []);
const value: AccordionContextValue = {
openItems,
toggle,
registerItem
};
return (
<AccordionContext.Provider value={value}>
{children}
</AccordionContext.Provider>
);
};
Discriminated Union으로 상태 관리
// 상태를 명확하게 구분
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
interface DataContextValue<T> {
state: FetchState<T>;
refetch: () => void;
}
function createDataContext<T>() {
const Context = createContext<DataContextValue<T> | null>(null);
const Provider = ({
children,
fetcher
}: {
children: ReactNode;
fetcher: () => Promise<T>
}) => {
const [state, setState] = useState<FetchState<T>>({ status: 'idle' });
const refetch = async () => {
setState({ status: 'loading' });
try {
const data = await fetcher();
setState({ status: 'success', data });
} catch (error) {
setState({ status: 'error', error: error as Error });
}
};
return (
<Context.Provider value={{ state, refetch }}>
{children}
</Context.Provider>
);
};
const useData = () => {
const context = useContext(Context);
if (!context) {
throw new Error('useData must be used within Provider');
}
return context;
};
return { Provider, useData };
}
// 사용
const { Provider: UserProvider, useData: useUserData } =
createDataContext<User>();
const UserProfile = () => {
const { state, refetch } = useUserData();
// TypeScript가 상태별로 타입을 좁혀줌
switch (state.status) {
case 'idle':
return <button onClick={refetch}>Load User</button>;
case 'loading':
return <div>Loading...</div>;
case 'success':
return <div>{state.data.name}</div>; // data가 T 타입으로 추론됨
case 'error':
return <div>Error: {state.error.message}</div>; // error가 Error 타입
}
};
참고 자료
공식 문서
라이브러리 예시
- Radix UI - Context + Compound Pattern의 완벽한 구현
- Headless UI - Tailwind의 headless component 라이브러리
- Chakra UI - 접근성이 뛰어난 컴포넌트 라이브러리
- Reach UI - 접근성에 집중한 컴포넌트
추가 학습 자료
결론
Context API와 Compound Pattern의 조합은 React에서 복잡한 UI 컴포넌트를 만들 때 매우 강력한 도구입니다.
핵심 요약:
- Prop Drilling 해결: Context로 깊은 계층의 컴포넌트에 직접 데이터 전달
- 유연한 구조: Compound Pattern으로 자유로운 컴포넌트 조합
- 완벽한 캡슐화: 내부 로직을 숨기고 깔끔한 API 제공
- 타입 안정성: TypeScript와 함께 사용하면 안전한 개발 가능
- 성능 최적화: useMemo, React.memo, Context 분리로 최적화
언제 사용할까?
- UI 컴포넌트 라이브러리 제작
- 복잡한 상호작용이 있는 컴포넌트 (Tabs, Accordion, Modal 등)
- 여러 서브컴포넌트가 상태를 공유해야 할 때
주의할 점:
- 과도한 리렌더링 방지
- Context 분리로 관심사 분리
- TypeScript로 타입 안정성 확보
- 명확한 에러 메시지 제공
이 패턴을 마스터하면 재사용 가능하고 유지보수하기 쉬운 컴포넌트를 만들 수 있습니다. 직접 Tabs나 Accordion 같은 컴포넌트를 만들어보면서 익히시길 추천합니다!
댓글