LogoSEO Jing
  • All Posts
  • SEO Jing
  • KD Team
  • CLab CoreTeam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

Cloudflare WorkersvinextMDX트러블슈팅DevLog

Cloudflare Workers에서 fs 모듈이 안 되는 이유와 해결법

2026년 3월 16일·13분 읽기

문제 상황

vinext + GitHub Actions 배포를 마치고 사이트에 접속했다. /blog 인덱스 페이지는 정상적으로 보였다.

그런데 개별 포스트를 클릭하면 아무것도 안 뜬다. 브라우저 Network 탭을 열어보니 .rsc 요청이 404 Not Found를 반환하고 있었다.

Request URL: https://web.tjwlsrb1021.workers.dev/blog/resume.rsc
Status Code: 404 Not Found

/blog 인덱스는 되는데 개별 포스트만 안 되는 상황. 라우팅 문제인가 싶었지만, 원인은 더 근본적인 곳에 있었다. 그리고 이 하나를 고치니 두 번째, 세 번째 문제가 연달아 터졌다.

이슈 1: Cloudflare Workers에는 파일시스템이 없다

블로그 포스트를 렌더링하는 [...slug]/page.tsx는 이렇게 동작했다.

ts
import fs from "node:fs";

export function getContentBySlug(contentDir: string, slug: string[]) {
  const filePath = path.join(contentDir, `${slug.join("/")}.mdx`);
  const raw = fs.readFileSync(filePath, "utf-8"); // 여기서 터짐
  // ...
}

Node.js의 fs.readFileSync로 MDX 파일을 직접 읽고 있다. 로컬 vinext dev에서는 Node.js 런타임이니까 당연히 동작한다.

하지만 Cloudflare Workers는 Node.js가 아니다. V8 isolate 기반의 경량 런타임이라 fs 모듈이 존재하지 않는다. nodejs_compat 플래그를 켜면 일부 Node.js API를 폴리필해주긴 하지만, 파일시스템 접근은 지원하지 않는다.

그러면 /blog 인덱스는 왜 동작했나?

인덱스 페이지의 위젯들은 빌드 시점에 생성된 content-tree.json을 import해서 사용한다.

ts
import contentTree from "@/generated/content-tree.json";

이 JSON은 빌드 시점에 번들에 포함된다. fs를 쓰지 않으니 Workers에서도 문제없다. 반면 개별 포스트는 fs.readFileSync로 런타임에 파일을 읽으려 하니 실패한 것이다.

해결: 빌드 타임에 JSON 프리컴파일

기존에 content-tree.json만 생성하던 빌드 스크립트를 확장해서, 각 MDX 파일마다 frontmatter와 source를 담은 개별 JSON 파일을 생성하도록 했다.

src/generated/
├── content-tree.json
├── content-loader.ts          # 자동 생성된 정적 import 맵
├── content/
│   ├── resume.json
│   └── SEOJing/
│       ├── reason.json
│       └── vinext-github-actions-deploy.json

그리고 config/index.ts에서 fs 기반 CONTENT_DIR을 걷어내고, JSON import 기반으로 교체했다.

여기서 한 가지 함정이 있었는데, Vite는 동적 경로의 import()를 분석하지 못한다.

ts
// Vite가 분석 불가 — 경고 + 런타임 실패
const mod = await import(`@/generated/content/${slugPath}.json`);

그래서 빌드 스크립트에서 content-loader.ts를 자동 생성하는 방식으로 바꿨다. 각 slug에 대한 import를 정적 문자열로 명시하면 Vite가 정확히 분석할 수 있다.

ts
// 빌드 시 자동 생성되는 content-loader.ts
const contentLoaders = {
  "SEOJing/reason": {
    json: () => import("@/generated/content/SEOJing/reason.json"),
    compiled: () => import("@/generated/content/SEOJing/reason.compiled.jsx"),
  },
  // ...
};

이슈 2: gray-matter의 eval이 Workers에서 차단된다

JSON 프리컴파일까지 끝내고 dev 서버를 돌렸더니 새로운 에러가 떴다.

EvalError: Code generation from strings disallowed for this context

원인은 gray-matter 라이브러리였다. frontmatter를 파싱하는 이 라이브러리가 내부적으로 eval()을 사용한다. Cloudflare Workers의 workerd 런타임은 보안상 eval()을 차단하기 때문에 에러가 발생했다.

gray-matter는 이제 런타임에서 사용하지 않고 빌드 스크립트에서만 쓰는데, 문제는 @app/utils의 barrel export(index.ts)를 통해 content.ts 전체가 번들에 딸려오고 있었다. calculateReadingTime이나 타입만 import해도 같은 파일에 있는 gray-matter import가 함께 들어온 것이다.

해결: 모듈 분리 + gray-matter 제거

두 가지를 동시에 진행했다.

1. 모듈 분리 — content.ts(fs + gray-matter 의존)에서 순수 함수와 타입을 별도 파일로 분리했다.

  • content-types.ts — 타입(ContentFrontmatter, ContentNode, ContentTree)과 순수 함수(getItemsForPath)
  • reading-time.ts — calculateReadingTime
  • content.ts — fs 기반 함수만 남김 (빌드 스크립트 전용)

index.ts(barrel export)에서는 content-types.ts와 reading-time.ts만 export한다. 런타임 번들에 content.ts가 포함되지 않으므로 gray-matter도 들어오지 않는다.

2. gray-matter 제거 — frontmatter 파싱을 직접 구현했다. 어차피 frontmatter는 ---로 구분된 간단한 key-value 블록이다. 사용하는 필드가 title, date, tags, description 네 개뿐이니 eval 없는 자체 파서로 충분하다.

ts
// parseFrontmatter — eval 없는 자체 구현
export function parseFrontmatter(raw: string) {
  const trimmed = raw.trimStart();
  if (!trimmed.startsWith("---")) return { data: {}, content: raw };

  const endIndex = trimmed.indexOf("\n---", 3);
  const yamlBlock = trimmed.slice(4, endIndex);
  const content = trimmed.slice(endIndex + 4) 

결과적으로 gray-matter 의존성을 완전히 제거했고, 빌드 시 나오던 eval 경고와 node:fs externalization 경고도 모두 사라졌다.

이슈 3: next-mdx-remote도 eval을 쓴다

gray-matter를 제거했는데 또 같은 에러가 떴다.
EvalError: Code generation from strings disallowed for this context
    at MDXRemote [Server]

이번엔 next-mdx-remote/rsc의 MDXRemote 컴포넌트가 범인이었다. 이 라이브러리의 내부 구현을 까보면 이렇다.

js
// next-mdx-remote/dist/rsc.js
const hydrateFn = Reflect.construct(Function, keys.concat(`${compiledSource}`));

MDX 소스 문자열을 받아서 @mdx-js/mdx로 컴파일한 뒤, new Function()으로 런타임에 실행한다. 이것도 본질적으로 eval이니 Workers에서 차단된다.

여기서 근본적인 질문이 생겼다. MDX를 쓰면서 eval을 안 쓸 수 있는가?

MDX의 본질은 마크다운 안에 JSX(React 컴포넌트)를 쓰는 것이다. 이걸 렌더링하려면 마크다운+JSX → 실행 가능한 JavaScript로 변환해야 한다.

두 가지 시점에서 할 수 있다.

  • 런타임 변환 — MDX 소스 문자열 → new Function() → 실행. next-mdx-remote가 하는 방식. eval 필수.
  • 빌드 타임 변환 — MDX → .jsx 파일 → Vite가 일반 모듈로 번들. eval 불필요.

즉, MDX를 쓰면서 eval을 안 쓰려면 빌드 타임에 JSX로 변환하는 것이 유일한 방법이다.

해결: 빌드 타임 MDX → JSX 컴파일

빌드 스크립트에서 @mdx-js/mdx의 compile()을 사용해 MDX를 ESM .jsx 모듈로 변환한다.

ts
import { compile } from "@mdx-js/mdx";

const compiled = await compile(result.source, {
  outputFormat: "program", // ESM 모듈로 출력 (eval 불필요)
  development: false,
  jsx: true,
});

fs.writeFileSync(`${slug}.compiled.jsx`, String(compiled));

핵심은 outputFormat: "program"이다. "function-body"로 하면 new Function()으로 실행해야 하는 문자열이 나오지만, "program"으로 하면 export default function MDXContent 형태의 일반 ESM 모듈이 나온다. Vite가 이걸 일반 모듈처럼 번들하므로 eval이 전혀 필요 없다.

페이지 컴포넌트에서는 MDXRemote를 제거하고, 컴파일된 컴포넌트를 직접 렌더링한다.

tsx
// Before — 런타임 eval 필요
import { MDXRemote } from "next-mdx-remote/rsc";
<MDXRemote source={content.source} components={mdxComponents} />;

// After — eval 불필요
const MDXContent = content.compiled.default;
<MDXContent components={mdxComponents} />;

최종 아키텍처

세 겹의 이슈를 모두 해결한 최종 데이터 흐름이다.
[빌드 타임] content/*.mdx
  → parseFrontmatter()로 frontmatter 추출 → .json
  → @mdx-js/mdx compile()로 JSX 컴파일 → .compiled.jsx
  → content-loader.ts 자동 생성 (정적 import 맵)

[런타임] 페이지 요청
  → content-loader에서 해당 slug의 .json + .compiled.jsx를 lazy import
  → MDXContent 컴포넌트 직접 렌더링
제거된 것들:
  • gray-matter — eval 사용, 자체 parseFrontmatter로 대체
  • next-mdx-remote — 런타임 eval 사용, 빌드 타임 컴파일로 대체
  • node:fs 런타임 의존 — 빌드 타임 JSON/JSX 프리컴파일로 대체

핵심 교훈

이 문제의 본질은 개발 환경과 배포 환경의 런타임 차이다.

  • 로컬 vinext dev → Node.js → fs 사용 가능, eval 사용 가능
  • Cloudflare Workers → V8 isolate → fs 사용 불가, eval 사용 불가
Edge 런타임에 배포할 때는 두 가지를 반드시 확인해야 한다.
  1. Node.js 전용 API (fs, path, child_process 등)에 의존하는 코드가 런타임 번들에 포함되는가?
  2. 런타임 코드 생성 (eval, new Function, Reflect.construct(Function, ...))을 사용하는 라이브러리가 있는가?

특히 두 번째는 라이브러리 내부에 숨어있어서 찾기 어렵다. gray-matter도 next-mdx-remote도 겉보기엔 평범한 라이브러리인데, 내부적으로 eval을 쓰고 있었다. Cloudflare Workers에서 원인 모를 EvalError가 나면 의존성 내부의 eval/new Function을 의심해보자.

포스트 목록

/SEOJing
파일 11개, 폴더 1개
Cloudflare Workers에서 fs 모듈이 안 되는 이유와 해결법모바일 웹에서 가로 모드를 강제하는 5가지 방법 — iOS Safari에서도 동작하는 코드 뷰어 만들기블로그 글을 PPT로 만들기 — DOM 클로닝 기반 프레젠테이션 모드100vh가 100%가 아닌 이유 — 모바일 뷰포트 단위 완전 정리Context로 퀴즈 컴포넌트를 만들다 막혀서 React.Children을 공부하게 된 이야기localStorage 읽기에서 하이드레이션 에러가 터지는 이유 useSyncExternalStore로 해결useEffect cleanup과 의존성 배열 — 실전 버그 사례로 이해하는 생애주기vinext + GitHub Actions로 Cloudflare Workers 배포하기vinext 오픈소스 기여기: 한국어 slug가 RSC에서 이슈를 일으킨 이유RSC 환경에서 WebAssembly가 차단되는 이유 — Shiki에서 rehype-prism-plus로vinext는 왜 빠를까? — SSR, Vite, Edge, 그리고 Web Vitals까지
.
replace
(
/^\r?\n/
,
""
)
;
// key: value 파싱 (문자열, 배열 지원)
// ...
}