글 목록으로개발 경험

FSD 레이어를 저장소에 맞게 다시 나눈 과정

레이어와 기능 모듈 사이의 import 방향을 표현한 일러스트

글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.

새 조회 훅을 만들 때마다 entities인지 features인지 다시 이야기했어요. 복지몰에는 entities, features, widgets, pages가 있었고 관리자 모노레포는 pages를 뺀 같은 설명을 따랐어요. 이름은 익숙했지만 실제 파일을 놓으려면 사람마다 답이 달랐어요.

관리자 쪽 변경은 develop에 합쳤고, 복지몰은 별도 브랜치에서 옮기던 과정이에요.

처음 기준은 "재사용될 것 같은 도메인 코드"였어요

저는 여러 feature에서 다시 쓸 것 같은 코드를 entities라고 설명했어요. 그런데 아직 없는 사용처를 예상해야 하니 분류가 자꾸 달라졌어요. API 타입은 entities에, 같은 조회의 훅은 features에 있고 표시 UI도 entities와 widgets에 갈렸어요.

관리 화면에서는 한 저장 요청을 고치면 조회와 폼, 버튼도 함께 바뀌었어요. 어느 코드는 도메인이고 어느 코드는 사용자 행동인지 이름만으로는 잘 나눠지지 않았어요.

필터를 배치하는 데도 문제가 있었어요. 옵션을 직접 조회하는 widgets 위에서 목록과 함께 조립해야 하는데, 그 일을 맡던 features는 widgets 아래에 있어요. 결국 app의 page.tsx에서 조합했고, 도메인의 필터 옵션 배열까지 그곳에 남았어요.

그래서 entities를 빼고 pages를 widgets 위에 올렸어요

entities와 features의 정의를 몰라서 생긴 일만은 아니었어요. 두 프로젝트는 서버가 정한 데이터를 조회해 화면에 보여 주는 일이 많았어요. 하나의 조회에 쓰는 API와 스키마, Query Options, UI를 두 레이어에서 찾는 일이 더 번거로웠어요.

공식 문서도 모든 레이어를 필수로 두지는 않아요. 클라이언트의 비즈니스 로직이 적으면 entities를 생략할 수 있다는 설명을 보고, 8월 말에는 다음처럼 바꿔 보기로 했어요.

text
app → pages → widgets → features → shared

서버 도메인별 API와 스키마, Query Options, 상태, UI는 features로 모았어요. 화면을 조합하는 pages는 widgets 위에 뒀어요. 그래야 라우트 파일을 빌리지 않고도 데이터를 조회하는 위젯과 목록을 함께 배치할 수 있어요.

pages에서는 한 화면의 JSX와 상호작용을 읽어요. 호출 순서와 상태 전이는 features에 남겨요. widgets는 여러 화면에 독립해서 붙는 UI, shared는 특정 도메인을 모르는 코드로 좁혔어요. Provider와 SDK 초기화, 라우팅은 app에 뒀어요.

8월 31일에 옮기기 시작했어요. 먼저 기존 features를 pages로 비운 다음 entities를 features로 옮겼어요. 반대로 하면 같은 이름의 슬라이스가 부딪혀요. 겹치는 배럴의 export는 직접 합쳤어요.

text
before
entities  도메인이라고 판단한 일부 API·모델·UI
features  사용자 행동이라고 판단한 일부 훅·폼·UI
 
after
features  서버 도메인 단위의 API·모델·스키마·도메인 UI
pages     화면 단위의 인터랙션과 조합

모듈 경로를 고칠 때 별칭 import만 검색했다가 상대 경로 하나를 놓쳤어요. 마지막 typecheck가 그 참조를 찾아 줬어요.

옮기고 나니 page 간 import가 드러났어요

기존 features끼리는 같은 레이어라 서로 가져올 수 있었어요. 그 파일들을 pages로 옮기자 서로 다른 화면을 직접 참조하는 코드가 됐어요. fsd/no-cross-page는 warning부터 켜고, 어디로 나눌지 정리한 뒤 error로 올렸어요.

상세 아래의 프로그램과 검색, 리뷰 작성처럼 부모 화면이 자식 화면을 가져오는 경우는 폴더 중첩을 바꿨어요. pages에서 형제 슬라이스였던 것을 라우트의 조합에 맞춰 묶으니 같은 슬라이스 안에서 가져올 수 있었어요.

처음에는 나머지 UI를 widgets로 내리려 했어요. 하지만 리뷰 항목과 카테고리 목록은 서버 DTO를 그대로 그리는 도메인 UI였어요. 이런 것은 각 도메인의 features/{domain}/ui에 뒀어요. 지도와 배너, 약관처럼 여러 화면이 쓰는 코드도 그 기준으로 옮겼어요.

컴포넌트가 화면 조각인지 도메인 UI인지까지 lint가 정해 주지는 않아요. 그 판단은 코드를 보고 하고, 정한 뒤의 import 방향과 Public API 사용을 검사하게 했어요. 분류를 피하려고 폴더 모양만 바꾸는 규칙은 두지 않았어요.

URL 모양을 따라가다가 빈 폴더가 늘었어요

처음에는 라우트와 같은 경로로 pages를 만들었어요. 9월 2일에는 동적 세그먼트를 detail로 접는 조건까지 정했는데, 한 시간 남짓 뒤 반례가 나왔어요. 나중에 형제 화면이 추가되면 원래 폴더 이름이 규칙에 안 맞게 돼요. 결국 한 폴더를 한 화면으로 보고 라우트 그룹과 버전 문자열은 따라 만들지 않기로 했어요. 같은 화면을 여러 라우트에서 여는 것도 허용했고요.

관리자 쪽도 메뉴와 URL로 화면을 찾기 편하게 1:1로 맞췄다가, URL을 지키기 위해 내용 없는 폴더를 계속 만들게 됐어요. 그날 안에 URL과 맞추는 것은 기본값으로만 두고 강제하지 않기로 했어요.

widgets도 다시 살펴봤어요. 여러 화면에 독립해서 붙는 것은 전역 내비게이션과 챗봇이었어요. 한 화면의 조각은 해당 pages로, 채팅과 분석 SDK 초기화는 app으로 옮겼어요. 챗봇은 한때 pages로 옮겼지만 URL로 여는 화면이 아니라서 그날 widgets로 되돌렸어요.

패키지를 합치자 테스트가 633초 걸렸어요

관리자 모노레포에는 ui, hooks, utils, types가 별도 workspace 패키지였어요. 모두 shared 코드인데 ui → hooks → utils → types로 이어지고 사용하는 곳에서도 따로 import했어요.

9월 2일에 shared 패키지 하나 안의 세그먼트로 합쳤어요. cookie와 환경 변수, 서버 전용 코드의 entrypoint는 따로 뒀어요. 일반 배럴에 함께 올리면 브라우저나 서버 전용 의존성이 Edge 번들로 들어갈 수 있기 때문이에요.

text
packages/shared/src/
├── ui/
├── hooks/
├── utils/
└── types/
 
@app/shared          ← 패키지 이름은 공개용 가명이에요
@app/shared/cookie
@app/shared/env
@app/shared/server

Vitest 설정도 합친 것이 문제였어요. 원래 node에서 돌던 utils까지 jsdom에서 실행돼 로컬 테스트가 633초 걸렸어요. document가 없을 때 서버용 인증 헤더를 만드는 테스트도 실패했고요. node와 jsdom 프로젝트를 나누니 8초로 돌아오고 테스트도 통과했어요. 빨라진 구조를 만든 게 아니라 합치면서 바꿔 버린 실행 환경을 복구한 거였어요.

타입 검사는 지나가도 Next.js 화면은 깨졌어요

폴더와 배럴을 옮긴 뒤에는 타입 검사와 lint, Vitest가 모두 통과해도 dev 서버나 빌드에서 실패했어요. 검사들이 따라간 import와 Next.js가 만드는 서버 그래프가 같지 않았어요.

옮긴 날인 8월 31일에는 도메인 배럴이 use client 없는 훅을 내보내 E2E가 실패했어요. React Hook Form을 쓰는 모듈에도 지시문을 붙여야 라우트의 500 응답이 사라졌어요.

9월 2일에는 리뷰 옵션 훅이 profile/index.ts를 거쳐 pages/index.ts에 올라갔어요. 그 배럴을 가져온 서버 라우트들이 500을 반환했어요. 훅을 배럴에서 빼고 use client를 붙인 뒤에는 다시 연 라우트들이 200이나 307로 응답했어요.

다음 날 widgets를 정리할 때도 비슷했어요. 필요한 모듈에 use client를 붙이고 로그인 배럴이 실제 화면만 내보내도록 줄였어요.

관리자 모노레포에서는 합친 shared 배럴이 문제였어요. 라우트 핸들러가 shared에서 값 하나를 가져오는데 클라이언트 UI 그래프까지 따라왔고, use client 없는 모듈에서 프로덕션 빌드가 깨졌어요. 이후 배럴 export나 패키지 구성을 바꾸면 pnpm build까지 실행하도록 규칙에 적었어요.

병원 관리자의 검수 요청이 Back-Office의 반려까지 이어지는 흐름은 별도로 E2E를 만들었어요. 이 이동을 확인하는 테스트와는 다른 작업이고, 그 이야기는 별도 글에 있어요.

화면 내부와 하위 화면의 공유 코드를 구분했어요

화면별 폴더가 생겨도 그 안의 코드가 현재 화면용인지 하위 화면이 함께 쓰는 것인지 헷갈렸어요. 9월 4일에는 @page와 @common을 붙여 구분했어요.

text
pages/catalog/search/
├── @page/            ← 이 화면의 slice
│   ├── ui/
│   ├── model/
│   └── lib/
├── @common/          ← 하위 화면이 함께 쓰는 코드
├── keyword/@page/
│   ├── KeywordPage.tsx
│   └── ui/ model/ lib/
└── category/@page/
    ├── CategoryPage.tsx
    └── ui/

@page 안에는 그 화면의 ui, model, lib를 두고, 하위 화면들이 함께 쓰는 코드는 @common에 뒀어요. 위 이름은 URL을 복사한 것이 아니라 화면을 묶는 예시예요. 더 넓게 쓰는 도메인 코드는 @common에 계속 쌓지 않고 해당 features 등 하위 레이어로 옮겨요.

지금 파일을 찾는 곳

복지몰에서는 레이어 방향과 Public API, page 간 참조가 lint error예요. 관리자 쪽은 앞의 두 규칙이 warning이고 page 간 참조 검사는 아직 없어요. 옮겨 둔 폴더와 검사가 똑같은 수준으로 적용된 것은 아니에요.

서버의 API와 모델, 도메인 UI가 궁금하면 features를 보고, 화면에서 무엇을 함께 그리는지는 pages를 봐요. URL을 닮은 폴더를 끝까지 따라가거나 entities와 features 중 어디일지 예상하는 대신, 실제로 함께 고치는 코드를 모아 둔 모양이에요.

참고