본문으로 건너뛰기

Container 중심 구조에서 Feature-Sliced Design으로

· 약 12분
방경민
방경민
Frontend Developer

프론트엔드 아키텍처를 설계할 때 가장 어려운 점은 규모가 커졌을 때도 유지 가능한 구조를 만드는 것이라고 생각합니다.

이 글에서는 제가 사용했던 Container 중심 아키텍처의 의도와 개념, 그리고 그 구조가 확장되며 드러난 한계를 정리하고, 왜 Feature-Sliced Design(FSD)을 다음 선택지로 정하게 되었는지를 이야기해 보려고 합니다.


1. 프로젝트 맥락

먼저 어떤 상황에서 이 구조를 선택했는지 간단히 공유합니다.

대규모 프로젝트 개발 중 대부분의 페이지가 필터 + 데이터 목록 + CRUD라는 유사한 구조를 가지고 있었고, 짧은 기간 안에 많은 페이지를 개발해야 했기 때문에 다음과 같은 목표를 세웠습니다.

  • 반복되는 UI 패턴을 표준화한다
  • 페이지마다 "어떻게 만들지?" 고민하는 시간을 줄인다
  • 일관된 구조로 PR 리뷰 시간을 단축한다

2. Container 중심 구조를 선택했던 이유

이 목표를 달성하기 위해 Presentational / Container 패턴을 기반으로 구조를 설계했습니다.

여기서 Container 구조의 핵심 철학은 단순합니다.

  • UI는 가능한 한 순수하게 유지하고 (Storybook 테스트 가능)
  • 로직은 조합 가능한 단위로 분리하고
  • 화면 흐름/분기는 상위 계층에서 일관되게 통제한다
Presentational (UI)Container (Logic)
Props로 데이터 수신API 호출
이벤트 핸들러를 Props로 수신상태 관리
순수 UI 렌더링비즈니스 로직
외부 의존성 없음에러 핸들링

Presentational 컴포넌트는 Storybook으로 시각적 테스트가 가능하도록 순수하게 유지하고, 모든 로직은 Container에 모으는 것이 핵심 의도였습니다.


3. 운영했던 다섯 가지 Container 개념과 그 의도

단순히 Container 하나로 모든 것을 처리하기엔 페이지가 복잡해질수록 한계가 있었습니다. 그래서 Container의 역할을 세분화해 다음과 같은 계층 구조로 운영했습니다.

3-1. Container (기본 단위)

하나의 기능을 담당하는 가장 작은 로직 단위입니다.

const SearchFilterContainer = () => {
const { filters, setFilter } = usePageQueryState();

return <SearchFilter filters={filters} onChange={setFilter} />;
};

검색 필터, 목록 조회, 등록/수정 폼 등 각각이 하나의 Container가 됩니다.

3-2. SectionContainer

여러 Container를 묶어 하나의 의미 있는 화면 섹션을 구성합니다.

const ManagementSectionContainer = () => {
return (
<Section>
<SearchFilterContainer />
<ListContainer />
</Section>
);
};

Container 간 공유가 필요한 상태가 있을 경우, SectionContainer에서 관리하도록 했습니다.

3-3. SwitchContainer

탭, 권한, 조건 등에 따라 서로 다른 SectionContainer를 분기합니다.

const TabSwitchContainer = () => {
const { activeTab, setActiveTab } = usePageQueryState();

return (
<>
<PrimaryTab activeTab={activeTab} onChange={setActiveTab} />
{activeTab === "issued" && <IssuedSectionContainer />}
{activeTab === "traded" && <TradedSectionContainer />}
</>
);
};

조건 분기 로직이 개별 Container로 흩어지는 것을 방지하고, 화면 흐름 제어를 한 곳에 모으기 위한 선택이었습니다.

3-4. PageContainer

페이지 단위의 최상위 진입점으로, 레이아웃 구성과 페이지 단위 상태 관리를 담당합니다.

const ManagementPage = () => {
return (
<PageLayout>
<TabSwitchContainer />
</PageLayout>
);
};

3-5. Guard

인증, 권한과 같은 횡단 관심사를 처리하기 위한 특수 Container입니다.

const PermissionGuard = ({ children, allowedRoles }) => {
const { user, isLoading } = useAuth();

if (isLoading) return <LoadingSpinner />;
if (!user) return <RedirectToLogin />;
if (!allowedRoles.includes(user.role)) return <AccessDenied />;

return <>{children}</>;
};

이를 통해 각 Container가 인증·권한 로직을 직접 알지 않도록 했습니다.


4. 이 구조가 가져다 준 것들

이 구조는 초기 목표를 달성하는 데 분명히 효과적이었습니다.

개발 효율성 측면에서

  • 패턴화된 구조로 신규 페이지 생성 시간이 단축되었습니다
  • 일관된 구조 덕분에 PR 리뷰 포인트가 명확해졌습니다
  • 새로운 팀원도 패턴만 익히면 빠르게 기여할 수 있었습니다

테스트 측면에서

  • UI 컴포넌트는 Storybook으로 시각적 테스트
  • Custom Hook은 단위 테스트로 로직 검증
  • Guard는 통합 테스트로 권한/인증 시나리오 검증

시간이 촉박한 대규모 프로젝트의 페이지를 기간 내 개발할 수 있었던 것은 이 구조 덕분이었다고 생각합니다.


5. 구조가 커지며 드러난 문제점들

하지만 규모가 커질수록 다음과 같은 문제들이 점점 명확해졌습니다.

가장 크게 체감했던 것은 "개발 속도"는 유지되었지만, 구조를 이해하고 수정하는 데 드는 비용이 점점 더 눈에 띄게 증가했습니다.

5-1. 페이지 내부에 Container가 과도하게 늘어났습니다

하나의 페이지 안에 검색 필터, 목록 조회, 상세 조회, 등록/수정 모달 등 수많은 Container들이 생기기 시작했습니다. 하나의 기능 흐름을 이해하기 위해 여러 Container 파일을 계속 오가야 했고, 탐색 단위가 페이지에 묶여 있는 구조가 되었습니다.

5-2. 같은 도메인이 여러 페이지에서 쓰이면 common 폴더로 이동해야 했습니다

Container가 페이지 기준으로 나뉘어 있다 보니, 같은 도메인 로직이 여러 페이지에서 사용되면 이를 common 폴더로 올릴 수밖에 없었습니다. 그 결과 common은 점점 의미 단위가 불분명해지고, 재사용 여부만으로 코드가 모이는 공간이 되어 갔습니다.

5-3. Container라는 개념이 명확한 책임 경계 없이 확장되며 점점 자유로워졌습니다

Container라는 이름 아래에 CRUD 전체를 담당하는 것, id만 받아 조회만 수행하는 것, 여러 Container를 조합하는 것이 공존하게 되었습니다. 모두 Container였지만, 책임의 깊이와 복잡도는 전혀 달랐습니다.

5-4. "이건 무슨 Container인가요?"라는 질문이 반복되었습니다

모든 로직 컴포넌트가 Container이다 보니, 이름만으로는 역할을 추론하기 어려워졌습니다. 구조를 이해하기 위해 설명이 필요한 시점이 오기 시작했습니다.

5-5. Container가 Container를 감싸는 구조가 늘어났습니다

SectionContainer가 커질수록 Container 안에 Container가 중첩되는 구조가 자연스럽게 만들어졌습니다. 상태 관리 위치를 추적하기 어려워지고, Props 전달 경로가 길어지며, 책임의 경계는 더욱 흐려졌습니다.


6. Feature-Sliced Design이란

이 문제들을 정리하던 중 Feature-Sliced Design(FSD)을 접하게 되었습니다.

FSD는 프론트엔드 아키텍처를 위한 설계 방법론으로, 다음과 같은 레이어 구조를 제안합니다.

app/        ← 앱 초기화, 프로바이더, 전역 설정
pages/ ← 라우트 진입점
widgets/ ← 독립적인 UI 블록 (여러 feature 조합)
features/ ← 사용자 행동 단위 (유스케이스)
entities/ ← 비즈니스 엔티티 (도메인 모델)
shared/ ← 공유 유틸리티, UI 킷

핵심 원칙은 단방향 의존성입니다. 상위 레이어는 하위 레이어만 의존할 수 있고, 같은 레이어 내에서 서로 다른 슬라이스끼리 직접 의존하지 않는 것을 기본으로 합니다.

그렇지만 같은 슬라이스 내부에서는 자유롭게 의존할 수 있습니다.

이 규칙은 린터와 코드 리뷰를 통해 구조적으로 검증될 수 있기 때문에, 의존성이 꼬이는 문제를 조기에 발견하는 데 도움이 됩니다.

또한 각 슬라이스는 index.ts를 통해 Public API만 외부에 노출합니다. 외부에서는 슬라이스 내부 파일을 직접 import할 수 없고, 반드시 진입점을 통해야 합니다. 이를 통해 내부 구현 변경이 외부에 영향을 주지 않으며, 린터(eslint-plugin-boundaries 등)로 의존성 규칙을 검증할 수 있습니다.


7. 기존 구조와 FSD의 연결점

흥미로웠던 점은 기존에 사용하던 개념들이 FSD의 레이어와 상당히 유사하다는 것이었습니다.

기존 개념FSD 레이어
Container (사용자 행동 포함)features
Container (조회/표시만)entities
Presentational (도메인 표현)entities
Presentational (범용 UI)shared
SectionContainerwidgets
SwitchContainerwidgets (조건부 조합)
PageContainerpages
Guard (인증/권한)shared

기존의 Container는 조회 UI부터 사용자 행동까지 다양한 책임을 포함하고 있었기 때문에, FSD에서는 그 역할에 따라 features 또는 entities로 분리됩니다.


8. FSD가 기존 문제를 어떻게 해결하는가

기존 문제FSD가 완화해주는 방식
Container가 과도하게 늘어남Feature 단위로 응집, 페이지가 아닌 기능 기준 탐색
common 폴더가 비대해짐entities로 도메인 로직 분리, shared로 유틸 분리
Container 역할이 모호함레이어별 명확한 책임 정의
이름만으로 역할 추론 불가레이어 이름 자체가 역할을 설명
중첩 구조로 경계 흐려짐단방향 의존성 규칙으로 경계를 유지하기 쉬움

특히 common으로 올라갔던 도메인 로직이 FSD에서는 entities 레이어로 명확히 분리된다는 점이 인상적이었습니다. "재사용되니까 common"이 아니라, "도메인 모델이니까 entities"로 의미 기반 분류가 가능해집니다.


9. 폴더 구조 변환 예시

기존 구조

src/
├── components/
│ ├── common/
│ └── domain/
├── containers/
│ └── [domain]/
├── section-containers/
│ └── [domain]/
├── switch-containers/
│ └── [domain]/
├── guards/
├── hooks/
│ ├── common/
│ └── [domain]/
└── pages/

FSD 구조

src/
├── app/
├── pages/
│ └── order/
├── widgets/
│ └── order-management/
├── features/
│ ├── order-search/ ← 사용자 행동: 주문 검색
│ └── order-create/ ← 사용자 행동: 주문 생성
├── entities/
│ └── order/
└── shared/
├── ui/
├── lib/
└── guards/
└── PermissionGuard.tsx ← 재사용 가능한 권한 체크 컴포넌트

핵심 변화는 페이지 중심 탐색에서 기능 중심 탐색으로 바뀐다는 점입니다.


10. Feature vs Entity 구분하기

FSD를 적용할 때 가장 많이 고민되는 부분이 "이건 feature인가, entity인가?"입니다. 저희 팀이 정한 기준은 다음과 같습니다.

기본 원칙

  • features: 사용자가 하는 것 (동사) — 검색하다, 생성하다, 필터링하다, 정렬하다
  • entities: 도메인에 존재하는 것 (명사) — 주문, 상품, 사용자

구체적인 판단 기준

상황레이어이유
주문 데이터를 카드 형태로 보여주는 컴포넌트entities엔티티의 UI 표현
주문 목록을 단순히 나열하는 컴포넌트entities엔티티 컬렉션의 UI 표현
주문 목록에서 키워드로 검색하는 기능features사용자 인터랙션(검색 행동)
주문 목록을 날짜순으로 정렬하는 기능features사용자 인터랙션(정렬 행동)
주문 목록 페이지네이션features사용자 인터랙션(탐색 행동)

애매한 경우의 판단

"주문 목록 컴포넌트"는 어디에?

  • 단순히 orders 배열을 받아 렌더링만 한다면 → entities/order/ui/OrderList.tsx
  • 페이지네이션, 정렬, 필터 상태를 내부에서 관리한다면 → features/order-list/

저희 팀에서는 상태와 인터랙션의 유무를 주요 판단 기준으로 삼았습니다. Props만 받아서 렌더링하면 entity, 내부에서 상태를 관리하고 사용자 행동을 처리하면 feature입니다.


11. View 컴포넌트 분리: Storybook 테스트를 위한 전략

FSD로 전환하더라도 순수한 View 컴포넌트를 Storybook으로 테스트한다는 원칙은 바꾸지 않으려고 합니다. 오히려 FSD의 구조 안에서 이 원칙을 더 명확하게 적용할 수 있다고 판단했습니다.

노트

모든 컴포넌트를 View로 분리해야 하는 것은 아닙니다. 상태와 로직의 복잡도가 있거나, 여러 UI 상태를 시각적으로 검증할 필요가 있는 경우에만 이 패턴을 적용합니다.

View 컴포넌트의 분리

entitiesfeaturesui 세그먼트 안에 ~View라는 접미사를 가진 순수 UI 컴포넌트를 만듭니다.

features/
└── order-search/
├── ui/
│ ├── OrderSearchView.tsx ← 순수 UI (Storybook 테스트 대상)
│ ├── OrderSearchView.stories.tsx
│ └── OrderSearch.tsx ← View + Hook 조합
├── model/
│ └── useOrderSearch.ts
└── index.ts

View 컴포넌트 예시

// features/order-search/ui/OrderSearchView.tsx
interface OrderSearchViewProps {
keyword: string;
status: OrderStatus | null;
dateRange: DateRange;
onKeywordChange: (keyword: string) => void;
onStatusChange: (status: OrderStatus | null) => void;
onDateRangeChange: (range: DateRange) => void;
onSearch: () => void;
isSearching: boolean;
}

export const OrderSearchView = ({
keyword,
status,
dateRange,
onKeywordChange,
onStatusChange,
onDateRangeChange,
onSearch,
isSearching,
}: OrderSearchViewProps) => {
return (
<div className="order-search">
<Input
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder="주문번호 또는 고객명"
/>
<StatusSelect value={status} onChange={onStatusChange} />
<DateRangePicker value={dateRange} onChange={onDateRangeChange} />
<Button onClick={onSearch} loading={isSearching}>
검색
</Button>
</div>
);
};

Storybook 스토리 작성

// features/order-search/ui/OrderSearchView.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { OrderSearchView } from "./OrderSearchView";

const meta: Meta<typeof OrderSearchView> = {
component: OrderSearchView,
title: "features/order-search/OrderSearchView",
};

export default meta;
type Story = StoryObj<typeof OrderSearchView>;

export const Default: Story = {
args: {
keyword: "",
status: null,
dateRange: { start: new Date(), end: new Date() },
isSearching: false,
},
};

export const Searching: Story = {
args: {
...Default.args,
keyword: "ORD-2024",
isSearching: true,
},
};

Hook을 조합한 완성 컴포넌트

API가 완성되면 View를 import하여 Hook과 조합합니다.

// features/order-search/ui/OrderSearch.tsx
import { OrderSearchView } from "./OrderSearchView";
import { useOrderSearch } from "../model/useOrderSearch";

export const OrderSearch = () => {
const {
keyword,
status,
dateRange,
setKeyword,
setStatus,
setDateRange,
search,
isSearching,
} = useOrderSearch();

return (
<OrderSearchView
keyword={keyword}
status={status}
dateRange={dateRange}
onKeywordChange={setKeyword}
onStatusChange={setStatus}
onDateRangeChange={setDateRange}
onSearch={search}
isSearching={isSearching}
/>
);
};

개발 흐름

이 구조의 장점은 API 완성 여부와 관계없이 UI 개발을 진행할 수 있다는 것입니다.

  1. 기획/디자인 확정 → ~View 컴포넌트 작성
  2. Storybook으로 다양한 상태 시각적 검증
  3. API 스펙 확정 → Hook 작성
  4. View + Hook 조합하여 최종 컴포넌트 완성

BE API가 늦어지더라도 UI 개발과 Storybook 테스트는 독립적으로 진행됩니다.


12. Adapter 위치

이전 글에서 소개했던 Adapter 패턴은 특정 도메인 API 응답값을 내부 View에 맞게 변환해주는 패턴인 만큼 FSD 구조에서도 자연스럽게 적용할 수 있습니다. Adapter의 위치는 데이터 소스와 용도에 따라 결정합니다.

상황Adapter 위치이유
단일 엔티티의 다양한 UI 표현 (조회)entities도메인 표현 책임
여러 entities 데이터를 조합하여 변환widgets조합 레이어 책임

entities/api는 가능한 한 도메인 형태(raw) 로 데이터를 다룹니다. View에서 필요한 형태로 바꾸는 과정은 entities/lib 또는 widgets/lib에서 Adapter로 처리합니다. feature는 "사용자 행동/상태"에 집중하고, 데이터 shape 변환은 최대한 바깥으로 밀어냅니다.

이렇게 하면 feature의 책임이 불필요하게 커지는 것을 방지할 수 있었습니다.


13. 전체 구조와 테스트 전략

각 레이어별 구성과 테스트 전략을 정리하면 다음과 같습니다.

레이어주요 구성요소테스트 방식
appProvider, 전역 설정통합 테스트
pages라우트 진입점, 레이아웃E2E 테스트
widgetsFeature 조합, Adapter통합 테스트
featuresView 컴포넌트, Mutation HookView → Storybook, Hook → 단위 테스트
entities도메인 타입, API Hook, 엔티티 UIUI → Storybook, Hook → 단위 테스트
shared유틸, UI 킷, 권한 Guard단위 테스트
src/
├── app/
├── pages/
│ └── order/
│ └── ManagementPage.tsx
├── widgets/
│ └── order-management/
│ ├── lib/
│ │ └── OrderManagementAdapter.ts ← 단위 테스트
│ └── ui/
│ └── OrderManagementWidget.tsx ← 통합 테스트
├── features/
│ ├── order-search/
│ │ ├── ui/
│ │ │ ├── OrderSearchView.tsx ← Storybook
│ │ │ └── OrderSearch.tsx
│ │ └── model/
│ │ └── useOrderSearch.ts ← 단위 테스트
│ └── order-create/
│ └── ui/
│ ├── OrderCreateFormView.tsx ← Storybook
│ └── OrderCreateForm.tsx
├── entities/
│ └── order/
│ ├── ui/
│ │ └── OrderCard.tsx ← Storybook
│ ├── model/
│ │ └── order.types.ts
│ └── api/
│ └── useOrderQuery.ts
└── shared/
├── ui/
├── lib/
└── guards/
└── PermissionGuard.tsx
노트

Guard를 shared에 둔 것은 도메인과 무관하게 재사용되는 횡단 관심사라는 판단에 따른 선택이며, 프로젝트 성격에 따라 다른 위치가 더 적합할 수도 있습니다.


14. 적용 시 예상되는 트레이드오프

FSD가 모든 문제를 해결해주는 것은 아닙니다. 도입 전 고려해야 할 트레이드오프를 정리했습니다.

초기 학습 비용

레이어 개념, 슬라이스 구분, 의존성 규칙을 팀 전체가 이해해야 합니다. 기존에 다른 구조에 익숙한 팀원이라면 적응 시간이 필요합니다.

지속적인 논의 비용

"이건 feature인가 entity인가?", "이 컴포넌트는 widget으로 승격해야 하나?", "Adapter는 어디에 둘까?" 등 개인마다 다른 의견이 나올 수 있습니다. 팀 컨벤션을 문서화하고 지속적으로 업데이트해야 합니다.

레이어 간 이동 시 리팩토링 비용

처음에 entities에 두었던 컴포넌트가 인터랙션이 추가되면서 features로 이동해야 하는 경우, 파일 이동뿐 아니라 import 경로 수정, 테스트 파일 이동 등의 작업이 필요합니다.

기존 도구/라이브러리와의 충돌

일부 보일러플레이트나 프레임워크(예: Next.js의 app router)는 자체적인 폴더 구조를 강제합니다. FSD와 조화롭게 사용하려면 추가적인 설정이나 타협이 필요할 수 있습니다.


15. 다음 프로젝트에서의 적용 계획

차기 프로젝트에서는 처음부터 FSD를 기준으로 구조를 설계하려고 합니다.

  • features: ~View 순수 컴포넌트 + Hook 조합 컴포넌트
  • widgets: 여러 feature 조합 + 필요시 Adapter 운영
  • entities: 도메인 모델, 기본 API Hook, 엔티티 UI 컴포넌트
  • pages: 라우트 진입점과 레이아웃
  • shared: 재사용 가능한 Guard를 포함한 횡단 관심사
  • app: 프로바이더, 라우터, 전역 스타일, 전역 타입 선언 등

기존 구조에서 효과적이었던 UI 선작업과 Storybook 중심 개발 흐름은 유지하되, 비즈니스 로직 책임의 경계는 FSD 레이어로 더 명확히 구분할 계획입니다.


마무리하며

돌이켜보면 Container 중심 구조는 문제를 해결하기 위한 합리적인 첫 선택이었습니다.

다만 규모가 커질수록, 자유도가 높은 구조는 명확한 기준 없이는 점점 설명이 필요한 구조로 변해갔습니다. 그리고 그 과정에서 "개발 속도"는 유지되었지만, 구조를 이해하고 수정하는 데 드는 비용이 점점 더 크게 체감되었습니다.

Feature-Sliced Design은 기존 설계를 부정하는 개념이 아니라, 그 다음 단계로 자연스럽게 이어지는 구조로 느껴졌습니다. 특히 순수 View 컴포넌트 분리와 Adapter 패턴이라는 기존의 좋은 관행들을 FSD 구조 안에서 더 명확한 위치에 배치할 수 있다는 점이 매력적이었습니다.

Container에서 FSD로의 전환은 “무엇이 옳다”의 문제가 아니라, 스케일에 따라 요구되는 기준이 달라지는 문제에 더 가까웠습니다. 아마 소규모 프로젝트에서는 여전히 Container 패턴을 사용할 수도 있습니다.

FSD가 만능은 아니라고 생각합니다. 프로젝트 규모, 팀 상황, 기존 코드베이스 등을 고려하여 도입 여부를 결정해야 합니다. 이런 이유로 현재 프로젝트를 당장 마이그레이션 하지 않을 생각입니다.

다음 프로젝트에서는 이 경험 위에서, 더 명확한 의미 단위를 가진 구조로 처음부터 시작해보고 FSD가 어떤 이점을 주는지 체감해보려 합니다.


참고 자료