React에서 컴포넌트를 어떻게 설계할 것인가?
SRP, Container/Presentational, Compound Component 패턴으로 재사용성과 유지보수성 높은 React 컴포넌트를 설계하는 방법을 정리했습니다.
- #react
안녕하세요?
이번 시간에는 React에서 컴포넌트를 어떻게 설계할 것인지에 대한 이야기를 보려고 합니다.
React 프로젝트를 진행하다 보면 다음과 같은 컴포넌트를 쉽게 만나게 됩니다.
- API 호출
- 상태 관리
- UI 렌더링
- 이벤트 처리
아마 컴포넌트를 만드시면서 모든 로직이 하나의 컴포넌트에 들어가 있는 경우를 종종 보셨을 겁니다.
이렇게 되면 컴포넌트가 점점 커지고, 재사용성과 유지보수성이 떨어지게 됩니다.
이 문제를 해결하기 위해 React에서는 몇 가지 설계 패턴이 자주 사용되는데요. 오늘은 이러한 문제를 해결하기 위한 설계 패턴들에 대해 알아봅시다!
SRP (Single Responsibility Principle)
SRP란 하나의 컴포넌트는 하나의 책임만 가져야 한다는 원칙이다.
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
return (
<div>
<h1>{user.name}</h1>
</div>
);
}위 코드는 API 패칭, UI 표시, 데이터 관리의 총 세 가지 책임을 가지고 있습니다.
이로 인하여 테스트가 어려워지거나, 재사용이 떨어지거나, 유지보수에 어려움이 생길 수 있습니다.
따라서 React에서는 컴포넌트를 책임 기준으로 나누는 전략을 사용합니다.
1. 데이터 로직
- API 호출
- 상태 관리
- 데이터 가공
2. UI 로직
- JSX
- 스타일
- 화면 렌더링
위와 같이 하나의 컴포넌트당 하나의 책임만을 가지게 하여 효율적인 개발이 가능하게 합니다.
Container vs Presentational 패턴
SRP를 React에서 구현하는 대표적인 방법이 Container / Presentational 패턴입니다.
Container는 로직을 담당하는 컴포넌트로,
- API 호출
- 상태 관리
- 데이터 가공
- 이벤트 처리
등을 담당합니다.
function UserProfileContainer() {
const { data } = useUser();
return <UserProfileView user={data} />;
}Presentational은 UI만 담당하는 컴포넌트로,
- 화면 렌더링
- 스타일
- 레이아웃
들을 담당합니다.
function UserProfileView({ user }) {
return <h1>{user.name}</h1>;
}Container vs Presentational 패턴의 장점
1. 테스트가 쉬워진다
Presentational 컴포넌트는 UI 렌더링에만 집중하고, 데이터 로직이나 API 호출과 같은 복잡한 동작을 포함하지 않습니다. 따라서 컴포넌트를 테스트할 때 외부 환경에 의존하지 않고 단순히 props만 전달해서 테스트할 수 있습니다.
예를 들어 Container 컴포넌트에 API 요청과 상태 관리가 포함되어 있다면 테스트를 위해 다음과 같은 요소들을 준비해야 합니다.
- API mocking
- 상태 관리 mocking
- 비동기 처리
- 네트워크 요청 처리
하지만 Presentational 컴포넌트는 이런 요소들이 필요 없습니다. 단순히 props로 전달된 데이터를 화면에 잘 렌더링하는지만 확인하면 됩니다.
function UserListView({ users }) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}이 컴포넌트를 테스트할 때는 API나 상태 관리 없이 단순히 데이터를 전달하면 됩니다.
render(<UserListView users={[{ id: 1, name: "Alice" }]} />);이처럼 입력(props) → 출력(UI) 구조가 명확하기 때문에 테스트 코드가 단순해지고, 테스트 안정성도 높아집니다.
2. 재사용성이 높아진다
Presentational 컴포넌트는 UI 표현만 담당하고 데이터 로직을 포함하지 않기 때문에 다양한 상황에서 재사용할 수 있습니다.
만약 UI 컴포넌트 내부에 API 요청이나 특정 상태 관리 로직이 포함되어 있다면, 다른 화면에서 동일한 UI를 사용하기 어렵습니다. 왜냐하면 컴포넌트가 특정 데이터 흐름에 강하게 결합되어 있기 때문입니다.
function UserList() {
const { data } = useUsers();
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}이 컴포넌트는 useUsers라는 특정 데이터 로직에 의존하고 있기 때문에 다른 상황에서 사용하기 어렵습니다.
하지만 UI와 로직을 분리하면 상황이 달라집니다.
Container 컴포넌트
function UserListContainer() {
const { data } = useUsers();
return <UserListView users={data} />;
}Presentational 컴포넌트
function UserListView({ users }) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}이렇게 분리하면 UserListView는 다양한 상황에서 재사용할 수 있습니다.
- 검색 결과 리스트
- 관리자 페이지 유저 목록
- 추천 사용자 목록
모두 같은 UI 컴포넌트를 사용할 수 있습니다. 즉, UI와 데이터 로직의 결합도가 낮아지기 때문에 재사용성이 높아집니다.
3. 유지보수가 쉬워진다
Container와 Presentational 컴포넌트를 분리하면 **관심사의 분리(Separation of Concerns)**가 이루어집니다. 즉, 각 컴포넌트가 담당하는 역할이 명확해집니다.
보통 React 컴포넌트는 다음 두 가지 역할을 수행합니다.
- 데이터를 가져오고 상태를 관리하는 역할
- 화면을 렌더링하는 역할
이 두 가지 역할이 하나의 컴포넌트에 섞여 있으면 코드가 점점 복잡해지고 수정하기 어려워집니다.
하지만 Container와 View로 분리하면 변경 범위가 명확해집니다.
| 변경 종류 | 수정 위치 |
|---|---|
| UI 디자인 변경 | Presentational 컴포넌트 |
| 데이터 로직 변경 | Container 컴포넌트 |
이처럼 변경의 영향을 최소화할 수 있기 때문에 유지보수가 훨씬 쉬워집니다.
실습 예시
이번 시간에 배워보는 내용들을 정리한 페이지를 제작하고 있습니다. 각 패턴에 대한 상세 페이지로 이동할 수 있는 카드 UI와 각 패턴의 데이터를 담은 Container를 분리해봤습니다.
PatternContainer: 패턴에 대한 데이터들만 처리합니다.PatternCard: 패턴에 대한 데이터들을 보여줄 UI를 처리합니다.
import { PatternList } from "../../constants/home/PatternList";
import PatternCard from "./PatternCard";
const PatternContainer = () => {
return (
<div className="flex flex-col md:flex-row w-full justify-center items-center gap-6 max-w-6xl mx-auto">
{PatternList.map((Pattern) => (
<PatternCard
key={Pattern.title}
title={Pattern.title}
description={Pattern.description}
icon={Pattern.icon}
color={Pattern.color}
tags={Pattern.tags}
link={Pattern.link}
/>
))}
</div>
);
};
export default PatternContainer;import { Link } from "react-router-dom";
import type { LucideIcon } from "lucide-react";
interface PatternCardProps {
title: string;
description: string;
icon: LucideIcon;
tags: string[];
color: string;
link: string;
}
const PatternCard = ({
title,
description,
icon: Icon,
color,
tags,
link,
}: PatternCardProps) => {
return (
<div className="flex flex-col w-full h-full gap-4 border border-gray-200 rounded-2xl p-6 hover:shadow-xl transition-all duration-300">
<div
className={`flex h-11 w-11 items-center justify-center rounded-lg ${color}`}
>
<Icon className="w-5 h-5" />
</div>
<div className="flex flex-col w-full gap-1 px-2">
<h3 className="text-lg font-bold">{title}</h3>
<p className="text-gray-500">{description}</p>
</div>
<div className="flex flex-col w-full gap-3 mt-auto">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="text-sm text-gray-500 px-2 py-1 rounded-md bg-gray-100"
>
{tag}
</span>
))}
</div>
<Link
to={link}
className="w-full text-center rounded-md bg-gray-500 text-white py-2"
>
자세히 보기
</Link>
</div>
</div>
);
};
export default PatternCard;요즘 React에서는 Container 대신 custom Hook을 활용하여 Hook에서 Component에 데이터를 넘겨주는 형식으로 진행하기도 합니다.
Compound Component 패턴
유명 UI 라이브러리인 Radix UI, Headless UI, shadcn/ui에서도 차용하는 패턴으로, 여기서 Compound는 여러 개가 결합된이라는 뜻을 가지고 있습니다.
<Tabs>
<Tabs.List>
<Tabs.Trigger>Tab1</Tabs.Trigger>
<Tabs.Trigger>Tab2</Tabs.Trigger>
</Tabs.List>
<Tabs.Content>내용</Tabs.Content>
</Tabs>Compound Component 패턴은 하나의 부모 컴포넌트 아래에 관련된 여러 컴포넌트를 조합하여 사용하는 패턴입니다.
일반적인 컴포넌트의 경우, 구조 변경이 불가능하고 커스터마이징이 불가능하지만, Compound 방식을 적용한다면 일관된 디자인을 유지하며 커스터마이징 및 구조 변경이 가능합니다.
Parent
const TabsContext = createContext(null);
function Tabs({ children }) {
const [active, setActive] = useState(0);
return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
);
}Trigger
function TabsTrigger({ index, children }) {
const { active, setActive } = useContext(TabsContext);
return <button onClick={() => setActive(index)}>{children}</button>;
}Content
function TabsContent({ index, children }) {
const { active } = useContext(TabsContext);
if (active !== index) return null;
return <div>{children}</div>;
}Example
<Tabs>
<TabsTrigger index={0}>Tab1</TabsTrigger>
<TabsTrigger index={1}>Tab2</TabsTrigger>
<TabsContent index={0}>내용1</TabsContent>
<TabsContent index={1}>내용2</TabsContent>
</Tabs>나의 사용 예시
toast.message(
<ToastContent
showCloseButton={true}
autoDisappear={false}
className="mt-0 flex min-w-85.5 flex-col gap-2 rounded-md p-3"
style={{ marginTop: "var(--safe-area-inset-top)" }}
>
<ToastHeader>
<ToastDescription>
충분히 이완되셨나요? 변화된 상태를 측정해 보세요!
</ToastDescription>
</ToastHeader>
<ToastFooter>
<Button
size="xs"
onClick={() => {
clearFlag();
closeToast();
nativeNavigate(ROUTE.DIAGNOSIS.HRV);
}}
>
<p>HRV 측정하러 가기</p>
<ArrowRightIcon className="size-3" />
</Button>
</ToastFooter>
</ToastContent>,
{ id: MILESTONE_TOAST_ID },
);기존의 토스트 메시지의 디자인에서 커스터마이징하여 UI를 작업한 모습입니다.
오늘은 다양한 패턴에 대해서 알아보았습니다.
위 패턴을 활용하여 재활용성 높은 컴포넌트를 구축하고, 효율적인 개발에 도움이 되었으면 좋습니다!
감사합니다.