Tooling

작은 React 프로젝트에서 FSD를 그대로 쓰지 않기로 했다

Vite + TanStack Router로 사내 관리 도구를 스캐폴드하며 FSD를 네 레이어로 깎았습니다. 파일 기반 라우팅이 폴더 구조를 먼저 정하는 문제, 그리고 그 경계를 ESLint로 강제하려다 두 번 조용히 실패한 기록입니다.

geonoooo

2 min read

NOTE

이 글에서 다루는 것 사내 관리 도구 프론트엔드를 새로 스캐폴드하면서 폴더 구조를 정한 기록입니다. FSD를 네 레이어로 깎은 이유, 라우터가 폴더 구조를 먼저 정해버리는 문제, 그리고 그렇게 정한 경계를 lint로 강제하려다 두 번 조용히 실패한 이야기까지 적었습니다.

스택부터

이 프로젝트는 사내에서만 쓰는 관리 도구입니다. SEO도 필요 없고 첫 페인트를 1초 안에 끝내야 할 이유도 없습니다. 그래서 SSR 없이 SPA로 갔습니다.

항목선택
빌드Vite사내 도구라 SSR이 필요 없다
UIReact 19팀이 쓰던 것
라우팅TanStack Router타입 안전한 파일 기반 라우팅 + 라우트 로더
서버 상태TanStack Query라우터와 같은 계열, 로더에서 프리페치

라우터를 TanStack으로 고른 게 이 글의 절반을 결정했습니다. 파일 기반 라우팅은 폴더 구조를 강제합니다. routes/ 아래 파일 경로가 그대로 URL이 되니까, 내가 어떤 아키텍처를 좋아하든 라우터가 먼저 폴더 하나를 가져갑니다. 여기서부터 원래 쓰던 구조와 충돌이 났습니다.

자주 쓰는 폴더 구조

저는 기본적으로 Feature-Sliced Design 폴더 구조를 자주 썼습니다. 폴더 구조를 정할 때 보는 기준이 하나인데 — 응집도는 높게, 결합도는 낮게 — FSD가 그걸 레이어로 강제해 주기 때문입니다. 같이 바뀌는 것은 같이 두고, 같이 안 바뀌는 것은 서로 모르게 한다는 뜻입니다.

FSD의 레이어는 이렇습니다.

app/       앱 초기화, 프로바이더, 전역 스타일
pages/     라우트 단위 화면
widgets/   화면 한 덩어리를 이루는 조립 블록
features/  사용자가 하는 행동 (댓글 달기, 필터 걸기)
entities/  도메인 개체 (User, Post)
shared/    도메인을 모르는 공용 코드

의존은 위에서 아래로만 흐릅니다. entitiesfeatures를 import하면 잘못된 것입니다. 이 방향 규칙 하나가 결합도를 잡아 줍니다.

다만 프로젝트가 작으면 저는 entities를 뺐습니다. 이유는 취향이 아니라 비용입니다.

  • 화면이 열 개도 안 되는 프로젝트에서 entities/user/는 보통 타입 하나와 배지 컴포넌트 하나로 끝납니다. 레이어를 하나 유지하는 값을 못 합니다.
  • 더 큰 비용은 "이건 entity인가 feature인가" 를 매번 다시 논의한다는 것입니다. entities/post/ui/PostCard인지 features/post-list/ui/PostCard인지는 답이 없는 질문이고, 답이 없는 질문은 리뷰에서 사람 수만큼 답이 나옵니다.

그래서 작은 프로젝트에서는 app / pages / widgets / shared 정도로 줄여 썼습니다. 이번에도 그 연장선에서 시작했는데, 라우터 때문에 한 번 더 깎게 됐습니다.

라우터가 pages를 이미 가져갔다

TanStack Router의 파일 기반 라우팅에서 routes/users.tsx/users가 됩니다. 폴더가 곧 URL입니다.

그런데 FSD의 pages/도 라우트 단위 화면입니다. 둘을 같이 두면 화면 하나가 두 군데에 삽니다. routes/users.tsx는 껍데기만 있고 실제 화면은 pages/users/에 있는 구조가 됩니다. 파일 하나 열어서 될 일이 두 개를 열어야 되는 일이 됩니다. 응집도를 높이려고 쓴 구조가 응집도를 깎는 셈입니다.

그래서 pages/를 버리고 routes/를 FSD의 pages 자리로 썼습니다. 여기까지 정리하면 네 레이어가 남습니다.

이 구조FSD 대응왜 이렇게
routes/pages라우터가 이미 이 폴더를 가져갔다. 두 벌로 만들지 않는다
app/app앱 셸. 사이드바·헤더·전역 네비게이션
modules/entities + features가르는 기준이 사람마다 달라서 합쳤다
shared/shared그대로
widgets한 화면만 쓰는 조립은 그 화면 옆에 둔다

widgets을 뺀 자리가 이 글의 다음 절입니다. 한 화면에서만 쓰는 조립 블록은 레이어로 올리지 않고 그 라우트 폴더 안에 둡니다.

레이어 넷, 한 방향

src/ 아래는 네 층이고 의존은 위에서 아래로만 흐릅니다. 위 레이어는 아래를 알아도 되지만, 아래 레이어는 위를 몰라야 합니다.

레이어하는 일모르는 것
routes/라우트 정의와 화면 조립. URL이 여기서 정해진다
app/앱 셸. 사이드바, 헤더, 전역 네비게이션개별 라우트 구현
modules/도메인. index.ts로만 열려 있다화면
shared/도메인도 화면도 모르는 것만도메인, 화면

말로만 보면 흔한 배치입니다. 차이는 각 층이 무엇을 모르는가를 문장으로 못 박아 뒀다는 데 있습니다. 그리고 그 문장이 뒤에서 그대로 lint 에러 메시지가 됩니다.

shared/가 뭘 모르는지가 제일 헷갈리는 자리라 예를 하나 들면, 목록 페이지 나누기 함수는 페이지 크기를 상수로 갖지 않습니다.

// src/shared/paging.ts
/*
 * 목록 페이지 나누기. 도메인을 모른다 — 한 페이지에 몇 건을 담을지는
 * 부르는 쪽이 정한다. 검수 이력은 10건, 시험방법 DB는 10건,
 * 용어집은 20건이라 크기가 화면마다 다르다.
 */
export function pageOf<T>(items: Array<T>, page: number, size: number): Array<T> {
  // …
}

PAGE_SIZE = 10을 여기 두는 순간 이 파일은 "검수 이력이 10건짜리 목록"이라는 걸 알게 됩니다. 그러면 용어집이 20건이 되는 날 상수가 두 개로 늘고, 결국 shared가 도메인을 알게 됩니다. 인자로 받으면 이 파일은 영원히 도메인을 모릅니다.

modules/는 반대쪽입니다. 도메인은 알지만 화면을 모릅니다. 그래서 modules/reports/api.ts, queries.ts, model.ts, ui/를 갖고 있고 index.ts 하나로만 열려 있습니다. 바깥에서 modules/reports/model을 직접 import하지 못하게 막습니다. 공개 API를 좁혀 두면 모듈 내부를 바꿀 때 영향 범위가 index.ts가 내보내는 것으로 한정됩니다.

- 접두사: 한 화면만 쓰는 것

FSD에서 widgets이 하던 일 — 화면 한 덩어리를 이루는 조립 — 은 레이어로 올리지 않고 라우트 옆에 둡니다. routes/ 안에 URL이 되지 않는 폴더를 섞는 방식입니다. -로 시작하는 것들입니다.

routes/
├── users.tsx              → /users
├── -users/                users.tsx 전용. URL 없음
│   ├── table.tsx
│   ├── model.ts
│   ├── model.test.ts
│   └── last-admin-dialog.tsx
└── reports/new/
    ├── route.tsx          → /reports/new (레이아웃)
    ├── draft.tsx          → /reports/new/draft
    ├── test-method.tsx    → /reports/new/test-method
    ├── -components/       이 세 화면 전용 UI
    ├── -draft/            draft.tsx 전용
    └── -hooks/            폼 상태 훅

이건 제가 만든 관례가 아니라 생성기의 설정값입니다. @tanstack/router-generator의 스키마에 기본값이 박혀 있습니다.

// @tanstack/router-generator/dist/esm/config.js
routeFilePrefix:        z.string().optional(),
routeFileIgnorePrefix:  z.string().optional().default("-"),
routeFileIgnorePattern: z.string().optional(),

바꿀 수 있는 기본값입니다(설정 문서). 다만 _는 못 씁니다. 같은 파일이 "routeFileIgnorePrefix" cannot be an underscore ("_"). This is a reserved character used to denote a pathless route.라며 명시적으로 던집니다. _pathless 레이아웃 라우트에 이미 쓰이고 있는 문자입니다.

효과는 단순합니다. routeTree.gen.ts를 grep하면 -users, -components는 한 번도 나오지 않습니다. 생성물에 아예 들어가지 않습니다.

그래서 - 폴더는 위치가 곧 범위 선언이 됩니다. 이게 응집도 쪽 이득입니다.

  • routes/-users/table.tsx는 그 라우트 말고 아무도 안 쓴다는 게 위치로 보장됩니다. 지울 때 화면과 같이 지우면 됩니다.
  • 반대로 widgets/user-table/에 있으면 지우기 전에 매번 "다른 데서도 쓰나?"를 확인해야 합니다. 확인이 귀찮으니 안 지우고, 안 지우니 공용 폴더가 계속 부풉니다.

승격은 두 번째 사용처가 왔을 때

그래서 규칙은 이렇게 정했습니다.

처음 만들 때는 화면 옆 - 폴더에 둔다. 두 번째 화면이 쓰기 시작하면 그때 modules/로 옮긴다.

흔한 실패는 반대쪽에서 옵니다. 만들자마자 components/에 넣고, 재사용될 것 같아서 utils/에 넣습니다. 그렇게 공용 폴더가 부풀고 정작 두 번째 사용처는 안 옵니다. 그리고 그 폴더는 6개월 뒤에 아무도 안 건드립니다.

기본값을 좁은 쪽에 두면 틀렸을 때의 비용이 작습니다. 좁게 뒀다가 두 번째 사용처가 오면 파일을 옮기면 되지만, 넓게 뒀다가 안 쓰이면 아무도 안 치웁니다.

그런데 폴더는 아무것도 막지 않는다

여기까지가 "어디에 둘지"입니다. 문제는 이걸 문서에 적어 놔도 지켜지지 않는다는 것입니다.

shared/에서 도메인 모듈을 import해도 아무 일도 일어나지 않습니다. 코드는 돌아가고, 리뷰어는 그날 그 줄을 못 보고 지나갑니다. 그렇게 한 줄이 들어가면 shared는 도메인을 알게 되고, 그다음부터는 규칙이 있으나 마나입니다.

그래서 규칙을 두 겹으로 나눴습니다. 어디에 둘지는 문서가 정하고, 넘으면 안 되는 선은 linter가 지킵니다. 설정 파일 맨 위에 그 문장을 그대로 적어 뒀습니다.

// fe/eslint.config.js
// 폴더 이름은 응집도를 지켜주지 않는다. 아래 규칙이 지킨다.

문제는 그 규칙을 넣는 데 두 번 실패했다는 것입니다. 그것도 조용히.

실패 1 — 리졸버가 없으면 no-restricted-paths는 조용히 통과한다

레이어 경계를 막는 정석은 import/no-restricted-paths입니다. zonetargetfrom을 적으면 끝나는, 이 일을 하라고 있는 규칙입니다.

그런데 이 규칙은 경로를 실제로 풀어야 동작합니다. import 문자열이 어느 파일을 가리키는지 알아야 그게 from 안에 있는지 판정할 수 있으니까요. 그 해석은 import-x의 리졸버가 합니다. 리졸버가 안 붙어 있으면 에러를 내지 않고 통과시킵니다.

규칙은 켜져 있고, 설정도 맞고, CI는 초록불입니다. 아무도 규칙이 죽은 걸 모릅니다.

WARNING

꺼진 규칙보다 나쁩니다 꺼진 규칙은 티가 납니다. 조용히 통과하는 규칙은 티가 안 나면서 안전하다는 착각까지 줍니다. 경계를 lint로 막았다고 믿는 팀이 실은 아무것도 안 막고 있는 상태가 몇 달 갑니다.

실패 2 — #으로 시작하는 별칭은 주석으로 먹힌다

그래서 리졸버가 필요 없는 쪽으로 갈아탔습니다. ESLint 코어의 no-restricted-imports는 import 문자열을 그대로 봅니다. 경로를 풀 필요가 없으니 리졸버도 필요 없습니다.

이 프로젝트는 #/*./src/* 별칭으로 씁니다(tsconfig.jsonpathspackage.jsonimports 양쪽에). 그러니 group: ['#/modules/*']이면 되겠다고 생각하는 게 자연스럽습니다.

안 됩니다. group 패턴은 gitignore 문법으로 해석되고, gitignore에서 #으로 시작하는 줄은 주석입니다. 패턴 전체가 없는 셈이 됩니다.

같은 import 한 줄에 세 가지 패턴을 동시에 걸고 돌려봤습니다.

// probe.eslint.mjs
rules: {
  'no-restricted-imports': ['error', {
    patterns: [
      { group: ['#/modules/*'],  message: 'A. group + 별칭(#으로 시작)' },
      { group: ['**/modules/*'], message: 'B. group + #없는 패턴' },
      { regex: '(^|/)modules/',  message: 'C. regex' },
    ],
  }],
}
 
// 대상 파일 한 줄
import { reports } from '#/modules/reports'
$ pnpm exec eslint src/shared/__probe.js
 
src/shared/__probe.js
  1:1  error  '#/modules/reports' import is restricted…  B. group + #없는 패턴
  1:1  error  '#/modules/reports' import is restricted…  C. regex
 
✖ 2 problems (2 errors, 0 warnings)

세 개를 걸었는데 두 개만 걸렸습니다. A는 에러도 경고도 없습니다.

패턴결과이유
group: ['#/modules/*']통과#이 gitignore 주석으로 먹혀 패턴이 사라진다
group: ['**/modules/*']차단#으로 시작하지 않아 정상 매칭
regex: '(^|/)modules/'차단gitignore 문법을 안 거친다

두 번 다 같은 모양의 실패입니다. 설정은 유효하고, 에러는 없고, 규칙만 죽어 있습니다. 그래서 최종 설정은 regex를 씁니다. 왜 그런지도 주석에 같이 적어 뒀습니다. 이 세 줄이 없으면 다음 사람이 "왜 굳이 regex야?" 하며 group으로 되돌립니다. 제가 6개월 뒤에 그럴 것 같습니다.

에러 메시지는 규칙 이름이 아니라 지시문이다

레이어마다 금지 목록을 붙이고, 공통으로 모듈 내부 직접 접근 금지를 하나 더 붙입니다.

// fe/eslint.config.js
/** 모듈 공개 API 강제 — 모든 레이어에 공통으로 붙는다. */
const MODULE_PUBLIC_API = {
  regex: "(^|/)modules/[^/]+/.+",
  message: "modules는 공개 API(index.ts)로만 들어간다.…",
};
 
layer(["src/app/**"], ["routes"], "앱 셸은 개별 라우트 구현을 import하지 않는다.…");
layer(["src/modules/**"], ["app", "routes"], "modules는 화면을 몰라야 한다.");
layer(
  ["src/shared/**"],
  ["app", "modules", "routes"],
  "shared는 도메인도 화면도 몰라야 한다. 도메인을 알게 됐으면 modules로 옮겨라.",
);

확인 삼아 shared/paging.ts에 도메인 import를 한 줄 넣고 돌려봤습니다.

/…/fe/src/shared/paging.ts
  41:1  error  '#/modules/reports' import is restricted from being used by a pattern.
               shared는 도메인도 화면도 몰라야 한다. 도메인을 알게 됐으면 modules로 옮겨라
               no-restricted-imports

메시지에 다음에 뭘 해야 하는지가 들어 있습니다. import restricted by pattern만 뜨면 걸린 사람은 규칙을 끄거나 우회할 방법부터 찾습니다. "modules로 옮겨라"라고 쓰여 있으면 파일을 옮깁니다.

규칙의 진짜 비용은 규칙을 쓰는 시간이 아니라 6개월 뒤 처음 걸린 사람이 그 규칙을 존중할지 우회할지고, 그건 메시지가 결정합니다.

규칙이 못 잡는 구멍

// fe/eslint.config.js
// 못 잡는 것: 다른 모듈 내부를 상대경로로 파고드는 경우(modules/a에서 '../b/api').
// 드물고 리뷰에서 보이므로 규칙으로 막지 않았다.

MODULE_PUBLIC_API의 regex는 경로에 modules/가 들어간 문자열을 봅니다. '../b/api'에는 그 글자가 없으니 안 걸립니다. 막을 수는 있지만 그러려면 경로를 풀어야 하고, 그건 앞에서 죽었던 리졸버 이야기로 돌아갑니다.

그래서 막지 않기로 하고, 안 막힌다는 사실을 적어 뒀습니다. 구멍을 아는 팀은 리뷰에서 그걸 보고, 모르는 팀은 규칙을 믿습니다.

useQuery를 감싸지 않는 이유

레이어 경계와 별개로 모듈 안에 방향이 하나 더 있습니다. api.ts → queries.ts → ui/. 그리고 규칙 하나가 붙어 있습니다. queries.tsuseQuery로 감싼 커스텀 훅을 내보내지 않는다.

보통은 useReports()로 감싸는 게 좋은 추상화입니다. 여기서는 아닙니다. 라우트 로더가 훅을 못 쓰기 때문입니다.

컴포넌트는 useReports()를 부르고, 로더는 훅을 못 부르니 queryOptions를 한 번 더 적습니다. 이제 같은 쿼리의 캐시 키가 두 군데에 삽니다. 둘이 어긋나는 날 — 필터 하나가 한쪽에만 추가되는 날 — 프리페치한 데이터는 버려지고 화면이 같은 걸 다시 받아옵니다. 느려지지만 아무것도 깨지지 않아서 아무도 눈치채지 못합니다.

그래서 queryOptions 하나만 내보내고 useQuery 호출은 컴포넌트에서 합니다. 로더와 컴포넌트가 같은 객체를 읽으면 어긋날 자리가 없습니다.

같은 논리로, 쿼리를 여러 개 엮거나 폼 상태를 다루는 훅은 도메인이 아니라 화면 옆 -hooks/에 둡니다.

// routes/reports/new/-hooks/use-test-method-form.ts
/**
 * 4-1 화면의 상태를 한 덩어리로 들고 있는다.
 * 기기·항목 선택과 폼 초안, 저장이 항상 같이 움직여서 훅으로 묶었다.
 * 계산(진행률, 미리보기 문장)은 여기 없다 — modules/reports/model.ts에 있다.
 */

훅에 넣는 것은 셋뿐입니다. 시간에 따라 변하는 상태, 바깥 세계와의 동기화, 쿼리·뮤테이션 조합. 인자만으로 결과가 정해지면 model.ts의 순수함수입니다. 훅으로 만드는 순간 렌더 밖에서 못 쓰고 테스트에 렌더러가 필요해집니다.

정직하게 — 이 구조는 아직 검증되지 않았다

여기까지 읽으면 잘 굴러가는 구조처럼 보입니다. 숫자를 보면 다릅니다.

항목
src 전체 줄 수 (테스트·생성물 제외)9,206
- 폴더에 있는 코드49% (routes 안에서는 61%)
modules/ 개수2
실제로 일어난 승격(파일 이동)0

git log --diff-filter=R로 확인하면 fe/src에 파일 이동 커밋이 한 건도 없습니다. 구조는 스캐폴드 커밋 하나에 통째로 들어왔습니다. 즉 "두 번째 화면이 쓰면 modules/로 승격"이라는 규칙은 아직 한 번도 발동한 적이 없습니다. 설계된 구조지 진화한 구조가 아닙니다.

회의적으로 읽으면 이렇게 됩니다. 코드의 절반이 - 폴더에 있고 모듈은 둘뿐이니 그냥 코로케이션에 이름만 붙인 것 아니냐. 맞습니다. 그리고 그게 의도입니다. - 폴더가 기본값이고 modules/가 예외이니, 승격이 0회라는 건 규칙이 안 돈다는 증거가 아니라 아직 아무것도 두 번 쓰이지 않았다는 뜻입니다.

다만 "실전에서 검증됐다"고는 못 씁니다. 진짜 시험은 첫 승격이 일어날 때 옵니다. 그때 파일 옮기는 게 정말 git mv 한 번으로 끝나는지.

그래서 FSD를 버린 건가

아닙니다. 방향은 그대로 가져왔습니다. 레이어를 세우고 의존을 한 방향으로만 흐르게 한다는 것, 그게 FSD에서 제일 중요한 부분이고 여기서도 같습니다.

깎아낸 건 가르는 기준이 애매한 레이어들입니다.

  • entities / featuresmodules/<도메인>/으로 합쳤습니다. 폴더 이름을 화면이 쓰는 도메인 말로 그대로 씁니다(reports, translations). "이건 entity인가 feature인가"라는 질문 자체가 없어집니다.
  • widgets → 한 화면만 쓰면 - 폴더, 두 화면이 쓰면 modules. 기준이 "몇 개가 쓰나" 하나입니다.
  • pages → 라우터가 routes/로 가져갔습니다.

잃는 것도 분명합니다. FSD의 entities/features 분리는 도메인 모델과 사용자 행동을 갈라 놓는데, 여기서는 둘 다 model.ts에 섞입니다. 도메인이 열 개로 늘고 한 모듈이 천 줄을 넘기면 그때 다시 쪼갤 문제입니다. 레이어를 넷으로 줄인 건 지금 규모에 맞춘 선택이지 FSD가 틀렸다는 주장이 아닙니다.

정리

  • 폴더 구조는 라우터가 먼저 정합니다. 파일 기반 라우팅을 쓰면 routes/와 FSD pages/가 겹칩니다. 한쪽을 포기해야 하고, 저는 pages/를 버렸습니다.
  • 가르는 기준이 애매한 레이어는 합치는 게 낫습니다. entities/features 논쟁은 코드가 아니라 리뷰 시간을 씁니다.
  • 기본값을 좁은 쪽에 두십시오. 공용 폴더가 기본이면 부풀고, 화면 전용이 기본이면 두 번째 사용처가 왔을 때 옮기면 됩니다.
  • lint 규칙이 켜져 있다고 도는 게 아닙니다. 경계 규칙을 넣었으면 일부러 어겨 보고 에러가 뜨는지 확인하십시오. no-restricted-paths는 리졸버 없이 조용히 통과하고, no-restricted-importsgroup#으로 시작하는 별칭을 주석으로 먹습니다. regex를 쓰십시오.
  • 에러 메시지에 다음 행동을 적으십시오. "restricted by pattern"은 우회할 이유가 되고, "modules로 옮겨라"는 옮길 이유가 됩니다.
  • 못 막는 것을 주석에 적으십시오. 한계를 아는 규칙이 완벽하다고 주장하는 규칙보다 오래갑니다.

같은 태도를 디자인 토큰에 적용한 기록은 선을 긋지 않는 디자인 토큰에 이어서 적었습니다.

Comments