회사에서 공유하기 기능을 구현하던 중, 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에서는 첫 클릭시 에러가 발생했습니다.
처음에는 “혹시 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 문제는 아니었습니다.
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('복사 실패');
}
};
하지만 이 방식도 첫 클릭 실패는 여전했습니다. 단순히 비동기를 동기로 바꾼다고 해결되는 문제가 아니었습니다.
마지막으로, 사용자 클릭 → 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() 는 다음 조건에서만 허용됩니다.
- 직접적인 사용자 제스처(클릭/터치) 안에서 호출
- 동기적 호출 스택 안에서 실행
- 한 번이라도 await, setTimeout, API 호출 같은 비동기 작업을 거치면 Safari는 “이건 더 이상 사용자 제스처랑 무관하다” 라고 판단 → 복사 거부
즉, 제가 만든 로직은 shortURL을 발급받기 위해 비동기 API 호출을 한 뒤 그 결과를 클립보드에 넣었기 때문에, Safari는 “이건 사용자가 누른 게 아니라 스크립트가 실행한 거야” 라고 보고 첫 클릭을 막아버린 겁니다.
두 번째부터는 shortURL이 이미 캐시/준비되어 있어서 곧바로 실행되니 정상 동작한 것이었고요.
저는 결국 Safari 전용 바텀시트 를 만들어서 문제를 해결했습니다. 진행 플로우는 이렇게 바뀌었습니다.
- 공유하기 버튼 클릭 → 바텀시트 열림
- 바텀시트가 열리면 shortURL을 API로 미리 요청
- 사용자가 바텀시트에서 링크 복사하기 버튼을 누름
- 이때 곧바로
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도 더 이상 문제를 일으키지 않습니다. 삽질은 길었지만, 덕분에 브라우저 호환성에 대해 깊이 이해할 수 있었습니다.😇