리팩토링

2026-05-12

GOD feature 리팩토링 with 코드레벨 skill 커스텀하기

레시피오 features/recipe-import-youtube 슬라이스를 통째로 리팩토링하며 만든 코드 레벨 스킬 — FSD 정책, 슬라이스 내부 구조, 옵셔널 타입의 비용.

레시피오에서 features/recipe-import-youtube 슬라이스 하나를 통째로 리팩토링했어요. 같은 세션에서 code-quality라는 코드 레벨 스킬을 직접 설계했고, 그 첫 적용처로 이 슬라이스를 골랐습니다. 결과는 5 커밋, 그리고 작업 도중 발견된 패턴 4개가 스킬에 새 룰로 추가됐어요.

본격적인 회고로 들어가기 전에, 이 작업의 더 큰 맥락 세 가지를 먼저 정리할게요. FSD라는 아키텍처에 대한 본인 정책, 슬라이스 내부 폴더명에 대한 결정, 그리고 옵셔널 타입이 컴포넌트를 어떻게 오염시키는지에 대한 시각. 이 세 토픽이 사실상 이번 작업 전체의 동기예요.

들어가며

FSD라는 게 뭐고, 왜 본인 스킬이 필요해졌나

Feature-Sliced Design(FSD)은 한 문장으로 표현하면 "프론트엔드 코드를 추상화 레벨로 계층화하고, 의존 방향을 한 방향으로 고정해두는 사고법"입니다. shared → entities → features → widgets → app 순으로 추상화가 올라가고, 같은 레이어끼리는 cross-import 금지. 폴더 구조가 곧 의존 그래프인 셈이죠.

레시피오는 이 구조를 따른다고 적어두고 있었어요. 그런데 시간이 지나면서 의미가 점점 퇴색됐습니다. 구체적인 증상이 세 개 있었어요.

첫째, widgets/ 안에 40개 넘는 슬라이스가 있는데 대부분이 PascalCase 폴더 하나에 동명의 .tsx 파일 한 개로 끝나는 형태였어요. FSD 정통이라면 슬라이스 안에서 ui/, model/, lib/ 같은 segment로 책임이 더 쪼개져 있어야 하는데, 그게 없었습니다.

둘째, 페이지 한 곳에서만 쓰이는 컴포넌트와 진짜로 widget 자격을 가진 (여러 페이지에서 조립 단위로 재사용되는) 컴포넌트가 구분되지 않고 같은 widgets/에 섞여 있었어요. Header, Footer, Toast 같은 전역 chrome과 특정 페이지 전용 섹션이 같은 레이어에 들어 있는 식이에요.

셋째, entities/에 mutation이 들어가고 features/에는 read query가 들어가는 등 레이어 간 책임 경계가 이미 흐려진 상태였습니다.

이걸 잡으려면 두 가지 중 하나가 필요했어요. 매번 코드 리뷰에서 잡거나, 룰로 강제하거나. 매번 잡는 건 사람이 잊는 영역이 있어서 결국 누적되더라고요. 특히 AI에게 별다른 제약 없이 React 코드를 맡기면 useState + useEffect로 모든 걸 해결하려는 패턴이 빠르게 쌓이고, FSD 레이어 위반도 흔하게 일어납니다. 한 사람이 매번 손으로 잡는 부담이 점점 커져요.

그래서 룰로 강제하기로 결정했어요. 그동안은 vercel-react-best-practices 스킬을 사용 중이었습니다. React와 Next.js 일반 룰을 잘 정리해둔 좋은 스킬이지만, 레시피오 한정 정책을 강제할 도구는 아니었어요. FSD 레이어 분할이라든가 슬라이스 내부 구조, 매퍼 위치 같은 부분은 우리 프로젝트가 직접 정해야 했고, 그 빈 공간에서 결국 자가부채가 누적됐습니다.

프로젝트에 맞춘 코드 레벨 스킬을 직접 설계하기로 한 이유가 여기에 있어요. 그리고 그 스킬을 설계할 때 가장 먼저 정의가 필요했던 게 FSD 정책입니다.

결정한 FSD 4단계 분류

레이어책임재사용 조건
shared/비즈니스 모름. 어디서든 재사용≥2곳
widget/feature·entity 조립자, 큰 페이지 섹션. read query OK, mutation은 feature에 위임≥1곳 재사용 가능성
app/(route)/_components/그 라우트 1곳 전용, flat 구조1곳 전용
app/(route)/page.tsxwidget + _components 조립만-

여기서 정통 FSD에 없는 게 하나 있는데 app/(route)/_components/ 레이어입니다. Next.js App Router의 private folder 컨벤션 (underscore prefix는 라우트로 잡히지 않음)을 이용해서, "이 컴포넌트는 이 페이지 전용이다"가 폴더 위치만으로 명확해지게 만들었어요.

entities/는 데이터와 그 표시(RecipeCard 같은)만, features/는 한 액션 단위(recipe-create, comment-like)만이라는 좁은 정의도 같이 명시했습니다.

트레이드오프

정통에서 벗어난 결정이라 비용도 있어요.

  • 기존 widgets/ 40+ 개 중 상당수는 _components/로 강등되거나 shared/ui/로 이동해야 합니다.
  • features/를 한 액션 단위로 잘게 쪼개니까 폴더 수가 30+ 에서 곧 50+ 까지 갈 거예요. 탐색 비용이 늘어나는 건 감수하기로 했고, IDE fuzzy search로 견딜 만하다고 판단했습니다.
  • ESLint로 레이어 import 룰을 자동 강제하는 부분은 별도 작업으로 남겼어요. 룰만 정의해두고 자동화는 점진적으로 진행할 계획입니다.

ui/ vs components/ — 슬라이스 내부 폴더명

스킬 룰을 정의하다 보니 또 하나 결정해야 했던 게 슬라이스 내부 폴더명이었어요. FSD 정통에서는 ui/, model/, lib/를 씁니다. React 프로젝트만 한 사람 입장에서는 components/가 더 직관적이라고 느낄 수 있고, 다른 개발자들 중에도 components가 더 명확하다고 보는 의견이 있었어요.

이 논쟁의 핵심은 단순히 네이밍 취향이 아니었어요. 두 이름은 슬라이스 폴더 구조 자체를 다르게 결정해요. 어떤 폴더가 같이 등장하고, 컴포넌트가 무엇을 직접 다루는지가 갈립니다.

components/ 쪽 사고법으로 슬라이스를 짜면 보통 이런 모양이 됩니다.

recipe-import-youtube/
├── components/
│   ├── DuplicateRecipeSection.tsx   ← 데이터 fetch + 상태 관리 + 렌더 다 함
│   ├── DuplicateRecipeCard.tsx
│   ├── PendingRecipeCard.tsx
│   └── ...
├── hooks/                            ← 옵션, 없을 수도 있음
│   └── useJobPolling.tsx
└── api.ts                            ← HTTP 함수만

컴포넌트가 자기 데이터를 자기 hook으로 직접 가져오는 게 자연스럽고, hooks/api.ts는 컴포넌트가 호출하는 도구 정도예요. 폴더 구조의 중심이 컴포넌트입니다. React 자체가 이런 코로케이션을 권장하니까 일리는 있어요.

ui/ 쪽 사고법으로 같은 슬라이스를 짜면 이렇게 바뀝니다.

recipe-import-youtube/
├── ui/
│   ├── DuplicateRecipeSection.tsx   ← model에서 받아온 걸 분기 + 렌더만
│   ├── DuplicateRecipeCard.tsx
│   ├── PendingRecipeCard.tsx
│   └── ...
├── model/
│   ├── api.ts                       ← HTTP 경계, fetch wrapper
│   ├── hooks.ts                     ← useQuery / useMutation 정의 (ViewModel 역할)
│   ├── store.ts                     ← Zustand 클라이언트 상태
│   ├── types.ts                     ← 타입 정의
│   ├── duplicateRecipeMapper.ts     ← 응답 → view-shape 변환
│   ├── useJobPolling.tsx            ← orchestrator hook
│   └── persistence.ts               ← localStorage 헬퍼
└── lib/
    ├── errors.ts                    ← 도메인 모르는 에러 변환
    └── urlValidation.ts             ← 순수 검증 함수

ui/에는 JSX만. model/에는 상태·API·변환·결정. ViewModel(hooks.ts, useJobPolling.tsx)과 Model(api.ts, store.ts, types.ts)을 둘 다 model/ 폴더 안에 넣는 게 FSD의 트레이드오프인데, 폴더가 너무 잘게 쪼개지지 않고 한 단계 추상화로 묶어 보기 쉽다는 이점이 있어요.

lib/는 도메인을 모르는 순수 헬퍼만. 슬라이스에 헬퍼가 1~2개뿐이면 안 만들고 model/utils.ts 한 파일로 묶어두기도 합니다. 슬라이스 규모에 따라 언제든 추가·삭제 가능한 보조 폴더예요.

저는 후자를 선택했어요. 한 컴포넌트가 자기 데이터를 직접 fetch하고 자기 상태를 관리하기 시작하면 시간이 지날수록 god component가 돼요. 200줄 컴포넌트 안에 useQuery + useMutation + useState + useEffect + 분기 렌더링이 다 들어 있는 상태고, 이번 리팩토링 전의 DuplicateRecipeSection.tsx가 정확히 그 모양이었고요.

ui/model 분리는 god component를 만들기 어렵게 만드는 장치예요. fetch와 변환과 상태 관리가 model/에 가 있으면, ui/로 흘러오는 건 이미 narrow된 데이터와 액션 함수뿐. 컴포넌트 안에 옵셔널 방어 코드가 쌓이는 부채를 처음부터 막아줍니다. (이 부분은 바로 다음 섹션에서 케이스로 보여드릴게요.)

그래서 레시피오는 ui/로 유지합니다. 단순히 FSD 정통을 따라간다기보다, view와 model을 폴더 단계에서 의도적으로 분리하겠다는 선언에 가까워요.

옵셔널 타입이 컴포넌트를 오염시키는 부채

앞 섹션에서 ui/model 분리가 god component를 만들기 어렵게 만드는 장치라고 했어요. 그 장치가 가장 강하게 발동되는 사례 하나를 짚고 갑니다. 레시피오에서 가장 큰 자가부채 중 하나, 옵셔널 타입이 컴포넌트까지 그대로 흘러내려오는 패턴이에요.

백엔드가 보내는 응답 중에 conditional shape인 게 있습니다. status에 따라 어떤 필드가 차고 비고가 결정되는 모양인데, 타입은 그 관계를 표현하지 못하고 다 optional ?로 깔립니다. 유튜브 추출 job의 status 응답이 대표적이에요.

type JobStatusResponse = {
  status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED";
  resultRecipeId?: string;  // 의미상 status === "COMPLETED"일 때만 있음
  code?: string;            // status === "FAILED"일 때만 있음
  message?: string;         // status === "FAILED"일 때만 있음
  progress?: number;        // IN_PROGRESS / PENDING에서만 의미 있음
};

status 하나만 좁혀지면 나머지 4개 필드 중 어떤 게 있고 없는지 결정됩니다. 그런데 타입에서는 그 관계가 보이지 않으니, 컴포넌트가 매번 직접 방어해야 해요.

<div>{job.message || "실패"}</div>
<Link href={`/recipes/${job.resultRecipeId ?? ""}`} />
<Progress value={job.progress ?? 0} />

한 군데에서 ?? "" 한 번 쓰는 건 별 일 아닙니다. 이게 슬라이스 수십 개에 누적되면 컴포넌트 전반이 옵셔널 방어 코드로 도배돼요. 진짜 부채는 코드량이 아니라 코드 가독성이 떨어지고, ai 에이전트가 실수할 수 있는 여지를 준다는거였어요. 또한 컴포넌트가 백엔드 응답 shape를 직접 알게 되는 거고, 백엔드가 응답 구조를 바꾸면 그 영향이 슬라이스 안 모든 컴포넌트로 전파됩니다.

이 부채를 끊으려면 폴더 구조가 도와줘야 해요. 앞 섹션에서 결정한 ui/model 분리가 여기서 실질적인 의미를 가집니다. model/ 안에 매퍼를 하나 두고 api 레이어에서 응답을 받자마자 narrow된 타입으로 변환하면, view ui/ 레이어는 narrow된 타입만 받게 됩니다.

// model/jobMapper.ts
type Job =
  | { state: "creating";  progress: 0 }
  | { state: "polling";   progress: number }
  | { state: "completed"; progress: 100; resultRecipeId: string }
  | { state: "failed";    code: string; message: string };

export const fromJobStatusResponse = (raw: JobStatusResponse): Job => {
  switch (raw.status) {
    case "COMPLETED":
      return { state: "completed", progress: 100, resultRecipeId: raw.resultRecipeId ?? "" };
    case "FAILED":
      return { state: "failed", code: raw.code ?? "UNKNOWN", message: raw.message ?? "실패" };
    case "PENDING":
    case "IN_PROGRESS":
      return { state: "polling", progress: raw.progress ?? 0 };
    default: {
      const _exhaustive: never = raw.status;
      throw new Error(`Unknown status: ${raw.status}`);
    }
  }
};

옵셔널 방어 코드가 단 한 곳, api 레이어 매퍼 안에만 모입니다. 컴포넌트는 switch (job.state) 한 번으로 narrow된 타입에 안전하게 접근해요.

switch (job.state) {
  case "completed": return <Success recipeId={job.resultRecipeId} />;  // 타입상 string 보장
  case "failed":    return <Error message={job.message} />;            // 타입상 string 보장
  case "polling":   return <Progress value={job.progress} />;
  case "creating":  return <Spinner />;
}

?? ""|| "실패"도 없습니다. 백엔드 응답 shape는 매퍼만 알면 되고, 컴포넌트는 도메인 모양만 알면 돼요.

발동 조건 — 모든 옵셔널이 다 매퍼 대상은 아닙니다

이 패턴이 항상 필요한 건 아닙니다. 옵셔널 필드 1~2개가 정말로 독립적으로 있을 수도 없을 수도 있는 경우 (예: User.nickname?, User.profileImage?)는 그냥 옵셔널로 두는 게 맞아요. 발동 조건은 두 가지가 동시에 만족될 때로 제한했어요.

  • 같은 객체에 옵셔널 필드가 3개 이상
  • 그 필드들의 존재 여부가 같은 discriminator (status / type / kind 같은 필드)에 따라 결정됨

여기서 저희 프로젝트의 JobStatusResponse는 두 조건 모두 만족했습니다. discriminated union으로 narrow + switch + never default로 exhaustive 보장했어요. 결과적으로 ts-discriminated-union-at-boundary라는 새 룰을 스킬에 추가했어요.

매퍼 위치 — feature 슬라이스 안 model/

매퍼를 어디에 둘지도 결정해야 했어요. 두 후보:

  • entities/recipe/model/ — entity 레이어에서 변환
  • features/recipe-import-youtube/model/ — feature 슬라이스 안

여기서도 후자를 선택했습니다. 매퍼는 feature-specific 변환이라고 생각했어요. 유튜브 추출 job 응답을 이 feature 컴포넌트가 쓸 모양으로 바꾸는 건 이 feature에서만 의미가 있습니다. entity로 올리면 entity 안에 "유튜브 추출"이라는 feature 도메인이 새고, FSD의 의존 방향(entities ← features)이 깨져요.

zod를 도입해야할까?

API 경계에서 매퍼만 쓸지, zod 같은 런타임 validator도 추가할지도 결정 사항이었어요. 저는 내부 API는 매퍼만, 외부에 강하게 종속되는 경우에만 zod로 가닥을 잡았어요.

레시피오 백엔드는 같은 팀에서 만들고 응답 shape가 자주 안 바뀝니다. zod의 가치(런타임 검증)보다 비용(번들 사이즈, 매 응답마다 parse cost, schema 유지 부담)이 더 커요.(사실 번들사이즈나 매 응답 cost도 미미해서 어느쪽이든 취향의 영역이라고 생각합니다.) 매퍼 안에서 TS 타입만으로 narrow하는 걸로 충분합니다.

zod는 third-party API (OpenAI, YouTube oEmbed 같은 거)나 사용자 입력 (JSON.parse, localStorage, URL query) 같이 진짜로 신뢰 못 하는 경계에만 사용하기로 했어요. 룰로도 그렇게 정리했어요.

리팩토링 범위

이제 본격적으로 리팩토링을 해볼게요. 이 슬라이스의 4개 파일이 룰 위반이었습니다.

파일줄수무엇
model/store.ts349두 store 한 파일, V1은 외부 사용 0건의 dead code
model/useJobPolling.tsx241useCallback 4개 chain — React Compiler가 켜져있는데도
ui/DuplicateRecipeSection.tsx201Skeleton + Card + auto-save effect 한 컴포넌트
ui/PendingRecipeCard.tsx166pending/success/error 3-way 분기 한 JSX

리팩토링 1 — V1 store dead code 제거

store.ts가 349줄이었던 이유의 절반은 V1 store(useYoutubeImportStore)가 같이 들어 있어서였습니다. V2가 폴링 기반 작업 큐로 진화하면서 V1은 외부 import 0건이 된 지 오래였는데 제가 안 지웠어요.. 그냥 부채입니다. 반성합니다.

grep으로 import site 0건 확인하고 V1 정의 108줄 + 그것만 의존하던 테스트 260줄 통째 삭제했습니다.


리팩토링 2 — store selector 추출

V1을 지운 뒤에도 store.ts가 248줄이라 size 룰의 hard limit(150줄)을 넘었습니다. 안을 보면 state shape + setter 9개 + selector 3개(getJobByUrl, getPendingJobs, getActiveJobCount)가 한 파일에 섞여 있었어요.

들어가며에서 model/은 ViewModel과 Model을 합친 폴더라고 했는데, 그 안에서도 한 단계 더 분리할 수 있는 영역이 있어요. setter는 상태를 바꾸는 책임, selector는 상태를 derived 형태로 읽기만 하는 책임이에요. 두 종류는 변경되는 이유가 달라요. 새 액션이 생기면 setter가 늘고, 새 화면 요구가 생기면 selector가 늘어납니다. 같은 파일에 두면 setter 9개 사이에 selector 3개가 끼어들어, 한 파일을 읽을 때 두 종류 작업을 머릿속에서 분리해 가며 읽게 돼요.

그래서 selector 3개를 model/storeSelectors.ts로 빼고 store method는 한 줄 wrapper만 남겼습니다.

// Before — store.ts 안에서 body까지 다 가짐
getJobByUrl: (url) => {
  const jobs = get().jobs;
  return Object.values(jobs).find((job) => job.url === url);
},

// After — body는 storeSelectors.ts, store는 호출만
getJobByUrl: (url) => selectJobByUrl(get(), url),

컨슈머는 메서드 시그니처를 그대로 쓸 수 있어요. store.getJobByUrl(url) 호출은 변경 0건. 동시에 selector 로직만 별 파일에서 관리되니까 단위 테스트도 store 인스턴스 없이 함수 하나로 가능합니다.

더 깊이 가면 컨슈머가 useStore(selectJobByUrl) 같은 reactive 구독 패턴으로 바꿀 수도 있어요. Zustand 공식 가이드도 그쪽을 권장합니다. 다만 그건 리렌더 동작이 미세하게 변하니까 "동작은 그대로" 규칙을 넘어요. 일단 wrapper까지만 두고 reactive 전환은 다음 라운드.

이 작업은 룰로 정리할 가치가 있는 패턴이에요. "Zustand store의 derived 로직(selector)은 별 모듈로 분리한다"는 한 줄. store 파일 크기가 자랄 때 size 룰과 묶여 자동으로 발동되도록 해두면 다음 store가 자랄 때 같은 정리가 반복되지 않아요.


리팩토링 3 — useJobPolling: god hook을 orchestrator로

여기가 이번 라운드의 메인입니다. useJobPolling.tsx가 241줄이었고, 안에 useCallback이 4개 있었어요.

const handleJobComplete    = useCallback(/* 30줄 */, [completeJob, queryClient, ...]);
const handleJobFail        = useCallback(/* 15줄 */, [failJob, addToast]);
const handleZombieRecovery = useCallback(/* 25줄 */, [..., handleJobFail]);
const pollJob              = useCallback(/* 50줄 */, [..., handleJobComplete, handleJobFail, handleZombieRecovery]);

useEffect(() => { /* polling loop */ }, [..., pollJob]);

react-compiler-memoization 룰만 보면 4개 다 정당해요. 각자가 다음 핸들러의 deps로 들어가는 chain이고, 마지막 pollJobuseEffect deps로 들어가니까요.

근데 chain이 4개나 쌓이는 게 정상인지가 의문이에요. React Compiler가 켜져 있는데 사람이 메모이즈 4개를 명시하고 deps 배열을 유지하는 게 어색했어요. 그 어색함을 따라가다 보니 커스텀 훅을 두 갈래로 봐도 되겠다 싶었어요. 결정을 내리는 hook(god에 가까운 쪽)과 조립만 하는 hook(orchestrator).

god hook vs orchestrator

핵심 질문은 hook이 비즈니스 결정을 직접 내리고 있느냐예요. "이 상태일 때 어떤 toast를 띄울지", "어떤 응답에서 어디로 redirect할지", "실패 시 몇 번 재시도할지" 같은 결정이 hook 본문 callback 안에 적혀 있으면 god에 가까워져요. 길이는 사실 상관 없어요. 50줄짜리에도 결정이 다섯 개 들어 있으면 god이고, 200줄짜리라도 그 안이 전부 helper 호출이면 god이 아닙니다.

반대로 orchestrator는 결정을 안 내려요. 외부 시스템(router, queryClient, store)을 구독하고 deps 객체를 만들고 effect로 wiring하는 일만 합니다. 결정은 helper에 위임. 100줄짜리 hook이 그 100줄을 전부 "외부 시스템 가져와서 helper에 넘기기"에 쓰고 있다면 그건 조립이지 결정 작업이 아니에요.

그래서 줄 수보다 결정의 개수를 봐요. hook 본문에서 "이 경우엔 X를 하고 저 경우엔 Y를 한다" 같은 비즈니스 분기가 하나라도 보이면 god 의심권에 들어갑니다. 그 분기가 helper 호출이고 helper 시그니처에 분기 결과가 담겨 있다면 안전.

useJobPolling은 어느 쪽이었나

리팩토링 전 useJobPolling은 god에 가까웠어요. handleJobComplete 하나 안에 다섯 가지 결정이 있었습니다.

  • 어떤 query를 invalidate할지 (["recipes"], ["recipes", "saved"], ["myInfo"])
  • 리뷰 게이트를 띄울지 말지 (trackReviewAction("youtube_extract"))
  • haptic 강도 (triggerHaptic("Success"))
  • toast의 variant와 콘텐츠 (제목, 썸네일, 액션 url)
  • 2초 후 job 제거하는 cleanup 타이밍

이게 다 hook 본문 안에 직접 있었습니다. 다섯 결정이 한 callback 안에. useCallback chain 4개는 hook이 너무 많은 결정을 들고 있던 것의 흔적이었어요.

결정을 모듈로 옮기기

해법은 결정을 hook 밖으로 빼는 거였어요. 모듈 레벨 pure 함수로 만들고, 결정에 필요한 의존성(queryClient, addToast, router, store actions)은 deps 객체로 받게 했습니다.

// model/jobPollingHandlers.tsx
export type JobPollingDeps = {
  queryClient: QueryClient;
  addToast: AddToast;
  router: AppRouterInstance;
  storeActions: { completeJob, failJob, removeJob, setJobId, ... };
};

export const completePollingJob = (deps: JobPollingDeps, idempotencyKey, recipeId) => {
  // 다섯 결정이 다 여기로
};

export const pollSingleJob = async (deps: JobPollingDeps, job: ActiveJob) => { ... };

이제 hook은 외부 시스템 구독 + deps 빌드 + effect wiring만 합니다.

const useJobPolling = () => {
  const router = useRouter();
  const queryClient = useQueryClient();
  const addToast = useToastStore((s) => s.addToast);
  // ...store actions 구독

  const deps = useMemo<JobPollingDeps>(() => ({
    router, queryClient, addToast, storeActions: { ... },
  }), [/* ... */]);

  useEffect(() => {
    // polling loop, 각 job에 대해 pollSingleJob(deps, job) 호출
  }, [isVisible, jobs, deps]);

  return { pendingJobCount, isPolling };
};

결과 : useCallback 0개. hook은 241 → 115줄. 줄 수 감소가 눈에 띄긴 한데, 더 중요한 건 hook이 결정을 그만 들고 있게 됐다는 점이에요. 어떤 toast가 뜨고 어떤 query가 invalidate되는지 알고 싶으면 이제 hook이 아니라 jobPollingHandlers.tsx를 봐야 합니다.

부수효과도 큽니다. 결정 함수들이 React 의존성 없는 pure 함수가 되니까 단위 테스트가 React renderer 없이 가능해요. completePollingJob(mockDeps, key, recipeId) 호출 후 mockDeps.addToast가 어떤 인자로 호출됐는지 검증하면 끝입니다.

한 가지 의도된 결합

핸들러가 완전히 deps에만 의존하지는 않습니다. useYoutubeImportStoreV2.getState()를 직접 부르는 곳이 두 군데 있어요. completePollingJobfailPollingJob에서 if (!job || job.state === "completed") return; 같이 멱등성 가드를 거는 부분.

이건 의도된 결합이에요. await 후 store 상태가 다른 job 처리로 바뀌었을 수 있으니, stale closure로 들고 있던 값이 아니라 실시간 store에서 다시 읽어야 race를 막을 수 있습니다. deps의 store action만 쓰면 이 가드가 작동하지 않으니, 의도적으로 helper가 store에 직접 결합되게 뒀어요. 추후 멀티 인스턴스 store 패턴으로 가면 이 결합이 깨질 텐데, 그땐 deps로 store 자체를 받게 바꿔야 합니다. 지금은 singleton이라 그 부담이 없어요.

룰로 정리

이 패턴이 일반화 가치가 커서 react-compiler-memoization.md 룰에 "Extraction alternative" 섹션을 추가했습니다. 핵심은 "useCallback 3개 이상이 chain되면 god hook을 의심하라. 결정을 모듈로 옮기고 hook은 조립만 남겨라."

또 하나 발견은 god hook과 orchestrator의 구분이 단순 size 룰보다 빠르게 발동된다는 거였어요. "120줄 넘으면 분리" 같은 사이즈 기준은 부채가 한참 누적된 뒤에야 작동하는데, hook이 들고 있는 결정의 개수는 그보다 훨씬 일찍 신호를 줍니다. chain된 useCallback이 그 흔적이라 패턴 등장 즉시 알아챌 수 있고요.


리팩토링 4 — DuplicateRecipeSection 분리와 매퍼 정책의 첫 적용

DuplicateRecipeSection.tsx가 201줄이었어요. 세 책임이 한 곳에 있었습니다. Skeleton motion 블록, Card motion 블록, auto-save useEffect. 들어가며에서 본 god component 정의 그대로예요. 자기 데이터를 자기 hook으로 fetch하고, 자기 상태를 들고, 자기 렌더링까지 다 합니다.

세 컴포넌트로 쪼갰어요. DuplicateRecipeSection은 queries + effect + 분기만 들고 있는 컨테이너로 남기고, DuplicateRecipeSkeletonDuplicateRecipeCard는 props 받아 JSX만 반환하는 presentational로 분리. 컨테이너와 presentational 분리는 폴더 단위가 아니라 컴포넌트 단위로 갔어요. 셋 다 ui/ 안에 같이 있고, 결정은 Section만 갖습니다.

매퍼를 빼면서 6개월짜리 버그가 드러났어요

원래 컴포넌트 안에 인라인 매핑이 있었습니다.

const detailedRecipeItem = recipeData && {
  id: recipeData.id,
  title: recipeData.title,
  // ...
  isYoutube: true,                      // ⚠️ 실제 타입엔 없는 필드
  youtubeChannelName: channelName,
};

return <DetailedRecipeGridItem recipe={detailedRecipeItem} />;

이 매핑을 model/duplicateRecipeMapper.ts로 옮기면서 return type을 DetailedRecipeGridItem으로 명시했더니, TS가 isYoutube: true를 즉시 거부했어요. 실제 타입에는 isYoutube 필드가 없습니다. 현재 필드는 source: "YOUTUBE"예요.

왜 그동안 컴파일이 됐을까요. TypeScript의 excess property check는 fresh 객체 literal을 직접 할당할 때만 발동합니다. 인라인 객체가 && short-circuit이나 중간 변수, JSX prop 같은 단계를 거치면 검사가 structural match로 약해져요. "필요한 필드 다 있으면 OK"가 되는 거죠. isYoutube가 진짜 타입에 없어도 객체에 필요한 다른 필드가 다 있으니까 그냥 통과됐어요. 6개월 동안 YouTube 마커가 정작 화면에 안 떴고요.

매퍼로 추출하면서 return type을 명시하니까 그 한 군데서 검사가 strict해졌고, 컴파일러가 잡아냈습니다. 수정은 한 줄(source: "YOUTUBE"). 결과적으로 YouTube 마커가 6개월 만에 화면에 보이기 시작했어요.

이건 우연한 부수효과가 아니라 정책의 직접 결과예요. 들어가며에서 "매퍼는 model/에 두고 typed return으로 narrow"라고 했는데, 그 정책이 첫 적용에서 latent 버그를 잡아낸 거죠. 룰로 정리한 ts-typed-mapper-extraction.md의 발동 사례이고, 매퍼 위치도 들어가며 결정 그대로 features/recipe-import-youtube/model/에 뒀어요.

auto-save useEffect는 손대지 않았어요

남은 한 가지가 auto-save useEffect입니다. hasAutoSavedRef로 한 번만 fire되게 만든 패턴.

const hasAutoSavedRef = useRef(false);

useEffect(() => {
  if (recipeStatus && !isFavorited && !isLoading && !hasAutoSavedRef.current && urlSource === "direct") {
    hasAutoSavedRef.current = true;
    toggleFavorite(undefined, { onSuccess: handleSaveSuccess });
  }
}, [/* ... */]);

솔직히 좀 거슬리는 모양이에요. effect 안에서 mutation을 트리거하는 데다 ref로 once-only 가드까지 깔고 있습니다. 더 좋은 패턴은 mutation의 onSuccess로 옮기거나 TanStack Query의 enabled 조건으로 분기하는 거예요.

다만 이건 logic 재설계라 이번 라운드 규칙(동작은 그대로, 코드 품질만 손질)을 넘어갑니다. 이 자동 저장이 어떤 조건에서 어떻게 작동해야 하는지부터 다시 정해야 하는 작업이라, 시각만 잡아두고 별 라운드로 미뤘어요.


리팩토링 5 — PendingRecipeCard 상태별 분리 + plan 정정 일화

PendingRecipeCard.tsx가 166줄, 안에 pending/success/error 3-way JSX 분기가 한 곳에 있었어요. 들어가며에서 짚은 "같이 변하지 않는 분기는 다른 컴포넌트" 원칙의 교과서 케이스입니다.

세 상태 컴포넌트로 분리. PendingState, SuccessState, ErrorState. 부모는 dispatch table만 남았어요.

{status === "pending" && <PendingState progress={progress} />}
{status === "success" && <SuccessState />}
{status === "error"   && <ErrorState message={message} onDismiss={() => removeJob(idempotencyKey)} />}

import 경로 다시쓰기

흥미로웠던 건 plan에 적혀 있던 AppRouterInstance import 경로였어요.

import type { AppRouterInstance }
  from "next/dist/shared/lib/app-router-context.shared-runtime";

이렇게 바꿔서 돌려줬어요.

import type { useRouter } from "next/navigation";
type AppRouterInstance = ReturnType<typeof useRouter>;

next/dist/...는 internal 경로라 Next.js 버전이 올라가면 깨질 위험이 있어요. 반면 useRouter는 public hook이라 안정적입니다. 정확한 판단이고, 일반화 가치도 컸어요.

이 케이스를 새 룰로 정리했습니다. ts-type-imports-from-dist.md — "pkg/dist/... 같은 내부 경로의 type import 금지. public hook의 ReturnType<typeof X>로 derive."

이런 종류 — "어떤 라이브러리의 어떤 경로를 import해야 안정적인가" — 는 사람 머리로 매번 기억하기 어려운 영역이에요. 룰로 자동 강제할 후보의 좋은 예시이고, 들어가며에서 다룬 "사람이 잊는 영역은 룰로 막는다"는 정책의 직접 사례입니다.


성과

파일BeforeAfter
model/store.ts349248
model/useJobPolling.tsx241115
ui/DuplicateRecipeSection.tsx201108
ui/PendingRecipeCard.tsx166129

5 suites / 80 tests 전체 통과 유지. npx tsc --noEmit clean. useCallback 4 → 0 (useMemo는 deps 객체용 1개 추가).

부수 산출물로 latent UX 버그 한 곳(YouTube 마커) fix. pre-existing 더티 파일들(eslint.config.mjs 등) 그대로 보존했어요.

code-quality 스킬에는 룰이 6개 늘었습니다.

  • 신설 4개 — ts-type-imports-from-dist, ts-typed-mapper-extraction, ts-discriminated-union-at-boundary, policy-nullish-coalescing
  • 확장 2개 — ts-any-and-as에 Zustand spread 케이스, react-compiler-memoization에 handler extraction 섹션

이 중 ts-discriminated-union-at-boundary는 들어가며 F3에서 깊게 다룬 그 정책입니다. 이번 라운드 규칙(동작은 그대로) 안에서는 마이그레이션까지 가지 못하고 룰 정의에서 멈췄어요. 컴포넌트 안 job.message || "실패" 류 방어 코드도 그대로 남아 있습니다. 다음 라운드(logic 재설계 허용)에서 처리합니다.

룰을 설계할 때 신경 쓴 두 가지

새 룰 4개를 작성하면서 일관되게 지키려고 한 게 두 가지 있어요.

첫째, 모든 룰이 symptom / recommended / anti-pattern / heuristic 네 섹션으로 구성됩니다. 어떤 코드 모양에서 룰이 발동되는지부터 자가 점검까지 적용 순서대로 정리돼 있어요. 룰을 "공감해야 하는 글"이 아니라 "트리거 → 대응 절차"로 쓰게 만드는 형식이고, AI 에이전트가 적용하기에도 이 모양이 가장 편합니다.

둘째, 트리거 조건이 grep 가능한 형태여야 한다는 것. 예를 들어 ts-type-imports-from-distimport type … from "X/dist/…" 패턴이 트리거. 사람도 AI도 코드를 보다가 즉시 발동 여부를 판단할 수 있어야 룰이 살아 있어요. "코드가 깨끗해야 한다" 류 추상 표현은 룰로 안 적습니다. 그건 슬로건이에요.


느낀점

스킬을 만든 직후가 첫 적용 적기였어요. 룰이 머리에 가장 선명한 상태로 폴더 하나에 적용하면, 어떤 룰이 어디서 발동하고 어떤 룰이 못 잡는지 바로 보입니다. 새 룰 4개가 다 이 과정에서 나왔어요. 들어가며에서 추상으로 적어둔 정책이 실제 코드 위에서 구체적인 변환으로 옮겨가는 걸 옆에서 본 셈입니다.

다음에 할 일이 몇 가지 남았어요. JobStatusResponse discriminated union 마이그레이션, status.progress || 0?? 0 정정(||는 0을 falsy로 잡아 의도와 어긋남), auto-save useEffect 재설계. 그리고 features/recipe-create-ai가 같은 V2 store 패턴을 쓰고 있어서, 같은 룰셋을 한 번 더 돌려볼 생각이에요.