왓챠 웹 PiP 적용기 — Document PiP는 어떻게 동작하고, 무엇이 어려웠나
2026년 6월 18일
원문에서 보기 ↗
1부에서 "왓챠 웹이 PiP를 지원한다"고 알려드렸습니다. 이번 편은 실제 적용기 입니다. 특히 DRM 영상·자막·왓챠파티가 얽힌 실서비스에 붙이는 건 전혀 다른 일이었습니다.
📑 목차
-
들어가기 전에 --- 웹 PiP에는 두 종류가 있습니다 (#b1c2)
-
동작 원리 --- Document PiP는 어떻게 가능한가 (#f285)
-
적용기 ①"옮기는 한 줄"이 React에선 안 통한다 (#8117)
-
적용기 ② "안 끊긴다"던 영상이, 가끔 굳는다 (#5cfd)
-
적용기 ③ 'X'를 누르면 끝내는 걸까, 크게 보는 걸까 (#d231)
-
요약 --- 적용 순서 한 눈에 보기 (#261d)
-
Q&A --- 한 발 더 들어가기 (#d392)
-
못다 한 것 (#27f5)
들어가기 전에 --- 웹 PiP에는 두 종류가 있습니다
바로 Standard PiP와 Document PiP입니다. 이 둘은 할 수 있는 일이 다릅니다.
📌 용어 안내. 사실 "Standard PiP"는 공식 명칭이 아닙니다. 영상 하나만 띄우는 원래 API의 정식 이름은 그냥 Picture-in-Picture API 이고, 나중에 나온 Document Picture-in-Picture API 가 이를 확장한 것입니다. 그런데 둘 다 "PiP"라 부르면 구별이 안 돼서, 이 글에선 앞엣것을 편의상 Standard PiP로 부르겠습니다.
핵심 차이는 **"무엇을 떼어내느냐"**입니다.
- Standard PiP는 브라우저가 <video> 하나만 floating 창으로 띄웁니다. 간단한 대신 우리가 DOM으로 그리는 자막, 커스텀 컨트롤, 다음 화 버튼은 따라가지 못합니다.
- Document PiP는 진짜 별도의 window를 열어줍니다. 그 안에 우리 DOM을 통째로 옮기면 자막도 컨트롤도 그대로 살아 있습니다. 대신 Standard PiP보다 관리가 복잡합니다.
왓챠는 왓챠만의 브랜딩된 UI와 자막 을 그려야 하기 때문에, 되도록 Document PiP를 지원하기로 했습니다. 그리고 Document PiP를 지원하지 않는 Safari 등에서는 Standard PiP로fallback시켰습니다.
동작 원리 --- 영상을 옮기는데 왜 안 끊길까
앞서 "Document PiP는 우리 DOM을 통째로 새 창으로 옮긴다"고 했습니다. 크롬 공식 가이드의 예시를 보면 꽤 간단하게 적용할 수 있습니다.
pipButton.addEventListener('click', async () => {
const player = document.querySelector("#player");
// PiP 창을 연다
const pipWindow = await documentPictureInPicture.requestWindow();
// 플레이어를 PiP 창으로 옮긴다 ← 이 한 줄이 핵심
pipWindow.document.body.append(player);
});
📄 출처: 크롬 공식 가이드 --- Document Picture-in-Picture
💡 직접 확인해보기 크롬이나 엣지를 쓰신다면, 지금 개발자도구(F12) 콘솔에 이렇게 쳐보세요. documentPictureInPicture 진짜로 브라우저에 내장된 기능이라는 걸 바로 볼 수 있습니다. 반대로 Safari나 Firefox에서 쳐보면 undefined 가 나옵니다 --- 아직 이 API를 지원하지 않기 때문이죠.
다시 코드로 돌아와보면 requestWindow()로 창을 열고, append(player)로 플레이어를 그 창에 옮긴다. 끝입니다. 말은 간단하지만, 생각해보면 이상한 일입니다.
재생 중인 영상을 다른 창으로 뜯어 옮기는데, 어떻게 끊기지 않을까요?
스펙과 브라우저 코드를 열어보면, 두 가지 장치가 맞물려 이걸 가능하게 합니다.
① 어떻게 우리 코드가 새 창(pipWindow)을 직접 채울 수 있나?
본인인증을 붙여본 개발자라면 이런 흐름이 익숙할 겁니다. 인증 창을 하나 띄우고 → 사용자가 그 안에서 인증을 끝내면 → 결과만 콜백으로 돌려받습니다. 정작 그 창 안에서 무슨 일이 벌어지는지 우리가 들여다보거나 손댈 수는 없죠. 그 창은 인증기관(다른 회사)의 페이지라 출처(origin)가 다르기 때문 입니다. 우리에겐 "열고, 결과를 받는" 정도의 통제권만 있습니다.
그런데 위 코드는 pipWindow.document.body에 우리 요소를 아무렇지 않게 붙입니다. 통제권이 거의 전부 우리에게 있는 셈이죠. 가능한 이유는, PiP 창의 주소가 about:blank(빈 페이지)이고 웹 표준상 빈 페이지는 자기를 연 페이지의 출처를 그대로 물려받기 때문입니다. 즉 본인인증 창과 달리, PiP 창은 우리와 출처가 같습니다. 남의 페이지가 아니라 사실상 우리 페이지의 연장이라, 그 안을 자유롭게 채우고 주무를 수 있습니다.
"This new window will be much like a blank same-origin window opened via the existing open() method on Window..." --- blank same-origin window
② 그런데 옮기는 순간 왜 안 멈추나?
영상을 새 창으로 옮기려면, 일단 옛 창의 DOM에서 빼야 합니다. 그런데 웹 표준에는 **"영상이 문서에서 빠지면 일시정지하라"**는 규칙이 있습니다. 상식적으로는 빼는 순간 멈춰야 맞습니다.
그런데 안 멈춥니다. 비밀은 그 규칙의 원문에 있습니다.
"Await a stable state, allowing the task that removed the media element from the Document to continue.""If the media element is in a document, return. ""Run the internal pause steps for the media element."* ---* HTML 표준, media playback
해석하면 이렇습니다. "안정 상태(stable state)가 될 때까지 일시정지를 미룹니다. 그리고 그때 확인합니다 --- 이 영상이 아직 어떤 문서엔가 붙어 있다면, 멈추지 않고 그대로 둔다(return)."
크롬의 렌더링 엔진 Blink는 이 "미루기"를 지연 시간이 0인 타이머 로 구현해 뒀습니다. "0"이라고 하면 "즉시"처럼 들리지만 사실은 반대입니다 --- 지금 당장 이 아니라, 지금 하던 일(영상을 빼서 새 창에 넣는 작업)이 다 끝난 바로 다음 차례에 실행하라는 뜻이거든요. 그 덕분에 일시정지 판단이 영상 이동이 끝난 뒤로 밀리게 되는 거죠.
// third_party/blink/renderer/core/html/media/html_media_element.cc
void HTMLMediaElement::RemovedFrom(ContainerNode& insertion_point) {
// 바로 멈추지 않고, 지연 0 타이머로 '정리'를 다음 차례로 미룬다.
// (스펙의 "Await a stable state"에 해당)
removed_from_document_timer_.StartOneShot(base::TimeDelta(), FROM_HERE);
...
}
🔗 Blink 소스에서HTMLMediaElement::RemovedFrom 함수 보기
그리고 우리가 영상을 옮기는 작업은 "예전 창에서 빼기 → 새 창에 넣기"가 동기적으로 일어납니다.
여기서 append가 하는 일을 한 번 짚고 가겠습니다. 이건 영상을 새로 만드는 게 아닙니다. 같은 영상이 어느 문서에 속하는지(소속)만 바꿉니다. 새 영상을 띄우는 게 아니라, 보던 그 영상이 집만 옮기는 셈이죠. 표준에서는 이 동작을 " 입양(adopt)" 이라고 부릅니다 --- 이 글 뒤에서도 계속 나올 개념이니 이름만 기억해두면 좋습니다.
빼기와 넣기가 이렇게 한 번에 끝나기 때문에, 타이머가 뒤늦게 깨어나 영상을 확인하는 순간엔 상황이 이미 끝나 있습니다.
"멈출까? ...어, 아직 문서 안에 들어가 있네. 그럼 둬."
그래서 멈추는 일은 일어나지 않습니다.
이 둘이 맞물린 결과, PiP 창은 브라우저가 평범하게 그려주는 진짜 웹페이지 가 됩니다. 그 안엔 영상도 자막도 버튼도 평범한 HTML로 들어 있으니 전부 그대로 보입니다.
단, 공짜는 아닙니다. 출처는 같아도 엄연히 다른 페이지라, 스타일(CSS)은 자동으로 공유되지 않습니다. 그래서 원래 페이지의 스타일을 새 창으로 한 번 복사해 넣어줘야 플레이어가 똑같이 보입니다.
우리 코드에선 copyStyleSheets가 이 일을 하는데, 자세한 건 뒤 적용기에서 다루겠습니다.
적용기 --- 교과서대로 되지 않았다
동작 원리에서 본 코드는 짧았습니다. 창 열고, append로 영상 옮기면 끝. 이론은 그렇습니다. 하지만 그건 <video> 하나만 있는 교과서 예제 얘기고, 왓챠 플레이어엔 React·DRM·자막·같이보기가 얽혀 있습니다.
먼저 동작 원리에서 예고한 스타일 복사부터. 이건 다행히 쉬웠습니다. PiP 창은 별개 페이지라 CSS가 안 따라오니, 원래 페이지의 스타일시트를 새 창에 한 번 복사해 넣습니다.
// usePiP.ts
function copyStyleSheets(target: Document) {
for (const sheet of Array.from(document.styleSheets)) {
try {
const cssRules = Array.from(sheet.cssRules).map(r => r.cssText).join("");
const styleEl = target.createElement("style");
styleEl.textContent = cssRules;
target.head.appendChild(styleEl);
} catch {
// 다른 사이트에서 온 스타일은 내용을 못 읽으니, 주소만 알려줘 새 창이 직접 받게 한다
if (sheet.href) {
const link = target.createElement("link");
link.rel = "stylesheet";
link.media = sheet.media.toString();
link.href = sheet.href;
target.head.appendChild(link);
}
}
}
}
대부분의 스타일은 텍스트를 그대로 읽어 새 창에 복사합니다. 다만 다른 사이트(CDN 등)에서 불러온 스타일 은 브라우저가 보안상 그 내용을 읽지 못하게 막습니다. 이런 건 텍스트를 베끼는 대신, 새 창에 "이 주소에서 직접 받아와"라고 <link>만 달아주면 됩니다. 이 부분은 크롬 공식 가이드의 예제와 거의 같습니다.
여기까진 순조로웠습니다. 하지만 곧 3가지 문제를 마주했습니다.
문제 ① append(player) 한 줄을 그대로 쓸 수 없다
동작 원리에서 본 그 한 줄, pipWindow.document.body.append(player)를 기억하실 겁니다. 왓챠에선 이 한 줄을 그대로 쓸 수 없었습니다.
증상. 단순하게 영상을 PiP 창으로 옮겼더니, PiP에 들어갈 때마다 인트로 스킵 버튼처럼 "처음 한 번만 떠야 할 요소"들이 다시 나타났습니다.
PiP로 전환할 때마다 뜨는 오프닝 건너뛰기 팝업 (DRM 때문에 검정 화면으로 보여요)
원인. React는 자신이 그리는 화면 조각을 "DOM의 어느 노드 안에 그릴지" , 즉 portal의 container 기준으로 추적합니다. PiP 창으로 옮기겠다고 createPortal의 container를 PiP 창으로 바꾸면, React는 이걸 "기존 트리를 unmount하고 새 위치에 다시 mount하라" 로 처리합니다. container가 바뀐 portal은 기존 DOM을 옮겨주는 게 아니라, 헐고 새로 짓습니다. 컴포넌트가 새로 mount되니 내부 useState가 전부 초기값으로 돌아가고(remount), 그래서 이미 닫았던 인트로 스킵 버튼이 다시 떠버립니다.
해결. React가 그리는 컨테이너는 손대지 않고, 그 컨테이너 통째로 PiP 창에 옮깁니다.
// 컨테이너 div는 최초 1회만 만든다 — 끝까지 같은 객체
const [portalContainer] = useState(() => {
const el = document.createElement("div");
el.className = styles.portalContainer;
return el;
});
// PiP가 켜지면 이 div를 PiP 창으로, 꺼지면 원래 자리로 그대로 옮긴다
useLayoutEffect(() => {
const target = pipWindow ? pipWindow.document.body : portalAnchor;
target?.appendChild(portalContainer);
}, [pipWindow, portalAnchor, portalContainer]);
// React가 보는 portal의 목적지는 '언제나 같은 portalContainer'
createPortal(playerSubtree, portalContainer);
핵심은, React가 보기엔 목적지(portalContainer)가 한 번도 안 바뀐다는 점입니다. 실제 div는 다른 창으로 이사 갔어도, React는 "아무것도 안 바뀌었네"라고 여겨 컴포넌트 상태를 그대로 보존합니다.
별도 팝업없이 재생이 정상적으로 이어짐 (DRM 때문에 검정 화면으로 보여요)
문제 ② "안 끊긴다"던 영상이, 가끔 굳는다
동작 원리에서 "영상을 옮겨도 재생이 안 끊긴다"고 했습니다. 표준대로라면 맞습니다. 그런데 현실의 브라우저는 표준만큼 깔끔하지 않았습니다.
증상. PiP 상태에서 다른 콘텐츠를 재생하거나 창을 오갈 때, 소리는 나는데 화면이 한 프레임에 멈추는 일이 가끔 생겼습니다.
원인. video 요소를 다른 창으로 옮기면, media clock( currentTime)은 계속 진행하는데 새 프레임이 화면에 그려지지 않는 상태가 생깁니다. 재생은 논리적으로 흐르지만, decode되어 표시된 프레임 수( getVideoPlaybackQuality().totalVideoFrames)가 늘지 않는 거죠. 가장 까다로운 건 이걸 "아직 로딩 중이라 프레임이 없는 것" 과 구별하는 일이었습니다. 둘 다 프레임은 안 느는데, 앞은 굳은 것이고 뒤는 정상이거든요. 그래서 currentTime 하나가 아니라 프레임 수와 readyState(첫 프레임 데이터 도착 여부) 를 함께 봐야 했습니다.
해결. "몇 초쯤 기다렸다 다시 틀어보자"는 식의 추측 대신, 실제로 화면이 진행 중인지를 주기적으로 확인하는 복구 루프 를 만들었습니다. 판단 기준은 재생 시간이 아니라 디코딩된 프레임 수입니다. (화면이 굳으면 프레임 수는 안 늘어납니다.)
// 화면 진행도 = 디코딩된 프레임 수 (없으면 재생 시간으로 fallback)
// eps = 진행으로 인정할 최소 변화량 (프레임이면 0.5, 시간이면 0.01)
function readProgress(video) {
const frames = video.getVideoPlaybackQuality?.().totalVideoFrames;
if (typeof frames === "number") return { eps: 0.5, value: frames };
return { eps: 0.01, value: video.currentTime };
}
그리고 0.5초마다 상태를 보고, 단계적으로 대응합니다.
- 일시정지 중이거나 아직 로딩 중이면 → 건드리지 않음 (정상)
- 프레임이 늘고 있으면 → 정상, 그냥 둠
- 정체가 이어지면 → 우선 **작은 강제 시크(0.05초)**로 디코더를 깨움
- 그래도 안 살아나면(3회) → 요소를 통째로 새로 만들어 교체
- 6초 안에 복구 못 하면 → 포기하고 감시 종료
이 숫자들은 이론이 아니라 관측에서 나온 경험치라, 환경에 따라 다를 수 있습니다.
무엇보다 "아직 로딩 중이면 건드리지 않는다"가 중요합니다.
PiP 창에서 새 콘텐츠(다음 화 등)를 막 불러오는 중엔 아직 첫 프레임이 디코딩되기 전이라, 프레임 수가 0인 게 당연합니다. 그런데 이걸 "굳었다"로 오인하면:
정체로 판정 → 요소를 새로 만듦 → 로딩 중이던 걸 버리고 0%부터 다시 받음 → 또 첫 프레임 전이라 프레임 0 → 또 정체로 오인 → 또 새로 만듦 → ...
항상 처음으로 돌아가는 루프가 됩니다. 실제로 윈도우 엣지에서 재현됐습니다.
에피소드를 변경하면 무한 로딩이 발생함
한 줄짜리 가드로 해결할 수 있습니다. 바로 첫 프레임 데이터조차 없으면 return하고 로딩 중 상태로 간주하는 것입니다.
// 아직 첫 프레임 데이터도 없으면 = 버퍼링 중. 정지가 아니므로 개입하지 않는다.
// (여기서 요소를 새로 만들면 로딩을 폐기하고 0%부터 재로드 → 무한 루프)
if (video.readyState < video.HAVE_CURRENT_DATA) {
return; // 시간도 세지 않고 그냥 지켜본다
}
이로써 표준이 약속한 "안 끊김"과 브라우저의 실제 동작 사이의 틈을 메울 수 있었습니다.
문제 ③: 'X' 를 누르면 끝내는 걸까, 크게 보는 걸까
PiP 창에서 빠져나가는 길은 두 가지입니다.
- 완전 종료 --- 영상을 끄고 PiP를 닫는다
- 크게보기 --- PiP만 닫고 원래 탭의 큰 화면으로 돌아간다
이 둘을 우리 UI에 그리고 싶었는데, 막상 보니 각 버튼에 대한 통제권이 달랐습니다.

크게보기 는 끌 수 있는 옵션(disallowReturnToOpener)이 있습니다. 마침 우리 PiP UI에 같은 기능 버튼을 직접 그리고 있으니, 브라우저 기본 버튼은 중복이라 우리 버튼으로 대체했습니다.
닫기(X)는 없앨 옵션이 없습니다. 그래서 우리 UI엔 닫기 버튼을 따로 만들지 않고, 브라우저의 X를 유일한 닫기로 두고, 창이 닫히는 이벤트(pagehide)를 받아 플레이어를 종료했습니다.
실제 적용된 모습
Safari는 통제권이 없다
Safari에 적용된 Standard PiP는 이 통제권이 거의 다 없습니다. 창에 영상만 떠서 우리 버튼을 그릴 수도 없고, 버튼을 바꿀 옵션도 없습니다. 대신 브라우저 기본 닫기가 곧 "크게보기"(탭 복귀) 로 동작하기 때문에, 그 동작에 그대로 따라가도록 두었습니다.
적용 순서 한 눈에 보기
0. 지원을 감지해 경로를 정한다
- documentPictureInPicture 있으면 → A. Document PiP
- 없으면 → B. Standard PiP
A. Document PiP 경로
- requestWindow()로 창을 연다 (크기는 플레이어 비율에 맞춰)
- 메인 문서의 스타일을 새 창으로 복사한다
- portal container 요소를 새 창 body로 옮겨 붙인다
- 새 창에서 재생 진행(프레임 수)을 주기적으로 확인하는 감시를 건다
- 창이 닫힐 때(pagehide) video를 메인 문서로 되돌린다
B. Standard PiP 경로 (Safari 등)
- video.requestPictureInPicture()를 호출한다
Q&A --- 한 발 더 들어가기
Q. PiP 창은 별도의 창인데, 우리 JS가 어떻게 그 안의 요소를 계속 제어하나요?
PiP 창은 우리 페이지와 같은 출처(same-origin) 의 window이고, requestWindow()가 그 창의 핸들을 직접 돌려줍니다. window.open()으로 같은 출처 창을 열면 그 창의 document를 스크립트로 만질 수 있는 것과 똑같습니다. 그래서 로직은 전부 원래 탭에서 돌고 , PiP 창은 그 결과를 보여주는 화면 역할만 합니다. "두 창이 코드를 나눠 갖는다"가 아니라, 하나의 JS가 두 창의 DOM을 동시에 다루는 겁니다.
Q. 그럼 JS나 이벤트 핸들러도 새 창에 다시 넣어줘야 하나요? 왜 스타일만 복사하죠?
JS는 넣을 필요가 없습니다. 우리는 요소를 새로 만드는 게 아니라 기존 요소를 새 문서로 옮길(adopt) 뿐이라, 같은 요소 객체에 걸려 있던 핸들러와 상태가 그대로 따라갑니다. 반면 CSS는 따라오지 않습니다. 스타일 규칙은 노드가 들고 있는 게 아니라 문서의 스타일시트와 대조해 계산되는 값이라, 새 문서로 가면 (비어 있는) 그 문서 기준으로 다시 계산돼 스타일이 빠집니다. 그래서 스타일만 손수 복사합니다. (스타일을 안 넣으면 기능은 다 되고 화면만 깨진 상태가 됩니다.)
Q. CSS도 브라우저가 자동으로 가져와주면 편하지 않나요?
재밌게도 초기 스펙엔 실제로 copyStyleSheets같은 옵션이 있었습니다. 그런데 빠졌어요. "복사"라는 게 생각보다 애매했거든요 --- <style>·<link>·adoptedStyleSheets 중 뭘 복사할지, 텍스트를 그대로(오타·공백까지) 베낄지 다시 만들지, 로딩 실패한 시트는 어쩔지... 깔끔하게 정의가 안 됐습니다. (실제 제기된 이슈) 게다가 어차피 JS로 할 수 있는 일이다보니 빠지게 된 것 같습니다.
Q. 이렇게 창을 옮겨다니는데, 그럼 두 창이 재생 상태를 공유하는 건가요? PiP로 한참 보다가 돌아와도 보던 위치 그대로던데요.
공유한다기보다는, 사실 video가 하나뿐이라 그렇습니다.
PiP로 가든 다시 돌아오든, 우리는 새 video를 만드는 게 아니라 보고 있던 그 video를 통째로 들고 다닙니다. 재생 위치나 버퍼 같은 건 그 video가 직접 쥐고 있는 값이라, 요소만 따라오면 위치도 자연히 따라옵니다.
그래서 위치를 어디 저장했다 복원하거나, 두 창을 맞춰주는 작업 같은 건 아예 없습니다. 맞출 상대가 없으니까요. 앞에서 컴포넌트 상태가 안 날아갔던 것(문제 ①)도 같은 이유고요 --- 안 만들고 그대로 옮기면, 그 안에 있던 건 다 따라옵니다.
원래 문서를 떠나 새 창으로 소속만 바뀔 뿐, 같은 요소 그대로 거든요. 그래서 DOM 표준에서도 이 동작을 "입양(adopt)" 이라고 부르나 봅니다. 새 집으로 보내도, 같은 아이니까요.
못다 한 것 --- 라이브러리로 만들고 싶었지만
사실 이걸 그대로 떼어내 라이브러리로 만들고 싶었습니다. 다른 서비스에서도 usePiP 하나로 끝날 수 있게요.
그런데 안 됐습니다. PiP가 어려운 건 API가 아니라, 재생 중인 영상 하나를 두 창이 번갈아 소유 하는데 그 영상이 DRM·같이보기·에피소드 전환 같은 우리 서비스 곳곳과 묶여 있기 때문이거든요. 떼어낼 수 있는 건 PiP 메커니즘까지였고, 정작 손많이 간 "서비스에 연결하는 부분"은 도메인에 박혀 있었습니다. 그래서 PiP가 추상화하기 어려운 기능이라 생각했습니다.
그래도 포털 트릭이나 복구 루프는 React + Document PiP를 쓰면 누구나 만나는 문제라 쓸모있을 것 같긴 합니다. 반응이 좋으면 패키지로 떼어내 공개하는 것도 고민해보겠습니다. 👀
마지막으로 --- 저희 라이브러리 있습니다.
npm에서 watcha를 검색해보면... 이미 있습니다. 그런데 그거, 저희 아닙니다. 😅
그래서 저희는 npm org를 하나 따로 팠습니다. 이름하여 @watcha-authentic --- 진짜(authentic) 왓챠입니다.
언젠가 이 org 아래에 @watcha-authentic/use-pip 같은 게 올라온다면, 그건 1편부터 여기까지 읽어주신 여러분 덕분일 겁니다. 그때까지, 왓챠 PiP 많이 써주세요. 🎬
왓챠 웹 PiP 적용기 --- Document PiP는 어떻게 동작하고, 무엇이 어려웠나 was originally published in WATCHA on Medium, where people are continuing the conversation by highlighting and responding to this story.