export가 sharp를 브라우저 번들로 보냈다
2026.08.30
2026.08.30
export * 한 줄이 sharp를 브라우저 번들로 실어 날랐다
pnpm build가 6개 에러로 죽었다.
겉보기엔 두 종류였다.
하나는 이미지 라이브러리 sharp가 브라우저 청크에 못 들어간다는 것,
다른 하나는 next/headers를 클라이언트에서 쓰면 안 된다는 것.
서로 상관없어 보이는 두 문제가 동시에 터졌다고 생각했다.
전부 한 줄에서 나온 파편이었다.
1. 그 한 줄
서버 액션에서 쓸 함수를 하나 만들고, 같은 디렉토리의 배럴에 얹었다.
// src/hooks/queries/auth/index.ts
export * from "./start-provider-login-server"; // ← 이것
export * from "./use-provider-login-start-query";
export * from "./useUserQuery";
start-provider-login-server.ts는 tRPC caller를 직접 부르는 서버 전용 함수다.
그리고 src/hooks/queries/auth/는 클라이언트 훅 디렉토리다.
아무 생각없이 서버 함수를 hooks 아래에 만들고 있었다...
더 큰 문제는 이 배럴을 "use client"인 파일이 import하고 있었다는 것이다.
그것도 useUserQuery 하나 때문에.
// src/contexts/auth-context.tsx
"use client";
import { useUserQuery } from "@/hooks/queries/auth";
export *는 "필요한 것만 골라 가져간다"가 아니다.
배럴 파일 전체가 그래프에 들어온다. 그래서 이렇게 이어졌다.
admin/****/layout.tsx (Server)
→ AdminHeader.tsx ("use client")
→ useAuth → auth-context.tsx
→ @/hooks/queries/auth ← 배럴
→ start-provider-login-server.ts ← 서버 모듈
→ auth-router.ts
├→ @/utils/supabase/server → next/headers 💥
├→ trpc/init → supabase/admin → "server-only" 💥
└→ @/lib/crud (또 배럴) → projects.ts → sharp 💥
useUserQuery 하나를 가져오려다 tRPC 라우터 전체와 그 뒤의 이미지 처리 파이프라인까지
브라우저 번들로 끌고 온 것이다.
2. 진단이 오래 걸린 이유
원인은 한 줄인데 찾는 데 한참 걸렸다. 세 가지가 방해했다.
에러 메시지가 거짓말을 한다
Error: You're importing a module that depends on "next/headers".
This API is only available in Server Components in the App Router,
but you are using it in the Pages Router.
이 레포에 Pages Router는 없다. Turbopack이 이 메시지를 쓸 때 "클라이언트 경계에서 임포트됨"을 저렇게 표현한다. 문구를 곧이 읽으면 있지도 않은 Pages Router를 찾아 헤매게 된다.
증상이 원인보다 시끄럽다
sharp는 네이티브 .node 바이너리를 쓴다.
그래서 브라우저 청크에 들어가려는 순간 이런 게 나온다.
Error: non-ecmascript placeable asset
Error: Module not found: Can't resolve 'fs'
at detect-libc/lib/filesystem.js
fs를 못 찾는다는 에러가 detect-libc라는 처음 보는 패키지에서 난다.
내가 쓴 코드에서 한참 멀리 떨어진 자리다.
sharp 설정을 의심하며 한참 뒤졌는데, sharp는 아무 잘못이 없었다.
고친 뒤에도 안 낫는다
원인을 고치고 다시 빌드했더니 이번엔 이게 떴다.
TurbopackInternalError: TaskId { id: 1081640 } AssetIdent::new_inner was canceled
패닉 로그까지 남기며 죽길래 또 다른 문제인 줄 알았다.
stale .next 캐시였다. rm -rf .next 한 번으로 사라졌다.
교훈:
AssetIdent::new_inner was canceled를 보면 원인 추적 전에 캐시부터 지운다.
3. 트레이스를 읽는 법
Turbopack 에러에는 Import traces: 블록이 붙는다. 이게 진짜 정보다.
Import traces:
Client Component Browser:
./node_modules/.../sharp/lib/index.js
./src/utils/common/convertToWebP.ts
./src/utils/image/image-files-to-buffers.ts
./src/lib/crud/projects.ts
./src/lib/crud/index.ts
./src/lib/trpc/routers/auth/auth-router.ts
./src/hooks/queries/auth/start-provider-login-server.ts ← 여기
./src/hooks/queries/auth/index.ts
./src/contexts/auth-context.tsx
...
두 가지만 기억하면 되는 것 같다.
[Client Component Browser]트레이스를 아래에서 위로 읽는다. 경계를 넘는 지점이 그대로 보인다.- 여러 에러의 트레이스에 공통으로 등장하는 파일이 진범이다.
6개 에러 전부가 start-provider-login-server.ts를 거치고 있었다.
처음부터 이걸 봤으면 5분이면 끝났을 일이다.
4. 진짜 배운 것 - 이건 재발이었다
여기까지는 그냥 실수담이다. 뼈아픈 건 다음이다.
같은 레포에 내가 이미 세워둔 규칙이 있었다.
query-provider.tsx의AppRouterimport는 반드시import type이어야 한다. 값 import로 바뀌면 sharp가 브라우저 번들로 끌려온다.
sharp가 브라우저로 새는 경로를 한 번 발견하고, 그 지점 하나를 막아둔 것이다. 그런데 이번엔 배럴이라는 다른 경로로 같은 sharp가 다시 들어왔다.
새는 경로를 하나씩 막는 방식은 다음 경로를 막지 못한다.
import type 키워드 하나에 브라우저 번들의 안전이 걸려 있었다는 게 문제의 본질이었다.
그건 경계가 아니었다....
5. 그래서 클래스로 막았다
server-only를 세운다
sharp를 import하는 모듈 맨 위에 한 줄을 넣었다.
import "server-only";
import sharp from "sharp";
이제 같은 실수를 해도 에러가 내 파일 이름으로 난다.
detect-libc의 Can't resolve 'fs'를 읽는 대신, 내가 쓴 모듈이 막힌다.
실제로 나중에 이 가드가 발동하는 걸 목격했다. 디버깅용 스크립트에서 무심코 import했더니 바로 막혔다.
Error: This module cannot be imported from a Client Component module.
배럴을 지웠다
src/lib/crud/index.ts도 같은 병을 앓고 있었다.
5줄짜리 export *가 서로 무관한 5개 도메인을 하나의 인터페이스로 노출하고 있었다.
지워도 되는지 판단하는 기준은 삭제 테스트였다. "이 모듈을 지우면 복잡도가 어디론가 모이는가, 아니면 그냥 흩어지는가?"
세어보니 이 배럴을 쓰는 곳 14군데 중 13곳이 한 모듈만 쓰고 있었다.
둘을 쓰는 건 sitemap.ts 하나뿐.
지우면 import 줄이 하나 늘어나는 곳이 딱 한 군데라는 뜻이다.
인터페이스가 구현보다 크면, 그건 모듈이 아니라 통로다.
가장 아팠던 건 이 줄이었다.
// src/lib/trpc/init.ts
import { getUserTRPC } from "../crud"; // ← 배럴
init.ts는 모든 tRPC 프로시저의 뿌리다. 모든 라우터가 이걸 import한다.
함수 하나가 필요해서 배럴을 물었는데, 그 대가로 tRPC 전체가 sharp에 전이 의존하게 됐다.
@/lib/crud/auth로 한 글자 좁히니 그 의존이 통째로 사라졌다.
6. 숫자
Next.js는 빌드할 때 라우트마다 .nft.json을 남긴다.
그 함수가 배포될 때 어떤 파일들을 싣고 가는지 적힌 파일이다. 세어봤다.
| 라우트 | 전 | 후 |
|---|---|---|
sitemap | 36 | 0 |
rss.xml | 36 | 0 |
| 전체 라우트 중 sharp를 싣는 수 | 7 | 3 |
sitemap과 RSS는 이미지를 만지지 않는다. 그런데 sharp + libvips(약 17MB)를 함께 싣고 있었다. 배럴 하나 때문에.
남은 3개는 api/trpc(업로드 처리), labs/animate, labs/iffy로
전부 sharp를 실제로 쓰는 라우트다.
7. 정리
배럴 export는 편의 기능처럼 보인다. import 줄이 짧아지니까... 그런데 실제로 벌어지는 일은 달랐다ㅜㅜ
export *는 "이 파일들은 전부 같은 실행 환경에 속한다"는 선언이다.
src/hooks/라는 디렉토리 이름도 마찬가지다.
"여기 있는 건 전부 클라이언트에서 돈다"는 선언인데,
컴파일러는 그 선언을 검사해주지 않는다.
그래서 지킬 방법은 셋뿐이다.
- 선언을 어기지 않는다 — 훅이 아닌 것을 훅 디렉토리에 두지 않는다
- 컴파일러가 검사하게 만든다 —
server-only처럼 어기면 실패하는 장치를 세운다 - 애초에 선언을 하지 않는다 — 배럴을 안 만들고 모듈을 지명한다
나는 이번에 셋 다 했다. 그런데 정말로 값을 낸 건 2번이다. 1번과 3번은 규율이고, 규율은 잊힌다. 실제로 나는 두 달 만에 잊었다.
산문(주석)으로 지키던 불변식은 드리프트한다. 실행 가능한 검사로 내려야 유지된다.
추신 - 세 번 어긋난 문서
이 사건을 정리하면서 ADR을 하나 썼다. "sharp를 import하는 파일은 이것들뿐이다"라는 표를 넣었다.
그 표가 세 번 어긋났다. 처음엔 내가 목록을 잘못 셌고, 두 번째는 리팩터링하면서 항목이 바뀌었고, 세 번째는 새 스크립트가 sharp를 쓰기 시작했는데 표를 안 고쳤다.
결국 표를 지키는 테스트를 붙였다.
// src·app·scripts 에서 sharp import 를 스캔해 ADR 표와 대조한다
expect(actualSharpImporters).toEqual(documentedInAdr);
이제 sharp를 새로 import하는 사람은 ADR도 함께 고쳐야 초록이 된다.
같은 이야기의 반복이다. 문서에 적어두는 것만으로는 지켜지지 않는다.