웹앱 복사 버튼이 안 될 때: HTTPS·사용자 클릭·Clipboard API 점검

웹앱에서 복사 버튼을 눌렀는데 붙여넣을 내용이 그대로이거나 NotAllowedError가 나타난다면, 먼저 복사 API 호출 여부, 실행 컨텍스트, 실제 쓰기 완료를 나누세요. 버튼을 눌렀다는 사실이나 성공 문구가 보였다는 사실만으로 클립보드 갱신을 확인할 수는 없습니다.

이 글은 사용자가 보고 있는 일반 텍스트를 명시적인 버튼 클릭으로 복사하는 경우를 다룹니다. HTML·이미지 복사, 기존 클립보드 읽기, 브라우저 확장 기능은 범위 밖입니다. 아래 예제는 화면의 테스트 메모만 복사하며 이 사이트 본문에서는 실행되지 않는 코드입니다.

증상마다 먼저 확인할 지점을 고르세요

증상 첫 확인 다음 행동
navigator.clipboard가 없음 isSecureContext와 API 지원 개발·배포 주소와 브라우저를 구분
NotAllowedError 사용자 동작·포커스·사이트 권한·임베딩 정책 한 원인으로 단정하지 말고 독립 페이지와 비교
성공 문구 뒤에도 이전 내용이 붙여넣어짐 Promise를 기다린 뒤 성공을 표시했는지 실패 분기와 복사한 원문을 테스트 값으로 대조
서버 응답을 기다린 뒤에만 실패 클릭부터 writeText까지의 비동기 작업 내용을 먼저 준비하고 별도의 클릭에서 복사
버튼을 누르면 폼이 제출되거나 화면 이동 button의 type과 이벤트 연결 복사 버튼은 type=”button”으로 명시
독립 페이지에서는 되고 iframe에서만 실패 부모 문서의 보안 컨텍스트와 권한 정책 임베딩 소유자와 필요한 기능 범위를 확인
빠르게 누를 때 결과 문구가 뒤섞임 미완료 복사 요청 수 진행 중 버튼과 상태를 한 요청에 연결

복사는 브라우저가 제공하는 시스템 기능입니다. CORS는 서버 응답 접근 정책이므로 클립보드 권한 오류를 고치기 위해 API 서버의 허용 출처를 넓힐 이유는 없습니다. 요청 자체가 실패해 복사할 텍스트가 준비되지 않은 경우와도 분리하세요.

HTTPS 여부와 사용자 클릭을 각각 확인하세요

writeText 문서는 보안 컨텍스트에서 텍스트를 쓰며, 허용되지 않으면 NotAllowedError가 발생할 수 있다고 설명합니다. 주소 표시줄만 보지 말고 실패한 창에서 window.isSecureContext를 확인하세요. 보안 컨텍스트 문서에 따르면 로컬 loopback 개발 환경과 일반 HTTP 배포 환경의 판단이 다르고 iframe은 상위 문서 조건도 영향을 받습니다. localhost에서 됐다는 사실이 운영 주소의 성공 증거는 아닙니다.

Clipboard API 설명은 브라우저마다 쓰기 권한과 일시적인 사용자 활성 상태를 요구하는 방식이 다름을 안내합니다. 모든 브라우저에서 같은 권한 조회가 지원된다고 가정하지 마세요. 예제는 클릭 처리 안에서 writeText를 바로 시작하고 성공·거부 결과를 처리합니다.

navigator.userActivation.isActive는 현재의 일시적인 활성 상태를 살펴보는 진단값이고, hasBeenActive는 과거 상호작용 여부입니다. hasBeenActive가 true라는 사실만으로 지금 복사 가능한 것은 아닙니다. 복사 전에 fetch나 타이머·권한 조회를 기다리는 흐름은 대기 시간과 브라우저에 따라 조건을 잃을 수 있으므로, 먼저 내용을 준비하고 사용자가 다시 복사 버튼을 누르게 분리하는 방법을 검토하세요.

성공 표시와 수동 선택을 구분한 예제

다음 HTML과 copy.js를 같은 폴더에 두고 적절한 웹 실행 환경에서 사용할 수 있도록 구성했습니다. 자동 복사 실패 시 원문 선택 버튼을 제공하며, 선택만 된 상태를 복사 성공이라고 표시하지 않습니다. 이 예제는 일반 텍스트와 한 개의 버튼 묶음을 전제로 합니다.

<label for="copy-source">복사할 본문</label>
<textarea id="copy-source" rows="3" readonly>테스트 메모: 한글과 줄바꿈
두 번째 줄</textarea>
<button id="copy-button" type="button">본문 복사</button>
<button id="select-button" type="button" hidden>본문 선택</button>
<p id="copy-status" role="status" aria-live="polite"></p>
<script src="copy.js" defer></script>
const source = document.querySelector('#copy-source');
const copyButton = document.querySelector('#copy-button');
const selectButton = document.querySelector('#select-button');
const status = document.querySelector('#copy-status');
let copying = false;

function offerManualCopy(message) {
  selectButton.hidden = false;
  status.textContent = message;
}

copyButton.addEventListener('click', async () => {
  if (copying) return;
  const text = source.value; // 표시한 원문을 그대로 복사합니다.
  selectButton.hidden = true;
  if (text.length === 0) {
    status.textContent = '복사할 본문이 없습니다.';
    return;
  }
  if (!window.isSecureContext) {
    offerManualCopy('보안 컨텍스트를 확인하세요. 본문을 직접 복사할 수 있습니다.');
    return;
  }
  if (typeof navigator.clipboard?.writeText !== 'function') {
    offerManualCopy('자동 복사를 사용할 수 없습니다. 본문을 직접 복사하세요.');
    return;
  }

  copying = true;
  copyButton.disabled = true;
  status.textContent = '복사 중입니다.';
  try {
    // 이 호출 앞에 fetch, 타이머, 권한 조회를 기다리지 않습니다.
    await navigator.clipboard.writeText(text);
    status.textContent = '본문을 복사했습니다.';
  } catch (error) {
    const message = error?.name === 'NotAllowedError'
      ? '복사가 허용되지 않았습니다. 본문을 직접 복사하세요.'
      : '자동 복사에 실패했습니다. 본문을 직접 복사하세요.';
    offerManualCopy(message);
  } finally {
    copying = false;
    copyButton.disabled = false;
  }
});

selectButton.addEventListener('click', () => {
  source.focus();
  source.select();
  status.textContent = '본문을 선택했습니다. 복사 메뉴나 단축키로 직접 복사하세요.';
});

writeText가 반환한 Promise를 await한 뒤에만 성공 문구를 표시합니다. copying 플래그는 진행 중의 중복 요청을 막고, finally에서 버튼을 다시 쓸 수 있게 합니다. 복사 대상은 클릭 시 읽은 source.value이며 trim이나 HTML 삽입 없이 그대로 전달합니다. 일반 텍스트 표시에는 textContent를 사용합니다.

본문 선택 버튼은 사용자가 누를 때 focus와 select()를 호출합니다. select는 텍스트를 선택할 뿐 클립보드에 쓰는 함수가 아닙니다. 모바일에서는 선택 표시와 복사 메뉴 조작을 대상 브라우저에서 확인해야 합니다. 자동 권한을 얻기 위해 읽기 API를 호출하거나, 선택만 해놓고 ‘복사됨’으로 표시하지 마세요.

모의 실행에서 확인한 것과 확인하지 않은 것

2026년 10월 26일 Node.js v24.19.0에서 위 copy.js를 그대로 읽어 모의 DOM과 모의 clipboard 객체로 10개 시나리오를 실행했고 모두 통과했습니다. 함수의 흐름과 상태 문구를 확인한 단위 수준 검사입니다. 운영체제 클립보드를 읽거나 쓰지 않았으며 실제 Chrome·Safari·Firefox의 권한·사용자 활성·모바일 선택 동작을 검증한 결과가 아닙니다.

모의 입력 관찰한 결과
성공, 한글·줄바꿈·앞뒤 공백 원문을 그대로 한 번 전달하고 완료 뒤 성공 표시
빈 문자열과 공백만 있는 문자열 빈 문자열은 호출하지 않고 공백 문자열은 변형하지 않음
보안 컨텍스트 false 또는 API 없음 자동 쓰기 없이 수동 선택 경로 제공
NotAllowedError와 즉시 발생한 예외 성공 표시 없이 실패 안내, 버튼 복구
미완료 Promise 중 같은 핸들러를 두 번 호출 쓰기 호출은 한 번, 완료 전 성공 표시 없음
수동 선택 버튼 focus·select 순서 호출, 자동 쓰기나 성공 표시 없음
정적 HTML과 API 사용 비제출 버튼·상태 영역 확인, readText·execCommand 호출 없음

위 표는 여러 시나리오를 묶어 요약했습니다. 모의 API가 성공했다고 실제 브라우저 권한도 허용됐다고 해석하면 안 됩니다. 코드는 페이지 요소가 이미 존재하는 조건에서 연결되므로, SPA에서는 컴포넌트 수명과 이벤트 중복 등록도 별도로 처리해야 합니다.

실제 브라우저에서 마칠 확인표

  1. 테스트용 문장을 표시한 뒤 실제 버튼을 클릭합니다. 빈 테스트 입력칸이나 메모 앱에 사용자가 붙여넣어 한글·줄바꿈이 의도대로 전달됐는지 비교합니다.
  2. 해당 사이트의 쓰기가 거부된 조건과 API를 사용할 수 없는 조건에서 거짓 성공 문구가 없는지 확인합니다. 보안 정책을 끄는 방식으로 시험 결과를 맞추지 않습니다.
  3. 빠른 두 번 클릭과 느린 Promise 상황에서 중복 요청과 버튼 복구를 확인합니다. 성공 후 다른 앱이 클립보드를 바꾼 경우까지 이 페이지가 보장하는 것은 아닙니다.
  4. 독립 페이지와 실제 iframe 배치, 데스크톱과 모바일 브라우저를 구분합니다. 브라우저 이름·버전·주소의 scheme·isSecureContext·클릭 경로·예외 이름을 함께 기록합니다.
  5. 자동 복사가 실패했을 때 선택 버튼과 직접 길게 눌러 복사하는 경로가 사용 가능한지 확인합니다. 선택만 된 상태와 사용자의 실제 복사 완료를 구분합니다.

복사 성공을 확인하려고 페이지가 readText로 기존 클립보드를 자동 수집할 필요는 없습니다. 테스트 데이터의 수동 붙여넣기로 비교하고, 운영 로그에는 본문·비밀번호·토큰 대신 오류 이름과 환경만 남기세요.

iframe과 배포 구조는 독립 페이지 비교 뒤에 보세요

Chromium 계열의 iframe은 관련 Permissions-Policy 허용 조건도 확인해야 합니다. 그러나 부모 문서의 정책, 출처와 sandbox 조건을 모른 채 모든 권한을 여는 것은 해결 절차가 아닙니다. 먼저 같은 내용을 독립된 보안 컨텍스트에서 시험하고, 임베딩된 곳에서만 실패하는지 증거를 확보하세요. Firefox·Safari의 지원·활성 조건은 별도로 확인해야 하며 다른 브라우저의 권한 응답으로 대신 판단하지 않습니다.

배포 후 이전 스크립트가 계속 실행된다면 복사 권한과 캐시 버전을 나눠 보세요. 코드가 실행됐는지조차 불분명한 상태에서는 캐시와 브라우저 권한을 동시에 바꾸지 않습니다. 최초 클릭 핸들러 진입, writeText 호출, Promise 결과 순서만 테스트 값으로 기록하면 어느 단계에서 멈췄는지 찾기 쉽습니다.

공식 자료와 검증 범위

2026년 10월 26일 아래 MDN 원문을 직접 읽고 적용 조건을 대조했습니다. 모의 테스트 10건은 이 글의 자체 코드에서 관찰한 결과이며 실제 OS 클립보드·브라우저 권한·실기기 실행 결과로 확대하지 않습니다. 본문 코드는 실제 사이트의 복사 기능으로 배포하지 않았습니다.

함께 확인할 가이드