관심사 분리 — Container-Presentational은 죽었는가
컴포넌트를 "어떻게 나눌 것인가"에 대한 정답은 정해져 있을까요?
React 초기에는 Container-Presentational 패턴이 사실상 표준이었습니다. 하지만 Hooks가 등장하면서 이 패턴의 창시자인 Dan Abramov 본인이 "더 이상 이렇게 나눌 필요 없다"고 말했습니다. 그렇다면 지금은 관심사를 어떻게 분리해야 할까요?
Container-Presentational 패턴이란
이 패턴은 컴포넌트를 두 가지 역할로 나눕니다.
Presentational (보여주기 전담)
function UserProfile({ name, email, avatar, onEdit }) {
return (
<div className="profile">
<img src={avatar} alt={name} />
<h2>{name}</h2>
<p>{email}</p>
<button onClick={onEdit}>수정</button>
</div>
);
}
- props만 받아서 UI를 렌더링합니다
- 상태를 직접 관리하지 않습니다 (UI 상태 제외)
- 데이터가 어디서 오는지 모릅니다
Container (데이터 전담)
function UserProfileContainer({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
const handleEdit = () => {
// 수정 로직
};
if (loading) return <Spinner />;
return <UserProfile {...user} onEdit={handleEdit} />;
}
- 데이터를 가져오고 상태를 관리합니다
- Presentational 컴포넌트에 필요한 데이터를 props로 전달합니다
이 패턴이 해결한 문제
- **재사용성 **: Presentational 컴포넌트는 다른 데이터 소스와도 조합 가능
- ** 테스트 용이성 **: UI를 데이터 없이 props만으로 테스트 가능
- ** 역할 분담 **: 디자이너는 Presentational, 개발자는 Container에 집중
왜 "죽었다"고 하는가
2019년, Dan Abramov는 자신의 글에 이런 업데이트를 추가했습니다.
"이 패턴을 더 이상 권장하지 않습니다. 당시에는 유용했지만 이제는 Hooks로 같은 목적을 달성할 수 있습니다."
Hooks로 대체하면
// 커스텀 Hook으로 로직 분리
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
return { user, loading };
}
// Container 없이 하나의 컴포넌트로
function UserProfile({ userId }) {
const { user, loading } = useUser(userId);
if (loading) return <Spinner />;
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
Container 컴포넌트 없이도 로직(Hook)과 UI(JSX)가 깔끔하게 분리됩니다.
Container 패턴이 불필요해진 이유
- Hook 자체가 "로직을 담는 컨테이너" 역할을 합니다
- 별도 래퍼 컴포넌트 없이 로직을 재사용할 수 있습니다
- props drilling 대신 Hook에서 직접 데이터를 가져옵니다
Hooks 시대의 관심사 분리
패턴은 죽었지만, 관심사 분리의 ** 원칙 **은 살아 있습니다. 형태만 바뀌었을 뿐입니다.
1. 커스텀 Hook으로 로직 분리
// 데이터 페칭 로직
function useProducts(category) {
const [products, setProducts] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
api.getProducts(category)
.then(setProducts)
.catch(setError);
}, [category]);
return { products, error };
}
// 필터링 로직
function useProductFilter(products) {
const [filter, setFilter] = useState('');
const filtered = useMemo(
() => products.filter((p) => p.name.includes(filter)),
[products, filter]
);
return { filtered, filter, setFilter };
}
// 컴포넌트는 조합만 한다
function ProductList({ category }) {
const { products, error } = useProducts(category);
const { filtered, filter, setFilter } = useProductFilter(products);
if (error) return <ErrorMessage error={error} />;
return (
<>
<SearchInput value={filter} onChange={setFilter} />
<ProductGrid items={filtered} />
</>
);
}
2. 순수 UI 컴포넌트 분리
Hook이 Container를 대체했다고 해서, 순수 UI 컴포넌트가 사라진 것은 아닙니다.
// 순수 UI 컴포넌트 — 여전히 유효
function Button({ variant, size, children, ...props }) {
return (
<button className={`btn btn-${variant} btn-${size}`} {...props}>
{children}
</button>
);
}
// 비즈니스 로직이 포함된 컴포넌트
function SubmitOrderButton({ orderId }) {
const { submit, isSubmitting } = useOrderSubmit(orderId);
return (
<Button variant="primary" size="lg" onClick={submit} disabled={isSubmitting}>
{isSubmitting ? '처리 중...' : '주문하기'}
</Button>
);
}
디자인 시스템의 기본 컴포넌트(Button, Input, Card 등)는 여전히 순수 Presentational로 만드는 것이 좋습니다.
Feature 기반 폴더 구조
관심사 분리는 코드 구조에도 반영됩니다.
전통적인 타입 기반 구조
src/
├── components/
│ ├── ProductCard.jsx
│ ├── UserProfile.jsx
│ └── OrderSummary.jsx
├── hooks/
│ ├── useProducts.js
│ ├── useUser.js
│ └── useOrder.js
├── utils/
└── styles/
파일이 늘어나면 관련 파일을 찾기 위해 여러 폴더를 넘나들어야 합니다.
Feature 기반 구조
src/
├── features/
│ ├── products/
│ │ ├── ProductCard.jsx
│ │ ├── ProductList.jsx
│ │ ├── useProducts.js
│ │ ├── productApi.js
│ │ ├── ProductCard.test.jsx
│ │ └── index.js
│ ├── user/
│ │ ├── UserProfile.jsx
│ │ ├── useUser.js
│ │ └── index.js
│ └── order/
│ ├── OrderSummary.jsx
│ ├── useOrder.js
│ └── index.js
├── shared/
│ ├── components/ // Button, Input 등 공유 UI
│ ├── hooks/ // useDebounce 등 범용 훅
│ └── utils/
└── App.jsx
Colocation 원칙
Feature 기반 구조의 핵심은 colocation(함께 배치) 입니다.
- 함께 변경될 코드는 물리적으로 가까이 둡니다
- 컴포넌트, Hook, 스타일, 테스트를 같은 폴더에 배치합니다
- "이 기능을 삭제하려면 이 폴더만 지우면 된다"가 이상적입니다
Barrel Export의 양면
Feature 폴더의 index.js에서 공개 API만 re-export하는 패턴입니다.
// features/products/index.js
export { ProductCard } from './ProductCard';
export { ProductList } from './ProductList';
export { useProducts } from './useProducts';
장점
- 외부에서 import 경로가 깔끔합니다:
import { ProductCard } from '@/features/products' - 내부 구현을 캡슐화할 수 있습니다
단점
- **Tree shaking 방해 **: 번들러가 barrel file을 통해 모든 모듈을 로드할 수 있습니다
- ** 순환 참조 위험 **: feature 간 barrel을 통한 import가 순환 의존성을 만들 수 있습니다
- ** 개발 서버 성능 **: Vite/webpack에서 barrel file이 많으면 HMR이 느려질 수 있습니다
// 주의: barrel import는 사용하지 않는 모듈까지 로드할 수 있다
import { ProductCard } from '@/features/products';
// 내부적으로 ProductList, useProducts도 모두 평가될 수 있음
// 직접 경로가 더 안전한 경우
import { ProductCard } from '@/features/products/ProductCard';
프로젝트 규모가 크다면 barrel file 사용을 신중하게 결정해야 합니다.
실전 구조 가이드
소규모 프로젝트 (10개 이하 페이지)
src/
├── components/ // 모든 컴포넌트
├── hooks/ // 모든 커스텀 훅
├── pages/ // 라우트 페이지
└── utils/
처음부터 feature 폴더를 만들 필요 없습니다. 복잡해지면 그때 리팩터링합니다.
중규모 프로젝트 (10-50 페이지)
src/
├── features/ // 비즈니스 기능별
├── shared/ // 공유 컴포넌트, 훅
├── pages/ // 라우트 페이지
└── lib/ // 외부 라이브러리 설정
대규모 프로젝트 (50+ 페이지)
src/
├── features/ // 비즈니스 도메인별
├── shared/ // 공유 모듈
├── pages/ // 라우트 (feature 조합)
├── lib/ // 외부 서비스 통합
└── app/ // 전역 설정, 프로바이더
주의할 점
Container 컴포넌트를 기계적으로 분리하는 실수
"모든 컴포넌트에 Container를 붙여야 한다"는 규칙은 불필요한 추상화를 만듭니다. 커스텀 Hook으로 로직을 분리하면 Container 컴포넌트 없이도 같은 효과를 얻을 수 있습니다.
Feature 폴더 안에서 다시 레이어로 분리
Feature 기반 구조를 도입하면서 각 feature 안에 또 components/, hooks/, services/ 같은 레이어를 과도하게 분리하면 colocation의 이점이 사라집니다. 파일 수가 적을 때는 flat 구조가 더 낫습니다.
정리
| 항목 | 설명 |
|---|---|
| 원칙 | 로직과 UI의 분리 — 여전히 유효 |
| 형태 변화 | Container 컴포넌트 → 커스텀 Hook이 로직 담당 |
| 순수 UI 컴포넌트 | 디자인 시스템 컴포넌트는 props만 받는 것이 여전히 좋음 |
| 현대적 구조 | Feature 기반 + colocation |
| 분리 기준 | "이 코드를 변경할 이유가 무엇인가" |
중요한 것은 특정 패턴의 이름이 아니라, "이 코드를 변경할 이유가 무엇인가"를 기준으로 분리하는 사고방식 자체입니다.