본문으로 건너뛰기
wooncloud

사파리에서만 클립보드 복사가 실패하는 이유 — transient activation

사파리에서 클립보드 복사가 실패하는 이유는 비동기 네트워크 요청으로 인해 사용자 제스처가 만료되기 때문이며 이를 해결하려면 복사 로직을 요청보다 앞서 실행하거나 Promise를 활용해 쓰기를 예약해야 합니다

·7 min read· views·

살짝 열린 문

"링크 복사" 버튼을 눌렀을 때 서버에 설정을 저장하고 그 URL 을 클립보드에 넣는, 흔한 흐름이다.

const onCopy = async () => {
  await saveSettings();                  // 네트워크 (약 40ms)
  await navigator.clipboard.writeText(url);
};

크롬에선 완벽하게 동작하는데 사파리에서만 복사가 실패한다. 네트워크 요청은 200 으로 성공하고, 콘솔엔 NotAllowedError 만 남는다.

왜 사파리에서만인가

WebKit 은 클립보드 쓰기를 클릭의 transient activation(사용자 제스처)이 살아 있는 동안에만 허용한다. await 로 네트워크 응답을 기다리는 순간 activation 이 만료되고, 그 뒤의 writeText 는 "사용자가 시킨 일"로 인정받지 못한다.

크롬은 같은 API 를 권한(permission) 모델로 다룬다. clipboard-write 는 포그라운드 탭의 자기 오리진에 자동 부여되므로 await 뒤에 호출해도 통과한다. 그래서 크롬에서는 영원히 재현되지 않는 버그가 된다.

여기서 흔히 놓치는 두 번째 함정이 있다.

execCommand("copy") 폴백은 이 문제를 구제하지 못한다.

레거시 폴백도 똑같이 사용자 제스처를 요구한다. 폴백이 살리는 상황은 "Clipboard API 부재 · 비-secure context"이지 "제스처 소실"이 아니다. 제스처가 끊긴 자리에서는 두 경로가 나란히 실패한다. 폴백을 붙여 뒀으니 어떻게든 되겠지, 라고 넘기면 원인을 한참 못 찾는다.

해결: 순서를 뒤집는다

먼저 복사할 문자열이 네트워크 응답에 의존하는지 확인한다. 대개는 의존하지 않는다 — 위 예시의 URL 도 리소스 ID 만으로 만들어지는 동기 함수의 결과였다. 저장과 복사가 한 버튼에 묶여 있다는 이유로 순서가 정해져 있었을 뿐이다.

의존하지 않는다면 제스처 안에서 복사를 먼저 시작하고, 네트워크와 병행시킨 뒤 결과 보고만 나중에 합친다.

동기 유효성 검사도 await 앞으로 빼야 한다. 그러지 않으면 "저장은 막혔는데 클립보드만 덮인" 어정쩡한 상태가 생긴다.

const onCopy = async () => {
  if (!validate(form)) return;           // 동기 검증 — 제스처를 소모하지 않는다
  const copying = copyToClipboard(url);  // ① 제스처 안에서 즉시 시작
  const saved = await saveSettings();    // ② 네트워크는 병행
  const copied = await copying;
  if (!saved) return;
  toast(copied ? "복사했습니다" : "복사에 실패했습니다");
};

copyToClipboard(url)호출하는 시점이 중요하지 await 하는 시점이 아니다. async 함수의 본문은 첫 await 전까지 동기로 실행되므로, 내부의 writeText 는 클릭 핸들러와 같은 틱에서 시작된다.

값이 정말 응답에 의존한다면

서버가 만들어주는 slug 처럼 응답 없이는 복사할 값 자체가 없는 경우엔 순서를 못 바꾼다. 이때는 WebKit 이 지원하는 Promise 를 담은 ClipboardItem 을 쓴다. 클릭 시점에 쓰기를 예약하고 값은 나중에 채우는 방식이다.

navigator.clipboard.write([
  new ClipboardItem({
    "text/plain": fetchUrl().then((u) => new Blob([u], { type: "text/plain" })),
  }),
]);

다만 이 경로는 execCommand 폴백과 함께 쓸 수 없다. 구형 WebView 를 지원해야 한다면 순서 뒤집기가 낫다.

테스트로 박제하기

"복사가 네트워크보다 먼저"는 시간으로 재면 안 된다. 네트워크 mock 을 deferred 로 pending 에 붙잡아 두고, 그 상태에서 writeText 가 이미 호출됐는지로 순서를 증명한다.

let resolveSave;
const pending = new Promise((r) => { resolveSave = r; });
vi.spyOn(apiClient, "save").mockReturnValue(pending);   // 응답을 주지 않는다
 
fireEvent.click(getByText("링크 복사"));
 
await waitFor(() => expect(writeText).toHaveBeenCalled());  // 응답 전에 이미 복사됨

폴백이 결과를 가려버리지 않도록 document.execCommandfalse 로 막아 둔다. 그래야 통과 조건이 "제스처 안에서 writeText 가 성공했다" 하나로 좁혀진다.

이 가드는 수정 코드에서 실패하는지 반드시 확인하고 넣는다. 순서 의존 버그는 통과하는 테스트를 쓰기가 너무 쉬워서, 실패를 못 본 가드는 아무것도 지키지 않는다.

정리

  • 브라우저마다 재현이 갈리는 클립보드 버그는 호출 시점(await 앞이냐 뒤냐) 부터 본다.
  • 사파리는 제스처 기반, 크롬은 권한 기반이라 크롬에서는 증상이 안 나온다.
  • execCommand 폴백은 제스처 소실을 구제하지 못한다.
  • 복사할 값이 응답에 의존하지 않으면 순서를 뒤집고, 의존하면 ClipboardItem 에 Promise 를 넘긴다.

참고