hongsoohyuk
홈이력서방명록프로젝트인스타그램블로그

© 2026 hongsoohyuk. All rights reserved.

GitHubLinkedIn
블로그/엔터프라이즈 SaaS 플랫폼에서 Feature-Sliced Design 점진적 마이그레이션

엔터프라이즈 SaaS 플랫폼에서 Feature-Sliced Design 점진적 마이그레이션

Software ArchitectureFrontend

마지막 수정: 2026년 2월 24일

개요

대규모 React 모노레포의 메인 앱(200+ 컴포넌트)을 플랫 디렉토리 구조에서 Feature-Sliced Design(FSD) 아키텍처로 점진적으로 전환한 경험을 정리했다. 약 50개의 커밋에 걸쳐 3개월간 진행 중인 마이그레이션의 전략, 도입 패턴, 그리고 실무에서 마주한 문제들을 다룬다.

배경

멀티 엔티티를 지원하는 SaaS 관리 플랫폼을 개발하고 있었다. 모노레포 안에 5개의 앱과 3개의 공유 패키지가 있었고, 메인 앱의 src/ 디렉토리는 전형적인 플랫 구조였다.

javascript
src/
  components/    (31개 디렉토리)
  hooks/         (17개)
  utils/         (17개)
  styled/        (6개)
  type/          (6개)

기능이 계속 추가되면서 components/ 안에 비즈니스 로직과 UI가 뒤섞이고, 도메인 간 의존성이 암묵적으로 얽혀갔다. "이 컴포넌트를 수정하면 어디까지 영향이 가는가"를 파악하기 어려운 상태였다.

해결 전략: 점진적 마이그레이션

빅뱅 방식의 전환은 현실적으로 불가능했다. 운영 중인 서비스에 기능 개발을 병행해야 했기 때문이다. 다음과 같은 전략을 세웠다.

1단계: 도메인 단위로 FSD 레이어 신설

가장 변경이 활발한 도메인(비용 관리 모듈)부터 시작했다. 기존 코드를 한 번에 옮기지 않고, 신규 FSD 레이어를 만들어 새로운 코드를 여기에 작성했다.

javascript
src/
  entities/cost/           # 도메인 모델, 쿼리 키
  features/cost-dashboard/ # 대시보드 기능
  widgets/cost/            # 복합 위젯
  pages/cost/              # 페이지 컴포넌트

하나의 도메인에서 entities → features → widgets → pages 순서로 레이어를 채워나갔다. entities에 15개의 API 훅과 queryKey 팩토리를 정의하고, features에 12개의 차트/리스트 UI를 배치했다.

2단계: 전역 상태의 app 레이어 정비

Redux slice와 Recoil atom이 여기저기 흩어져 있었다. 이것들을 app/store/slices/, app/store/atoms/로 모았고, 설정과 i18n도 app/config/, app/i18n/으로 분리했다.

이 단계에서 참조 경로 오류가 연쇄적으로 발생했다. 4개의 연속 커밋에 걸쳐 수정해야 했는데, 이는 "한 번에 너무 많이 옮기지 말 것"이라는 교훈을 남겼다.

3단계: 레거시 격리

당장 이관할 수 없는 레거시 코드는 features/TO_BE_REPLACED/라는 격리 디렉토리에 모았다. 현재 32개의 레거시 디렉토리가 여기에 있다. 이 방식의 장점은:

  • 새 코드가 레거시 패턴을 따라가는 것을 방지
  • 남은 이관 작업의 범위가 명확히 보임
  • 기존 기능은 깨지지 않음

4단계: ESLint로 레이어 규칙 강제

import/no-restricted-paths 규칙으로 레이어 간 import 방향을 강제했다.

javascript
'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 설정에 바로 추가 가능해서 이쪽을 택했다.

도입한 핵심 패턴

ViewModel 패턴

UI 컴포넌트에서 비즈니스 로직을 분리하기 위해 useXxxViewModel 커스텀 훅 패턴을 도입했다.

typescript
// 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 */}</>;
}

Query Key Factory 패턴

@lukemorales/query-key-factory를 도입해 쿼리 키를 체계적으로 관리했다. 기존에는 문자열 배열을 직접 쓰다 보니 오타와 불일치가 잦았다.

typescript
// entities/cost/api/queryKeyFactory.ts
export const costKeys = createQueryKeyStore({
  cost: {
    list: (params) => ({ queryKey: [params] }),
    detail: (id) => ({ queryKey: [id] }),
  },
});

슬라이스 공개 API

각 슬라이스는 index.ts를 통해서만 외부에 노출된다. 내부 구현을 캡슐화하여 변경 영향 범위를 제한했다.

실무에서 마주한 난제: 슬라이스 간 경계 모호성

FSD를 적용하면서 가장 많이 고민한 부분은 "이 코드가 어느 슬라이스에 속하는가"였다. 이론적으로는 명확해 보이지만, 실제 도메인에서는 하나의 API가 여러 슬라이스의 경계에 걸치는 경우가 빈번했다.

사례: 앱 관리자 조회 API는 어디에?

SaaS 플랫폼에서 "앱 인스턴스의 관리자(Manager) 목록을 조회"하는 API가 있다고 하자. REST 엔드포인트는 /apps/{appId}/managers이고, 응답 모델은 사용자(User) 정보를 확장한 타입이다.

직관적으로는 /apps/{appId}/... 경로니까 entities/app-instance에 두는 게 자연스러워 보인다. 실제로 담당자, 그룹, 라이선스 사용자 등 유사한 하위 리소스 API들도 entities/app-instance에 있었다.

하지만 한 가지 제약이 있었다. 이 API의 return type이 UserBaseModel을 확장한 타입이라는 점이다.

typescript
// 응답 타입 — 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에 배치했다.

이 판단이 남긴 트레이드오프

원칙에는 부합하지만, 실용적으로는 몇 가지 어색한 점이 남았다:

  • API 경로와 슬라이스가 불일치: /apps/{appId}/managers는 app-instance의 하위 리소스인데, 코드는 user 슬라이스에 있다
  • 소비자와의 거리: 이 API를 실제로 사용하는 컴포넌트는 모두 widgets/app-instance에 있다
  • 유사 API와의 비일관성: 같은 패턴의 다른 하위 리소스 API(담당자, 라이선스 사용자)는 entities/app-instance에 있는데, 관리자만 entities/user에 있다

이런 트레이드오프는 FSD를 도입하면 필연적으로 마주하는 문제다. 완벽한 정답은 없고, 팀이 합의한 기준을 일관되게 적용하는 것이 개인의 직관보다 중요하다는 걸 배웠다.

경계 판단에서 배운 것

  • cross-import 회피가 최우선: 같은 레이어 간 의존을 만들면 슬라이스 독립성이 무너진다. 약간 어색해 보여도 cross-import를 피하는 방향이 장기적으로 유지보수에 유리하다.
  • return type 기준은 명확한 규칙이 된다: "API 경로 기준", "소비자 기준" 같은 판단은 상황마다 흔들릴 수 있지만, return type 기준은 기계적으로 판단할 수 있어 팀 내 합의가 쉽다.
  • 합의를 문서화하라: 이런 경계 판단 기준은 구두 합의만으로는 부족하다. 새 팀원이 오면 같은 고민을 반복하게 된다.
  • 슬라이스 간 조합이 너무 많다면 FSD 도입 자체를 재고하라: 도메인 간 경계가 명확한 서비스에서는 FSD가 잘 작동한다. 하지만 하나의 화면에서 여러 도메인의 데이터를 조합해야 하는 경우가 대부분이라면, cross-import와 상위 레이어 조합이 폭발적으로 늘어난다. 슬라이스를 나눈 의미가 퇴색되고, 오히려 단순한 구조보다 복잡도만 높아질 수 있다. FSD는 도메인 경계가 비교적 뚜렷한 서비스에 적합하며, 도입 전에 "우리 서비스의 도메인들이 실제로 독립적으로 분리 가능한가"를 먼저 판단해야 한다.

배운 점

  • 점진적 마이그레이션은 "새 코드는 새 구조에" 원칙이 핵심이다. 레거시를 한 번에 옮기려 하면 실패한다. 새 기능부터 FSD 구조로 작성하고, 레거시는 격리한 뒤 여유가 있을 때 이관한다.
  • Store 이동은 가장 위험한 작업이었다. 전역 상태는 참조점이 많아서, 한 번 경로를 바꾸면 수십 개 파일이 영향받는다. 작은 단위로 나눠서 이동해야 한다.
  • ESLint 규칙은 초기에 설정해야 한다. 레이어 규칙 없이 구조만 만들면, 금세 잘못된 import가 쌓인다. FSD 디렉토리를 만들자마자 ESLint 규칙을 같이 적용했다.
  • TO_BE_REPLACED 패턴은 의외로 효과적이다. "완벽하게 이관할 때까지 시작하지 않겠다"보다 "레거시를 격리하고 점진적으로 줄여나가겠다"가 실용적이다.
  • 네이밍 컨벤션 통일은 이관 시에 함께 해야 한다. custom_field에서 custom-field로의 변환을 별도 작업으로 했는데, 이관 커밋에 포함시켰으면 리뷰가 더 깔끔했을 것이다.

참고 자료

  • Feature-Sliced Design 공식 문서
  • @lukemorales/query-key-factory
  • eslint-plugin-import no-restricted-paths