FSD 글(화면 코드를 층으로 나누는 구조 FSD를 공부한 글)의 결론은 가설 하나였다. 「폴더는 지금 표준대로 두고, 『아래는 위를 못 부른다』는 방향 규칙만 가져온다.」 그리고 확인하지 못한 것 셋을 남겼는데, 그중 하나가 「무엇으로 막나」였다. 자바는 private이나 같은 패키지에서만 보이는 클래스를 다른 곳에서 쓰려고 하면 컴파일할 때 에러가 나서 막힌다. 자바스크립트에는 그런 장치가 없어서, 어느 파일이든 경로만 적으면 import 할 수 있다.
FSD 말고 같은 질문(파일을 어느 폴더에 두고, 누가 누구를 불러 쓸 수 있나)에 답하는 구조가 또 있는지 찾아보다가 Bulletproof React를 만났다. 이 글은 그것을 공부하고 FSD와 장단점을 나눈 기록이다.
이 글은 내가 AI 대화 상대인 Claude(Anthropic이 만든 AI)와 대화하며 공부한 것을 옮긴 것이다.
먼저: 앞의 글들에서 가져오는 것
이 글은 FSD 글과 계속 견준다. 그 글을 읽지 않아도 따라올 수 있게 필요한 것만 추린다.
React 는 화면을 컴포넌트라는 조각으로 나눠 만드는 자바스크립트 도구다. 컴포넌트 하나는 자바스크립트 함수 하나이고, 그 함수가 화면 모양(HTML처럼 생긴 코드)을 돌려준다. 그래서 「화면 모양이 든 코드 파일」이 있을 수 있다. Next.js 는 React 위에서 주소별 화면을 쉽게 만들게 해 주는 도구이고, 그중 App Router 방식은 app/ 폴더 구조가 곧 웹 주소가 된다. app/about 폴더를 만들면 /about 주소가 생긴다.
FSD(Feature-Sliced Design) 는 화면 코드를 여섯 층으로 나누고 「위층 파일만 아래층 파일을 불러 쓸 수 있다」는 방향 규칙을 거는 구조다. 위에서부터 app(앱 전체 설정), pages(주소 하나의 화면), widgets(여러 부품을 조립한 화면 구역), features(사용자가 하는 행동, 즉 동사. 「좋아요 누르기」), entities(업무의 명사. 「후기」, 「상품」을 보여주기만 하는 조각), shared(업무를 전혀 모르는 공용 도구)다. 「업무」는 우리 서비스가 다루는 내용, 즉 후기 · 댓글 · 토론 글 같은 것을 말한다. FSD는 같은 층 안을 다시 업무별 폴더(조각)로 나누고, 조각마다 밖에 보여줄 것만 내보내는 창구 파일 index.js(공개 API)를 둔다. 자바에는 이런 파일이 없다. 목적만 견주면, 패키지 안의 클래스 중 public으로 열어 둔 것만 밖에서 쓸 수 있게 하는 것과 비슷하다. 밖에 보일 것만 골라 둔다는 점이 같다. FSD는 widgets 층도 공식 가이드에서 쓰지 말라고 권한다. 「행동」을 맡는 features와 「조립」을 맡는 widgets의 경계가 흐리다는 이유다. 그 조립은 한 화면에서만 쓰면 그 pages 안에, 여러 화면에서 쓰면 features로 보낸다.
우리 앱은 Next.js로 만든 React 앱 셋이다. client(방문자가 보는 회사 공개 사이트), admin(직원용 관리자 화면), business(계약서 전자서명 화면)다. 폴더는 대체로 app/(주소별 화면), components/(화면 조각), lib/(서버에 데이터를 달라고 요청하는 함수와 데이터를 다루는 함수), design-system/(버튼 · 색깔 같은 공용 부품)으로 나뉜다. 화면 조각을 어디에 둘지는 컴포넌트 자리 표준이 이미 정했다. 모든 페이지에 붙는 틀은 components/layout/, 한 갈래(성격이 비슷한 주소들의 묶음)에서만 쓰면 app/<갈래>/_components/, 여러 갈래에서 쓰면 components/다.
FSD 글에서 그 세 앱의 import 줄을 폴더 단위로 세어 봤다. lib/과 design-system/의 파일이 components/나 app/을 가리키는 import, components/의 파일이 app/을 가리키는 import는 세 앱 모두 0개였다. 위아래로 놓으면 맨 아래가 lib/과 design-system/, 그 위가 components/, 맨 위가 app/이고, 아래 폴더가 위 폴더를 부르는 일이 없었다는 뜻이다. 그래서 FSD에서 폴더 모양은 가져오지 않고 「아래는 위를 못 부른다」는 방향 규칙만 가져오자는 가설을 세웠다.
먼저: 이 글의 근거는 Bulletproof React 저장소의 문서와 예제 코드다
Bulletproof React는 GitHub(코드와 문서를 올려 두는 사이트)에 있는 저장소 alan2207/bulletproof-react(커밋 9506629 판)다. 받아서 둘을 읽었다.
docs/project-structure.md: 폴더 구조와 규칙을 설명한 문서다. 이 글에 옮긴 Bulletproof React 인용은 모두 여기서 왔다apps/nextjs-app/: 그 구조로 실제로 만든 예제 앱이다. 우리 앱과 같은 Next.js(App Router) 버전이라 골랐다
FSD와 견줄 때는 FSD 글에서 받아 둔 FSD 공식 문서 원본(feature-sliced/documentation)을 다시 열어 확인했다.
먼저: 이 글의 예제는 토론 글과 댓글이 있는 앱이다
Bulletproof React 예제 앱은 팀을 만들고, 팀원끼리 토론 글(discussions)을 올리고, 그 아래 댓글(comments)을 다는 서비스다. 토론 글 하나를 열면 본문이 보이고 그 아래 댓글 목록과 댓글 작성 칸이 붙는다. 이 화면이 이 글 내내 쓰는 장면이다.
예제의 파일 경로에 app/app/discussions/[discussionId]/처럼 app이 두 번 나온다. 앞의 app은 Next.js의 주소 폴더이고, 뒤의 app은 그 안에서 로그인한 뒤 쓰는 대시보드 화면들을 모아 둔 폴더 이름이다. 그 옆에는 로그인 화면을 모은 auth/와 누구나 보는 화면을 모은 public/이 있다. 그래서 주소는 /app/discussions/...가 된다. [discussionId]처럼 대괄호로 감싼 폴더 이름은 그 자리에 토론 글 번호처럼 매번 다른 값이 들어간다는 Next.js의 표시다.
자바스크립트 예제는 짧은 import 몇 줄뿐이다. import { Comments } from '@/features/comments/components/comments';는 자바의 import와 같은 뜻으로, 「src/features/comments/components/comments 파일에서 Comments를 가져온다」는 것이다. @/는 프로젝트의 src/ 폴더를 가리키는 줄임 표시다.
Bulletproof React는 설치해서 쓰는 도구가 아니라 「이렇게 짜면 튼튼하다」를 보여 주는 예제다
README 첫 줄이 이렇다.
A simple, scalable, and powerful architecture for building production ready React applications.
실제 서비스에 쓸 React 앱을 만들기 위한, 단순하고 커져도 버티는 구조.
이름의 bulletproof는 「총알도 막는(방탄)」이라는 뜻이다. 라이브러리처럼 설치해서 쓰는 게 아니라, 폴더를 어떻게 나누고 무엇을 지키면 되는지를 문서와 예제 앱으로 보여 준다. README는 이게 모든 앱의 정답은 아니라고 미리 말해 둔다(「silver bullet이 되려는 건 아니다」).
폴더는 공용 · 기능 · 앱 세 층으로 나뉜다
FSD는 층이 여섯이었다. Bulletproof React는 셋이다.
src/
├── app/ ← ③ 앱: 주소별 화면(페이지)과 앱 전체 설정
├── features/ ← ② 기능: 토론 글, 댓글, 팀, 사용자, 로그인
│ ├── comments/
│ ├── discussions/
│ └── ...
└── components/ ┐
hooks/ │
lib/ ├ ① 공용: 어느 기능에서나 쓰는 것
utils/ │
types/ ... ┘
처음 이 그림을 코드와 영어 인용으로 설명 들었을 때는 이해하지 못했다. 식당으로 다시 들으니 잡혔다. 주방이 세 구역이다.
- 공용 창고(① 공용 폴더): 칼, 도마, 냄비, 소금. 누구나 가져다 쓴다. 칼은 자기가 파스타를 써는지 케이크를 써는지 모른다
- 요리 코너(②
features/안의 폴더 하나하나): 「파스타 코너」, 「디저트 코너」가 따로 있다. 파스타에 필요한 재료, 레시피, 접시가 파스타 코너에 다 모여 있어서, 파스타를 고치려면 이 코너만 보면 된다 - 서빙 담당(③
app/): 손님에게 나갈 코스 요리를 접시에 담는다. 파스타 코너의 파스타와 디저트 코너의 케이크를 가져와 한 쟁반에 올린다
기능 폴더 하나 안은 이렇게 생겼다. 예제의 features/comments/다.
features/comments/
├── api/
│ ├── get-comments.ts 댓글 목록 가져오기
│ ├── create-comment.ts 댓글 쓰기
│ └── delete-comment.ts 댓글 지우기
└── components/
├── comments-list.tsx 댓글 목록 화면
├── create-comment.tsx 댓글 작성 칸
└── ...
「댓글」에 관한 서버 호출(api/)과 화면 조각(components/)이 이 폴더 하나에 모여 있다. 서버 호출은 화면이 서버에 「댓글 목록 줘」, 「이 댓글 저장해 줘」라고 요청하는 함수다. 댓글은 서버에 저장되니 화면이 서버에 물어봐야 한다. 공용 폴더의 hooks/는 React에서 상태(화면이 기억하는 값)를 다루는 함수인 훅을 모아 두는 곳이다. FSD 글의 「세로로 썰기」, 기능 하나를 폴더 하나에 모으기와 같다. 문서는 api, components, hooks 같은 칸을 기능마다 다 만들 필요는 없고 필요한 것만 두라고 한다. 파일 이름 끝의 .ts는 TypeScript(자바스크립트에 타입을 붙인 언어) 파일, .tsx는 화면 모양까지 든 TypeScript 파일이라는 표시다.
규칙 1. 기능끼리는 서로 import 하지 않고, 앱이 조립한다
문서의 첫째 규칙이다.
It might not be a good idea to import across the features. Instead, compose different features at the application level.
기능끼리 서로 import 하는 건 좋지 않다. 대신 여러 기능을 앱 단계에서 조립하라.
식당으로는 「코너끼리는 서로의 재료나 냄비를 집어 가지 않는다」다. 파스타 요리사가 디저트 코너의 생크림을 몰래 가져다 쓰면, 디저트 요리사는 생크림이 왜 줄었는지 모른다.
공부하며 받은 확인 문제는 「토론 글 화면에 본문과 그 아래 댓글이 같이 나와야 한다. 둘을 한 화면에 붙이는 일은 공용, 기능, 앱 중 누가 하나」였다. 나는 서빙 담당, 즉 앱이라고 답했다. 맞았다. 예제 앱에서 토론 글 화면을 조립하는 파일(app/app/discussions/[discussionId]/_components/discussion.tsx)의 import가 그대로 그렇다.
import { ContentLayout } from '@/components/layouts/content-layout';
import { Comments } from '@/features/comments/components/comments';
import { useDiscussion } from '@/features/discussions/api/get-discussion';
import { DiscussionView } from '@/features/discussions/components/discussion-view';
한 줄씩 보면 이렇다.
- 첫 줄: 공용 폴더(
components/)에서 화면 틀(ContentLayout)을 가져온다 - 둘째 줄: 댓글 기능에서 댓글 화면(
Comments)을 가져온다 - 셋째·넷째 줄: 토론 글 기능에서 토론 글을 불러오는 함수(
useDiscussion)와 토론 글 화면(DiscussionView)을 가져온다.useDiscussion은 서버에서 토론 글을 받아 오는 일을 화면에서 쓰기 좋게 감싼 훅이다. 문서는 기능의api/칸에 「그 기능의 서버 요청과, 그걸 감싼 훅」을 함께 둔다고 적었다. 공용hooks/에는 여러 기능이 같이 쓰는 훅만 둔다
앱 쪽 파일 하나가 공용 하나와 기능 둘을 같이 불러 와서 한 화면으로 붙인다. 댓글 기능은 토론 글 기능을 모르고, 토론 글 기능도 댓글 기능을 모른다. 둘을 아는 건 이 파일뿐이다.
그리고 이 파일이 사는 곳이 _components/ 폴더다. 우리 컴포넌트 자리 표준의 「한 갈래(성격이 비슷한 주소들의 묶음)에서만 쓰면 app/<갈래>/_components/」와 같은 모양이다. 둘 다 같은 Next.js 규칙, 「이름이 밑줄로 시작하는 폴더는 주소가 되지 않는다」를 쓰기 때문이다.
기능끼리 서로 기대게 되면 하나를 고칠 때 다른 하나가 깨진다
규칙 1을 어기면 무슨 일이 생기는지는 식당 장면으로 봤다. 손님이 파스타에 추가 치즈를 주문했다. 그러면 「오늘 남은 치즈 개수」도 줄어야 하는데, 그 장부가 치즈케이크 때문에 디저트 코너에 있다. 파스타 요리사가 직접 디저트 코너에 가서 장부를 고치면 이렇게 된다.
- 디저트 요리사는 장부가 왜 줄었는지 모른다
- 디저트 코너가 장부 쓰는 방식을 바꾸면 파스타 코너까지 고장 난다
- 파스타 코너만 떼어 다른 지점에 옮길 수 없다. 디저트 코너에 묶여 버렸다
코드로 옮기면 「댓글을 쓰면 토론 글의 댓글 수도 바뀌어야 한다」는 상황이다. 댓글 폴더가 토론 글 폴더를 불러 숫자를 고치는 순간 둘이 한 몸이 된다. 토론 글 쪽을 고치면 댓글 쪽이 깨질 수 있고, 댓글 기능만 떼어 내지도 못한다. 해법은 둘이다.
- 서빙 담당이 잇는다. 앱 쪽 화면 파일이 둘을 연결한다. 예를 들면 앱 파일이 댓글 화면을 부를 때 「댓글이 써지면 이 함수를 불러 줘」라며 함수 하나(콜백)를 넘겨준다. 댓글 기능은 댓글을 다 쓰면 받은 함수를 부르기만 하고, 그 함수가 무엇을 하는지는 모른다. 그 함수 안에서 앱 파일이 토론 글 숫자를 새로 불러온다
- 둘 다 쓰는 건 창고로 내린다. 두 기능이 정말 같이 써야 하는 게 있으면, 특정 기능의 것이 아니니 공용 폴더로 옮긴다
FSD 글에서 본 「같은 층끼리는 서로 모른다. 둘 다 필요하면 아래층으로 내리거나 위층에서 조립한다」와 똑같은 해법이다.
규칙 2. 의존은 공용 → 기능 → 앱 한 방향으로만 흐른다
파일 A가 파일 B를 import 하면 「A가 B에 의존한다(기댄다)」고 한다. 문서가 「코드가 흐른다」고 할 때 화살표는 import의 반대 방향, 즉 「B가 A로 가져다 쓰인다」는 방향으로 그린다. 그래서 앱이 기능을 import 하면 화살표는 기능 → 앱이 된다.
문서의 둘째 규칙이다.
You might also want to enforce unidirectional codebase architecture. This means that the code should flow in one direction, from shared parts of the code to the application (shared -> features -> app).
코드가 한 방향으로만 흐르게 강제하는 것도 좋다. 코드는 공용 부분에서 앱 쪽으로, 한 방향(공용 → 기능 → 앱)으로 흘러야 한다.
③ app 기능과 공용을 둘 다 가져다 쓸 수 있다
↑
② features 공용만 가져다 쓸 수 있다 (다른 기능은 안 된다)
↑
① shared 아무것도 가져다 쓰지 않는다 (누구나 이걸 가져다 쓴다)
화살표는 「위로 가져다 쓰인다」는 방향이다. 창고 물건은 코너와 서빙 담당이 가져다 쓰고, 코너의 요리는 서빙 담당이 가져다 쓴다. 거꾸로, 창고가 코너를 알거나 코너가 서빙 담당을 아는 일은 없다.
공부하며 받은 마지막 확인 문제가 이 규칙이었다. 「버튼 모양(공용)이 『댓글 쓰기 버튼일 때는 파랗게』를 판단하려고 댓글 폴더를 불러 써도 되나? 안 된다면 누가 정하나?」 나는 「안 된다, 서빙 담당이 정한다」고 답했다. 앞은 정확했다. 버튼이 댓글을 알게 되면 버튼은 더 이상 「아무 데서나 쓰는 칼」이 아니라 댓글 코너 전용 도구가 된다.
뒤는 하나를 다듬었다. 서빙 담당도 정할 수 있지만, 더 자연스러운 쪽은 댓글 코너 자신이다. 「댓글 쓰기」 버튼은 댓글 작성 칸 안에 있고, 그 작성 칸은 댓글 폴더에 산다. 댓글 작성 칸이 공용 버튼을 가져다 쓰면서 「너는 파란색이야」라고 건네주고, 버튼은 「색을 받으면 그 색으로 칠한다」만 안다. 칼은 무엇을 써는지 모르고, 요리사가 칼을 쥐고 무엇을 썰지 정하는 것과 같다. 기준은 이 한 줄이다.
「이게 무엇인지」를 아는 쪽이 정하고, 공용은 받기만 한다.
규칙은 ESLint가 검사해서 지키게 한다
규칙을 적어 두기만 하면 바쁠 때 누군가 슬쩍 어긴다. Bulletproof React는 주방 출구에 감독을 세운다. ESLint라는 코드 검사 도구다. 코드를 실행하지 않고 읽어서, 정해 둔 규칙을 어긴 import를 찾아 에러로 알려 준다. 문서는 그 설정을 그대로 실어 두었다. 핵심만 옮기면 이렇다.
'import/no-restricted-paths': [
'error',
{
zones: [
// 댓글 기능은 다른 기능을 import 할 수 없다
{ target: './src/features/comments', from: './src/features', except: ['./comments'] },
// 기능은 앱을 import 할 수 없다
{ target: './src/features', from: './src/app' },
// 공용 폴더는 기능과 앱을 import 할 수 없다
{ target: ['./src/components', './src/hooks', './src/lib', './src/types', './src/utils'],
from: ['./src/features', './src/app'] },
],
},
],
한 줄씩 보면 이렇다.
'import/no-restricted-paths': 「이 경로에서 저 경로를 import 하면 안 된다」를 검사하는 규칙의 이름이다.'error'는 어기면 에러로 막으라는 뜻이다zones: 금지 구역 목록이다. 하나하나가 「target(이 폴더의 파일은)from(이 폴더를) 가져다 쓰면 안 된다」다- 첫째 구역:
features/comments안의 파일은features안의 다른 폴더를 가져다 쓸 수 없다.except: ['./comments']는 자기 폴더만 예외라는 뜻이다. 이 경로는 바로 앞from에 적은./src/features를 기준으로 쓴다. 그래서./comments는./src/features/comments다. 문서에는 기능마다 이 줄이 하나씩 있다 - 둘째 구역:
features안의 파일은app을 가져다 쓸 수 없다 - 셋째 구역: 공용 폴더들의 파일은
features와app을 가져다 쓸 수 없다
예제 앱(apps/nextjs-app/.eslintrc.cjs)에도 같은 규칙이 실제로 걸려 있다. FSD 글에서 남겨 둔 「무엇으로 막나」의 답이 이것이다. 컴파일러 대신 검사 도구에 규칙을 적어서 막는다.
다만 컴파일러만큼 확실하지는 않다. 자바 컴파일 에러는 실행 전에 반드시 걸리지만, ESLint는 누군가 돌려야만 걸린다. 개발자가 직접 명령으로 돌리거나, 코드를 저장할 때 편집기가 돌리거나, 코드를 합치기 전에 자동 검사가 돌리게 해 둬야 한다. 아무도 안 돌리면 규칙을 어긴 코드도 그냥 통과한다. 그래서 막는 힘은 「어디서 자동으로 돌게 해 두었나」에 달려 있다.
우리 저장소는 이미 그 자리가 있다. 우리 앱 저장소의 PR 검사(.github/workflows/pr-check.yml)에 「프런트 lint」 단계가 있어서, 코드를 합치자고 올리면 npm run lint(ESLint)가 자동으로 돈다. 다만 그 단계가 도는 앱은 admin과 client 둘이고, business 앱은 빠져 있다.
FSD와 견주면 Bulletproof React는 덜 나누는 대신 덜 헷갈린다
이제 둘의 장단점을 나눈다. 먼저 둘의 모양을 나란히 놓는다.
| FSD | Bulletproof React | |
|---|---|---|
| 층 | 6개(app · pages · widgets · features · entities · shared) | 3개(app · features · shared) |
| 명사와 동사 | 나눈다(entities와 features) | 나누지 않는다(둘 다 기능 폴더 하나에) |
| 같은 층끼리 | 서로 import 금지 | 기능끼리 서로 import 금지 |
| 의존 방향 | 위층만 아래층을 부른다 | 공용 → 기능 → 앱 |
| 업무 폴더의 창구 파일 | 업무 폴더(조각)마다 index 파일(공개 API)을 둔다 | index 파일을 두지 말고 안쪽 파일을 직접 import 하라고 한다 |
| 규칙 검사 도구 | Steiger를 권한다. 폴더 배치가 FSD대로인지 본다 | ESLint 설정을 예제로 싣는다. import 한 줄씩 본다 |
표의 마지막 줄은 바로잡은 것이다. 공부하는 중에는 「FSD는 문서에 규칙만 있다」고 들었는데, 글을 쓰며 FSD 문서를 다시 읽어 보니 공개 API를 다룬 부분에 이렇게 적혀 있었다. 바로 앞 문단은 「index 파일을 만들어 두어도, 누군가 그걸 건너뛰고 안쪽 파일을 직접 import 하는 걸 실제로 막지는 못한다」는 문제였다.
To catch these issues automatically, we recommend using Steiger, an architectural linter with a ruleset for Feature-Sliced Design.
이런 문제를 자동으로 잡으려면, Feature-Sliced Design용 규칙을 갖춘 구조 검사 도구인 Steiger를 권한다.
FSD도 검사 도구가 있다.
Steiger는 import 한 줄이 아니라 폴더 배치가 FSD 설계도대로인지를 본다
글을 쓰고 나서 「Steiger 이건 뭐야?」가 궁금해져서 원본 저장소 feature-sliced/steiger(커밋 750e546 판)를 받아 읽었다. README 첫 줄이 이렇다.
Universal file structure and project architecture linter.
파일 구조와 프로젝트 구조를 검사하는 도구.
ESLint와 보는 곳이 다르다. ESLint는 코드 한 줄 한 줄, 「이 import가 규칙을 어겼나」를 본다. 식당으로 치면 주방 출구에서 다른 코너 재료를 들고 나가는 사람을 막는 감독이다. Steiger는 폴더와 파일의 배치, 「이 폴더 구조가 FSD 설계도대로인가」를 본다. 주방 배치도를 보고 코너가 제자리에 있는지 확인하는 검사관이다.
FSD용 규칙 묶음(packages/steiger-plugin-fsd)에 규칙이 스무 개 넘게 있다. FSD 글에서 공부한 것과 이어지는 것만 추리면 이렇다.
forbidden-imports: 위층을 부르거나 같은 층 조각끼리 서로 부르는 import를 막는다. FSD의 「아래는 위를 못 부른다」, 「같은 층끼리는 서로 모른다」다no-public-api-sidestep: 업무 폴더의 index 파일(창구)을 건너뛰고 안쪽 파일을 직접 import 하는 것을 막는다. 앞에서 본 「index 파일을 둬도 건너뛰는 걸 막지 못한다」는 문제를 이 규칙이 잡는다segments-by-purpose: 폴더 안의 칸 이름을hooks,components처럼 「종류」로 짓는 것을 말린다. FSD 문서의 「종류가 아니라 목적으로 이름 짓는다」다insignificant-slice: 한 군데서만 쓰이는 업무 폴더를 찾아 「위층에 합쳐라」고 제안한다. FSD 문서의 「여러 페이지에서 다시 쓸 때만 features로 꺼낸다」를 사람이 판단하지 않고 도구가 세어서 알려 주는 셈이다
쓰는 법은 npx steiger ./src 한 줄이다. src/ 폴더를 통째로 읽어 규칙에 어긋난 곳을 보여 주고, --watch를 붙이면 파일을 고칠 때마다 다시 검사한다.
주의할 점이 둘이다. 하나, README에 「베타이고 활발히 개발 중이라 일부 사용법이 바뀔 수 있다」고 적혀 있다. 둘, 기본 규칙이 FSD용이라 폴더가 FSD 모양(app/ · pages/ · features/ · entities/ · shared/)이어야 규칙들이 의미가 있다.
그래서 우리 앱에는 Steiger보다 ESLint가 맞다. 우리는 폴더를 FSD 모양으로 바꾸지 않기로 했다. 그러면 Steiger의 규칙 대부분은 우리 폴더를 보고 「FSD 층이 아니다」라고 할 뿐이다. 반면 ESLint는 폴더 모양을 그대로 두고 「이 폴더는 저 폴더를 부르면 안 된다」만 적으면 되고, 아래에서 보듯 우리 PR 검사에서 이미 돌고 있다. 나중에 폴더를 정말 FSD로 바꾸기로 한다면, 그때는 Steiger가 맞는 도구가 된다.
Bulletproof React의 장점은 경계 질문이 적고, 막는 방법이 바로 있다는 것이다
하나, 「이건 어느 층이지?」를 덜 묻는다. FSD 공부에서 가장 오래 헤맨 곳은 층의 경계였다. 검색창이 widgets인지 features인지, 후기 카드가 entities인지 widgets인지를 두 번이나 틀렸고, FSD 문서도 widgets 층을 쓰지 말라고 권할 만큼 그 경계가 흐렸다. Bulletproof React는 명사와 동사를 나누지 않고, widgets 같은 중간 층도 없다. 물을 질문이 「이건 공용인가, 어느 기능의 것인가, 화면 조립인가」 셋뿐이다.
둘, 막는 방법이 같이 온다. 규칙 두 개를 ESLint 설정 몇 줄로 막는 방법이 문서와 예제 앱에 그대로 있다. 이미 널리 쓰는 ESLint의 규칙 하나로 끝나서, 새 도구를 들이지 않아도 된다.
셋, index 파일 때문에 느려지는 일이 없다. Bulletproof React 문서는 이렇게 적었다.
In the past, it was recommended to use barrel files to export all the files from a feature. However, it can cause issues for Vite to do tree shaking and can lead to performance issues. Therefore, it is recommended to import the files directly.
예전에는 기능의 모든 파일을 index 파일(barrel file) 하나로 내보내라고 권했다. 그런데 그게 빌드 도구가 안 쓰는 코드를 덜어 내는 일을 방해하고 성능 문제를 일으킬 수 있다. 그래서 파일을 직접 import 하라고 권한다.
풀어 쓰면 이렇다. 빌드 도구(Vite는 그런 도구 이름이다)는 사이트를 올리기 전에 코드 파일들을 묶어 방문자에게 보낼 자바스크립트 파일을 만든다. 이때 어디서도 쓰지 않는 코드는 덜어 내야 방문자가 받는 양이 줄어 화면이 빨리 뜬다. 그런데 index 파일 하나가 기능의 파일을 전부 한꺼번에 내보내면, 그중 하나만 쓰려고 index를 import 해도 나머지까지 딸려 들어와서 덜어 내기가 잘 안 된다.
FSD 문서도 같은 대가를 스스로 적어 두었다. index 파일이 많으면 개발 서버(개발하는 동안 내 컴퓨터에서 고친 코드를 바로 화면으로 보여 주는 프로그램)가 느려질 수 있다고 하고, index 파일을 둬도 누군가 그걸 건너뛰고 안쪽 파일을 직접 import 하는 걸 실제로 막지는 못한다고 한다.
Bulletproof React의 단점은 기능 폴더 안을 스스로 정리해야 한다는 것이다
하나, 기능 폴더가 커지면 그 안이 복잡해진다. FSD는 명사(entities)와 동사(features)를 나눠서, 「후기를 보여주는 카드」와 「후기에 좋아요 누르기」가 다른 층에 산다. Bulletproof React는 둘 다 features/reviews/ 하나에 들어간다. 기능이 작을 땐 편하지만, 폴더 하나에 보여주기와 바꾸기가 섞여 쌓이면 그 안을 어떻게 나눌지는 다시 스스로 정해야 한다.
둘, 여러 기능이 같이 쓰는 명사를 둘 자리가 따로 없다. FSD에서는 상품 카드처럼 여러 기능이 같이 쓰는 명사를 entities 층으로 내렸다. Bulletproof React에서는 그런 게 생기면 어느 기능에도 넣을 수 없으니 공용으로 내려야 한다. 그런데 공용은 「업무를 모르는 것」이어야 하니, 업무를 아는 명사가 공용에 들어가면 공용의 뜻이 흐려진다.
셋, 기능끼리 import 금지 규칙을 기능마다 한 줄씩 적어야 한다. 문서의 ESLint 설정은 기능 하나당 금지 구역 한 줄이다. 기능이 새로 생길 때마다 이 목록에 한 줄을 더해야 하고, 빠뜨리면 그 기능만 검사에서 빠진다.
우리 앱에서 가져올 것은 폴더 모양이 아니라 ESLint로 방향을 막는 방법이다
FSD 글의 가설은 「폴더는 지금 표준대로 두고, 방향 규칙만 가져온다」였다. 그리고 우리 세 앱(client · admin · business)의 import를 폴더 단위로 세어 보니, lib/이나 공용 화면 부품 폴더가 화면 폴더를 거꾸로 부르는 곳이 이미 0개였다.
Bulletproof React는 그 가설에 맞는 도구를 준다. 폴더 모양을 바꾸지 않고, ESLint 규칙에 우리 폴더 이름만 넣으면 된다. 우리 폴더는 Bulletproof React의 세 층과 딱 맞지는 않는다. 이름이 같은 components/가 특히 다르다. Bulletproof React의 components/는 업무를 모르는 공용 조각만 두는 맨 아래 층이다. 우리 components/에는 후기 카드(ReviewCard)나 좋아요 버튼(ReviewActions)처럼 업무를 아는 조각이 섞여 있다. 그래서 우리 components/는 공용이 아니라 Bulletproof React의 공용과 기능이 한 폴더에 섞인 것에 가깝고, 위아래로는 가운데에 둔다. 진짜 공용에 가까운 건 버튼 · 색깔만 든 design-system/이다.
그래서 FSD 글에서 예로 든 위아래를 그대로 쓴다. 맨 위에 app/(그 안의 app/<갈래>/_components/도 여기 든다), 그 아래 components/(그 안의 모든 페이지 틀 components/layout/은 공용 조각을 모아 틀을 짜니 components/보다 한 칸 위로 센다), 맨 아래에 lib/과 design-system/이다. 규칙으로 옮기면 금지 구역이 셋이다. 「components/는 app/을 import 할 수 없다」, 「lib/과 design-system/은 components/와 app/을 import 할 수 없다」, 그리고 components/layout/을 한 칸 위로 센 것을 반영한 「components/의 나머지 파일은 components/layout/을 import 할 수 없다」다. 위 설정의 target 자리에 아래 폴더를, from 자리에 위 폴더를 넣는 식이다. 이 순서는 FSD 글에서도 예일 뿐이라고 밝혀 둔 것이고, 정한 것은 아니다. 지금 코드가 이미 방향을 지키고 있으니, 규칙을 걸어도 고칠 코드가 거의 없을 것으로 본다. 다만 실제로 걸어 보지는 않았다. 몇 개의 에러가 나는지는 재 보지 않은 채로 남긴다.
판단 기준 정리
| 질문 | 답 | 근거 |
|---|---|---|
| Bulletproof React는 무엇인가 | 설치하는 도구가 아니라 구조를 보여 주는 예제 저장소 | README |
| 층은 몇 개인가 | 셋. 공용 · 기능 · 앱 | docs/project-structure.md |
| 기능끼리 불러 써도 되나 | 안 된다. 앱이 조립한다 | 서로 기대면 하나를 고칠 때 다른 하나가 깨진다 |
| 의존 방향은 | 공용 → 기능 → 앱 | 공용은 무엇에도 기대지 않는다 |
| 공용 버튼의 색은 누가 정하나 | 「이게 무엇인지」 아는 쪽(댓글 코너)이 정해서 건넨다 | 공용이 업무를 알면 공용이 아니게 된다 |
| 규칙은 무엇으로 막나 | ESLint의 import/no-restricted-paths |
문서와 예제 앱에 설정이 있다 |
| ESLint는 컴파일러만큼 확실한가 | 아니다. 누군가 돌려야 걸린다 | 우리 PR 검사에 lint 단계가 이미 있다. 다만 admin · client만 돌고 business는 빠져 있다 |
| FSD에 비해 얻는 것 | 경계 질문이 적다, 막는 방법이 바로 있다, index 파일 대가가 없다 | widgets · entities 경계에서 헤맸다 |
| FSD에 비해 잃는 것 | 기능 폴더 안은 스스로 정리, 공용 명사 자리가 없다, 금지 줄을 기능마다 적는다 | 명사와 동사를 나누지 않는다 |
| 우리 앱에서 가져올 것 | 폴더 모양이 아니라 ESLint로 방향을 막는 방법 | 폴더 사이 거꾸로 import가 이미 0개였다. 실제로 걸어 보지는 않았다 |
이 정리에 닿기까지
시작은 「FSD 말고 다른 것도 있나」였다. 같은 질문에 답하는 구조를 찾다가, FSD 글의 숙제 「무엇으로 막나」에 답하는 사례로 Bulletproof React를 골랐다. Next.js 공식 문서도 확인했는데, Next.js는 폴더를 어떻게 정리할지 정해 두지 않고 전략 몇 가지를 보여 줄 뿐이었다.
처음 설명은 이해하지 못했다. 폴더 그림, 영어 인용, 실제 import 코드를 한 번에 들었다. 「더 쉽게」를 부탁했고, 식당 하나로 다시 들었다. 공용 창고, 요리 코너, 서빙 담당. 폴더 셋과 규칙 둘이 그 그림 하나에 다 들어갔다.
확인 문제 셋으로 규칙을 붙들었다. 토론 글과 댓글을 붙이는 건 서빙 담당이었다. 코너끼리 장부를 고치면 둘이 묶인다는 것도 치즈 장부로 봤다. 버튼 색은 서빙 담당이 정한다고 답했다가, 「이게 무엇인지 아는 쪽」인 댓글 코너가 정한다는 데까지 다듬었다.
FSD와 견주려고 FSD 문서를 다시 열었다가 바로잡을 게 나왔다. 공부 중에는 「FSD는 규칙만 있고 막는 도구는 없다」고 들었는데, FSD 문서가 Steiger라는 검사 도구를 권하고 있었다. 그리고 index 파일의 대가를 두 문서가 똑같이 적고 있었다.
장단점을 나누고 나니 가져올 것이 좁혀졌다. 층을 셋으로 줄인 단순함은 장점이자 단점이었다. 우리 앱에 필요한 건 폴더 모양이 아니라, FSD 글의 가설대로 방향만 막는 방법이었고, Bulletproof React의 ESLint 설정이 그 방법이었다. 실제로 걸어 보는 건 이번에는 하지 않았다.
정리
- Bulletproof React는 공용 · 기능 · 앱 세 층과 규칙 둘뿐이다. 기능끼리는 서로 모르고, 의존은 공용 → 기능 → 앱 한 방향이다
- 기능 둘을 한 화면에 붙이는 일은 앱이 한다. 공용은 업무를 모르고, 무엇에 쓸지는 아는 쪽이 정해서 건넨다
- 규칙은 ESLint의
import/no-restricted-paths로 막는다. FSD 글의 숙제 「무엇으로 막나」의 답이다 - FSD에 비해 덜 나누는 대신 덜 헷갈린다. 대신 기능 폴더 안은 스스로 정리해야 하고, 여러 기능이 같이 쓰는 명사를 둘 자리가 없다
- FSD도 검사 도구(Steiger)가 있다. 공부 중에 들은 「FSD는 도구가 없다」는 틀렸다. Steiger는 폴더 배치가 FSD대로인지 보는 도구라, FSD 모양이 아닌 우리 앱에는 ESLint가 맞다
- ESLint는 돌게 해 둬야 막는다. 우리 PR 검사가 이미 admin · client에서 lint를 돌린다
- 우리 앱에서 가져올 것은 ESLint로 방향을 막는 방법이다. 실제로 걸어서 에러가 몇 개 나는지는 아직 재지 않았다
자신만의 철학을 만들어가는 중입니다.
댓글남기기