디버깅

2026-05-11

배럴 import 사용할 때 주의해야 할 것

Turbopack dynamic_imports.rs 패닉 한 줄에서 시작한, FSD 슬라이스 self-barrel cycle 추적기.

어제까지 잘 되던 큐레이션 생성 API가 갑자기 500을 뱉었어요. 라우트는 컴파일은 되는데 응답이 안 와요. 추적해보니 entities/recipeindex.ts 한 줄이 원인이었어요. self-barrel import 한 줄이 어떻게 Turbopack을 무너뜨렸는지, 그리고 같은 함정에 다시 빠지지 않으려고 무엇을 했는지 이야기해볼게요.

결과부터 말하면 fix는 한 줄이에요. @/entities/recipe@/entities/recipe/lib/visibility로 바꾸는 것. 그 한 줄까지 가는 데 11번의 dev 재시작이 필요했고, 그 과정에서 같은 패턴이 프로젝트 안에 5곳 더 잠복해 있다는 걸 알게 됐어요.

왜 FSD에서 배럴을 쓰게 됐을까

Feature-Sliced Design을 쓰는 프로젝트라면 각 슬라이스(예: entities/recipe, features/recipe-create)가 자기 디렉터리 끝에 index.ts라는 배럴 파일을 두는 패턴에 익숙할 거예요. 외부에서는 슬라이스의 공개 API만 알면 되니까 import 경로가 깔끔해지고, 내부 구조를 리팩토링해도 외부의 import 경로가 그대로 유지되거든요.

저희 프로젝트도 같은 패턴이에요.

// src/entities/recipe/index.ts
export * from "./model/api";
export { ensureSource, isPrivateRecipe } from "./lib/visibility";
export type { Recipe, RecipeStep } from "./model/types";

페이지나 다른 슬라이스에서는 그냥 @/entities/recipe만 쓰면 다 가져올 수 있어요. 깔끔하고, 팀이 받아들인 표준이에요.

문제 발생 — 갑자기 500

어느 날 admin 페이지의 큐레이션 생성 기능이 500 응답을 받기 시작했어요. dev 콘솔에 남은 건 이것뿐이었어요.

thread 'tokio-runtime-worker' panicked at crates\next-api\src\dynamic_imports.rs:70:26:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
 ✓ Compiled /api/admin/curation/generate in 686ms
 POST /api/admin/curation/generate 500 in 1915ms

이상한 건, 라우트는 ✓ Compiled라고 나오는데 응답은 500이라는 점이에요. 라우트 핸들러에 console.error를 박아둬도 아무것도 안 찍혀요. JS가 실행되기 전 단계에서 뭔가 폭발한 거예요.

더 이상한 건 같은 프로젝트의 다른 API 라우트는 모두 멀쩡하다는 점. /api/recipes, /api/search 다 정상 동작. 오직 이 라우트 하나만 죽었어요.

첫 가설 — Turbopack 버그?

dynamic_imports.rs:70이라는 위치, Option::unwrap() on None이라는 Rust panic 시그니처. 누가 봐도 Turbopack 내부 버그처럼 보였어요. 실제로 Next.js 깃헙 이슈를 검색해보면 비슷한 panic 리포트가 여러 건 있고, 대부분 "최신 버전에서 fix됐다"는 답글이 달려 있어요. 우리 프로젝트는 Next.js 15.3.8이고, 15.5쯤으로 올리면 풀린다는 보고도 있었어요.

여기서 갈림길이었어요. 버전업으로 회피할까, 아니면 더 파볼까.

버전업의 비용을 빠르게 가늠해봤어요. App Router 동작 변화, React Compiler 호환성, server actions 시그니처. 잘못 건드리면 다른 곳이 깨질 수 있고, 어쨌든 "왜 우리 프로젝트의 이 라우트만" 터지는지에 대한 답은 안 줘요. 다른 라우트는 같은 Turbopack에서 멀쩡한데 이 라우트만 죽는다면, 우리 코드 어딘가에 트리거가 있다는 뜻이거든요. 같은 panic이 GitHub에 보고됐다는 사실은 우리 코드에 트리거가 있을 가능성을 부정하지 않아요.

파보기로 했어요.

의심을 좁히는 — 이진 탐색

먼저 route.ts를 빈 핸들러로 줄여봤어요.

import { type NextRequest, NextResponse } from "next/server";

export async function POST(_req: NextRequest) {
  return NextResponse.json({ diag: "empty" });
}

panic이 사라지고 200 응답이 돌아왔어요. 그러면 import 그래프 어딘가가 트리거라는 게 확정돼요.

이제 한 줄씩 다시 추가하면서 어디서 터지는지 찾았어요. 원래 라우트의 추가 import는 세 줄이에요.

import { CurationError, type GenerateCurationInput } from "@/entities/curation";
import { generateCuration } from "@/app/actions/curation";
import { assertAdminApi } from "@/shared/lib/admin-guard";

가장 큰 그래프를 가진 @/app/actions/curation부터 다시 붙여봤어요. panic 부활. 이 그래프 안에 트리거가 있어요. 그 안의 import는 많아요. ai-sdk, zod, entities, shared, 그리고 app/admin/curation-test/lib/* 9개.

각각 단독으로 떼서 시험해봤어요.

  • @ai-sdk/openai + ai만: 통과
  • @/app/admin/curation-test/lib/slugify만: 통과
  • "use server" + @/shared/lib/admin-guard: 통과
  • "use server" + @/entities/recipe/model/api: panic.

getRecipe 한 줄을 추가하는 순간 죽었어요. 그 모듈을 열어봤어요.

// src/entities/recipe/model/api.ts
import { ensureSource } from "@/entities/recipe";  // ← 이 한 줄
import { END_POINTS } from "@/shared/config/constants/api";
// ...

export const getRecipe = async (id: string) => {
  const r = await api.get<Recipe>(END_POINTS.RECIPE(id));
  return r;
};

api.ts@/entities/recipe를 import해요. 그게 자기가 속한 슬라이스의 배럴이에요. 그리고 그 배럴은:

export * from "./model/api";  // ← api.ts 자기 자신을 re-export

자기 자신으로 돌아오는 cycle이 있었어요.

진짜 원인 확정

직접 경로로 한 줄 바꿔봤어요.

- import { ensureSource } from "@/entities/recipe";
+ import { ensureSource } from "@/entities/recipe/lib/visibility";

panic이 사라졌어요. 11단계 isolation 끝에 한 줄 변경으로 끝났어요.

그래프로 보면

문제 그래프는 이렇게 생겼어요.

flowchart LR
    route["route.ts"] --> actions["actions/curation"]
    actions --> api["entities/recipe/model/api"]
    api --> barrel["@/entities/recipe (barrel)"]
    barrel -.->|re-exports model/api| api

    style barrel fill:#ffe4e4,stroke:#c00
    style api fill:#ffe4e4,stroke:#c00

api.ts가 자기 슬라이스의 배럴을 import하고, 배럴은 api.ts를 다시 export하면서 self-cycle 형성. fix 후 그래프는 이래요.

flowchart LR
    route["route.ts"] --> actions["actions/curation"]
    actions --> api["entities/recipe/model/api"]
    api --> vis["entities/recipe/lib/visibility"]

    style vis fill:#e4ffe4,stroke:#0a0

자기 자신으로 돌아오는 길이 없어요.

왜 찾기가 어려웠나

세 가지가 겹쳤어요.

첫째, 에러 메시지가 우리 코드를 가리키지 않아요. crates/next-api/src/dynamic_imports.rs:70은 Next.js 내부 Rust 코드 경로예요. 우리 프로젝트 어디가 잘못됐는지 한 글자도 안 알려줘요.

둘째, console.error도 안 찍혀요. Turbopack 워커가 라우트 모듈을 빌드하다 죽었기 때문에, 우리가 짠 JS 코드는 한 줄도 실행되지 않았어요. catch도, log도, sentry breadcrumb도 다 무의미해요.

셋째, 잠복형이에요. cycle은 이미 며칠 전 커밋에서 만들어져 있었어요. 다만 single generate는 server action 직접 호출이라 라우트가 한 번도 컴파일된 적이 없었고, batch도 dev에서 안 눌러봤어요. 오늘 처음으로 라우트가 hit되면서 처음으로 컴파일이 시도됐고, 거기서 처음으로 panic이 났어요. "어제까지 잘 됐는데"라는 감각이 강해질수록 디버깅이 더 헤매는 구조였어요.

더 좋은 방법이 있었을까. 돌아보면 RUST_BACKTRACE=1로 dev를 띄웠으면 어느 Turbopack 내부 단계에서 unwrap이 일어났는지 더 빨리 봤을 수도 있어요. 또는 eslint-plugin-importno-cycle 규칙을 켜뒀으면 커밋 시점에 잡혔을 거예요. 다만 no-cycle은 전체 import 그래프를 돌아서 느린 걸로 유명해서, 그동안 켜는 걸 미뤘던 결정에 비용이 청구된 셈이에요.

다른 곳도 위험했다

같은 패턴이 프로젝트 안에 더 있는지 궁금해서 grep 스크립트를 짰어요.

// scripts/find-self-barrel.mjs
const layers = ["entities", "features", "widgets"];
for (const layer of layers) {
  for (const slice of readdirSync(`src/${layer}`)) {
    const barrel = `@/${layer}/${slice}`;
    walk(`src/${layer}/${slice}`, (file) => {
      // file 안에 `from "${barrel}"` 가 정확 매칭으로 있는지
    });
  }
}

결과는 5개 파일. 이미 fix한 entities/recipe/model/api.ts까지 합치면 6곳이었어요.

파일노출 그래프
entities/recipe/model/api.tsroute-handler + server-action ← 터진 곳
entities/recipe/ui/RecipeStepList.tsxpage graph
features/comment-like/model/hooks.tspage graph
features/comment-like/ui/CommentLikeButton.tsxpage graph
features/ingredient-add-fridge/ui/IngredientSearchDrawer.tsxpage graph
features/recipe-create/ui/RecipeFormLayout.tsxpage graph

cycle은 6개였는데 터진 건 1개. 나머지 5개는 잠재 폭탄이었어요. 누가 그 모듈 중 하나를 server-only context (route handler, server action, instrumentation 등)에 끌어들이는 순간 같은 panic이 재발해요.

왜 페이지에서는 동작했고, 라우터에서 터졌을까

Next.js는 한 가지 모듈 그래프만 빌드하지 않아요. 페이지용, 라우트 핸들러용, server-actions registry용, middleware용 — 그래프를 여러 개 만들고 각각 다른 분석 패스를 거쳐요. cycle은 모든 그래프에서 형성되지만, 페이지 그래프는 cycle을 우회할 수 있는 다른 경로가 많아서 Turbopack이 별 문제없이 통과시켜요.

문제는 route-handler 그래프와 server-actions registry 그래프가 dynamic_imports.rs라는 더 엄격한 분석기를 추가로 통과한다는 점이에요. 그 안에서 module map lookup이 None을 받았는데 코드가 그걸 .unwrap()해버리면 워커 스레드가 panic. Next.js는 모듈을 못 만들고 500을 던지고요.

같은 cycle을 가진 같은 파일이라도 어느 그래프에 들어가느냐에 따라 운명이 갈리는 셈이에요. 그래서 1개만 터졌고 5개는 조용히 잠복할 수 있었어요.

재발 방지 — 세 겹의 가드

cycle을 사람 눈으로 찾는 건 한계가 있어요. 자동화 가드를 깔았어요.

1) ESLint 규칙으로 막기

no-restricted-imports로 슬라이스마다 자기 배럴 import를 금지했어요. 슬라이스 이름을 매번 하드코딩하면 새 슬라이스 추가될 때마다 룰 수정해야 하니까, fs.readdirSync로 동적으로 슬라이스 목록을 읽었어요.

// eslint.config.mjs
const selfBarrelGuards = ["entities", "features", "widgets"].flatMap((layer) =>
  collectSlices(layer).map((slice) => ({
    files: [`src/${layer}/${slice}/**/*.{ts,tsx}`],
    rules: {
      "no-restricted-imports": ["error", {
        patterns: [{
          regex: `^@/${layer}/${slice}$`,
          message: `Self-barrel import. Use direct path like '@/${layer}/${slice}/model/foo'`,
        }],
      }],
    },
  })),
);

여기서 한 가지 함정이 있었어요. patterns[].group을 처음 썼더니 minimatch 매칭이라 deep path까지 다 걸려요. @/features/recipe-create를 group에 넣으면 @/features/recipe-create/ui/Button 같은 정상 deep path도 빨갛게 표시됐어요. regex로 바꿔서 ^@/${layer}/${slice}$ 정확 매칭만 잡게 하니 의도대로 동작했어요. deep path는 통과, 배럴 정확 매칭만 차단.

2) 안전망 grep 스크립트

ESLint 없이도 돌릴 수 있는 plain Node 스크립트를 scripts/find-self-barrel.mjs에 두고, npm run lint:self-barrel로 호출할 수 있게 했어요. CI 파이프라인이나 pre-push hook에 한 줄로 박을 수 있어요.

3) 팀이 공유할 룰

마지막으로, "왜 그러면 안 되는지"가 본문에 안 남아 있으면 또 누가 무심코 같은 패턴을 들고 와요. 프로젝트의 .claude/skills/ 디렉터리에 lesson 한 파일로 박아뒀어요. 누군가가 같은 panic 시그니처를 보면 그 룰이 자동으로 끌려나와요.

느낀점

배럴은 밖에서 들어오는 입구예요. 슬라이스 외부에서는 깔끔하게 한 줄로 들고 갈 수 있게 해주는 인터페이스. 슬라이스 내부에서는 직접 경로로 형제 모듈에 접근하는 게 맞아요. 입구를 통해 자기 동네를 한 바퀴 도는 모양은, 사람 눈에는 자연스러워 보여도 모듈 그래프에는 cycle을 그리는 일이거든요.

디버깅에 11단계를 쓴 건 부끄럽기도 한데, 잘 짜인 isolation 절차가 없었으면 비슷한 panic을 만났을 때 또 비슷한 시간이 들어갔을 거예요. 빈 핸들러로 줄이기 → 한 줄씩 다시 붙이기 → cycle 의심하기. 이 순서만 다음에 5분 안에 도달할 수 있다면 이번 시간이 아깝지는 않아요.

다음에 잡을 건 import/no-cycle을 켜는 일이에요. 빌드 비용이 들어도 cycle이 코드 어딘가에 또 숨어 있을 가능성이 있고, 이번에 다섯 개나 찾은 걸 보면 그건 가능성이 아니라 거의 확실한 일이거든요.