export가 sharp를 브라우저 번들로 보냈다

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
    ...

두 가지만 기억하면 되는 것 같다.

  1. [Client Component Browser] 트레이스를 아래에서 위로 읽는다. 경계를 넘는 지점이 그대로 보인다.
  2. 여러 에러의 트레이스에 공통으로 등장하는 파일이 진범이다.

6개 에러 전부가 start-provider-login-server.ts를 거치고 있었다. 처음부터 이걸 봤으면 5분이면 끝났을 일이다.


4. 진짜 배운 것 - 이건 재발이었다

여기까지는 그냥 실수담이다. 뼈아픈 건 다음이다.

같은 레포에 내가 이미 세워둔 규칙이 있었다.

query-provider.tsxAppRouter import는 반드시 import type이어야 한다. 값 import로 바뀌면 sharp가 브라우저 번들로 끌려온다.

sharp가 브라우저로 새는 경로를 한 번 발견하고, 그 지점 하나를 막아둔 것이다. 그런데 이번엔 배럴이라는 다른 경로로 같은 sharp가 다시 들어왔다.

새는 경로를 하나씩 막는 방식은 다음 경로를 막지 못한다. import type 키워드 하나에 브라우저 번들의 안전이 걸려 있었다는 게 문제의 본질이었다. 그건 경계가 아니었다....


5. 그래서 클래스로 막았다

server-only를 세운다

sharp를 import하는 모듈 맨 위에 한 줄을 넣었다.

import "server-only";

import sharp from "sharp";

이제 같은 실수를 해도 에러가 내 파일 이름으로 난다. detect-libcCan'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을 남긴다. 그 함수가 배포될 때 어떤 파일들을 싣고 가는지 적힌 파일이다. 세어봤다.

라우트
sitemap360
rss.xml360
전체 라우트 중 sharp를 싣는 수73

sitemap과 RSS는 이미지를 만지지 않는다. 그런데 sharp + libvips(약 17MB)를 함께 싣고 있었다. 배럴 하나 때문에.

남은 3개는 api/trpc(업로드 처리), labs/animate, labs/iffy로 전부 sharp를 실제로 쓰는 라우트다.


7. 정리

배럴 export는 편의 기능처럼 보인다. import 줄이 짧아지니까... 그런데 실제로 벌어지는 일은 달랐다ㅜㅜ

export *는 "이 파일들은 전부 같은 실행 환경에 속한다"는 선언이다.

src/hooks/라는 디렉토리 이름도 마찬가지다. "여기 있는 건 전부 클라이언트에서 돈다"는 선언인데, 컴파일러는 그 선언을 검사해주지 않는다.

그래서 지킬 방법은 셋뿐이다.

  1. 선언을 어기지 않는다 — 훅이 아닌 것을 훅 디렉토리에 두지 않는다
  2. 컴파일러가 검사하게 만든다server-only처럼 어기면 실패하는 장치를 세운다
  3. 애초에 선언을 하지 않는다 — 배럴을 안 만들고 모듈을 지명한다

나는 이번에 셋 다 했다. 그런데 정말로 값을 낸 건 2번이다. 1번과 3번은 규율이고, 규율은 잊힌다. 실제로 나는 두 달 만에 잊었다.

산문(주석)으로 지키던 불변식은 드리프트한다. 실행 가능한 검사로 내려야 유지된다.


추신 - 세 번 어긋난 문서

이 사건을 정리하면서 ADR을 하나 썼다. "sharp를 import하는 파일은 이것들뿐이다"라는 표를 넣었다.

그 표가 세 번 어긋났다. 처음엔 내가 목록을 잘못 셌고, 두 번째는 리팩터링하면서 항목이 바뀌었고, 세 번째는 새 스크립트가 sharp를 쓰기 시작했는데 표를 안 고쳤다.

결국 표를 지키는 테스트를 붙였다.

// src·app·scripts 에서 sharp import 를 스캔해 ADR 표와 대조한다
expect(actualSharpImporters).toEqual(documentedInAdr);

이제 sharp를 새로 import하는 사람은 ADR도 함께 고쳐야 초록이 된다.

같은 이야기의 반복이다. 문서에 적어두는 것만으로는 지켜지지 않는다.