Last edited: February 24, 2026
대규모 React 모노레포의 메인 앱(200+ 컴포넌트)을 플랫 디렉토리 구조에서 Feature-Sliced Design(FSD) 아키텍처로 점진적으로 전환한 경험을 정리했다. 약 50개의 커밋에 걸쳐 3개월간 진행 중인 마이그레이션의 전략, 도입 패턴, 그리고 실무에서 마주한 문제들을 다룬다.
멀티 엔티티를 지원하는 SaaS 관리 플랫폼을 개발하고 있었다. 모노레포 안에 5개의 앱과 3개의 공유 패키지가 있었고, 메인 앱의 src/ 디렉토리는 전형적인 플랫 구조였다.
src/
components/ (31개 디렉토리)
hooks/ (17개)
utils/ (17개)
styled/ (6개)
type/ (6개)
기능이 계속 추가되면서 components/ 안에 비즈니스 로직과 UI가 뒤섞이고, 도메인 간 의존성이 암묵적으로 얽혀갔다.
빅뱅 방식의 전환은 현실적으로 불가능했다. 운영 중인 서비스에 기능 개발을 병행해야 했기 때문이다.
가장 변경이 활발한 도메인(비용 관리 모듈)부터 시작했다.
src/
entities/cost/ # 도메인 모델, 쿼리 키
features/cost-dashboard/ # 대시보드 기능
widgets/cost/ # 복합 위젯
pages/cost/ # 페이지 컴포넌트
Redux slice와 Recoil atom이 여기저기 흩어져 있었다. 이것들을 app/store/slices/, app/store/atoms/로 모았다.
이 단계에서 참조 경로 오류가 연쇄적으로 발생했다. 4개의 연속 커밋에 걸쳐 수정해야 했는데, 이는 "한 번에 너무 많이 옮기지 말 것"이라는 교훈을 남겼다.
당장 이관할 수 없는 레거시 코드는 features/TO_BE_REPLACED/라는 격리 디렉토리에 모았다. 현재 32개의 레거시 디렉토리가 여기에 있다.
'import/no-restricted-paths': ['error', {
zones: [
{ target: './apps/*/src/shared',
from: ['./apps/*/src/entities', './apps/*/src/features',
'./apps/*/src/widgets', './apps/*/src/pages', './apps/*/src/app'],
message: 'shared 레이어는 상위 레이어를 import할 수 없습니다.' },
]
}]
// features/cost/model/useCostDetailViewModel.ts
export function useCostDetailViewModel(id: string) {
const { data } = useQuery(costKeys.detail(id));
const mutation = useMutation(/* ... */);
return { data, isEditable: data?.status === 'DRAFT', submit: mutation.mutate };
}
// features/cost/ui/CostDetail.tsx — 순수 렌더링만 담당
function CostDetail({ id }: Props) {
const { data, isEditable, submit } = useCostDetailViewModel(id);
return <>{/* UI only */}</>;
}
export const costKeys = createQueryKeyStore({
cost: {
list: (params) => ({ queryKey: [params] }),
detail: (id) => ({ queryKey: [id] }),
},
});
각 슬라이스는 index.ts를 통해서만 외부에 노출된다. 내부 구현을 캡슐화하여 변경 영향 범위를 제한했다.
FSD를 적용하면서 가장 많이 고민한 부분은 "이 코드가 어느 슬라이스에 속하는가"였다.
// 응답 타입 — User 도메인에 의존
type AppManagerModel = {
id: string;
createdAt: number;
user: UserBaseModel & {
profileImageUrl: string | null;
state: UserStateModel;
};
};
만약 이 API 훅을 entities/app-instance에 두면, entities/user의 타입을 import해야 한다. 즉, 같은 entities 레이어 내에서 cross-import가 발생한다.
결과적으로 이 API 훅은 entities/user에 배치했다.
widgets/app-instance에 있다