블로그 개발기 (Code-Block Copy Button)


안녕하세요 youngminss 입니다. 저번 블로그 개발기 (Code-Block Customizing) 에서 글이 길어져 함께 소개하지 못한 코드 블록 복사 기능을 추가하는 방법을 소개하려 합니다.
✅ Rehype-Pretty-Code 의 Copy Button 플러그인 적용 (Experimental)
코드 블록 복사 기능 추가를 위해 시도했던 첫 번째 방법은 코드 블록 스타일 커스텀하는 단계의 첫 단계에서 적용했던 Rehype-Pretty-Code 플러그인에서 제공하는 Copy Button 플러그인 을 사용했습니다.
Experimental 기능이라고 표현해 놓았지만 블록 복사 기능은 제 기준에서는 선택 기능이라고 생각했기 때문에 기능 역할을 잘 한다면 빠르게 적용하고 넘어가도 좋다고 생각했습니다.
이 기능을 사용하기 위해서는 @rehype-pretty/transformers 의존성 패키지를 추가로 설치해야 합니다.
npm install @rehype-pretty/transformers
설치한 후 가이드처럼 적용도 간단해 보입니다. rehypePlugins 에 주입할 때 선언해 놓았던 rehype-pretty-code 의 Options
타입인 객체에 설치했던 @rehype-pretty/transformers 로부터 transformerCopyButton
을 import 해서 transformers
속성에 다음과 같이 할당해 줍니다.
...
import rehypePrettyCode, { type Options } from "rehype-pretty-code";
import { transformerCopyButton } from "@rehype-pretty/transformers";
...
const prettyCodeOptions: Options = {
...
transformers: [
transformerCopyButton({
visibility: "always",
feedbackDuration: 3000,
}),
],
};
const PostBody = ({ post }: { post: TPost }) => {
...
return (
<MDXRemote
...
options={{
mdxOptions: {
...
rehypePlugins: [
...
[rehypePrettyCode, prettyCodeOptions],
],
},
}}
/>
);
};
export default PostBody;
참고로 transformers 속성은 아래와 같이 ShikiTransformer
타입을 요소로 가지는 Array 형태의 데이터가 있어야 하는데요.
interface Options {
grid?: boolean;
theme?: Theme | Record<string, Theme>;
keepBackground?: boolean;
defaultLang?:
| string
| {
block?: string;
inline?: string;
};
tokensMap?: Record<string, string>;
transformers?: Array<ShikiTransformer>;
filterMetaString?(str: string): string;
getHighlighter?(
options: BundledHighlighterOptions<any, any>,
): Promise<Highlighter>;
onVisitLine?(element: LineElement): void;
onVisitHighlightedLine?(element: LineElement, id: string | undefined): void;
onVisitHighlightedChars?(element: CharsElement, id: string | undefined): void;
onVisitTitle?(element: Element): void;
onVisitCaption?(element: Element): void;
}
transformerCopyButton 함수의 반환 값이 ShikiTransformer 타입이기 때문에 할당이 가능한 것입니다.
declare function transformerCopyButton(
options?: CopyButtonOptions,
): ShikiTransformer;
transformerCopyButton 함수의 param 으로는 options 객체를 받습니다. CopyButtonOptions
타입의 이 객체는 아래와 같은 타입의 속성을 제공합니다. (자세한 명세는 여기를 확인하세요.)
interface CopyButtonOptions {
feedbackDuration?: number; // copy 되었을 경우 UI 가 얼마나 보일 것인지
copyIcon?: string; // copy 버튼 아이콘
successIcon?: string; // copy 성공 시 아이콘
visibility?: "hover" | "always"; // copy 버튼이 언제 보일 것인지
}
여기까지 진행했다면 다음과 같이 코드 블록 우측 상단에 코드 블록 복사 버튼이 보이는 것을 확인할 수 있습니다. 하지만, 그와 함께 좌측 하단에 불안한 에러 메시지도 함께 보이네요 🤔

😢 (SSR 환경에서) 발생한 이슈
에러 메시지 확인해 봤을 때와 실제 렌더링 된 코드 블록 복사 버튼의 결과를 보면 각각 다음과 같습니다.
에러 메시지 | 실제 렌더링된 코드 블록 복사 버튼 |
---|---|
![]() | ![]() |
일단 관련된 문제에 대한 Github issue 을 확인해 볼 수 있었으며 해당 이슈에 대한 관리자에 답변 도 확인할 수 있습니다. 결론은 Server Component 환경에서는 정상적으로 작동하지 않는 문제가 있다는 것입니다.(2024.08.27 기준)
에러 메시지가 의미하는 것은 button 에 대해 onClick 핸들러가 정의되어 있지 않으며, data 속성에 string 형태의 코드들이 삽입되어 있기 때문으로 파악했습니다. 결론적으로 해당 방법은 실패 !
✅ 직접 구현하자
스펙 파악
직접 구현하기 전에 이 버튼의 스펙을 정의해 보면 다음과 같습니다.
- 사용자가 버튼을
클릭하면
버튼이 포함된코드 블록
들의 내용을 클립보드에 복사하는 기능이 있으면 된다. - 이 버튼은 SEO 에 중요한 요소는 아니다.
사용자의 액션
이 필요하되, SEO 에는 중요하지 않다면
이 부분만 Client Component 로 만들어 주입해 주면 될 것 같습니다.
구현
마크다운으로 작성한 코드 블록이 렌더링 된 결과에서는 어떤 식으로 노출되는지 확인했습니다.

실제 코드 블록이 담기는 구조는 대략 pre 👉 code -> ... 구조입니다. 상세한 구조까지는 필요가 없고 코드 블록이 담기는 구조의 시작이 pre
태그가 래핑하고 있다는 것이 포인트입니다.
위 정보까지 파악한 다음에는 MDXRemote 의 Component 재정의를 활용해서 접근하면 됩니다. 첨부한 링크의 예제에서 확인할 수 있듯이 MDX 의 Component 에서 제공하는 element 에 대해 원하는 형태로 사용자의 환경에 맞게 재정의가 가능합니다. (remark-gfm 플러그인을 사용하면 기본 지원 element 외 것들도 제어가 가능합니다.)
다시 복사 버튼 기능 추가로 돌아와서, 우리는 그럼 코드 블럭이 담기는 pre 태그를 재정의해서 기존 구조에서 Client Component 로 생성한 코드 블록 복사 버튼을 주입해주면 되는 것 아닐까요 ?
먼저 코드 블록 복사 버튼 부터 정의해야겠습니다.
"use client";
import { Check, Copy } from "lucide-react";
import { DetailedHTMLProps, HTMLAttributes, useRef, useState } from "react";
const CustomCodeBlock = ({
className = "",
children,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) => {
const [isCopied, setIsCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
// 코드 복사 버튼 이벤트 핸들러
const handleClickCopy = async () => {
const code = preRef.current?.textContent; // 코드 블록 내용 추출
if (code) {
setIsLoading(true);
await navigator.clipboard.writeText(code); // 클립보드에 복사
setIsLoading(false);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1500);
}
};
return (
{/* 커스텀 코드 블록 컨테이너 */}
<div className="relative">
{/* 코드 블록 복사 버튼 */}
<button
disabled={isCopied || isLoading}
aria-label={isCopied ? "Copied!" : "Copy code"}
onClick={handleClickCopy}
className="absolute right-[1rem] top-[1rem] flex h-fit w-fit items-center gap-x-[0.4rem] rounded-[0.4rem] bg-black px-[0.8rem] py-[0.4rem] !text-[rgb(220,220,213)]"
>
<span className="...">
{isCopied ? <Check size="100%" /> : <Copy size="100%" />}
</span>
<span className="...">{isCopied ? "Copied !" : "Copy"}</span>
</button>
{/* 코드 블록 */}
<pre ref={preRef} {...props} className={className}>
{children}
</pre>
</div>
);
};
export default CustomCodeBlock;
이제 정의한 CustomCodeBlock
컴포넌트를 MDXRemote 컴포넌트 components
속성의 pre 태그 에 매핑시켜주면 됩니다.
import { MDXRemote } from "next-mdx-remote/rsc";
import CustomCodeBlock from "./common/CustomCodeBlock";
...
const componentsConfig = {
...,
pre: (props: any) => <CustomCodeBlock {...props} />,
};
...
const PostBody = ({ post }: { post: TPost }) => {
...
return (
<MDXRemote
componentsConfig={{ ...componentsConfig }}
...
/>
);
};
export default PostBody;
그러면 다음과 같이 의도한 코드 블록 복사 기능을 추가할 수 있습니다.

CustomCodeBlock 의 JSX 를 보면 pre 태그 내부에 button 태그를 배치한 것이 아닌 button 과 pre 태그를 래핑하는 div 태그가 존재하는데요. 이유는 pre 태그에 담기는 코드 블록에서 특정 line 의 내용이 길어질 경우 스크롤이 생길 수 있습니다. 이때 코드 블록 복사 버튼이 가로로 스크롤 해도 우측 상단에 fixed 하게 고정되길 바랬던 것과 달리 pre 태그 기준으로 우측 상단에 위치해 애매하게 배치되는 것을 해결하고자 한 의도입니다.
As-is | To-be |
---|---|
![]() | ![]() |
👋 마무리하며
이번 글에서는 지난 블로그 개발기 (Code-Block Customizing) 에 이어 생성된 코드 블록의 내용을 클립보드에 복사할 수 있는 버튼을 구현했던 과정을 소개했습니다. 선택 기능이긴 했으나 레퍼런스로 참고한 타 블로그에서도 많이 볼 수 있었고 구현하는 단계에서 참고할 만할 것도 많아서 생각보다 쉽게 완성할 수 있었습니다.
혹시나 비슷한 개발 환경에서 블로그 개발 중, 코드 블록 복사 기능을 추가하고 싶다면 참고해 봐도 좋습니다. 긴 글 읽어주셔서 감사합니다 😋
오류 제보나 피드백은 언제나 환영입니다. 🙂