Safari에서만 클립보드 복사를 실패하는 이유

sujin·2025년 8월 26일
0
post-thumbnail

회사에서 공유하기 기능을 구현하던 중, Safari 버그를 만났습니다. 크롬이나 다른 브라우저에서는 멀쩡하게 잘 되는데, Safari만 클릭 첫 번째 시도에서 “실패했습니다”라는 alert이 뜨고, 두 번째 클릭부터는 또 멀쩡하게 잘 복사가 되는 이상한 상황이었습니다.

이때부터 저의 삽질은 시작됐습니다 😇


문제 상황

제가 구현한 기능은 공유하기 버튼을 누르면 shortURL을 발급받고, 그걸 클립보드에 복사하는 단순한 기능이었습니다.

await navigator.clipboard.writeText(text); // 🚨 Safari 첫 클릭에서만 에러!

혹시 몰라 fallback으로 document.execCommand('copy') 도 넣어두긴 했는데, Safari에서는 첫 클릭에서만 writeText 부분이 터지면서 에러 alert이 뜨는 상황이었습니다.

조금 더 전체 흐름을 보자면 이런 식이었습니다.


const { data: shortUrl, isLoading } = useGetShortUrl(currentId); // SWR shortURL API 통신

const shareItem = (id: number) => {
  if (currentId === id && shortUrl?.url && !isLoading) {
    handleShare(shortUrl.url);
    return;
  }
  setCurrentId(id);
};

const handleShare = (url: string) => {
  const shareData = { title: '공유하기 테스트', url };
  copyToClipboard(shareData.url); // 클립보드 복사
};

겉보기에는 큰 문제가 없어 보였지만, Safari에서는 첫 클릭시 에러가 발생했습니다.


삽질 과정

1. SWR 대신 직접 호출해보기

처음에는 “혹시 SWR 캐싱 때문에 응답이 늦게 들어와서 그런가?” 싶어서 아예 SWR을 걷어내고, 직접 API를 호출해서 응답을 받은 뒤 클립보드에 넣어봤습니다.

const handleShareClick = async () => {
  try {
    // ✅ API 직접 호출
    const res = await fetch(`/api/short-url?postId=${postId}`);
    const data = await res.json();

    // ✅ 응답 후 바로 clipboard 실행
    await navigator.clipboard.writeText(data.url);
    alert('공유 링크가 복사되었습니다');
  } catch (err) {
    alert('복사 실패');
  }
};

하지만 결과는 똑같이 실패했습니다. 즉, SWR 문제는 아니었습니다.

2. 동기 방식으로 강제 실행해보기

Clipboard API가 async 함수라 혹시 Safari가 “비동기라서 제스처 컨텍스트를 잃었다” 고 생각하나 싶어서,
클립보드 복사 부분을 동기 fallback 코드(execCommand)로 강제 실행해봤습니다.

const copyToClipboard = (text: string) => {
  if (!text) return;

  try {
    const textarea = document.createElement('textarea');
    textarea.value = text;

    textarea.style.position = 'absolute';
    textarea.style.left = '-9999px';
    document.body.appendChild(textarea);
    textarea.select();

    // ✅ 동기 방식으로 강제 실행
    document.execCommand('copy');
    document.body.removeChild(textarea);

    alert('공유 링크가 복사되었습니다');
  } catch (err) {
    alert('복사 실패');
  }
};

하지만 이 방식도 첫 클릭 실패는 여전했습니다. 단순히 비동기를 동기로 바꾼다고 해결되는 문제가 아니었습니다.

3. 클릭 안에서 끝까지 처리해보기

마지막으로, 사용자 클릭 → API 호출 → 응답 → clipboard 실행 이 흐름이 문제라면, 클릭 이벤트 안에서 끝까지 실행하면 괜찮지 않을까 싶었습니다.

const handleShareClick = () => {
  fetch(`/api/short-url?postId=${postId}`)
    .then(res => res.json())
    .then(data => {
      return navigator.clipboard.writeText(data.url);
    })
    .then(() => {
      alert('공유 링크가 복사되었습니다');
    })
    .catch(() => {
      alert('복사 실패');
    });
};;

하지만 역시 결과는 동일했습니다. Safari 입장에서는 API 호출 자체가 비동기이기 때문에, 이 방법 또한 이미 사용자 제스처 컨텍스트가 사라졌다고 판단한 것이었습니다.


진짜 원인

Safari의 보안 정책상 navigator.clipboard.writeText() 는 다음 조건에서만 허용됩니다.

  1. 직접적인 사용자 제스처(클릭/터치) 안에서 호출
  2. 동기적 호출 스택 안에서 실행
  3. 한 번이라도 await, setTimeout, API 호출 같은 비동기 작업을 거치면 Safari는 “이건 더 이상 사용자 제스처랑 무관하다” 라고 판단 → 복사 거부

즉, 제가 만든 로직은 shortURL을 발급받기 위해 비동기 API 호출을 한 뒤 그 결과를 클립보드에 넣었기 때문에, Safari는 “이건 사용자가 누른 게 아니라 스크립트가 실행한 거야” 라고 보고 첫 클릭을 막아버린 겁니다.

두 번째부터는 shortURL이 이미 캐시/준비되어 있어서 곧바로 실행되니 정상 동작한 것이었고요.


해결 방법

저는 결국 Safari 전용 바텀시트 를 만들어서 문제를 해결했습니다. 진행 플로우는 이렇게 바뀌었습니다.

  1. 공유하기 버튼 클릭 → 바텀시트 열림
  2. 바텀시트가 열리면 shortURL을 API로 미리 요청
  3. 사용자가 바텀시트에서 링크 복사하기 버튼을 누름
  4. 이때 곧바로 navigator.clipboard.writeText() 실행 (사용자 제스처 안에서 호출)
// 클릭 시 모달 열고 shortURL 미리 요청
const onShareClick = async (itemId: number) => {
  setShowModal(true);
  setLoading(true);

  try {
    const res = await fetch(`/api/short-url?itemId=${itemId}`);
    const json = await res.json();
    setShortUrl(json.url); // shortURL 준비
  } finally {
    setLoading(false);
  }
};

// 바텀시트에서 "복사하기" 클릭 시
const onCopyClick = () => {
  if (!shortUrl) {
    alert('링크 준비 중입니다.');
    return;
  }

  navigator.clipboard.writeText(shortUrl)
    .then(() => alert('링크가 복사되었습니다'))
    .catch(() => alert('복사 실패'));
};

Safari에서는 클릭 이벤트 핸들러 안에서 즉시 실행 되는 코드만 허용하기 때문에, onCopyClick 안에서 바로 writeText() 를 호출하는 구조로 바꿔서 버그를 해결할 수 있었습니다.


마무리

결론적으로, Safari에서 발생한 이 버그는 코드 문제가 아니라 브라우저 정책 때문이었습니다.
생각보다 해결책은 단순했습니다. 비동기 작업으로 URL을 받아온 뒤 곧바로 복사하는 게 아니라, 사용자 클릭 시점에서 즉시 복사 동작을 실행 하도록 구조를 바꾸는 것!

결국 문제의 핵심은 “언제 클립보드 복사 동작을 실행하느냐”였습니다. 비동기 API 호출과 사용자 제스처 사이의 타이밍만 맞춰주면 Safari도 더 이상 문제를 일으키지 않습니다. 삽질은 길었지만, 덕분에 브라우저 호환성에 대해 깊이 이해할 수 있었습니다.😇


📚 참고
류쥰열의 기술 블로그 | 사파리에서 클립보드 복사 이슈

profile
개발댕발

0개의 댓글