React 앱에서 텍스트 복사/붙여넣기 경험은 전환율과 생산성에 큰 영향을 줍니다. 최신 브라우저는 navigator.clipboard로 안전하고 일관된 클립보드 접근을 제공합니다. 이 글은 실무에서 바로 쓰는 패턴과 함정을 중심으로 정리했습니다.
1. 클립보드 API 핵심과 전제 조건
Clipboard API는 navigator.clipboard.writeText, readText, write, read를 통해 텍스트와 바이너리를 다룹니다. 다음 전제 조건을 꼭 지켜야 합니다.
- HTTPS에서만 동작합니다(개발용 localhost는 예외). - 사용자의 명시적 제스처(클릭/키입력) 내에서 호출해야 합니다. - 일부 권한은 Permissions API와 프롬프트에 의존합니다(특히 iOS/Safari의 붙여넣기).
2. 텍스트 복사: 가장 간단한 패턴
async function copyText(text) {
if (!navigator?.clipboard?.writeText) throw new Error('Clipboard API 미지원');
await navigator.clipboard.writeText(text);
}
// React 핸들러 예시
function onCopyClick() {
copyText('hello world').catch(console.error);
}
- 호출은 버튼 onClick 같은 사용자 제스처 안에서 수행합니다. - 실패 시 사용자에게 오류를 알려주고 재시도 유도합니다.
3. 텍스트 붙여넣기: 권한과 UX
async function pasteText() {
if (!navigator?.clipboard?.readText) throw new Error('붙여넣기 미지원');
const text = await navigator.clipboard.readText();
return text;
}
// 버튼으로 제스처 보장
async function onPasteClick(setValue) {
try {
const text = await pasteText();
setValue(text);
} catch (e) {
alert('붙여넣기를 사용할 수 없습니다. Cmd/Ctrl+V를 사용하세요.');
}
}
- 붙여넣기는 브라우저/OS에서 더 엄격합니다. iOS/Safari는 사용자 제스처와 권한 프롬프트가 필수입니다. - 입력 필드에서는 onPaste 이벤트로 더 세밀하게 제어할 수 있습니다(아래 7장).
4. 권한 체크: Permissions API
async function canReadClipboard() {
if (!navigator?.permissions) return false;
try {
const status = await navigator.permissions.query({ name: 'clipboard-read' });
return status.state === 'granted' || status.state === 'prompt';
} catch {
return false; // 일부 브라우저는 query 자체가 에러를 던집니다.
}
}
async function canWriteClipboard() {
if (!navigator?.permissions) return true; // 일반적으로 write는 prompt 없이 동작
try {
const status = await navigator.permissions.query({ name: 'clipboard-write' });
return status.state === 'granted' || status.state === 'prompt';
} catch {
return true;
}
}
- 권한 체크는 힌트일 뿐이며, 최종 성공 여부는 실제 호출에서 판단합니다.
5. 실무용 useClipboard 훅
import { useCallback, useState } from 'react';
export function useClipboard({ timeout = 1500 } = {}) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(null);
const copy = useCallback(async (text) => {
setError(null);
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
// 폴백: execCommand
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
document.body.appendChild(textarea);
const selection = document.getSelection();
const selectedRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
if (!ok) throw new Error('execCommand copy 실패');
if (selectedRange) {
selection.removeAllRanges();
selection.addRange(selectedRange);
}
}
setCopied(true);
const id = setTimeout(() => setCopied(false), timeout);
return true;
} catch (e) {
setError(e);
return false;
}
}, [timeout]);
const readText = useCallback(async () => {
setError(null);
try {
if (!navigator?.clipboard?.readText) throw new Error('readText 미지원');
return await navigator.clipboard.readText();
} catch (e) {
setError(e);
return '';
}
}, []);
return { copy, readText, copied, error };
}
- copied 상태로 토스트/툴팁을 제어합니다. - execCommand 폴백은 보안 정책에 따라 막힐 수 있으므로 실패 처리를 필수로 둡니다.
6. 버튼/입력 컴포넌트 예제
import React, { useState, useCallback } from 'react';
import { useClipboard } from './useClipboard';
export function CopyButton({ text }) {
const { copy, copied } = useClipboard();
return (
<button onClick={() => copy(text)} aria-live='polite'>
{copied ? '복사됨' : '복사'}
</button>
);
}
export function PasteInput() {
const [value, setValue] = useState('');
const onPaste = useCallback((e) => {
const text = e.clipboardData.getData('text/plain');
e.preventDefault();
// 간단한 정규화 예시: 공백 압축
const sanitized = text.replace(/\s+/g, ' ');
setValue((v) => v + sanitized);
}, []);
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onPaste={onPaste}
placeholder='여기에 붙여넣기 (Ctrl/Cmd+V)'
/>
);
}
- onPaste로 입력을 가로채고 sanitize합니다. 리치 텍스트를 plain text로 제한해 보안과 일관성을 얻습니다.
7. 이미지/리치 콘텐츠 다루기
// 이미지 복사 (지원 브라우저에서만)
async function copyImageFromUrl(url) {
const res = await fetch(url);
const blob = await res.blob();
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob })
]);
}
// 클립보드에서 이미지 읽기
async function readImage() {
if (!navigator?.clipboard?.read) throw new Error('바이너리 read 미지원');
const items = await navigator.clipboard.read();
for (const item of items) {
for (const type of item.types) {
if (type.startsWith('image/')) {
const blob = await item.getType(type);
return URL.createObjectURL(blob); // 미리보기용
}
}
}
return null;
}
- write/read는 더 제한적이며, 사용자 제스처와 권한 요구가 강합니다. 크로스브라우저 필요 시 텍스트만을 우선 고려합니다.
8. 브라우저 호환과 폴백 전략
- 1순위: navigator.clipboard 사용, 실패 시 사용자 안내. - 2순위: execCommand('copy') 폴백(가능한 경우에만). - 최종: 수동 복사 가이드(입력 선택 + 키보드 단축키 안내).
function selectAndGuide(elem) {
elem.select();
alert('복사를 사용할 수 없습니다. 키보드 단축키(Ctrl/Cmd+C)를 사용하세요.');
}
9. SSR/모바일 Safari 주의사항
- SSR: window / navigator 접근 전 typeof window !== 'undefined'로 가드합니다. - iOS/Safari: 첫 붙여넣기 시 시스템 프롬프트가 뜨며, 제스처가 없으면 실패합니다. readText는 onClick 같은 핸들러 안에서만 호출합니다. - 권한 프롬프트를 거부하면 이후 자동 실패하므로 재시도 UX를 제공합니다.
10. 보안과 접근성 베스트 프랙티스
- 민감 정보 자동 복사는 지양하고, 사용자의 명시적 의도를 확인합니다. - 붙여넣기 입력은 반드시 sanitize/검증을 거칩니다(스크립트/HTML 제거). - 복사 성공/실패는 시각+스크린리더 모두 인지 가능하도록 aria-live, role='status' 등을 사용합니다. - 토스트는 1~2초로 짧게, 재복사 즉시 가능하게 합니다.
11. 테스트 팁(Jest/Cypress)
// Jest 예시
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(),
readText: jest.fn().mockResolvedValue('mocked'),
},
});
// Cypress 예시
cy.window().then((win) => {
win.navigator.clipboard = {
writeText: cy.stub().resolves(),
readText: cy.stub().resolves('mocked'),
};
});
12. 체크리스트 요약
- HTTPS/localhost인지 확인합니다. - 사용자 제스처 안에서만 호출합니다. - 실패 대비(권한/브라우저 미지원) 폴백과 안내를 제공합니다. - useClipboard 훅으로 상태/에러/타임아웃을 통합합니다. - onPaste로 입력을 통제하고 sanitize합니다. - iOS/Safari 제약을 문서화하고 프롬프트 UX를 설계합니다.
위 패턴을 토대로 복사/붙여넣기 UX를 표준화하면, 제품 전반의 일관성과 신뢰도가 크게 향상됩니다.
'React' 카테고리의 다른 글
| React 앱에서 Toast/Notification 시스템 구축하기 (0) | 2026.05.16 |
|---|---|
| React로 멀티 스텝(Form Wizard) 폼 구현하기 (0) | 2026.05.16 |
| React에서 Lottie 애니메이션 통합하기 (0) | 2026.05.15 |
| React로 마이크로 프론트엔드 구현하기 (0) | 2026.05.14 |
| React 앱에서 Atomic Design 패턴 적용하기 (0) | 2026.05.14 |