이 블로그는 MDX 기반으로 작성되어 있고, 코드 블록의 구문 강조(Syntax Highlighting)를 위해 rehype-highlight를 사용해왔습니다. highlight.js 기반으로 동작하는 이 라이브러리는 간단하게 적용할 수 있었지만, 블로그를 운영하면서 점점 아쉬운 점이 쌓였습니다.

  • 라인 하이라이팅 불가 (특정 줄을 강조 표시할 수 없음)
  • 파일명 표시를 위해 별도 플러그인(rehype-code-titles) 필요
  • 복사 버튼 미지원
  • 라인 넘버 미지원
  • 테마 커스텀이 CSS 변수 수십 개를 직접 관리해야 하는 구조

그러다 Shiki 기반의 rehype-pretty-code를 알게 되었습니다.

  • VS Code에서 사용하는 것과 동일한 TextMate 문법을 사용하여 높은 하이라이팅 품질
  • rehype-highlight에서 지원하지 않던 기능들을 지원
  • 심지어 빌드타임에 처리

단순 기능 개선 정도라 포스팅으로 남길까 고민을 하기도 했습니다. 하지만 곧 회사에서 문서 사이트를 개발할 일이 있는데, 이때 활용하고자 교체 과정 및 사용법을 기록으로 남기기로 했습니다. 혹시나 도움이 될까 싶어 자세히 적어보겠습니다.

기존의 rehype-highlighthighlight.js를 기반으로 동작합니다. highlight.js는 정규식 기반의 모드(modes) 트리 구조로 토큰을 분류하기 때문에 간단한 반면, 문맥을 정확히 파악하지 못하는 경우가 있습니다. (highlight.js Language Definition Guide)

반면 Shiki는 VS Code의 TextMate 문법 파일(.tmLanguage)을 그대로 사용합니다. 에디터에서 보는 것과 동일한 수준의 하이라이팅을 제공하며, 약 60개의 번들 테마와 200개 이상의 언어를 지원해요. VS Code 마켓플레이스의 커스텀 테마도 로드할 수 있어서 확장성도 뛰어납니다. (Shiki 공식 문서)

결정적인 차이는 빌드타임 처리

Shiki는 빌드 시점에 HTML을 생성하기 때문에 클라이언트에 JS 번들이 추가되지 않습니다. highlight.js도 서버에서 처리할 수 있지만, Shiki 쪽이 품질과 기능 면에서 확실히 앞섭니다.

아래의 두 하이라이터와 Prism.js의 차이점을 자세히 비교한 글도 참고할 만합니다. TypeScript 하이라이팅 품질을 기준으로 비교하고 있어요.

https://chsm.dev/blog/2025/01/08/comparing-web-code-highlighters

Shiki 도입을 저울질할 때 고민했던 Shiki의 단점이 있습니다. 번들 사이즈가 다른 하이라이터에 비해 상당히 커요. Shiki가 highlight.js 대비 압축 기준 약 18배, Prism.js 대비 약 24배 큽니다.

하이라이터Raw 크기압축 크기
Prism.js27.3 KiB11.7 KiB
highlight.js35.7 KiB15.6 KiB
Shiki905.1 KiB279.8 KiB

뭐 때문에 이렇게 큰가 하고 봤더니 TextMate 문법 파싱을 위한 Oniguruma WASM 바이너리 때문이었습니다. 이게 전체 용량의 대부분(압축 231 KiB)을 차지해요. 정규식 기반 파서에 비해 구조적으로 무거울 수밖에 없습니다. (출처: Size Comparison)

위 비교는 TypeScript(TSX) 하이라이팅 기준입니다. 언어와 테마에 따라 달라질 수 있습니다.

하지만 이 블로그에서는 이 트레이드오프를 충분히 감수할 만했습니다.

  1. 빌드타임 처리이므로 사용자에게 전달되지 않습니다 — Shiki는 빌드 시점에 HTML을 생성하기 때문에 905 KiB는 빌드 서버에서만 필요해요. 클라이언트에 JS가 전달되지 않으므로 사용자 경험에는 영향이 없습니다.
  2. 제가 원했던 기능을 하나로 해결합니다 — 라인 하이라이팅, 파일명 타이틀, diff 표기, 듀얼 테마 등 highlight.js로는 별도 플러그인을 조합하거나 아예 구현이 불가능했던 기능들이 rehype-pretty-code 하나로 해결돼요.
  3. Fine-grained 번들로 최적화할 수 있습니다 — Shiki는 shiki/core만 로드하고 필요한 언어와 테마를 동적 import로 가져오는 방식도 지원합니다. (Shiki - Bundles) 클라이언트 사이드에서 사용하는 경우 번들 크기를 크게 줄일 수 있어요.

블로그의 기능과 성능을 개선하면서 느끼는 게 있는데요, 결국 핵심은 어디서 실행되느냐입니다.

SSG/SSR 환경에서 빌드타임에 처리한다면 번들 크기는 개발자 경험(빌드 시간)에만 영향을 미치고, 사용자에게는 순수 HTML만 전달됩니다. 이 블로그처럼 MDX를 프리컴파일하는 구조에서는 Shiki의 번들 크기가 사실상 문제가 되지 않습니다.

https://rehype-pretty-code.netlify.app/

rehype-pretty-code는 Shiki를 rehype 플러그인으로 감싼 라이브러리로, 기존 rehype 파이프라인에 드롭인 교체가 가능합니다. 마크다운의 ``` 문법을 그대로 유지하면서 아래 기능들을 메타 문자열로 제어할 수 있습니다.

  • title="파일명" - 파일명 표시
  • {1,3-4} - 라인 하이라이팅
  • showLineNumbers - 라인 넘버 표시
  • 듀얼 테마 지원 (light/dark)

제 이번 코드 블록 개선의 핵심 목표 기능이었는데, 이쯤 되면 shiki로 구성된 블로그를 어딘가에서 보고 목표로 설정했을 수도 있겠네요. 딱 마음에 듭니다.

본격적으로 글을 작성하면서 이번 전환의 결과인 코드 블록을 잔뜩 써보겠습니다.

Shiki의 자세한 사용법은 아래 rehype-pretty-code 사용법 섹션에서 더 다루겠습니다.

Terminal
pnpm remove rehype-highlight rehype-code-titles pnpm add rehype-pretty-code shiki

rehype-code-titles도 함께 제거했습니다. rehype-pretty-code가 메타 문자열의 title 속성을 직접 처리하기 때문에 별도 플러그인이 필요 없어졌습니다.

이 블로그는 플러그인 설정이 두 곳에 있습니다. 런타임용 plugins.ts와 빌드 스크립트용 compile-mdx.mjs. 둘 다 동일하게 수정했습니다.

src/lib/mdx/plugins.ts
import rehypeCodeTitles from 'rehype-code-titles'; import rehypeHighlight from 'rehype-highlight'; import rehypePrettyCode, { type Options } from 'rehype-pretty-code'; export const prettyCodeOptions: Options = { theme: { dark: 'github-dark', light: 'github-light' }, keepBackground: true, }; export const rehypePlugins: any[] = [ rehypeSlug, rehypeCodeTitles, rehypeHighlight, [rehypePrettyCode, prettyCodeOptions], rehypeUnwrapImages, ];

theme 옵션에 객체를 전달하면 듀얼 테마 모드가 활성화됩니다. 그런데 이 듀얼 테마의 동작 방식이 처음 예상과 달랐어요. 이 부분에서 꽤 삽질을 했는데, 아래 트러블슈팅 섹션에서 자세히 다루겠습니다.

기존 highlight.css.hljs 클래스 기반 셀렉터와 CSS 변수 수십 개로 구성되어 있었습니다. 이를 [data-rehype-pretty-code-figure] 기반 셀렉터로 전면 교체했습니다.

highlight.css
html:not(.dark) [data-rehype-pretty-code-figure] [data-highlighted-line] { background: rgba(59, 130, 246, 0.12); } html.dark [data-rehype-pretty-code-figure] [data-highlighted-line] { background: rgba(96, 165, 250, 0.1); }

rehype-pretty-code는 메타 문자열에서 {1,3-4} 같은 표현을 파싱해 해당 라인에 data-highlighted-line 속성을 자동으로 추가합니다. CSS만 정의해두면 됩니다.

highlight.css
[data-rehype-pretty-code-title] { padding: 0.6em 1.2em; font-family: var(--font-mono); font-size: 0.82em; border-radius: 0.6em 0.6em 0 0; } [data-rehype-pretty-code-title] + pre { border-top-left-radius: 0 !important; border-top-right-radius: 0 !important; }

타이틀이 있으면 pre 블록 위에 [data-rehype-pretty-code-title] 요소가 생성되고, 인접 형제 셀렉터(+)로 pre의 상단 border-radius를 제거해서 자연스럽게 연결되도록 했습니다.

!important를 지양하려 하는데, 영향 범위가 지엽적인 특수 상황이라 사용했습니다.

공식 transformerCopyButton이 React/MDX 환경에서 동작하지 않는 문제가 있어서(아래 트러블슈팅 - Copy 버튼 참고), MDX 컴포넌트 오버라이드 방식으로 직접 구현했습니다. 코드량도 적고, 서버/클라이언트 컴포넌트 분리를 직접 제어할 수 있다는 장점이 있습니다.

pre 태그를 래핑하는 서버 컴포넌트를 만들고, 내부에 클라이언트 컴포넌트인 CopyButton을 배치했습니다.

MdxPre.tsx
import CopyButton from './CopyButton'; function extractText(node: React.ReactNode): string { if (typeof node === 'string') return node; if (typeof node === 'number') return String(node); if (!node) return ''; if (Array.isArray(node)) { return node.map(extractText).join(''); } if (typeof node === 'object' && 'props' in node) { const element = node as React.ReactElement<{ children?: React.ReactNode }>; return extractText(element.props.children); } return ''; } const MdxPre = (props: ComponentPropsWithoutRef<'pre'>) => { const { children, ...rest } = props; const rawText = extractText(children).trimEnd(); return ( <pre {...rest}> {children} <CopyButton text={rawText} /> </pre> ); };

extractText 함수가 핵심입니다. rehype-pretty-code는 코드를 pre > code > [data-line] > span 구조로 깊게 중첩시키기 때문에, React children 트리를 재귀적으로 탐색해서 순수 텍스트를 추출해야 합니다.

CopyButton.tsx
'use client'; import { Check, Copy } from 'lucide-react'; const CopyButton = ({ text }: { text: string }) => { const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { const success = await copyToClipboard(text); if (success) { setCopied(true); setTimeout(() => setCopied(false), 2000); } }, [text]); return ( <button onClick={handleCopy} className="code-copy-btn"> {copied ? <Check size={14} /> : <Copy size={14} />} </button> ); };

'use client' 지시어로 클라이언트 컴포넌트로 분리했습니다. 이미 블로그에서 사용 중이던 copyToClipboard 유틸리티와 lucide-react 아이콘을 재활용했습니다.

highlight.css
.code-copy-btn { position: absolute; top: 0.6em; right: 0.6em; opacity: 0; transition: opacity 0.15s, background 0.15s; } [data-rehype-pretty-code-figure] pre:hover .code-copy-btn { opacity: 1; }

코드 블록에 마우스를 올리면 복사 버튼이 나타나는 방식으로 구현했습니다. 평소에는 코드 읽기를 방해하지 않도록 숨겨둡니다.

rehype-pretty-code의 장점 중 하나는 마크다운 코드 블록의 메타 문자열만으로 다양한 기능을 제어할 수 있다는 점입니다. MDX 파일에서 별도 컴포넌트 없이 ``` 뒤에 옵션을 붙이면 됩니다.

title 속성을 추가하면 코드 블록 상단에 파일명 바가 표시됩니다.

mdx
```ts title="example.ts" const greeting = 'hello'; ```
example.ts
const greeting = 'hello';

중괄호 안에 라인 번호를 지정하면 해당 라인이 강조 표시됩니다. 범위 지정(3-4)도 가능합니다.

mdx
```ts {1,3} const a = 1; const b = 2; const c = a + b; ```
result.ts
const a = 1; const b = 2; const c = a + b;

showLineNumbers를 추가하면 각 라인 왼쪽에 번호가 표시됩니다.

mdx
```ts showLineNumbers function add(a: number, b: number) { return a + b; } const result = add(1, 2); ```
result.ts
function add(a: number, b: number) { return a + b; } const result = add(1, 2);

이 옵션들은 조합해서 사용할 수 있습니다. 실제로 이 포스트의 코드 블록들도 이렇게 작성했습니다.

mdx
```ts title="plugins.ts" showLineNumbers {3-4} import rehypePrettyCode from 'rehype-pretty-code'; export const prettyCodeOptions = { theme: { dark: 'github-dark', light: 'github-light' }, }; ```
plugins.ts
import rehypePrettyCode from 'rehype-pretty-code'; export const prettyCodeOptions = { theme: { dark: 'github-dark', light: 'github-light' }, };

라인 넘버, 라인 하이라이팅 모두 CSS만 정의해두면 됩니다. rehype-pretty-code가 빌드 시점에 data-line-numbers, data-highlighted-line 같은 data attribute를 자동으로 추가해주기 때문입니다.

Shiki의 @shikijs/transformers 패키지에서 제공하는 transformerNotationDiff를 사용하면 git diff 스타일의 추가/삭제 라인 하이라이팅이 가능합니다.

코드 라인 끝에 // [!code ++] 또는 // [!code --] 주석을 붙이면 됩니다. 주석은 렌더링 시 자동으로 제거됩니다.

example.ts
const greeting = 'hello'; // [!code --] const greeting = 'hi'; // [!code ++] console.log(greeting);
result.ts
const greeting = 'hello'; const greeting = 'hi'; console.log(greeting);

diff 표기가 각 언어의 주석 문법을 따른다는 것에 주의하세요.

JavaScript/TypeScript는 //를 사용하지만, CSS처럼 /* */ 주석만 지원하는 언어에서는 /* [!code ++] */ 형식으로 작성해야 합니다.

example.css
.old-class { /* [!code --] */ .new-class { /* [!code ++] */ color: red; }
result.css
.old-class { .new-class { color: red; }

플러그인 설정에 transformer를 추가해주면 됩니다.

plugins.ts
import { transformerNotationDiff } from '@shikijs/transformers'; export const prettyCodeOptions: Options = { theme: { dark: 'github-dark', light: 'github-light' }, keepBackground: true, transformers: [transformerNotationDiff()], };

transformerCopyButton이 React/MDX에서 동작하지 않는다

rehype-pretty-code는 공식적으로 @rehype-pretty/transformers 패키지를 통해 transformerCopyButton을 제공해요. (Shiki - Copy Button) Shiki transformer로 동작하며, transformers 배열에 추가하면 코드 블록에 자동으로 복사 버튼이 붙습니다.

Shiki 공식 문서에서도 experimental 태그가 붙어있지만, 일단 설치해서 테스트해봤습니다. npm 버전(v0.13.2)을 설치하고 transformers 배열에 추가하면 <button class="rehype-pretty-copy"> 요소 자체는 렌더링되긴 해요. 하지만 클릭해도 아무 동작이 없었습니다.

이 transformer가 복사 기능을 인라인 onclick 문자열 핸들러로 주입하기 때문입니다.

transformer가 생성하는 HTML
<button onclick="navigator.clipboard.writeText(this.attributes.data.value); ..." class="rehype-pretty-copy">

MDX → React 변환 과정에서 React는 문자열 이벤트 핸들러를 허용하지 않으므로, onclick 속성이 렌더링 시점에 제거됩니다. 결과적으로 버튼만 보이고 기능이 없는 상태가 됩니다.

이 문제는 아래 GitHub Issue에서도 찾아볼 수 있었습니다.

내용에 따르면 JSR 버전(v0.13.4)에서 jsx: true 옵션과 registerCopyButton() 클라이언트 등록 함수로 해결했다고 합니다. 다만 이 수정은 npm에는 아직 반영되지 않았고, 패키지 자체도 여전히 experimental 상태로 유지되고 있습니다.

MDX 컴포넌트 오버라이드 방식으로 직접 구현했습니다. 코드량도 적고, 서버/클라이언트 컴포넌트 분리를 직접 제어할 수 있다는 장점이 있습니다. 자세한 구현은 위의 복사 버튼 구현 섹션을 참고하세요.

구문 강조 색상이 전혀 나오지 않는다

전환 후 빌드는 성공했지만, 구문 강조 색상이 전혀 나오지 않았습니다. 코드가 단일 색상의 텍스트로만 표시되었죠.

컴파일된 MDX 캐시의 출력을 직접 뜯어보았습니다.

.mdx-cache 컴파일 결과
// .mdx-cache에서 확인한 실제 컴파일 결과 { "style": { "--shiki-dark": "#e1e4e8", "--shiki-light": "#24292e", "--shiki-dark-bg": "#24292e", "--shiki-light-bg": "#fff" }, "data-theme": "github-dark github-light" }

예상과 완전히 달랐습니다. 처음에는 data-theme="dark"data-theme="light" 두 벌의 pre 블록이 생성되고, CSS display: none으로 토글하는 방식이라고 생각했어요. 하지만 실제로는 하나의 pre 블록--shiki-dark, --shiki-light CSS 변수가 함께 들어가는 CSS Variables 모드였습니다.

data-theme 값도 "github-dark github-light"로 합쳐져 있어서, pre[data-theme='dark'] 같은 셀렉터는 아예 매칭되는 셀렉터가 없습니다.

Shiki 공식 문서에서는 이 방식을 CSS Variables 테마라고 부릅니다. 코드를 한 번만 렌더링하고, 각 토큰의 라이트/다크 색상을 CSS 변수에 담아두는 방식입니다. 테마 전환 시 CSS만 바꾸면 되므로 HTML을 두 벌 생성할 필요가 없어요.

rehype-pretty-code는 듀얼 테마 설정 시 내부적으로 Shiki의 defaultColor: false 옵션을 사용합니다. 이 옵션은 인라인 color 스타일 대신 모든 색상을 CSS 변수로만 출력합니다. 덕분에 CSS에서 !important 없이도 변수 매핑만으로 테마 전환이 가능해요. (Shiki 기본 설정에서는 라이트 테마가 인라인 스타일로 들어가기 때문에 !important가 필요합니다.)

Shiki 공식 문서에서 CSS Variables 방식, 미디어 쿼리 방식, 클래스 기반 방식 등 여러 접근법을 설명하고 있으니 참고하세요.

https://shiki.style/guide/dual-themes

CSS Variables 모드에서는 각 span--shiki-light--shiki-dark 변수가 모두 들어갑니다. 현재 테마에 맞는 변수를 실제 color 속성으로 매핑해주면 됩니다.

highlight.css
/* 라이트 모드: --shiki-light 변수를 color로 매핑 */ html:not(.dark) [data-rehype-pretty-code-figure] span { color: var(--shiki-light); font-style: var(--shiki-light-font-style); font-weight: var(--shiki-light-font-weight); text-decoration: var(--shiki-light-text-decoration); } /* 다크 모드: --shiki-dark 변수를 color로 매핑 */ html.dark [data-rehype-pretty-code-figure] span { color: var(--shiki-dark); font-style: var(--shiki-dark-font-style); font-weight: var(--shiki-dark-font-weight); text-decoration: var(--shiki-dark-text-decoration); }

color뿐 아니라 font-style, font-weight, text-decoration도 매핑해야 합니다. 일부 테마에서는 키워드를 italic으로, 타입을 bold로 표시하는 등 스타일 속성을 활용하기 때문이에요.

배경색도 동일한 방식으로 처리합니다.

highlight.css
html:not(.dark) [data-rehype-pretty-code-figure] pre { color: var(--shiki-light); background-color: var(--shiki-light-bg); } html.dark [data-rehype-pretty-code-figure] pre { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); }

이렇게 하면 구문 강조 색상이 정상적으로 나옵니다.

이 코드 블록 전환은 제 블로그의 오랜 숙원 사업 중 하나였어요. 전환 자체는 플러그인 교체와 CSS 수정만으로 완료되었습니다. 기존 MDX 콘텐츠를 전혀 수정할 필요가 없었다는 점에서 rehype 파이프라인 구조의 장점을 크게 느꼈죠. 복사 버튼 구현도 하마터면 안 될 뻔 했지만 MDX 컴포넌트 오버라이드 패턴으로 깔끔하게 해결한 점이 마음에 듭니다.

내용에서 정리하지 않았지만 테마 오버라이드의 중요성도 적고 넘어가고 싶어요. 라이브러리가 제공하는 기본값이 사이트 디자인과 맞지 않을 수 있거든요. github-light의 #fff 배경은 흰색 페이지에서는 구분이 안 됩니다. 저도 그랬고요. 커스텀은 환경에 맞춰 꼭 필요합니다.

한편 듀얼 테마의 CSS Variables 모드를 처음에 인지하지 못해서 삽질을 좀 한 게 아쉽기도 해요. rehype-pretty-code 문서에서는 동작 방식을 명확히 설명하고 있으니, 적용 전에 컴파일 결과물의 HTML 구조를 먼저 확인하는 게 좋겠습니다.

이상으로 글 마쳐봅니다. 감사합니다.