마지막 수정: 2026년 2월 24일
대규모 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가 뒤섞이고, 도메인 간 의존성이 암묵적으로 얽혀갔다. "이 컴포넌트를 수정하면 어디까지 영향이 가는가"를 파악하기 어려운 상태였다.
빅뱅 방식의 전환은 현실적으로 불가능했다. 운영 중인 서비스에 기능 개발을 병행해야 했기 때문이다. 다음과 같은 전략을 세웠다.
가장 변경이 활발한 도메인(비용 관리 모듈)부터 시작했다. 기존 코드를 한 번에 옮기지 않고, 신규 FSD 레이어를 만들어 새로운 코드를 여기에 작성했다.
src/
entities/cost/ # 도메인 모델, 쿼리 키
features/cost-dashboard/ # 대시보드 기능
widgets/cost/ # 복합 위젯
pages/cost/ # 페이지 컴포넌트하나의 도메인에서 entities → features → widgets → pages 순서로 레이어를 채워나갔다. entities에 15개의 API 훅과 queryKey 팩토리를 정의하고, features에 12개의 차트/리스트 UI를 배치했다.
Redux slice와 Recoil atom이 여기저기 흩어져 있었다. 이것들을 app/store/slices/, app/store/atoms/로 모았고, 설정과 i18n도 app/config/, app/i18n/으로 분리했다.
이 단계에서 참조 경로 오류가 연쇄적으로 발생했다. 4개의 연속 커밋에 걸쳐 수정해야 했는데, 이는 "한 번에 너무 많이 옮기지 말 것"이라는 교훈을 남겼다.
당장 이관할 수 없는 레거시 코드는 features/TO_BE_REPLACED/라는 격리 디렉토리에 모았다. 현재 32개의 레거시 디렉토리가 여기에 있다. 이 방식의 장점은:
import/no-restricted-paths 규칙으로 레이어 간 import 방향을 강제했다.
'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할 수 없습니다.' },
// entities, features, widgets, pages도 동일 패턴...
]
}]eslint-plugin-boundaries도 검토했지만, import/no-restricted-paths가 설정이 단순하고 기존 ESLint 설정에 바로 추가 가능해서 이쪽을 택했다.
UI 컴포넌트에서 비즈니스 로직을 분리하기 위해 useXxxViewModel 커스텀 훅 패턴을 도입했다.
// 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 */}</>;
}@lukemorales/query-key-factory를 도입해 쿼리 키를 체계적으로 관리했다. 기존에는 문자열 배열을 직접 쓰다 보니 오타와 불일치가 잦았다.
// entities/cost/api/queryKeyFactory.ts
export const costKeys = createQueryKeyStore({
cost: {
list: (params) => ({ queryKey: [params] }),
detail: (id) => ({ queryKey: [id] }),
},
});각 슬라이스는 index.ts를 통해서만 외부에 노출된다. 내부 구현을 캡슐화하여 변경 영향 범위를 제한했다.
FSD를 적용하면서 가장 많이 고민한 부분은 "이 코드가 어느 슬라이스에 속하는가"였다. 이론적으로는 명확해 보이지만, 실제 도메인에서는 하나의 API가 여러 슬라이스의 경계에 걸치는 경우가 빈번했다.
SaaS 플랫폼에서 "앱 인스턴스의 관리자(Manager) 목록을 조회"하는 API가 있다고 하자. REST 엔드포인트는 /apps/{appId}/managers이고, 응답 모델은 사용자(User) 정보를 확장한 타입이다.
직관적으로는 /apps/{appId}/... 경로니까 entities/app-instance에 두는 게 자연스러워 보인다. 실제로 담당자, 그룹, 라이선스 사용자 등 유사한 하위 리소스 API들도 entities/app-instance에 있었다.
하지만 한 가지 제약이 있었다. 이 API의 return type이 UserBaseModel을 확장한 타입이라는 점이다.
// 응답 타입 — User 도메인에 의존
type AppManagerModel = {
id: string;
createdAt: number;
user: UserBaseModel & {
profileImageUrl: string | null;
state: UserStateModel;
};
};만약 이 API 훅을 entities/app-instance에 두면, entities/user의 타입을 import해야 한다. 즉, 같은 entities 레이어 내에서 cross-import가 발생한다. FSD에서 동일 레이어 슬라이스 간 직접 import는 원칙적으로 금지되어 있고, 팀에서도 "return type의 도메인을 기준으로 슬라이스를 결정한다"는 합의가 있었다.
결과적으로 이 API 훅은 entities/user에 배치했다.
원칙에는 부합하지만, 실용적으로는 몇 가지 어색한 점이 남았다:
/apps/{appId}/managers는 app-instance의 하위 리소스인데, 코드는 user 슬라이스에 있다widgets/app-instance에 있다entities/app-instance에 있는데, 관리자만 entities/user에 있다이런 트레이드오프는 FSD를 도입하면 필연적으로 마주하는 문제다. 완벽한 정답은 없고, 팀이 합의한 기준을 일관되게 적용하는 것이 개인의 직관보다 중요하다는 걸 배웠다.
custom_field에서 custom-field로의 변환을 별도 작업으로 했는데, 이관 커밋에 포함시켰으면 리뷰가 더 깔끔했을 것이다.