
안녕하세요?
이번 시간에는 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} />;
}
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 디자인이 변경되는 경우를 생각해봅시다.
이 경우, [UI 디자인 변경 -> Presentational 컴포넌트만 수정]이라고 생각할 수 있습니다.
반대로 데이터 처리 방식이 변경되는 경우도 있습니다.
예를 들어
- REST API -> GraphQL 변경
- React Query 도입
- 데이터 캐싱 추가
이 경우에는 데이터 로직 변경 → Container 컴포넌트만 수정으로 변경 범위가 분리됩니다.
따라서 다음의 표처럼 정리할 수 있습니다.
| 변경 종류 | 수정 위치 |
| 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;
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;
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를 작업한 모습입니다.
오늘은 다양한 패턴에 대해서 알아보았습니다.
위 패턴을 활용하여 재활용성 높은 컴포넌트를 구축하고, 효율적인 개발에 도움이 되었으면 좋습니다!
감사합니다.
'React' 카테고리의 다른 글
| TanStack Query 에 대해서 알아보기! (0) | 2026.04.04 |
|---|---|
| 상태 관리 전략 비교 - Context API, Zustand, Redux Toolkit 중 무엇을 선택해야 할까? (0) | 2026.03.23 |
| React useState 완전 이해하기 (0) | 2026.03.21 |
| React Hook 완전 이해하기 (0) | 2026.03.21 |
| 커스텀 훅과 책임 분리 (0) | 2026.03.16 |
