붙여넣으면 알아서 — 에디터에 영상이 들어왔어요
URL을 붙여넣으면 이미지·영상·링크를 알아서 분류해요. 붙여넣기 하나로 분기하는 판정 순서와, 남의 사이트를 내 페이지에 끼워 넣을 때의 보안 처리를 적었습니다.

영상을 넣고 싶은데 방법이 세 가지였어요. 그리고 사용자는 그 세 가지를 구분하고 싶어 하지 않죠.
YouTube 주소를 복사해 왔든, 영상 파일이 있든, 그냥 붙여넣으면 알아서 되기를 바랍니다. 저부터요.
그래서 목표를 이렇게 잡았어요. 붙여넣기 하나로 끝난다.
붙여넣은 게 무엇인지 판정하기
클립보드에서 온 텍스트가 무엇인지 순서대로 봅니다.
1. URL 형태가 아니다 → 그냥 텍스트로 삽입
2. 이미지 확장자로 끝난다 → 이미지 블록
3. 아는 영상 서비스 주소다 → 영상 블록 (embed)
4. 영상 파일 확장자로 끝난다 → 영상 블록 (direct)
5. 그 외 → 링크순서가 중요해요. 4번을 2번보다 먼저 두면 어떤 확장자는 잘못 잡히고, 5번을 위로 올리면 전부 링크가 됩니다.
그리고 판정에 실패하면 가장 안전한 쪽으로 떨어집니다. 모르는 건 링크예요. 링크는 어떤 주소든 표현할 수 있는 최소 형태거든요.
판정기는 이렇게 생겼어요. 순서가 곧 우선순위입니다.
function classifyPaste(text) {
const url = parseUrl(text.trim());
if (!url) return { kind: "text" };
if (IMAGE_EXT.test(url.pathname)) return { kind: "image", src: url.href };
if (VIDEO_EXT.test(url.pathname)) return { kind: "video", source: { kind: "direct", src: url.href } };
const provider = matchProvider(url); // youtube · vimeo
if (provider) return { kind: "video", source: provider };
return { kind: "link", href: url.href }; // 모르면 링크
}여기서 matchProvider를 호스트 이름 목록으로 판정해요. 정규식 하나로 URL 전체를 훑지 않습니다. 주소 안 어딘가에 youtube라는 글자가 들어 있다고 유튜브인 건 아니거든요.
// 이렇게 하면 안 됩니다
/youtube/.test(url.href)
// https://example.com/blog/youtube-review ← 걸립니다
// 호스트로 판정합니다
const YOUTUBE_HOSTS = new Set([
"youtube.com", "www.youtube.com", "youtu.be", "m.youtube.com",
]);
YOUTUBE_HOSTS.has(url.hostname)영상 블록의 계약
{
type: "video",
source: { kind: "direct" | "youtube" | "vimeo", src: "..." },
title?: "..."
}kind를 나눈 이유가 있어요. direct는 우리가 파일을 서빙하지만, 나머지는 남의 페이지를 내 페이지 안에 끼워 넣는 일이거든요. 성격이 완전히 다릅니다.
블록 계약은 이렇게 뒀어요. 필드마다 이유가 있습니다.
| 필드 | 왜 있나 |
|---|---|
| kind | 우리가 서빙하는 파일과 남의 임베드는 성격이 달라서 |
| src | 재생할 대상 |
| poster | 재생 전에 보여줄 이미지 — 없으면 검은 상자가 남음 |
| aspectRatio | 높이를 미리 잡아서 레이아웃이 안 튀게 |
| caption | 설명이자 접근성 텍스트 |
네 번째가 실무적으로 제일 자주 잊히는 항목이에요. 비율을 안 적어 두면 영상이 로드되는 순간 아래 글이 통째로 밀립니다. 읽던 문장이 사라지는 거죠.
그리고 kind를 나눈 게 뒤에 나올 보안 처리의 갈림길이 돼요. direct는 우리 파일이라 우리가 책임지고, 나머지는 남의 페이지를 내 페이지 안에 끼워 넣는 일이니까요.
왜 붙여넣기에 이렇게 공을 들였나
기능 목록으로 보면 "영상 넣기"는 한 줄이에요. 그런데 실제로 시간을 쓴 곳은 영상이 아니라 붙여넣기였습니다.
이유는 이 블로그의 사용자가 저만이 아니기 때문이에요. 가족이 씁니다.
가족에게 "이건 이미지 버튼, 이건 영상 버튼, 이건 링크 버튼"을 설명하고 싶지 않았어요. 설명이 필요한 순간 그 기능은 안 쓰이거든요.
| 버튼으로 나눴을 때 | 붙여넣기 하나일 때 |
|---|---|
| 무엇을 넣을지 먼저 판단 | 일단 붙여넣기 |
| 잘못 고르면 지우고 다시 | 알아서 분류 |
| 버튼이 늘수록 도구 모음이 복잡 | 도구 모음 그대로 |
| 새 종류가 생기면 버튼 추가 | 판정 규칙만 추가 |
마지막 줄이 설계 관점의 이득이에요. 나중에 다른 종류가 생겨도 사용자가 배울 게 없습니다.
판정이 틀렸을 때 어떻게 되나
자동 판정에는 항상 오판이 따라옵니다. 그래서 오판했을 때 어떻게 되는지가 중요해요.
여기서 원칙을 하나 뒀습니다. 오판의 비용이 비대칭이면 안전한 쪽으로 기울인다.
| 오판 | 결과 | 심각도 |
|---|---|---|
| 영상인데 링크로 판정 | 링크가 하나 생김 — 클릭하면 보임 | 낮음 |
| 링크인데 영상으로 판정 | 임베드가 깨져서 빈 상자 | 높음 |
| 이미지인데 링크로 | 링크가 하나 생김 | 낮음 |
그래서 확신이 없으면 링크로 떨어뜨립니다. 링크는 최악의 경우에도 "클릭하면 되는 것"이라서 사용자가 복구할 수 있어요.
반대로 임베드로 잘못 판정하면 빈 상자가 남는데, 사용자는 그게 왜 안 나오는지 알 방법이 없습니다.
(자동화의 품질은 맞힐 때가 아니라 틀릴 때 드러나요.)
그리고 붙여넣기는 서식도 가져옵니다
한 가지 더 있었어요. 다른 곳에서 복사한 텍스트에는 서식이 딸려 옵니다. 웹페이지에서 복사하면 폰트, 색, 크기까지요.
그대로 받으면 글이 누더기가 돼요. 남의 사이트 스타일이 제 블로그 안에 섞이거든요.
그래서 붙여넣을 때 우리 문서 형식으로 다시 해석합니다. 우리가 아는 구조 — 문단, 제목, 목록, 코드 — 로만 번역하고 나머지 서식은 버려요.
버리는 게 손해 같지만 반대예요. 문서 형식이 우리 것이라는 원칙을 지키는 지점이 여기입니다. 붙여넣기 한 번에 남의 형식이 들어오면 그 원칙이 무너지거든요.
서식 정화는 허용 목록 방식이에요. 지울 것을 세는 게 아니라 남길 것만 셉니다.
// 우리가 아는 구조로만 번역합니다
const ALLOWED = {
P: "paragraph", H1: "heading", H2: "heading", H3: "heading",
UL: "bulletList", OL: "orderedList", LI: "listItem",
PRE: "codeBlock", CODE: "codeBlock",
A: "link", STRONG: "bold", EM: "italic",
};
// 목록에 없으면 자식만 꺼내고 자기는 버립니다
// <span style="color:#f00"><b>글자</b></span> → <b>글자</b>금지 목록으로 하면 언젠가 새는 게 생겨요. 웹에서 복사되는 마크업의 종류는 계속 늘어나는데, 우리가 아는 구조는 열 몇 개로 고정돼 있거든요. 적은 쪽을 세는 게 맞습니다.
버리는 게 손해 같지만 반대예요. 문서 형식이 우리 것이라는 원칙을 지키는 지점이 여기입니다. 붙여넣기 한 번에 남의 형식이 들어오면 그 원칙이 무너지거든요.
남의 화면을 끼워 넣을 때
임베드는 편한 만큼 위험합니다. 그래서 두 가지를 했어요.
| 처리 | 막는 것 |
|---|---|
| 주소를 추적 없는 도메인으로 정규화 | 방문자가 영상 서비스에 추적당하는 것 |
| iframe 권한을 최소로 제한 | 끼워 넣은 페이지가 부모 페이지를 건드리는 것 |
첫 줄이 특히 신경 쓰였어요. 독자는 제 글을 읽으러 온 거지 영상 서비스에 방문 기록을 남기러 온 게 아니잖아요. 그래서 주소를 바꿔 심습니다.
두 번째는 기본 원칙이에요. 임베드에 필요 없는 권한은 다 끄고 시작합니다. 필요해지면 그때 켜요.
임베드는 이렇게 심어요.
<iframe
src="https://www.youtube-nocookie.com/embed/{id}"
sandbox="allow-scripts allow-same-origin allow-presentation"
allow="encrypted-media; picture-in-picture; fullscreen"
referrerpolicy="strict-origin-when-cross-origin"
loading="lazy"
title="{caption}"
/>
<!-- 없는 것들:
allow-popups, allow-top-navigation, allow-forms,
autoplay, camera, microphone, geolocation -->| 처리 | 막는 것 |
|---|---|
| 쿠키 없는 도메인 | 독자가 방문 기록을 남기지 않음 |
| sandbox 최소 권한 | 임베드가 상위 페이지를 이동시키거나 팝업을 띄우는 것 |
| allow 목록 최소화 | 자동 재생 · 카메라 · 위치 요청 |
| referrerpolicy | 내 글의 전체 주소가 남의 서버로 넘어가는 것 |
두 번째 줄이 실제로 있었던 사고 유형이에요. 임베드가 상위 창을 다른 데로 보내버리는 것. sandbox 없이 iframe을 쓰면 기본적으로 가능한 일입니다.
원칙은 하나예요. 임베드에 필요 없는 권한은 다 끄고 시작한다. 필요해지면 그때 켭니다.
업로드는 이미지와 같은 문법으로
파일 업로드 쪽은 새로 만들지 않았어요. 이미지가 이미 쓰던 경로를 그대로 씁니다.
그래서 사용자 입장에서는 배울 게 없어요. 이미지 넣던 방식으로 영상도 넣습니다. 새 기능이 새 사용법을 요구하지 않는 게 좋은 확장이라고 봐요.
업로드 경로를 재사용했다고 했는데, 그렇다고 검증까지 같은 건 아니에요.
| 검증 | 이미지 | 영상 |
|---|---|---|
| 확장자 | 허용 목록 | 허용 목록 |
| 실제 형식 | 파일 시그니처 확인 | 파일 시그니처 확인 |
| 최대 크기 | 작음 | 훨씬 큼 |
| 변환 | WebP로 정규화 | 원본 유지 · 썸네일만 추출 |
세 번째 줄 때문에 업로드 흐름을 조금 손봤어요. 영상은 이미지보다 훨씬 크니까 진행률을 보여줘야 하거든요. 아무 반응 없이 30초가 지나면 사용자는 실패한 줄 압니다.
그래도 사용자가 배울 건 없어요. 이미지 넣던 방식으로 영상도 넣습니다. 새 기능이 새 사용법을 요구하지 않는 게 좋은 확장이라고 봐요.
그리고 여기서 경계가 값을 했어요
영상 블록을 추가하면서 고친 곳이 어디였는지 세어봤는데, 전부 플랫폼 안쪽이었습니다.
문서 형식에 video 블록 추가 (플랫폼)
에디터에 붙여넣기 판정 추가 (플랫폼)
렌더러에 video 처리 추가 (플랫폼)
blog-* 패키지 변경 0곳TipTap을 플랫폼에 가둬둔 결과예요. 블로그는 영상이 생긴 줄도 모르고, 그런데 영상이 됩니다. 다이어리도 마찬가지고요.
경계를 잘 그으면 기능 추가가 한 곳에서 끝나요. 그게 경계의 값어치입니다.
이걸로 "쓰고, 발행하기"는 닫아요. 그런데 다 만들어놓고 보니 읽는 사람이 안 보이더라고요. 다음 시리즈는 그 이야기입니다.


