Deallo

아키텍처 · API

대표 글

FSD 도입 — 입사 첫 업무에서 구조를 제안하다

입사하고 처음 받은 업무는 Settings 도메인이었다. 환경설정 CRUD — 합류 직후 받기 무난한 기능 티켓. 그런데 기능을 얹으려고 코드를 파악하다 보니, 기능보다 먼저 눈에 들어온 게 있었다. 컨벤션도 폴더 규칙도 없었고, 같은 성격의 코드가 components/·hooks/·api/ 여기저기 흩어져 있었다. 탓할 일은 아니었다 — 그동안 프론트는 한 명이 관리해왔고, 혼자일 땐 규칙이 없어도 충돌도 혼란도 생기지 않으니까. 하지만 이건 새 프로젝트였고, 프론트가 혼자에서 둘이 되는 시점(기존 동료 + 나)이었다. 지금 구조를 다져놓으면 앞으로의 모든 작업이 잘 만들어진 틀 안에서 훨씬 효율적으로 굴러가겠다고 판단했다.

그래서 먼저 물었다 — "구조와 컨벤션을 새로 잡아봐도 될까요?" 팀과 CTO가 흔쾌히 열어줬고("하고 싶은 대로 해봐"), 대신 나는 결정의 근거가 남도록 일을 키웠다. 현재 코드의 문제를 분석하고 FSD 도입의 이점을 정리한 제안 문서를 만들어 공유했고(.claude AI 협업 표준도 이때 함께 제안), 그 합의 위에서 Settings 개발과 구조 마이그레이션을 병행했다. 구조는 모두가 매일 쓰는 약속이라, 문서 없이 깔면 결국 안 지켜진다.

물론 공짜는 아니었다. 제안한 사람이 책임지는 게 맞다고 생각해서, Settings는 원래 일정에 맞춰 내면서 그 위에 마이그레이션을 얹었다. 입사 첫 주부터 야근이 이어졌지만 후회는 없었다 — 하고 싶은 것을 말할 수 있고 실제로 해볼 기회가 주어지는 팀이라면, 그 기회를 잡고 시간을 들여 어떻게든 해내는 것까지가 내 몫이라고 생각했다.

팀 설득에 쓴 FSD 도입 제안서

초기 코드 — 컨벤션이 없으니 ad-hoc하게 새고 있었다

정해진 구조가 없으니 초기 코드부터 ad-hoc했고, 도메인(바이어·재고·주문)이 붙으면서 세 가지가 눈에 밟혔다.

① 캐시 키가 어긋나 invalidation이 새던 버그

React Query 캐시 키를 파일마다 손으로 적다 보니 표기가 갈렸다.

// 리스트는 'buyers'
useQuery({ queryKey: ["buyers", params] });
 
// 다른 파일, mutation 후 — 갱신은 'buyer'
queryClient.invalidateQueries(["buyer"]);
// → 'buyers' 리스트는 invalidate 안 됨!
//   이메일을 고쳤는데 목록은 옛날 값 그대로 남는 버그

키 한 글자(buyer/buyers) 차이가 "수정했는데 목록은 안 바뀐다"는 버그로 나타났다.

any와 인라인 타입이 곳곳에

http.get<any, BuyerListResponse>(...);
// 인라인 타입 → 백엔드가 바뀌면 사람이 일일이 동기화
// types/response.ts에 전 도메인이 혼재 → 파일 비대화, 변경 시 넓은 diff

any가 끼면 오타가 런타임에서야 터지고, 인라인 타입은 백엔드 명세와 수동으로 맞춰야 했다.

③ 공유 폴더라 충돌하고, "이 코드 어디?"가 잦았다

공유 폴더 구조는 한 명일 땐 멀쩡하다가, 둘만 돼도 conflict 제조기가 된다. 그리고 "바이어 태그 코드"가 api/·hooks/·components/ 세 폴더에 흩어져 있어 — Settings 하나 얹으려고 코드를 읽던 나부터가 그 헤맴의 당사자였다.

왜 FSD인가 — 제안서에 담은 근거

팀을 설득하려면 "FSD가 좋다"가 아니라 우리 프로젝트에서 무엇이 좋아지는가를 수치로 보여야 했다.

이점기존FSD
Git 충돌여러 명이 components/·hooks/·api/ 공유 폴더 동시 수정 → conflict 빈발각자 다른 도메인 슬라이스에서 작업 → 겹칠 일이 구조적으로 없음 (~80%↓)
AI 협업 토큰"바이어 이메일 수정" 한 줄에 무관한 API 20개·타입 80개(~1,300줄)를 컨텍스트에해당 슬라이스(~100줄)만 → 같은 토큰으로 10배 정확
기능 추가/삭제한 기능 삭제에 api·hooks·components·types·store 6~10개 폴더 수동 정리rm -rf features/edit-buyer-email + import 제거로 끝
온보딩"바이어 태그 코드가 api·hooks·components 곳곳에…"features/buyer/manage-tags/ — 여기가 전부
순환 의존성feature A↔B 순환 참조같은 레이어 교차 import 금지 → ESLint로 0건

특히 AI 협업 토큰 효율Git 충돌은, 여러 명이 동시에 작업하고 AI를 적극 쓰는(그래서 .claude 세팅도 같이 제안한) 우리 팀의 통점을 정확히 때렸다. 유행이라서가 아니라, 당장 첫 업무에서 내가 겪은 헤맴을 없애는 선택이었다.

레이어 설계 — App Router와 결합

바뀐 게 뭔지는 "바이어 이메일 수정" 하나를 고칠 때 닿는 파일로 비교하면 가장 빠르다.

Before — 수정 하나에 닿던 파일들

libs/api/buyers.ts       ← 200줄 (20개 함수 전부)
types/response.ts        ← 500줄 (모든 도메인 혼재)
types/request.ts         ← 400줄 (모든 도메인 혼재)
hooks/useBuyerEmail.ts
components/EmailForm.tsx ← 도메인 무관하게 한 폴더에
 
# 실제 필요한 건 ~150줄인데
# 수정 한 번의 컨텍스트가 ~1,300줄

After — 슬라이스 하나로 응집

features/buyer/edit-email/
├── ui/EditEmailForm.tsx
├── model/useEditEmail.ts
└── index.ts
 
entities/buyer/   ← Query·타입
shared/api/       ← HTTP·DTO
 
# 같은 수정의 컨텍스트 ~100줄

src/components/EmailForm.tsx는 열어봐야 어느 도메인인지 알 수 있지만, features/buyer/edit-email/ui/EditEmailForm.tsx경로가 곧 문서다.

전체 구조는 이렇다 — Next.js app/이 FSD의 app/pages 역할(라우팅·레이아웃·조립)을 맡고, src/엔 순수 4-레이어만 둔다.

app/                  ← Next.js 라우팅·레이아웃·조립
└── src/
    ├── widgets/      ← 독립 UI 영역(명사) — BuyerProfile, Header
    ├── features/     ← 사용자 액션(동사) — EditEmail, FilterBuyers
    ├── entities/     ← 도메인 모델 — Buyer, Product (Query + 타입)
    └── shared/       ← 공용 인프라 — HTTP, UI Kit, 유틸

의존 방향은 app → widgets → features → entities → shared 한 방향. feature는 "동사(액션)", widget은 "명사(영역)" — edit-email·filter-list는 feature, buyer-profile·header는 widget이다.

entities부터 — 버그를 없애는 순서로

추상 원칙부터 깔지 않고 위에서 본 세 버그를 없애는 순서로 옮겼다. 그래야 설득도 됐고, 업무를 병행하면서도 "왜 지금 이걸 하는지"가 설명됐다.

①을 잡으려고 entities/buyer에 캐시 키 팩토리를 두어 키를 한곳에서 만들게 했다.

// entities/buyer/api/queries.ts — 키를 손으로 안 적고 팩토리로
export const buyerKeys = {
  all: ["buyer"] as const,
  lists: () => [...buyerKeys.all, "list"],
  detail: (id: number) => [...buyerKeys.all, "detail", id],
};
 
// mutations.ts — 무효화도 같은 팩토리로
onSuccess: (_, { id }) => {
  queryClient.invalidateQueries({ queryKey: buyerKeys.detail(id) });
  queryClient.invalidateQueries({ queryKey: buyerKeys.lists() }); // 리스트도 확실히
};

키를 만드는 곳과 쓰는 곳이 한 슬라이스에 모이니 buyer/buyers 같은 어긋남이 구조적으로 사라졌다. 그리고 한 달 뒤 Orval을 도입하면서 쿼리 훅·키 생성 자체가 스웨거에서 자동으로 만들어지게 돼, 이 문제는 아예 원천 차단됐다. ②는 도메인 타입을 한곳에 모아 any를 걷어냈고(이 흐름도 Orval 자동 생성으로 이어진다), ③은 features/buyer/edit-email처럼 도메인·액션 2단계로 묶어 "그 코드는 여기 하나"가 되게 했다.

규약을 코드로

규칙은 사람의 의지에 맡기면 샌다. ESLint + Husky(pre-commit)로 강제했다.

  • 상위 레이어는 하위만 import (역방향 금지)
  • 같은 레이어의 다른 슬라이스 교차 import 금지 — 외부에선 index.ts로만
  • 위반 시 커밋 차단

임팩트 · 배운 점

  • "이 코드 어디 둘지"가 규칙으로 결정돼 의사결정·리뷰 비용이 줄었다 (import 방향만 봐도 위반 판별)
  • 캐시 키 어긋남·any처럼 컨벤션 부재가 만들던 버그류가 구조적으로 사라졌다
  • 구조는 합의의 문제다. 혼자 좋다고 깔면 안 지켜진다. 그래서 코드보다 분석·제안 문서를 먼저 공유해 합의를 만들었고, 추상 원칙이 아니라 실제 버그를 없애는 순서로 풀어 결과로 보여줬다
  • 기능 티켓 하나를 받았을 때가 구조를 보기 가장 좋은 순간이었다 — 프로젝트 초기에만 열리는 창이고, 코드가 쌓인 뒤였다면 이만큼 못 바꿨다