컴포넌트를 "어떻게 나눌 것인가"에 대한 정답은 정해져 있을까요?

React 초기에는 Container-Presentational 패턴이 사실상 표준이었습니다. 하지만 Hooks가 등장하면서 이 패턴의 창시자인 Dan Abramov 본인이 "더 이상 이렇게 나눌 필요 없다"고 말했습니다. 그렇다면 지금은 관심사를 어떻게 분리해야 할까요?

Container-Presentational 패턴이란

이 패턴은 컴포넌트를 두 가지 역할로 나눕니다.

Presentational (보여주기 전담)

JSX
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 (데이터 전담)

JSX
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로 대체하면

JSX
// 커스텀 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으로 로직 분리

JSX
// 데이터 페칭 로직
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 컴포넌트가 사라진 것은 아닙니다.

JSX
// 순수 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 기반 폴더 구조

관심사 분리는 코드 구조에도 반영됩니다.

전통적인 타입 기반 구조

PLAINTEXT
src/
├── components/
│   ├── ProductCard.jsx
│   ├── UserProfile.jsx
│   └── OrderSummary.jsx
├── hooks/
│   ├── useProducts.js
│   ├── useUser.js
│   └── useOrder.js
├── utils/
└── styles/

파일이 늘어나면 관련 파일을 찾기 위해 여러 폴더를 넘나들어야 합니다.

Feature 기반 구조

PLAINTEXT
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하는 패턴입니다.

JS
// 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이 느려질 수 있습니다
JS
// 주의: barrel import는 사용하지 않는 모듈까지 로드할 수 있다
import { ProductCard } from '@/features/products';
// 내부적으로 ProductList, useProducts도 모두 평가될 수 있음

// 직접 경로가 더 안전한 경우
import { ProductCard } from '@/features/products/ProductCard';

프로젝트 규모가 크다면 barrel file 사용을 신중하게 결정해야 합니다.

실전 구조 가이드

소규모 프로젝트 (10개 이하 페이지)

PLAINTEXT
src/
├── components/     // 모든 컴포넌트
├── hooks/          // 모든 커스텀 훅
├── pages/          // 라우트 페이지
└── utils/

처음부터 feature 폴더를 만들 필요 없습니다. 복잡해지면 그때 리팩터링합니다.

중규모 프로젝트 (10-50 페이지)

PLAINTEXT
src/
├── features/       // 비즈니스 기능별
├── shared/         // 공유 컴포넌트, 훅
├── pages/          // 라우트 페이지
└── lib/            // 외부 라이브러리 설정

대규모 프로젝트 (50+ 페이지)

PLAINTEXT
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
분리 기준"이 코드를 변경할 이유가 무엇인가"

중요한 것은 특정 패턴의 이름이 아니라, "이 코드를 변경할 이유가 무엇인가"를 기준으로 분리하는 사고방식 자체입니다.

댓글 로딩 중...