---
title: "사파리에서만 클립보드 복사가 실패하는 이유 — transient activation"
description: "사파리에서 클립보드 복사가 실패하는 이유는 비동기 네트워크 요청으로 인해 사용자 제스처가 만료되기 때문이며 이를 해결하려면 복사 로직을 요청보다 앞서 실행하거나 Promise를 활용해 쓰기를 예약해야 합니다"
date: 2026-08-11
updated: 2026-08-14T01:33:03.218Z
tags: [frontend, safari, clipboard, browser-api]
canonical: https://blog.wooncloud.com/posts/safari-clipboard-transient-activation
---

![살짝 열린 문](/images/posts/safari-clipboard-transient-activation/393129f5-51b4-45df-8000-658b25c490f9.webp)

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

```ts
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` **앞으로** 빼야 한다. 그러지 않으면 "저장은 막혔는데 클립보드만 덮인" 어정쩡한 상태가 생긴다.

```ts
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`** 을 쓴다. 클릭 시점에 쓰기를 예약하고 값은 나중에 채우는 방식이다.

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

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

## 테스트로 박제하기

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

```ts
let resolveSave;
const pending = new Promise((r) => { resolveSave = r; });
vi.spyOn(apiClient, "save").mockReturnValue(pending);   // 응답을 주지 않는다

fireEvent.click(getByText("링크 복사"));

await waitFor(() => expect(writeText).toHaveBeenCalled());  // 응답 전에 이미 복사됨
```

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

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

## 정리

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

## 참고

- [MDN — Clipboard.writeText()](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText)
- [MDN — Transient activation](https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation)
- [WebKit Blog — Async Clipboard API](https://webkit.org/blog/10855/async-clipboard-api/)
