Claude Code로 Clipboard API 구현하기: 복사 버튼, 권한, fallback, 테스트
Claude Code로 Clipboard API를 안전하게 구현합니다. 복사, 붙여넣기, 권한, React, Playwright까지 다룹니다.
Clipboard API 구현은 처음에는 단순해 보입니다. 버튼을 누르면 navigator.clipboard.writeText()를 호출하면 끝이라고 생각하기 쉽습니다. 하지만 실제 서비스에서는 HTTPS 여부, 사내 HTTP 미리보기 URL, iframe, 권한 프롬프트, 모바일 Safari, 복사 실패 안내, 붙여넣은 텍스트의 개인정보, E2E 테스트 안정성까지 같이 다뤄야 합니다.
이 글에서는 Claude Code에 작업을 맡기는 흐름으로 React/TypeScript에서 바로 쓸 수 있는 복사와 붙여넣기 UX를 만듭니다. Clipboard API는 브라우저가 운영체제의 클립보드에 읽고 쓰는 Web API입니다. Async Clipboard는 Promise로 결과를 기다리는 비동기 API이고, secure context는 HTTPS나 localhost처럼 브라우저가 신뢰하는 실행 환경입니다. fallback은 최신 API가 막혔을 때 시도하는 예비 경로입니다.
함께 보면 좋은 ClaudeCodeLab 글은 Claude Code 접근성 개선, Claude Code Playwright 테스트, Claude Code 폼 검증입니다. 공식 문서는 MDN Clipboard API, MDN writeText, W3C Clipboard API and events, Playwright BrowserContext, WebKit Async Clipboard API, Claude Code docs를 확인하세요.
Claude Code에 성공 조건부터 넘긴다
“복사 버튼 만들어줘”라고만 하면 브라우저 차이와 실패 처리가 빠지기 쉽습니다. 처음부터 요구사항을 좁고 구체적으로 적습니다.
Goal: Implement Clipboard API copy and paste UX in React.
Scope: edit only src/lib/clipboard.ts, src/components/CopyButton.tsx, and matching tests.
Requirements:
- Use navigator.clipboard.writeText in secure contexts.
- Keep the write call inside a user click handler.
- Provide a textarea fallback for unsupported or HTTP pages.
- Never read clipboard on page load.
- Show accessible copied/error feedback.
- Add Playwright tests for copy success and paste normalization.
Do not stage, commit, or edit unrelated files.
구현 흐름은 다음과 같습니다.
flowchart TD
A["사용자가 복사 클릭"] --> B{"Async Clipboard 사용 가능?"}
B -->|yes| C["writeText"]
B -->|no| D["textarea + execCommand fallback"]
C --> E{"성공?"}
D --> E
E -->|yes| F["aria-live로 복사 완료 알림"]
E -->|no| G["수동 복사 안내 표시"]
H["사용자가 명시적으로 붙여넣기"] --> I["readText 또는 onPaste"]
I --> J["정규화, 길이 제한, 검증"]
가장 중요한 원칙은 클립보드를 자동으로 읽지 않는 것입니다. 사용자의 클립보드에는 비밀번호, 주소, 사내 URL, 고객 정보, 소스 코드가 들어 있을 수 있습니다. 읽기는 눈에 보이는 붙여넣기 버튼이나 입력 필드의 일반적인 onPaste 이벤트에만 연결합니다.
복사 처리를 유틸리티로 분리한다
먼저 React와 무관한 copyText를 만듭니다. UI 컴포넌트마다 navigator.clipboard.writeText를 직접 쓰면 fallback과 에러 처리, 테스트가 흩어집니다.
// src/lib/clipboard.ts
export type CopyResult =
| { ok: true; method: "async-clipboard" | "textarea-fallback" }
| { ok: false; method: "async-clipboard" | "textarea-fallback" | "unsupported"; error: string };
export async function copyText(text: string): Promise<CopyResult> {
if (!text) {
return { ok: false, method: "unsupported", error: "Copy text is empty." };
}
if (canUseAsyncClipboard()) {
try {
await navigator.clipboard.writeText(text);
return { ok: true, method: "async-clipboard" };
} catch (error) {
const fallback = fallbackCopyText(text);
if (fallback) return { ok: true, method: "textarea-fallback" };
return {
ok: false,
method: "async-clipboard",
error: error instanceof Error ? error.message : "Clipboard write was blocked.",
};
}
}
if (fallbackCopyText(text)) {
return { ok: true, method: "textarea-fallback" };
}
return {
ok: false,
method: "unsupported",
error: "Clipboard API is unavailable in this browser or context.",
};
}
function canUseAsyncClipboard(): boolean {
return (
typeof window !== "undefined" &&
window.isSecureContext &&
typeof navigator !== "undefined" &&
Boolean(navigator.clipboard?.writeText)
);
}
function fallbackCopyText(text: string): boolean {
if (typeof document === "undefined") return false;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "-9999px";
textarea.style.opacity = "0";
const selection = document.getSelection();
const selectedRange =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
return document.execCommand("copy");
} catch {
return false;
} finally {
document.body.removeChild(textarea);
if (selection && selectedRange) {
selection.removeAllRanges();
selection.addRange(selectedRange);
}
}
}
document.execCommand("copy")는 오래된 API입니다. 기본 경로가 아니라 마지막 fallback으로만 둡니다. 그래도 구형 브라우저, 제한된 WebView, HTTP 미리보기에서는 도움이 됩니다. 단, fallback도 사용자의 클릭이나 터치 안에서 실행되지 않으면 실패할 수 있습니다.
React hook과 접근 가능한 버튼
다음 hook은 복사 중, 성공, 실패 상태를 관리합니다. 컴포넌트는 role="status"와 aria-live를 사용해 스크린 리더에 결과를 알립니다.
// src/components/CopyButton.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { copyText, type CopyResult } from "../lib/clipboard";
type ClipboardStatus = "idle" | "copying" | "copied" | "failed";
export function useClipboard(resetAfter = 2000) {
const [status, setStatus] = useState<ClipboardStatus>("idle");
const [message, setMessage] = useState("");
const timerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, []);
const copy = useCallback(
async (text: string): Promise<CopyResult> => {
if (timerRef.current) window.clearTimeout(timerRef.current);
setStatus("copying");
setMessage("Copying...");
const result = await copyText(text);
if (result.ok) {
setStatus("copied");
setMessage("Copied to clipboard.");
} else {
setStatus("failed");
setMessage("Copy failed. Select the text and copy it manually.");
}
timerRef.current = window.setTimeout(() => {
setStatus("idle");
setMessage("");
}, resetAfter);
return result;
},
[resetAfter],
);
return { copy, status, message };
}
type CopyButtonProps = {
text: string;
label?: string;
copiedLabel?: string;
className?: string;
};
export function CopyButton({
text,
label = "Copy",
copiedLabel = "Copied",
className = "",
}: CopyButtonProps) {
const { copy, status, message } = useClipboard();
const isCopying = status === "copying";
return (
<div className="inline-flex items-center gap-2">
<button
type="button"
className={className}
onClick={() => void copy(text)}
disabled={isCopying}
aria-label={status === "copied" ? copiedLabel : label}
>
{status === "copied" ? copiedLabel : label}
</button>
<span role="status" aria-live="polite" className="sr-only">
{message}
</span>
</div>
);
}
화면에 보이는 toast만으로는 부족합니다. 보조 기술을 사용하는 사용자는 성공 여부를 알 수 있어야 합니다. 별도의 status 영역은 Playwright 테스트에서도 안정적인 기준점이 됩니다.
코드 블록 복사 UX
기술 문서와 블로그의 코드 블록이 가장 흔한 유스케이스입니다. Masa가 ClaudeCodeLab에서 처음 만든 구현은 “Copy”가 “Copied”로 바뀔 때 버튼 폭이 늘어나 코드 영역이 흔들렸습니다. 최소 폭을 정해두면 이런 작은 레이아웃 이동을 막을 수 있습니다.
// src/components/CodeBlockWithCopy.tsx
import { CopyButton } from "./CopyButton";
type CodeBlockWithCopyProps = {
code: string;
language?: string;
};
export function CodeBlockWithCopy({ code, language = "text" }: CodeBlockWithCopyProps) {
return (
<figure className="relative my-6 overflow-hidden rounded-md border border-slate-700 bg-slate-950">
<figcaption className="flex min-h-10 items-center justify-between border-b border-slate-800 px-3 text-xs text-slate-300">
<span>{language}</span>
<CopyButton
text={code}
label="Copy code"
copiedLabel="Copied"
className="min-w-24 rounded bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-400 disabled:opacity-60"
/>
</figcaption>
<pre tabIndex={0} className="overflow-x-auto p-4 text-sm leading-6">
<code>{code}</code>
</pre>
</figure>
);
}
같은 버튼은 CLI 명령, 초대 링크, 쿠폰 코드, 지원 티켓 ID, 생성된 프롬프트에도 사용할 수 있습니다. Claude Code에는 코드 블록 전용 구현보다 재사용 컴포넌트와 사용 예시를 함께 요청하는 편이 좋습니다.
붙여넣기는 민감한 입력이다
입력 필드에서는 브라우저의 일반 onPaste를 우선 사용합니다. navigator.clipboard.readText()는 사용자가 누르는 명시적인 붙여넣기 버튼 뒤에만 둡니다.
// src/components/PasteImportBox.tsx
import { useState } from "react";
export function normalizePastedText(input: string): string {
return input
.replace(/\r\n?/g, "\n")
.replace(/\u0000/g, "")
.slice(0, 10_000);
}
export function PasteImportBox() {
const [value, setValue] = useState("");
const [message, setMessage] = useState("");
async function pasteFromClipboard() {
if (!navigator.clipboard?.readText || !window.isSecureContext) {
setMessage("Use your browser paste shortcut instead.");
return;
}
try {
const text = await navigator.clipboard.readText();
setValue(normalizePastedText(text));
setMessage("Pasted from clipboard.");
} catch {
setMessage("Paste was blocked. Use Ctrl+V or Cmd+V in the text area.");
}
}
return (
<section aria-labelledby="paste-import-title">
<h2 id="paste-import-title">Import prompt</h2>
<button type="button" onClick={pasteFromClipboard}>
Paste from clipboard
</button>
<textarea
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
onPaste={(event) => {
const text = event.clipboardData.getData("text/plain");
if (!text) return;
event.preventDefault();
setValue(normalizePastedText(text));
setMessage("Pasted text was normalized.");
}}
aria-describedby="paste-import-help"
/>
<p id="paste-import-help" role="status" aria-live="polite">
{message}
</p>
</section>
);
}
붙여넣은 텍스트는 신뢰하지 않습니다. 길이 제한, 줄바꿈 정규화, 제어 문자 제거, 형식 검증을 넣습니다. HTML을 받는다면 렌더링 전에 sanitize하고, 클립보드 HTML을 dangerouslySetInnerHTML에 바로 넣지 않습니다.
실무 유스케이스와 실패 모드
| 유스케이스 | 구현 포인트 | 흔한 실패 |
|---|---|---|
| 문서 코드 복사 | 코드 문자열만 복사하고 성공을 알림 | 버튼 문구 변화로 레이아웃이 흔들림 |
| 관리자 화면 주문 ID 복사 | ID만 복사하고 행 전체는 제외 | 고객명이나 주소가 함께 복사됨 |
| 지원 도구 로그 붙여넣기 | 길이 제한, 정규화, secret 검사 | 토큰이나 쿠키가 제한 없이 저장됨 |
| 초대 링크 공유 | 만료 시간을 보여주고 URL만 복사 | 만료된 링크가 복사되어 문의가 늘어남 |
ClaudeCodeLab 제품과 교육 콘텐츠에서도 이 원칙은 중요합니다. 무료 PDF의 명령어, 워크숍 설치 절차, 컨설팅 진단 템플릿은 정확히 복사되면 전환율이 좋아집니다. 반대로 라이선스 키, 구매자 이메일, 고객 정보는 한 번에 복사되는 편의 기능으로 묶지 않는 것이 안전합니다.
HTTP, iframe, 모바일 Safari 주의점
현대 브라우저의 Async Clipboard는 secure context를 전제로 합니다. HTTPS와 localhost에서는 동작해도 http://192.168.x.x 같은 미리보기에서는 navigator.clipboard 자체가 없을 수 있습니다. 이때 fallback을 시도하고, 그래도 실패하면 수동 복사 안내를 보여줍니다.
iframe 내부에서는 Chromium 계열 브라우저에서 Permissions Policy 또는 allow 속성이 필요할 수 있습니다.
<iframe
src="https://docs.example.com/embed"
allow="clipboard-read; clipboard-write"
title="Documentation preview"
></iframe>
Safari와 iOS WebKit에서는 사용자의 클릭 또는 탭과 clipboard 호출의 연결이 특히 중요합니다. writeText 전에 fetch, timer, 애니메이션, 라우팅을 기다리지 마세요. 복사할 값은 미리 준비하고, 클릭 핸들러 안에서 즉시 복사한 뒤 후속 UI를 처리합니다. 중요한 흐름은 데스크톱 Chrome만 보지 말고 실제 iOS 기기에서 확인해야 합니다.
Playwright로 clipboard UX 테스트
방문하는 origin과 권한을 부여하는 origin이 같아야 합니다. Playwright 문서는 권한 지원이 브라우저와 버전에 따라 달라질 수 있다고 안내합니다. 실무에서는 Chromium에서 실제 클립보드 내용을 검증하고, WebKit에서는 UI 상태를 검증하는 식으로 나눌 수 있습니다.
// tests/clipboard.spec.ts
import { expect, test } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
test.describe("clipboard UX", () => {
test.beforeEach(async ({ context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"], {
origin: baseURL,
});
});
test("copies a code block", async ({ page }) => {
await page.goto(`${baseURL}/docs/install`);
await page.getByRole("button", { name: /copy code/i }).first().click();
await expect(page.getByRole("status")).toContainText(/copied/i);
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toContain("npm");
});
test("normalizes pasted text", async ({ page }) => {
await page.goto(`${baseURL}/support/import`);
await page.evaluate(() => navigator.clipboard.writeText("line1\r\nline2\u0000"));
await page.getByRole("button", { name: /paste from clipboard/i }).click();
await expect(page.getByRole("textbox")).toHaveValue("line1\nline2");
});
});
CI에서 실패하면 origin부터 확인합니다. http://localhost:4173과 http://127.0.0.1:4173은 서로 다른 origin입니다. 그다음 브라우저 프로젝트가 해당 권한을 지원하는지, 번역된 버튼 문구 때문에 role query가 깨졌는지 확인합니다.
접근성 체크리스트
- 클릭 가능한
div대신 실제button을 사용한다. - 성공과 실패를
role="status"와aria-live="polite"로 알린다. - 라벨은 “Copy code”, “Copy invite link”, “Copy order ID”처럼 구체적으로 쓴다.
- 키보드 포커스가 보이게 한다.
- 복사 후 레이아웃이 움직이지 않도록 버튼 최소 폭을 둔다.
- 실패 시 사용자가 다음에 할 행동을 알려준다.
- 성공을 색상만으로 표현하지 않는다.
- 클립보드 읽기는 명시적인 붙여넣기 동작 뒤에만 실행한다.
Claude Code 리뷰 프롬프트
구현 후에는 막연하게 “검토해줘”라고 하지 말고, 위험 지점을 지정합니다.
Review only clipboard-related changes.
Check:
1. Clipboard read is never triggered on page load.
2. writeText is called from a user action.
3. HTTP or unsupported browser fallback is handled.
4. copied/error feedback is accessible.
5. pasted text is normalized and size-limited.
6. Playwright tests grant permissions for the correct origin.
Return findings with file and line references.
ClaudeCodeLab에서는 이런 작은 Web API 기능을 교육 주제로 자주 씁니다. 사양 확인, 구현, fallback 설계, 브라우저 테스트, 접근성 리뷰, 문서화까지 한 번에 연습할 수 있기 때문입니다. 템플릿과 실전 가이드는 ClaudeCodeLab 제품을, 팀 도입과 리뷰 체계는 Claude Code 교육을 확인하세요.
실제로 해본 결과
Masa가 코드 블록 복사 UI를 만들 때 첫 문제는 시각적인 흔들림이었습니다. 성공 후 버튼이 넓어져 코드 영역이 밀렸습니다. 두 번째 문제는 휴대폰 HTTP 미리보기에서 Async Clipboard가 사라진 것이었습니다. 안정화한 버전은 복사 로직을 유틸리티로 분리하고, textarea fallback과 수동 복사 안내를 넣고, 버튼 폭을 고정하고, Playwright가 정확한 origin에 권한을 부여하도록 했습니다. Clipboard API는 작은 기능이지만, 권한, 개인정보, 접근성, 테스트를 함께 설계해야 프로덕션 품질이 됩니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.