저는 어느 날 components/ 폴더를 열었다가 “이건 게시판 버튼인가, 프로필 버튼인가?”를 구분하지 못하고 노트를 덮은 적이 있습니다. 체감상 500개가 넘는 파일이 한 자리에 섞여 있었거든요. 그래서 Feature-Sliced Design(FSD) 패턴을 도입해 도메인별로 폴더를 재구성했습니다.
entities에는 도메인 모델과 UI, features에는 사용자 시나리오, widgets에는 페이지 조립용 블록을 배치했습니다.views 계층에서만 정의하고, 데이터 접근은 공유 라이브러리와 훅을 통해 이루어지도록 했습니다.entities 폴더를 만들었습니다.ui, hooks, model, libs 디렉터리를 템플릿으로 삼아 새 도메인을 열 때도 구조가 흔들리지 않게 했습니다.pnpm check:dead-code를 CI에 걸어 의존성 루프와 사용되지 않는 모듈을 빠르게 잡았습니다.기존 코드를 pnpm check:visual로 그래프화해 보고, 어떤 컴포넌트가 어디서 사용되는지 Notion에 기록했습니다. 데이터 접근 로직은 shared 계층으로 모으는 식으로 큰 덩어리를 먼저 이동했습니다.
entities → features → widgets → views로만 의존성이 흐르도록 리뷰 기준을 정했습니다. 자동 검증 대신, PR 템플릿에 “도메인 컴포넌트를 상위 계층에서 직접 참조하지 않았나요?” 같은 질문을 넣어 자가 검토를 유도했습니다.
경로 수정은 TypeScript가 제공하는 리팩터 기능과 path alias 덕분에 생각보다 수월했습니다. 필요한 경우 일괄적으로 import를 바꾸고, tsc로 누락된 타입을 검증했습니다.
import { ChatMessageList } from '@/entities/chat';
import { useSendMessage } from '@/features/chat/send-message';
export function ChatRoomPage() {
// feature 계층의 훅으로 도메인 로직을 읽어오고,
// entity 계층의 UI 컴포넌트를 조합해 페이지를 구성합니다.
const sendMessage = useSendMessage();
return (
<ChatLayout>
<ChatMessageList />
<ChatComposer onSubmit={sendMessage} />
</ChatLayout>
);
}
온보딩 문서에 “entities에서는 데이터 요청을 직접 하지 않는다” 같은 룰을 정리했고, 신규 입사자가 바로 읽고 따라 할 수 있게 사례를 링크했습니다.
마지막으로 각 뷰를 점검하면서 widgets에서 shared 컴포넌트를 잘 가져다 쓰는지 리뷰했습니다. 라우트별 코드가 정리되니 모바일 전용 뷰도 금방 정리됐습니다.
pnpm check:dead-code:filtered로 추가 확인했습니다.지금은 새 페이지를 추가할 때 “view → widget → feature → entity” 순서로 자연스럽게 손이 갑니다. 어디에 파일을 둘지 고민하는 시간보다 사용자 문제를 어떻게 풀지 고민하는 시간이 늘어난 게 가장 큰 수확입니다. 다음에는 서버 컴포넌트 비중이 늘어나는 만큼 shared 계층을 더 작게 나누는 실험을 해볼 생각입니다.
여러분은 대규모 프론트엔드를 어떻게 정리하고 계신가요? 다른 패턴을 사용하고 계시다면 댓글로 자랑해 주세요. 서로의 폴더 구조를 비교해 보는 것도 꽤 재밌더라고요.