---
title: "뷰어에만 핀치 줌 켜기, 최상위 viewport 스왑이 iframe 문서까지 확대하는 이유"
description: "전역에서 핀치 줌을 막아 둔 앱에서 iframe 문서 뷰어만 확대되게 하려면 iframe 이 아니라 최상위 페이지의 viewport 메타를 바꿔야 하는 이유와 함정을 정리했습니다."
date: 2026-11-11
updated: 2026-11-11T10:19:26.573Z
tags: [frontend, mobile-web, iframe, viewport]
canonical: https://blog.wooncloud.com/posts/scoped-native-pinch-zoom-through-iframes
---

SPA 전역에서 핀치 줌을 꺼 둔 앱에서 문서 뷰어만 손가락으로 확대하게 해 달라는 요구가 들어왔습니다. 목록 화면의 드래그 선택(lasso)과 더블탭이 핀치와 충돌해서, `index.html` 에 확대를 일부러 막아 둔 앱이었습니다.

```html
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
```

문서(PDF, 오피스 등)는 서드파티 뷰어를 `<iframe>` 으로 임베드해서 보여 주고 있었습니다.

## 문제가 두 개 겹쳤습니다

먼저 그 뷰어에는 자체 핀치 핸들러가 없고, 브라우저의 기본 핀치 줌에 기대고 있었습니다. 페이지에 `user-scalable=no` 가 걸려 있으면 네이티브 핀치가 죽으니 문서도 확대되지 않습니다. 서드파티라 뷰어 안에 PointerEvents 핀치를 주입할 수도 없었습니다. 제가 제어하는 DOM 이 아니니까요.

![iframe 자신의 viewport 메타를 바꿔도 핀치 허용 여부는 변하지 않지만, 최상위 부모 페이지의 viewport 메타를 바꾸면 iframe 안의 문서까지 확대된다.](/images/posts/scoped-native-pinch-zoom-through-iframes/c7350c26-9ab4-4313-bf98-4fe0b3cda4a0.webp)

그렇다고 전역에서 `user-scalable=no` 를 빼면 목록 화면까지 확대되어 기존 UX 가 깨집니다.

그럼 확대 대상이 iframe 안의 문서니까 iframe 의 viewport 메타를 바꿔야 할까요? 직관적으로는 그럴 것 같지만, 아무 효과가 없습니다.

## 바꿀 곳은 iframe 이 아니라 부모입니다

레버는 하나입니다. **뷰어가 열려 있는 동안에만 최상위(부모) 페이지의 viewport 메타에서 확대 금지 토큰을 빼고, 닫으면 원래대로 복원합니다.** iframe 은 건드리지 않습니다.

모바일 핀치 줌은 최상위 document 의 visual viewport 에 대한 브라우저 제스처입니다. 합성된 페이지 전체를 스케일하는 시각적 변형이라서, 그 안에 박힌 same-origin iframe 의 렌더 픽셀까지 같이 확대됩니다.

iframe 자신의 viewport 메타는 iframe 내부의 레이아웃 폭만 정할 뿐, 부모가 핀치를 허용하는지와는 무관합니다. 그래서 부모만 바꾸면 iframe 문서가 확대됩니다.

![핀치 허용 여부는 최상위 visual viewport 를 가진 부모 페이지의 viewport 메타가 정하므로, 부모에서 확대 금지를 풀면 iframe 렌더 픽셀까지 확대되고 iframe 자신의 메타는 관여하지 않는다.](/images/posts/scoped-native-pinch-zoom-through-iframes/63977f1a-9f27-4320-8e75-fe2367df172a.svg)

뷰어 컴포넌트의 라이프사이클에 묶으면 끝입니다.

```ts
import { useEffect } from "react";

// 확대 허용 viewport. 확대 금지 토큰(maximum-scale=1.0, user-scalable=no)만 제거한다.
const ZOOMABLE = "width=device-width, initial-scale=1.0, viewport-fit=cover";

export function usePinchZoom(active: boolean): void {
  useEffect(() => {
    if (!active) return;
    const meta = document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
    if (!meta) return;
    const previous = meta.content;   // 마운트 시점의 현재 값 저장
    meta.content = ZOOMABLE;
    return () => {
      meta.content = previous;       // 닫으면 원본 복원, 목록은 다시 확대 금지
    };
  }, [active]);
}
```

마운트 시점의 `content` 를 그대로 저장했다가 복원하니 React StrictMode 의 이중 마운트(mount, unmount, mount)에도 `previous` 가 오염되지 않습니다. 뷰어를 열어 둔 채 prev/next 로 문서를 넘겨도 `active` 가 계속 `true` 라서 effect 가 다시 돌지 않고, 줌 상태가 유지됩니다.

## 함정이 몇 개 있습니다

복원이 곧 줌 리셋은 아닙니다. 엔진마다 다릅니다. 닫을 때 `user-scalable=no`(또는 낮은 `maximum-scale`)를 다시 박으면 Blink(안드로이드 Chrome, WebView)는 현재 줌을 1배로 맞춰 줍니다. 그런데 iOS WKWebView 는 로드 뒤의 동적 viewport 변경을 무시하는 편차가 있어서, 확대한 채 닫으면 목록이 확대된 상태로 남을 수 있습니다. 실기기 검증이 필수입니다.

뷰어가 `position: fixed` 풀스크린 오버레이라면 또 하나 걸립니다. 확대하고 패닝하면 오버레이의 닫기 버튼이 visual viewport 밖으로 밀립니다(고정 요소는 layout viewport 기준이라서요). 핀치 아웃하면 다시 보이지만, 하드웨어 Back 이나 ESC 같은 비시각적 탈출구를 꼭 남겨야 합니다. 없으면 사용자가 갇힌 느낌을 받습니다.

반대 경우도 있습니다. 내 앱 자체가 다른 호스트 페이지의 iframe 으로 박혀 있다면, 핀치를 통제하는 건 최상위 호스트 페이지의 viewport 입니다. 이때 내(중첩된) viewport 를 바꿔 봐야 아무 일도 안 일어납니다. 같은 메커니즘의 반대편이죠.

## 언제 이 방법을 쓸까요?

확대 대상이 제가 제어할 수 없는 서드파티 콘텐츠(iframe)라면, 네이티브 핀치의 범위를 좁히는 이 방법이 사실상 유일한 레버입니다.

내 DOM 안의 요소 하나(이미지 등)만 확대하고 페이지는 그대로 두고 싶다면 이야기가 다릅니다. `user-scalable=no` 는 Chrome 55 이상에서 touchmove 를 passive 로 강제해서 커스텀 `preventDefault()` 까지 막습니다. 그래서 viewport 에서 그걸 빼고, `touch-action: none` 과 PointerEvents 로 직접 핀치를 구현하는 쪽이 맞습니다.

둘은 배타적이지 않고, 콘텐츠를 내가 제어하느냐로 갈립니다.

## 참고

- [MDN Visual Viewport API](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API)
- [MDN Viewport meta tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag)
- [Chrome Scrolling Intervention](https://developer.chrome.com/blog/scrolling-intervention)
