웹 에디터 간 복사/붙여넣기 호환성 구현하기
개요
자사 웹 에디터를 개발하며, 유저 편의성을 고려해 외부 에디터와 복사/붙여넣기 상호 호환성을 지원하기로 했습니다. 처음에는 단순히 콘텐츠를 시맨틱 태그로 렌더링하면 외부 에디터에서도 원하는 형식대로 나올 것이라 생각했는데요.

실제로는 외부 WYSIWYG 에디터에서 작성한 글을 우리 에디터에 붙여넣으면 화면에 보이지 않던 접근성 텍스트가 나타나고, 반대로 우리 에디터에서 콘텐츠를 복사하면 다른 에디터에서 이미지 붙여넣기가 안 되는 문제가 나타났습니다. 외부 서비스의 동작을 제어할 수 없는 상황에서, 복사/붙여넣기 호환성은 단순한 HTML 변환 이상의 문제라는 것을 깨달았습니다.
이 문제를 해결하기 위해 HTML 구조만 들여다보는 대신, 복사·붙여넣기 과정에서 브라우저가 실제로 어떤 데이터를 전달하고 있는지를 확인해 보기로 했습니다.
그 과정에서 문제의 핵심은 HTML이 아니라, 클립보드가 여러 형식의 데이터를 동시에 저장하고 이를 에디터마다 다르게 해석한다는 점에 있다는 것을 알게 되었습니다.
이 문서에서는 클립보드의 다중 MIME 타입 구조를 이해하고, 이를 활용해 서로 다른 웹 에디터 간 콘텐츠 호환성을 구현하는 방법을 다룹니다.
클립보드가 여러 형식의 데이터를 동시에 저장하는 원리를 이해하면, 외부 에디터에서 복사한 콘텐츠를 깔끔하게 붙여넣거나, 우리 에디터의 콘텐츠를 다른 에디터에서도 사용할 수 있도록 만들 수 있습니다.
이 문서는 웹 기반 WYSIWYG 에디터를 개발하거나, 외부 서비스와의 콘텐츠 호환성을 개선하려는 프론트엔드 개발자를 대상으로 합니다. 코드 예시는 Chrome과 Edge 기준으로 작성되었으며, Safari에서는 일부 API가 제한적으로 동작할 수 있습니다.
클립보드는 여러 형식을 동시에 저장한다
MIME 타입이란
복사/붙여넣기 기능을 구현하려면 먼저 클립보드의 동작 원리를 이해해야 합니다.
클립보드는 단순히 텍스트 하나만 저장하는 게 아니라, 여러 형식의 데이터를 동시에 저장하는 컨테이너에 가깝습니다. 이때 각 데이터 형식을 구분하는 식별자가 MIME 타입입니다.
예를 들어 웹 페이지에서 서식이 있는 텍스트를 복사하면, 클립보드에는 다음과 같은 데이터가 함께 저장됩니다.
| MIME 타입 | 저장되는 데이터 |
|---|---|
text/plain | 서식 없는 순수 텍스트 |
text/html | 서식이 포함된 HTML |
image/* | 이미지 Blob (스크린샷 복사, 이미지 요소 복사, ClipboardItem API 사용 시) |
즉, 복사 동작은 하나의 데이터를 저장하는 행위가 아니라,
같은 콘텐츠를 서로 다른 표현 방식으로 여러 개 저장하는 과정이라고 볼 수 있습니다.
에디터마다 우선순위가 다르다
그렇다면 왜 같은 클립보드 데이터를 붙여넣었는데 결과가 달라질까요?
원인은 각 에디터가 클립보드에 저장된 여러 MIME 타입 중 어떤 형식을 우선적으로 선택하느냐에 있습니다.
몇 가지 대표적인 예를 살펴보면 다음과 같습니다.
- 네이버 블로그:
text/html을 우선 처리 - 노션:
text/html을 우선 처리하되,image/*가 있으면 이미지로 인식 - 메모장:
text/plain만 처리
이 차이 때문에, 스크린샷을 복사해 노션에 붙여넣으면 이미지가 삽입되지만 같은 스크린샷을 메모장에 붙여넣으면 아무 일도 일어나지 않습니다.
클립보드에는 이미지 데이터가 존재하지만, 메모장은 이를 해석하지 않기 때문입니다.
클립보드 데이터 직접 확인하기
이 가설을 검증하기 위해, 붙여넣기 이벤트가 발생했을 때 클립보드에 어떤 MIME 타입이 실제로 전달되는지를 직접 확인해 보았습니다.
다음 코드를 브라우저 콘솔에서 실행하면, 붙여넣기 시점에 클립보드에 포함된 데이터 타입을 확인할 수 있습니다.
document.addEventListener("paste", (e) => {
const types = e.clipboardData.types;
console.log("MIME 타입 목록:", types);
types.forEach(type => {
// 참고: image/* 타입은 getData()로 문자열을 얻을 수 없습니다.
// 이미지 데이터는 e.clipboardData.files나 items를 통해 접근해야 합니다.
const data = e.clipboardData.getData(type);
console.log(`${type}:`, data || "(바이너리 데이터)");
});
});

외부 HTML을 붙여넣을 때 노이즈 필터링하기
클립보드가 여러 MIME 타입을 담고 있고, 에디터마다 이를 다르게 해석한다는 사실을 확인한 뒤에는 외부 에디터에서 복사한 콘텐츠를 text/html로 처리하는 방향이 가장 합리적으로 보였습니다. 서식과 구조를 최대한 유지할 수 있으니까요.
하지만 실제로 HTML을 그대로 렌더링해 보니, 또 다른 문제가 드러났습니다. "사진 설명을 입력하세요.", "AI 활용 설정"과 같이 사용자가 작성한 적 없는 텍스트와 요소들이 본문에 함께 나타났습니다.

외부 에디터 HTML에 숨어있는 UI 요소들
네이버 블로그 같은 외부 에디터에서 콘텐츠를 복사하면 text/html에 예상치 못한 요소들이 포함되어 있습니다.
- 플레이스홀더 텍스트: "사진 설명을 입력하세요", "출처 입력"
- 스크린리더 전용 텍스트: "0열 선택", "셀 전체 선택"
- 에디터 컨트롤 버튼: 삭제 버튼, 크기 조절 버튼
이런 요소들은 외부 에디터에서는 편집 시 UX 향상과 접근성 지원을 위해 필요하지만, 콘텐츠의 일부가 되지는 않습니다.
따라서 우리 에디터에 HTML을 붙여넣을 때 어느정도 정제가 필요하다는 결론에 이르게 됩니다.
무시할 클래스 패턴 정의하기
외부 에디터의 동작을 직접 수정할 수 없는 상황에서,
현실적인 해결책은 외부 에디터가 UI 요소에 사용하는 패턴을 식별해 걸러내는 것이었습니다.
HTML을 파싱한 뒤 DOM을 순회하면서,
특정 클래스 패턴을 가진 요소는 렌더링 대상에서 제외하도록 처리했습니다.
const IGNORED_CLASS_PATTERNS = [
'se-placeholder', // 플레이스홀더 텍스트
'se-blind', // 스크린리더 전용 텍스트
'se-cell-context-menu', // 테이블 컨텍스트 메뉴
'se-image-delete-button', // 이미지 삭제 버튼 ...
];
// HTML을 파싱한 후 DOM을 순회(traversal)하면서 각 요소에 대해 호출합니다.
const shouldIgnoreElement = (element) => {
const className = element.className;
if (typeof className !== 'string') return false;
return IGNORED_CLASS_PATTERNS.some(pattern =>
className.includes(pattern)
);
};
se- 접두사는 네이버 스마트에디터(SmartEditor)의 클래스 네이밍 컨벤션입니다. 다른 에디터를 지원해야 한다면, 동일한 방식으로 해당 에디터의 클래스 패턴을 분석해 추가할 수 있습니다.
다만 이 방식은 외부 서비스의 내부 구현에 의존한다는 한계가 있습니다.
클래스 네이밍이 변경되면 필터링이 실패할 수 있으므로, 주기적으로 검증하고 예외 상황을 추적할 수 있도록 로그를 남겨두는 것이 좋습니다.
특수 요소 인식하기
필터링만 하면 될 것 같지만, 보존해야 할 특수 요소도 있습니다. 예를 들어 네이버 블로그의 인용구는 <blockquote> 태그를 사용하지 않고, se-quotation과 같은 클래스 조합으로 스타일링되어 있습니다. 이 요소를 무시해 버리면, 사용자가 작성한 인용구가 일반 문단으로 변환되어 버립니다.
따라서 이러한 요소는 클래스로 의미를 인식해 우리 에디터의 표현 방식으로 변환하는 과정을 거쳤습니다.
const checkQuotationElement = (element) => {
const className = element.className;
if (typeof className !== 'string') {
return { isQuotation: false, variant: 'default' };
}
const isQuotation =
className.includes('se-quotation') ||
className.includes('se-section-quotation') ||
className.includes('se-quote');
if (!isQuotation) {
return { isQuotation: false, variant: 'default' };
}
// 스타일 variant 결정
const isDefaultStyle = className.includes('se-l-default');
const variant = isDefaultStyle ? 'default' : 'solid';
return { isQuotation: true, variant };
};
이처럼 외부 HTML을 처리할 때는 제거해야 할 요소와 의미를 보존해야 할 요소를 구분하는 기준이 필요합니다.
외부 이미지의 CORS 문제 해결하기
외부 에디터에서 복사한 콘텐츠를 그대로 활용하기에는 한 가지 문제가 더 남아 있습니다.
외부에서 업로드한 이미지를 임시저장이나 글 발행 등 우리 서비스에 저장하려는 순간부터 오류가 발생했습니다.
문제 상황
외부 에디터에서 이미지가 포함된 콘텐츠를 복사하면 <img src="<https://외부도메인/image.jpg>"> 형태의 HTML이 넘어옵니다. 이 이미지를 우리 서비스에서 사용하려면 해당 경로로 다운로드해서 우리 서버에 저장해야 하는데, 브라우저에서 직접 fetch하면 CORS 에러가 발생합니다.
Access to fetch at '<https://외부도메인/>...'
from origin '<https://datepop.co.kr>' has been blocked by CORS policy네이버 블로그 같은 서비스는 이미지 핫링킹(hotlinking)을 방지하기 위해 다른 도메인에서의 요청을 차단합니다.
해결: 서버사이드 이미지 프록시
브라우저에서 직접 다른 서버의 리소스를 fetch하려면 CORS 정책에 의해 차단되지만, 서버에서는 브라우저와 달리 CORS 정책의 적용을 받지 않는다는 점을 이용해 이미지 프록시 API를 만들었습니다.
Next.js 환경에서는 API Routes를 사용하면 프론트엔드 코드와 함께 서버사이드 로직을 비교적 간단하게 구성할 수 있습니다.
프록시 서버는 반드시 별도의 백엔드 환경에서 동작해야 합니다. 웹 프레임워크에 따라 구현 방식은 조금씩 달라집니다.
순수 React 환경(CRA, Vite 등)에서는 클라이언트 코드만 빌드되므로, Express, Nest.js 등의 기존 백엔드에 프록시 엔드포인트를 추가하거나 AWS Lambda, Cloudflare Workers 같은 Serverless Functions를 활용해야 합니다.
// 프록시가 필요한 도메인 판별
const proxyRequiredDomains = [
'blogfiles.pstatic.net',
'postfiles.pstatic.net',
'mblogthumb-phinf.pstatic.net',
];
const fetchImage = async (url) => {
const parsedUrl = new URL(url);
const needsProxy = proxyRequiredDomains.some(
domain => parsedUrl.hostname === domain ||
parsedUrl.hostname.endsWith(`.${domain}`)
);
const fetchUrl = needsProxy
? `/api/image-proxy?url=${encodeURIComponent(url)}`
: url;
const response = await fetch(fetchUrl);
return response.blob();
};
프록시 서버 구현 시 보안에 주의해야 합니다. 아무 URL이나 프록시하면 서버가 악용될 수 있습니다. 허용할 도메인을 일일이 지정하는 방식도 있지만, 외부 에디터마다 이미지 호스팅 도메인이 다르기 때문에 현실적으로 모든 케이스를 커버하기 어렵습니다.
대신 차단 조건을 두는 방식이 더 실용적입니다.
// 서버사이드 프록시 API (핵심 부분)
const isPrivateIP = (hostname) => {
// 사설 IP 대역 및 localhost 차단 (SSRF 방지)
const privatePatterns = [
/^localhost$/i,
/^127\\./,
/^10\\./,
/^172\\.(1[6-9]|2[0-9]|3[0-1])\\./,
/^192\\.168\\./,
];
return privatePatterns.some(pattern => pattern.test(hostname));
};
// 차단 조건 검사
if (isPrivateIP(parsedUrl.hostname)) {
return new Response('Access denied', { status: 403 });
}
// 응답 후 검증
const contentType = response.headers.get('content-type');
if (!contentType?.startsWith('image/')) {
return new Response('Not an image', { status: 415 });
}
// 응답 크기 제한 (예: 10MB)
const MAX_SIZE = 10 * 1024 * 1024;
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > MAX_SIZE) {
return new Response('Image too large', { status: 413 });
}
추가로 인증된 사용자만 프록시 API를 호출할 수 있도록 제한하고, Rate limiting을 적용하면 남용을 방지할 수 있습니다.
이미지 처리 흐름
전체 흐름을 정리하면 다음과 같습니다.
- 외부 HTML에서 이미지 URL 추출
- 프록시가 필요한지 판별
- 이미지 다운로드 (필요시 프록시 경유)
- 이미지 압축 (용량 최적화)
- data URL로 변환하여 에디터에 삽입
대용량 이미지를 처리할 때는 메모리 사용량에 주의해야 합니다. 특히 여러 이미지를 동시에 처리하면 브라우저 탭이 느려지거나 크래시될 수 있으므로, 순차 처리하거나 이미지 크기 제한을 두는 것이 좋습니다.
다른 에디터에서도 붙여넣을 수 있도록 복사하기
ClipboardItem API로 여러 형식 저장하기
앞에서 클립보드가 여러 MIME 타입을 동시에 저장한다고 했습니다. 복사 기능을 구현할 때도 이 원리를 활용하면 다른 에디터와의 호환성을 높일 수 있습니다.
const handleCopy = async (event) => {
event.preventDefault();
const clipboardItems = {
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' }),
};
await navigator.clipboard.write([
new ClipboardItem(clipboardItems)
]);
};
navigator.clipboard.write는 HTTPS 환경에서만 동작하며, 사용자 제스처(클릭, 키보드 이벤트 등) 내에서만 호출할 수 있습니다. Safari에서는 지원이 제한적이므로, 실패 시 document.execCommand('copy')로 폴백하는 것을 고려하세요.
data URL 이미지의 특수 처리
여기서 한 가지 문제가 있습니다. 에디터 내부에서 이미지를 data URL(data:image/png;base64,...)로 저장하고 있다면, 이대로 HTML에 포함해서 복사하면 다른 에디터에서 인식하지 못하거나 용량 제한에 걸릴 수 있습니다.
해결책은 스크린샷 붙여넣기 원리를 역이용하는 것입니다. 스크린샷을 복사하면 클립보드에 image/png 타입으로 이미지 Blob이 저장되고, 대부분의 에디터는 이를 인식해서 이미지로 삽입합니다. 우리도 data URL을 Blob으로 변환해서 image/* 타입으로 저장하면 됩니다.
// data URL을 Blob으로 변환
const dataUrlToBlob = (dataUrl) => {
const [header, base64] = dataUrl.split(',');
const mimeType = header.match(/:(.*?);/)?.[1] || 'image/png';
const binary = atob(base64);
const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
array[i] = binary.charCodeAt(i);
}
return new Blob([array], { type: mimeType });
};
이제 복사 핸들러에서 data URL 이미지를 감지하고 Blob으로 변환해서 추가합니다.
const handleCopy = async (event) => {
event.preventDefault();
const clipboardItems = {
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' }),
};
// data URL 이미지가 있으면 Blob으로 변환해서 추가
const imgNode = findDataUrlImageNode(fragment);
if (imgNode?.src?.startsWith('data:image/')) {
const imageBlob = dataUrlToBlob(imgNode.src);
clipboardItems[imageBlob.type] = imageBlob;
}
await navigator.clipboard.write([
new ClipboardItem(clipboardItems)
]);
};
이렇게 하면 우리 에디터에서 이미지를 복사해서 노션이나 다른 에디터에 붙여넣을 때도 이미지가 제대로 인식됩니다.
이 방식의 한계
Blob 변환 방식은 단일 이미지를 복사할 때는 잘 동작하지만, 여러 이미지가 포함된 콘텐츠를 복사할 때는 이미지 순서를 보장할 수 없습니다. 클립보드의 image/* 타입에는 하나의 이미지만 저장할 수 있기 때문입니다.
텍스트 사이에 이미지 A, B, C가 순서대로 있는 콘텐츠를 복사하면, text/html에는 세 이미지의 위치 정보가 있지만 image/*에는 하나의 이미지만 담깁니다. 붙여넣는 쪽 에디터가 image/*를 우선 처리하면 이미지 하나만 삽입되고, text/html을 처리하면 data URL을 지원하지 않아 이미지가 깨질 수 있습니다.
이 문제를 근본적으로 해결하려면 Presigned URL 방식의 이미지 업로드를 고려해야 합니다. 이미지를 에디터에 삽입하는 시점에 서버에 업로드하고, 실제 URL을 에디터에 저장하는 방식입니다. 이렇게 하면 복사 시 text/html만으로도 모든 이미지가 올바른 순서로 전달됩니다. 다만 이미지 삽입 시점에 네트워크 요청이 발생하고, 업로드된 이미지 관리(미사용 이미지 정리 등)가 필요하다는 트레이드오프가 있습니다.
정리
이번 작업을 통해 얻은 웹 에디터 간 복사/붙여넣기 호환성 구현의 핵심 인사이트를 정리하면 다음과 같습니다.
1. 클립보드는 컨테이너다
클립보드는 단일한 텍스트나 HTML을 저장하는 공간이 아니라, text/plain, text/html, image/* 등 여러 형식의 데이터를 동시에 담는 컨테이너에 가깝습니다. 붙여넣기 결과는 이 중 어떤 형식을 붙여넣는 쪽 에디터가 선택하느냐에 따라 달라집니다.
2. 상대방이 이해하는 형식으로 제공하기
외부 에디터와 호환되려면 상대방이 이해하는 형식으로 데이터를 제공해야 합니다. HTML만 저장하면 이미지가 누락되고, 이미지만 저장하면 서식이 사라집니다. 여러 형식을 함께 저장하는 것이 핵심입니다.
3. 이미지는 별도의 전략이 필요하다
이미지는 텍스트와 달리, CORS 정책, 호스팅 도메인, MIME 타입 해석 등 추가적인 제약이 많습니다.
외부 이미지는 서버사이드 프록시를 통해 처리해야 했고, 복사 시에는 image/* 타입으로 명시적으로 제공해야다른 에디터에서도 안정적으로 인식되었습니다.
복사/붙여넣기 기능은 브라우저가 알아서 처리해 주는 영역처럼 보이지만, 실제로는 에디터 간의 암묵적인 약속과 선택 위에 동작합니다.
이 문서에서 정리한 접근 방식이, 서로 다른 에디터 환경에서도 사용자가 의도한 콘텐츠를 최대한 그대로 전달하는 데 도움이 되기를 바랍니다.
