
본문 이미지를 나중에 넣는 게 아니라 — SEOJing 글쓰기.
SEOJing에서 새 글을 쓸 때 대표 이미지와 본문 이미지를 빼먹지 않도록, 블로그 맵과 글쓰기 파이프라인 안에 시각 판단 단계를 넣은 과정을 정리했다.
WebAssembly(Wasm)는 브라우저와 서버에서 실행할 수 있는 저수준 바이너리
포맷이다. C, C++, Rust 같은 언어로 작성된 코드를 컴파일해서 .wasm 바이너리로
만들고, JavaScript에서 WebAssembly.instantiate()로 로드해 실행한다.
JavaScript보다 빠른 성능이 필요할 때 사용된다. 이미지 처리, 암호화, 정규표현식 엔진 같은 CPU 집약적 작업이 대표적이다.
// WebAssembly 모듈 로드의 기본 형태
const response = await fetch("module.wasm");
const { instance } = await WebAssembly.instantiateStreaming(response);
instance.exports.someFunction();
문제는 모든 런타임이 WebAssembly를 허용하지는 않는다는 점이다.
Shiki는 VS Code와 동일한 TextMate 문법을 사용하는 구문 강조 라이브러리다.
TextMate 문법의 정규표현식은 JavaScript의 RegExp로는 처리할 수 없는
Oniguruma 정규표현식 문법을 사용한다.
Oniguruma는 원래 C 라이브러리다. 이걸 브라우저와 Node.js에서 쓰려면
WebAssembly로 컴파일해야 한다. Shiki가 내부적으로 onig.wasm을 로드하는
이유가 이것이다.
shiki/dist/onig.wasm ← Oniguruma를 WebAssembly로 컴파일한 바이너리
즉, codeToHtml()을 호출하면 내부에서 이런 일이 벌어진다.
codeToHtml() 호출
→ Oniguruma 엔진 초기화
→ WebAssembly.instantiate(onig.wasm) ← 여기서 터짐
→ TextMate 문법으로 토큰화
→ HTML 생성
블로그의 코드 블록 하이라이팅에 Shiki를 사용하고 있었다. MDX 컴포넌트 매핑에서
pre 태그를 가로채 Shiki로 하이라이팅하는 방식이다.
// MdxRenderer.tsx — 문제의 코드
import { codeToHtml } from "shiki";
pre: async ({ children, ...props }) => {
const highlighted = await codeToHtml(codeString, {
lang: language ?? "text",
theme: "github-dark",
});
return <CodeBlock code={highlighted} language={language} />;
},
로컬 vinext dev에서는 잘 동작했다. 하지만 Cloudflare Workers 환경에서
렌더링하면 이런 에러가 터졌다.
CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedder
React Server Components(RSC)는 서버에서 컴포넌트를 렌더링하고, 그 결과를 직렬화해서 클라이언트로 스트리밍하는 아키텍처다.
Cloudflare Workers에서 WebAssembly를 사용하려면 모듈을 정적으로 import하는 방식만 허용된다.
// Workers에서 허용되는 방식
import wasm from "./module.wasm";
const instance = await WebAssembly.instantiate(wasm);
// Workers에서 차단되는 방식 — Shiki가 하는 방식
const response = await fetch("onig.wasm");
await WebAssembly.instantiateStreaming(response);
Shiki는 런타임에 동적으로 WASM 바이너리를 로드하고 인스턴스화한다. 이 방식이 Workers의 보안 정책에 의해 차단되는 것이다.
브라우저도 마찬가지다. Content-Security-Policy에 wasm-unsafe-eval을 추가하면
브라우저에서는 동작하지만, RSC 렌더링은 서버(Workers)에서 일어나므로 CSP
헤더와는 무관하다.
해결: 런타임 하이라이팅에서 빌드 타임 하이라이팅으로
rehype-prism-plus를 선택했다. 이 라이브러리는 Prism.js 기반의 rehype
플러그인으로, 순수 JavaScript만 사용한다. WASM 의존성이 전혀
없다. 그리고 rehype 플러그인이므로 MDX 컴파일 시점에
동작한다.
pnpm remove shiki
pnpm add -D rehype-prism-plus
빌드 스크립트의 compile() 호출에 rehype 플러그인을 추가한다. 이제 MDX를
JSX로 컴파일할 때 코드 블록이 자동으로 하이라이팅된다.
import rehypePrismPlus from "rehype-prism-plus";
const compiled = await compile(result.source, {
outputFormat: "program",
development: false,
jsx: true,
rehypePlugins: [[rehypePrismPlus, { ignoreMissing: true }]],
});
컴파일 결과를 보면 코드 블록의 각 토큰이 <span className="token keyword"> 같은
Prism 클래스로 감싸져 있다. 빌드 시점에 이미 하이라이팅이 완료된 것이다.
더 이상 런타임에 하이라이팅할 필요가 없다. pre 컴포넌트에서 Shiki 호출을
제거하고, 빌드 타임에 생성된 React 요소를 그대로 렌더링한다.
// Before — 런타임 Shiki (WASM 필요)
import { codeToHtml } from "shiki";
pre: async ({ children }) => {
const highlighted = await codeToHtml(code, { lang, theme: "github-dark" });
return <CodeBlock code={highlighted} />;
},
// After — 빌드 타임 하이라이팅 (WASM 불필요)
pre: ({ children }) => {
return (
<pre className="...">
<code className="font-mono">{codeProps.children}</code>
</pre>
);
},
주의할 점이 하나 있었다.
MDX의 code 컴포넌트 매핑이 인라인 코드(backtick)와 코드 블록 내부의 <code> 태그에 모두 적용된다.
인라인 코드용 스타일(배경색, 패딩)이 코드 블록 안에서도 적용되면 디자인이 깨진다.
code: ({ className, ...props }) => {
// 코드 블록 내부의 code인지 판별
const isInCodeBlock = className?.split(" ")
.some(c => c.startsWith("language-") || c === "code-highlight");
if (isInCodeBlock) {
return <code className="font-mono" {...props} />;
}
// 인라인 코드
return <code className="rounded bg-gray-100 px-1.5 py-0.5 ..." {...props} />;
},
rehype-prism-plus가 생성하는 <span className="token keyword"> 같은 요소에 색상을 입혀야 한다.
globals.css에 GitHub Dark 스타일의 Prism 토큰 CSS를 추가했다.
.token.keyword {
color: #ff7b72;
}
.token.string {
color: #a5d6ff;
}
.token.function {
color: #d2a8ff;
}
.token.comment {
color: #8b949e;
}
/* ... */
[Before] 페이지 요청
→ RSC 렌더링 시작
→ pre 컴포넌트에서 codeToHtml() 호출
→ Shiki가 onig.wasm 로드 시도
→ WebAssembly.instantiate() 차단
→ 에러
[After] 빌드 타임
→ MDX 컴파일 시 rehype-prism-plus가 토큰화
→ .compiled.jsx에 하이라이팅된 span이 포함됨
[After] 페이지 요청
→ RSC 렌더링 시작
→ pre 컴포넌트가 이미 하이라이팅된 children을 그대로 렌더링
→ WASM 불필요 → 에러 없음
이 문제는 이전 포스트의 eval 차단 이슈와 같은 뿌리에서 나온다. 런타임에서 할 필요 없는 작업을 런타임에서 하고 있었다는 것이다.
gray-matter의 eval() → 빌드 타임 frontmatter 파싱으로 해결next-mdx-remote의 new Function() → 빌드 타임 MDX 컴파일로 해결WebAssembly.instantiate() → 빌드 타임 구문 강조로 해결패턴이 보인다. Edge 런타임(Cloudflare Workers, Vercel Edge 등)에서는
런타임 코드 생성(eval, new Function)과 WASM 동적 로드가 차단
된다. 이 제약을 만나면 해결책은 항상 같다 — 빌드 타임으로 옮기기.
그리고 WASM 의존성은 라이브러리 내부에 숨어있어서 찾기 어렵다. Shiki가
Oniguruma WASM을 쓴다는 건 직접 에러를 만나기 전엔 모르기 쉽다. Edge 환경에
배포할 때는 의존성 트리에서 .wasm 파일이 있는지 미리 확인해보자.
# 의존성 중 .wasm 파일이 있는지 확인
find node_modules -name "*.wasm" -type f
Post Q&A
RSC 환경에서 WebAssembly가 차단되는 이유 — Shiki에서 rehype-prism-plus로 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

SEOJing에서 새 글을 쓸 때 대표 이미지와 본문 이미지를 빼먹지 않도록, 블로그 맵과 글쓰기 파이프라인 안에 시각 판단 단계를 넣은 과정을 정리했다.
SEOJing 글을 소셜용 영상으로 따로 소비시키는 게 아니라, 포스트 상단 요약과 블로그 유입 장치로 연결하기 위해 summaryVideo frontmatter와 Supertonic3 기반 요약 쇼츠 파이프라인을 붙인 과정을 정리했다.

SEOJing 블로그에 대표 이미지를 자동으로 붙이는 실험을 실제로 돌려봤다. 검색 기반 cover 삽입과 Codex CLI 기반 정적 SVG 생성이 같은 frontmatter 경로로 연결됐다.

SEOJing 포스트 목록을 파일 탐색기처럼만 두지 않고, 최신 글부터 실제 사진 기반 리소그래프 배경을 붙이는 실험을 정리합니다. 이미지는 배경만 만들고, 제목과 아이콘은 블로그 UI가 맡는 쪽으로 방향을 바꿨습니다.
SEO Jing 개발 열두째 날. code-block 테스트 14개 추가로 커버리지 대폭 개선, 모바일 프레젠테이션에서 FullscreenView 방향 전환 문제와 스크롤 멈춤 버그 수정.
useEffect 의존성 배열에 불필요한 값이 포함되면 cleanup과 재실행이 뒤엉켜 DOM 상태가 꼬일 수 있다. 프레젠테이션 모드에서 발생한 모바일 스크롤 고착 버그를 통해 원인과 해결 패턴을 정리한다.
SEO Jing 개발 열한째 날. PC 프레젠테이션 확대/축소 컨트롤 추가, 모바일 orientation 판단 로직 개선, FullscreenView를 독립 컴포넌트로 분리 및 PC 대응.
한국어 MDX 블로그를 만들다 vinext 프레임워크의 ByteString 버그를 발견하고, 이슈를 작성하고, PR을 올리기까지의 과정
vinext가 빠른 이유를 이해하기 위해, SSR부터 Hydration, 빌드 도구, Edge Runtime, Web Vitals, RSC, CDN 캐싱, ISR, PPR까지 웹 렌더링 성능의 전체 그림을 정리한다
모바일 Safari에서 100vh가 화면을 넘치는 이유, vh/svh/lvh/dvh의 차이, JavaScript에서 실제 뷰포트를 구하는 방법, 그리고 전체화면 UI를 만들 때 알아야 할 CSS zoom과 모바일 판정 패턴까지 정리한다.
SEO Jing 개발 열째 날. 프레젠테이션 모드의 모바일 UX 문제들을 전면 수정. 퀴즈·코드블록·이미지·포스트목록 처리 개선, 롱프레스 UX 및 하단 바 레이아웃 안정화. 모바일 뷰포트·리스트 분할 문제 수정, 채움 비율 보수적으로 조정, 포스트 탐색기 자연 정렬 적용.
SEO Jing 개발 아홉째 날. 프레젠테이션 기능 추가, 퀴즈 구조 변경, 모바일 반응형, 코드블럭 사용성, 테스팅 도입.
모바일 웹에서 코드 블록을 가로로 넓게 보여주고 싶었다. screen.orientation.lock()은 iOS에서 안 되고, PWA manifest는 브라우저에서 무시된다. 결국 CSS transform으로 가짜 회전을 만들었고, 그 과정에서 엄지 접근성까지 고민하게 됐다.
MDX 파일을 수정하지 않고, 렌더된 DOM을 h2 기준으로 자르고 화면 높이에 맞춰 자동 페이지네이션하는 프레젠테이션 모드를 만들었다. 리스트 높이 측정이 왜 틀리는지 디버깅한 과정과, ul/ol을 li 단위로 분할하는 해결책을 정리한다.
SEO Jing 개발 여덟째 날. 아티클 퀴즈 디자인시스템 구현과 스터디 대면 자료 작성.
MDX 블로그에 퀴즈 컴포넌트를 만들면서, Context 기반 Compound Component로 시작했다가 index 문제에 막혀 React.Children API를 채택하게 된 과정을 정리한다.
SEO Jing 개발 일곱째 날. Front Matter CMS 설치와 관련 게시물 이동 탐색기 구현.
SEO Jing 개발 여섯째 날. 데스크탑 비율 수정과 씨랩 스터디 사전 진단 자료 작성.
SEO Jing 개발 다섯째 날. shiki를 rehype-prism-plus로 교체하고, gray-matter 직접 구현, MDX 모듈화, 페이지 내 검색, 테이블 디자인시스템까지.
SEO Jing 개발 넷째 날. lint, codecov, Cloudflare 배포, fs 런타임 이슈.
배포 후 블로그 포스트가 404를 반환하던 문제부터, gray-matter eval 차단, next-mdx-remote eval 차단까지 — 세 겹으로 터진 이슈를 하나씩 해결한 기록
localStorage를 읽는 컴포넌트에서 하이드레이션 불일치가 발생하는 원인과, useState+useEffect가 아닌 useSyncExternalStore가 정답인 이유를 정리한다.
vinext 프로젝트를 GitHub Actions로 Cloudflare Workers에 자동 배포하는 방법과 실제 겪은 트러블슈팅 기록
코드 하이라이팅에 Shiki를 쓰면 왜 RSC에서 WebAssembly.instantiate() 에러가 터지는지, 그리고 빌드 타임 하이라이팅으로 어떻게 해결했는지 정리한다.
CLI 코드 리뷰에서 받은 피드백과 전체 코드 수정 계획을 정리했다.
localStorage만으로 글 읽기 추적, 스크롤 진행률, 댓글 감지를 구현한 과정을 정리했다.
블로그 디테일 페이지에서 MDX를 렌더링하기 위해 검토한 라이브러리들과 최종 선택 과정.
SEO Jing 개발 셋째 날. MDX 라이브러리 이슈, 반응형, 코드블럭, 댓글, 다크모드, 코드 리뷰.
디자인 시스템 구현 시 파일 구조, 디자인 토큰, 유의 사항을 정리했다.
Tailwind v4 환경에서 폰트가 메인 페이지에서만 적용되지 않던 원인과 Hydration Mismatch 이슈를 정리했다.
MDX 파일의 경로 탐색 로직과 콘텐츠 트리 생성 과정을 정리했다.
MDX 파일 구조를 JSON으로 변환하기 위해 Node.js의 fs 모듈을 배워봤다.
SEO Jing 개발 둘째 날. 디자인 시스템 확장, 블로그 스켈레톤, 폰트 이슈 해결.
SEO Jing 프로젝트의 기술 스택 선정과 전체적인 개발 플로우 정리.
Storybook의 사용법과 디자인 시스템 개발에서의 장점을 정리했다.
MDX의 개념과 블로그에서 활용하는 이유를 정리했다.
SEO Jing 개발 첫째 날. 디자인 컨셉 설정과 디자인 시스템 구축을 시작했다.
SEO Jing을 개발하게 된 이유입니다.
프로젝트가 중단되는 이유에 대한 자기 회고입니다.