던플 개발기 (14): market-web을 FSD로 다시 세우기
지난 편에서 대시보드를 재설계하며 코드가 불어났다면, 이번엔 그 market-web을 다시 계층화한 이야기입니다. 대시보드·터미널·검색·즐겨찾기·실시간 구독이 차례로 붙는 동안, 새로 만든 파일은 대부분 app/_components/와 app/_lib/ 두 폴더로 흘러 들어갔습니다. UI 컴포넌트, 비즈니스 로직 훅, BFF 클라이언트가 한 폴더에서 서로를 자유롭게 import했고, 어느 순간 "이 파일이 저 파일을 알아도 되는가"를 아무도 대답할 수 없게 됐습니다.
돌아보니 파일이 커진 건 기능이 많아서가 아니라 경계가 없어서였습니다. 경계가 없으면 모든 파일이 모든 파일을 참조할 자격을 갖고, 참조가 자유로우면 결국 전부가 전부에 엮입니다. 이 편은 market-web을 Feature-Sliced Design(FSD)으로 다시 세우며, "무엇이 무엇을 알아도 되는가"를 취향이 아니라 규칙으로 못 박은 기록입니다.
한눈에 보면
- 문제는
app/_components·app/_lib한 폴더에 UI·비즈니스 로직·데이터 접근이 뒤섞여, import가 아무 방향으로나 흘렀다는 점이었습니다. - FSD 6계층(
app → views → widgets → features → entities → shared)으로 나누고, import는 오른쪽으로만 흐르게 했습니다. market-bff클라이언트를shared/api로 옮겨, app이 아닌 계층도 데이터에 닿을 수 있게 했습니다.- 실시간은 구독(feature)·wire decode(shared)·patch reducer(entity)로 갈라, 한 파일이 하던 일을 계층으로 흩었습니다.
- 라우트는
page.tsx → @/views/*한 줄로 얇게, 화면 블록은 widget으로 뺐습니다. widget이 widget을 import할 수 없으므로 여러 위젯을 엮는 orchestrator는 view에 둡니다. features/items를 검색 feature와 item entity로 쪼개, 개념(entity)과 행위(feature)를 분리했습니다.- 이 경계는 문서가 아니라 도구(eslint
no-restricted-imports+ boundary spec)로 강제합니다.
기능이 늘자 뭉친 코드
리팩터링 직전 구조는 이랬습니다. Next.js App Router의 src/app 아래에 route가 아닌 파일을 담는 관례 폴더 두 개가 있었고, 새 기능은 거의 다 여기로 들어갔습니다.
flowchart TD
P["page.tsx · layout.tsx"] --> C
subgraph app["src/app (규칙 없는 한 덩어리)"]
C["_components/<br/>home-dashboard · popular-ranking-table<br/>favorites-aside · site-header · item-logo<br/>use-favorites · use-dashboard-realtime ..."]
L["_lib/<br/>market-bff · bff-browser · dashboard-client<br/>dashboard-patch · realtime-message<br/>session · favorites ..."]
end
C <-->|서로 자유롭게 import| L
C -.->|형제끼리도 자유롭게| C문제는 폴더 이름이 아니라 폴더가 경계를 만들지 않는다는 데 있었습니다. _components 안의 컴포넌트는 _lib의 BFF 클라이언트를 직접 부르고, 실시간 훅은 patch reducer를 옆에서 끌어다 쓰고, 형제 컴포넌트끼리도 제한 없이 참조했습니다. 데이터 접근·비즈니스 로직·화면이 한 층위에 평평하게 놓여, "이 UI가 wire message를 파싱해도 되나?" 같은 질문에 코드가 스스로 답하지 못했습니다.
그리고 이 구조에서는 app이 데이터에 접근하는 유일한 통로였습니다. BFF 클라이언트가 app/_lib에 있으니, 나중에 재사용 블록을 밖으로 빼려 해도 그 블록이 app을 import해야 하는데 — 이건 뒤에 세울 규칙(하위 계층은 상위 계층을 몰라야 한다)을 정면으로 위반합니다. 경계를 세우려면 먼저 데이터 접근부터 아래로 내려야 했습니다.
FSD 계층을 도입
FSD는 코드를 여섯 계층으로 나누고, 의존은 한 방향으로만 흐르게 합니다. 한 계층은 자기 오른쪽 계층만 import할 수 있고, 왼쪽으로도 옆 슬라이스 내부로도 들어가지 못합니다.
flowchart LR
app --> views --> widgets --> features --> entities --> shared
classDef n fill:#eef,stroke:#88a
class app,views,widgets,features,entities,shared n각 계층이 소유하는 것과 금지된 것을 한 표로 정리하면 이렇습니다.
| 계층 | 소유 | 금지 |
|---|---|---|
app |
Next.js route 파일, layout, providers, global CSS, metadata, route 파라미터 적응 — 조립 지점 |
도메인 로직·BFF 매핑·patch 로직·포매터 |
views |
라우트 조합. 한 뷰가 한 라우트를 위해 widget/feature를 엮음 | 재사용 비즈니스 로직 |
widgets |
자족적인 화면 블록: 인기 랭킹표, 무버스, 즐겨찾기 aside, 터미널 뷰, 사이트 헤더 | 다른 widget을 import |
features |
사용자 행위·프로세스: 아이템 검색, 즐겨찾기 토글, 대시보드/터미널 실시간 구독 | widget·view로 거슬러 올라가기 |
entities |
비즈니스 개념: item, market-price, dashboard, favorite, provider-status, session |
feature·widget을 알기 |
shared |
도메인 무관 인프라: BFF client base, realtime transport, config, 포매터, 재사용 UI | 특정 market 슬라이스를 알기 |
여기에 더해, FSD의 소유권 경계와 별개로 책임 경계를 4밴드로 겹쳐 봤습니다. 모든 파일은 "무엇을 하는가"로 정확히 한 밴드에 놓여야 합니다.
flowchart TD
Pres["Presentation / Routing<br/>app · views"] --> Comp["Component / UI<br/>widgets·features·entities·shared 의 ui/"]
Comp --> Biz["Business Logic<br/>features·entities 의 model/ · entities 의 lib/"]
Biz --> Data["Data Access<br/>shared/api · features/*/api"]책임도 아래로만 흐릅니다. 라우트가 뷰를 그리고, 뷰가 UI 블록을 조합하고, UI 블록이 비즈니스 로직 훅을 부르고, 훅이 데이터 접근 클라이언트를 통해 읽습니다. 데이터 접근은 React 컴포넌트 안에서 정의되지 않습니다. app·views가 BFF 클라이언트를 만들거나 WebSocket 프로토콜을 파싱하는 순간, 이미 밴드를 어긴 것입니다.
이 방향을 실제로 세우려면 가장 먼저 데이터 접근을 밑바닥으로 내려야 했습니다. app/_lib에 있던 market-bff 클라이언트(서버 fetcher + DTO 타입), bff-browser(브라우저 URL 해석), dashboard-client(브라우저 fetcher + query key)를 통째로 shared/api/market-bff로 옮기고, 슬라이스 하나당 하나의 공개 API(index.ts)만 노출했습니다. 약 24개의 import가 상대경로 ../_lib에서 @/shared/api/market-bff alias로 바뀌었습니다.
// before — app/_components/home-dashboard.tsx: app 안에서만 닿는 데이터
import type { HomeDashboard, RankingWindow } from '../_lib/market-bff';
import { dashboardKeys, fetchHomeDashboard } from '../_lib/dashboard-client';
// after — 데이터 접근이 shared/api로 내려가, 어느 계층에서든 닿음
import type { HomeDashboard, RankingWindow } from '@/shared/api/market-bff';
import { dashboardKeys, fetchHomeDashboard } from '@/shared/api/market-bff';로직은 한 줄도 바꾸지 않았습니다. 클라이언트가 shared에 있다는 사실만으로 이제 widget·feature·view가 app을 거치지 않고 데이터에 닿을 수 있게 됐고, 나머지 이동의 발판이 생겼습니다.
realtime decode와 patch를 계층으로
가장 얽혀 있던 건 실시간 경로였습니다. app/_components/use-dashboard-realtime.ts 하나가 WebSocket 연결, wire message 파싱, 그리고 patch를 캐시에 적용하는 일까지 전부 했고, 그 옆에 app/_lib/realtime-message.ts(decode)와 app/_lib/dashboard-patch.ts(reducer)가 나란히 있었습니다. 셋 다 app 안이라, 화면 훅이 wire 포맷을 직접 아는 구조였습니다.
이걸 성격에 따라 세 계층으로 갈랐습니다.
- 구독(feature) —
features/dashboard-realtime/model/use-dashboard-realtime.ts: 게이트웨이에 연결하고 재연결을 관리하는 사용자 프로세스. - decode(shared) —
shared/lib/realtime/realtime-message.ts: wire frame을 검증해 타입으로 바꾸고, 깨진 메시지는 경계에서 버리는 도메인 무관 로직. - patch(entity) —
entities/dashboard/model/dashboard-patch.ts: 검증된 patch를 read-model에 적용하는 순수 reducer.
flowchart LR
GW["realtime gateway (WS)"] --> F["features/dashboard-realtime<br/>use-dashboard-realtime (구독)"]
F --> S["shared/lib/realtime<br/>parseItemPricePatch (decode·검증)"]
S --> E["entities/dashboard<br/>patchHomeDashboard (reducer)"]
E --> W["widgets/popular-ranking-table<br/>안정된 뷰 행"]계층으로 나눈 뒤 feature 훅의 import를 보면, 이제 훅이 어디에 의존하는지가 그대로 드러납니다.
// after — features/dashboard-realtime/model/use-dashboard-realtime.ts
import type { ItemPricePatch } from '@/shared/api/market-bff';
import { realtimeWsUrl } from '@/shared/api/market-bff'; // transport (shared)
import { parseItemPricePatch } from '@/shared/lib/realtime'; // decode (shared)
// after — .../use-dashboard-price-patch.ts: patch는 entity reducer로
import { patchHomeDashboard, patchItemSummaries } from '@/entities/dashboard';feature는 shared(transport·decode)와 entity(reducer)만 알고, 위쪽 widget은 전혀 모릅니다. 반대로 widget은 patch가 어디서 오는지 몰라도 안정된 뷰 행만 받습니다. (3)편에서 "React Query는 스냅샷, WebSocket은 실시간 patch"로 역할을 나눴다면, 이번엔 그 patch가 흐르는 경로 자체를 계층으로 나눈 셈입니다. 깨진 payload를 경계에서 버려 테이블 상태를 오염시키지 않는다는 원칙도, 이제 shared/lib/realtime이라는 명확한 자리에서 지켜집니다.
한 가지 관례 정리도 함께 했습니다. features/dashboard-realtime의 훅을 여러 위젯이 쓰지만, 훅 파일을 직접 deep-import하지 않고 슬라이스의 index.ts만 참조합니다. 형제 컴포넌트가 하던 import { useFavorites } from './use-favorites' 같은 상대경로도 이때 공개 API로 바뀌었습니다.
// before — app/_components/home-dashboard.tsx: 형제 파일을 상대경로로
import { useDashboardPricePatch } from './use-dashboard-price-patch';
import { useDashboardRealtime } from './use-dashboard-realtime';
import { useFavorites } from './use-favorites';
// after — feature 공개 API만 참조
import { useDashboardPricePatch, useDashboardRealtime } from '@/features/dashboard-realtime';
import { useFavorites } from '@/features/favorite-toggle';라우트를 얇게, 뷰를 위젯으로
계층을 세운 뒤 app을 순수한 라우팅 층으로 되돌렸습니다. app/page.tsx는 SSR fetch와 레이아웃 JSX를 30줄쯤 안고 있었는데, 그 조합 책임을 views/home으로 옮기고 라우트는 뷰를 렌더하기만 하게 만들었습니다.
// before — app/page.tsx (약 31줄: fetch + 레이아웃까지)
export default async function HomePage() {
let initial: HomeDashboard | null = null;
try {
initial = await getHomeDashboard('24h');
} catch {
initial = null;
}
return (
<div className="...">
<HomeDashboardClient initial={initial} />
<aside>
...
<FavoritesAside />
</aside>
</div>
);
}
// after — app/page.tsx (라우트는 뷰만 렌더)
import { HomePageView } from '@/views/home';
export const dynamic = 'force-dynamic';
export default function HomePage() {
return <HomePageView />;
}SSR fetch(getHomeDashboard)는 사라지지 않고 views/home/ui/home-page-view.tsx로 옮겨졌습니다. 라우트 파일이 얇아진 만큼, "이 라우트가 무슨 데이터를 어떻게 조합하는가"라는 질문의 답은 이제 라우트가 아니라 뷰 한 곳에 모입니다. 사이트 헤더도 같은 방식으로 위젯이 됐습니다.
// before — app/layout.tsx
import { SiteHeader } from './components/site-header';
// after — 헤더는 재사용 화면 블록(widget)
import { SiteHeader } from '@/widgets/site-header';여기서 이름 하나를 의도적으로 바꿨습니다. 정통 FSD는 라우트 조합 계층을 pages라 부르지만, Next.js App Router에서 pages는 레거시 Pages Router와, app/.../page.tsx의 멘탈 모델과 충돌합니다. 그래서 FSD의 Pages 계층을 **views**로 개명했습니다. app은 얇은 Next.js 라우팅 층으로 남고, views는 page.tsx가 렌더하는 FSD 조합 층입니다.
그리고 규칙 하나가 설계에 직접 영향을 줬습니다. widget은 다른 widget을 import할 수 없습니다(같은 계층 참조 금지). 그런데 홈 화면은 인기 랭킹표·무버스 같은 여러 형제 위젯을 fetch/subscribe하며 한꺼번에 조합하는 orchestrator가 필요합니다. 여러 위젯을 엮는 건 view 계층에서만 합법이므로, home-dashboard-client·popular-items-infinite 같은 orchestrator는 widgets/home-dashboard가 아니라 views/<route>/ui에 뒀습니다. FSD 기본 마이그레이션 맵을 그대로 따르지 않고, "코드가 더 잘 맞는 자리"를 문서에 근거로 남긴 override입니다.
item-search를 entity에서 떼기
마지막으로 개념과 행위를 갈랐습니다. 원래 features/items 한 슬라이스가 아이템이라는 개념(타입)과 아이템을 검색하는 행위(UI + 쿼리)를 함께 안고 있었습니다. FSD에서 개념은 entity, 행위는 feature입니다.
MarketItem타입은entities/item/model/item.types.ts로 옮겨 item entity의 공개 타입이 됐습니다.- 검색 UI·TanStack Query·axios BFF 클라이언트는
features/item-search로 남았고, 이제 타입을@/entities/item에서 가져옵니다.
// after — features/item-search/api/items.api.ts
import type { MarketItem } from '@/entities/item'; // 개념은 entity에서라우트도 같은 손질을 받았습니다. app/search/page.tsx가 feature를 직접 import하던 것을, 쿼리 파라미터만 파싱해 뷰에 넘기는 얇은 라우트로 바꿨습니다.
// before — app/search/page.tsx: feature를 라우트가 직접
import { ItemSearch } from '../../features/items';
// after — 라우트는 파라미터만 파싱해 뷰로
import { SearchPageView } from '@/views/search';이렇게 나누면 나중에 이득이 분명해집니다. 아이템 개념(entity)은 검색뿐 아니라 터미널·랭킹 어디서나 재사용되고, 검색 행위(feature)는 자기 UI와 쿼리만 책임집니다. 개념이 특정 행위에 묶여 있지 않으니, 새로운 행위가 붙어도 개념을 건드릴 일이 없습니다.
경계가 규칙으로 선다는 말이 공허하지 않으려면, 규칙을 사람이 아니라 도구가 지켜야 합니다. market-web은 gRPC·.proto·Prisma·네오플 client를 절대 import하면 안 되는데, 이걸 eslint no-restricted-imports와 별도의 boundary spec(items-boundaries.spec.ts)이 소스를 스캔해 막습니다. spec은 package.json만 보는 게 아니라 실제 import 구문을 훑어, 슬쩍 들어온 상대경로·deep import까지 잡아냅니다. "무엇이 무엇을 알아도 되는가"가 리뷰어의 기억이 아니라 CI의 판정이 되는 지점입니다.
아직 안 나눈 것
리팩터링은 여기서 멈췄습니다. 파일 배치 마이그레이션은 끝났고 — app은 라우팅만, 모든 슬라이스는 제자리, 위로도 옆으로도 새는 import가 없다는 경계 감사도 통과합니다. 하지만 일부러 미룬 게 하나 있습니다.
item 계열은 DTO→Model seam의 레퍼런스로 완성했습니다. shared/api가 wire 타입(MarketItemDto)을, entities/item이 도메인 타입(MarketItem)과 toMarketItem 매퍼를 소유하고, 검색 클라이언트가 경계에서 변환합니다. 반면 dashboard·terminal 타입은 아직 branded DTO 하나가 wire 모양과 도메인 모델을 겸합니다. 이 둘을 지금 가르면 매퍼가 identity 함수(들어온 값을 그대로 내보내는)에 그쳐, 유지할 순수 중복만 늘어납니다.
그래서 규칙을 이렇게 뒀습니다. 도메인 모양이 wire와 달라져야 하는 순간에만 그 계열을 가른다. 예컨대 터미널 위젯이 인라인으로 하던 파생(등급 분류, 가격 등락 계산, 준비 상태)을 도메인 모델로 미리 계산해야 할 때, 그때 *Dto(shared/api)와 *Model+매퍼(consuming entity)로 나눕니다. 필요 없는 추상화를 미리 세우지 않는 것도 경계 설계의 일부입니다 — 없어도 되는 층은 만들지 않는 편이 정직합니다.
정리하면
이 편의 전제는 하나였습니다. 파일이 커진 건 실력이 아니라 경계가 없어서였다.
- 경계 없는
app/_components·app/_lib는 모든 파일이 모든 파일을 참조할 자격을 줬고, 그래서 뭉쳤습니다. - FSD 6계층으로 import 방향을 오른쪽 한 방향으로 세우고, 데이터 접근을
shared/api로 내려 발판을 만들었습니다. - 실시간을 구독(feature)·decode(shared)·patch(entity)로 갈라, 한 파일이 하던 일을 계층으로 흩었습니다.
- 라우트를 뷰로 얇게, 화면 블록을 위젯으로 뺐고, widget→widget 금지 규칙 때문에 orchestrator는 view에 뒀습니다.
- 개념(entity item)과 행위(feature item-search)를 분리하고, 경계는 eslint와 boundary spec으로 강제했습니다.
FSD가 준 건 새 폴더 이름이 아니라 "무엇이 무엇을 알아도 되는가"에 대한 대답이었습니다. 그 대답이 규칙이 되니, 파일이 커지는 게 아니라 제자리를 찾아 나뉘었습니다. 다음 편에서는 이 위에 한 겹을 더 올립니다 — Gold·ItemId·AuctionId를 그냥 number/string으로 두지 않는 branded types와, 방금 미뤄 둔 그 DTO→Model seam을 실제로 세운 이야기입니다.
