본문 바로가기

React

React 앱에서 클립보드 API 사용하기

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를 표준화하면, 제품 전반의 일관성과 신뢰도가 크게 향상됩니다.