---
title: "모바일 웹 핀치 줌 직접 구현, user-scalable=no 함정과 focal point 보정 수식"
description: "user-scalable=no 가 있으면 touchmove 가 passive 로 강제되어 커스텀 핀치가 막히는 함정과, 두 손가락 중점을 고정하는 translate 보정 수식을 정리했습니다."
date: 2026-11-01
updated: 2026-11-01T06:37:43.181Z
tags: [frontend, touch, pointer-events, mobile-web]
canonical: https://blog.wooncloud.com/posts/mobile-pinch-zoom-viewport-trap-focal-math
---

이미지 뷰어에서 이미지만 핀치 줌되는 기능을 넣다가, 터치 핸들러를 붙였는데도 아무 반응이 없어서 한참 헤맸습니다. 페이지 전체가 확대되면 안 되고, 두 손가락 제스처로 이미지 요소만 `transform: scale` 되어야 하는 요구였습니다.

![user-scalable=no가 있으면 Chrome이 touchmove를 passive로 강제해 preventDefault가 통하지 않지만, 그 토큰을 걷어내고 대상에만 touch-action:none을 주면 커스텀 핀치가 동작한다.](/images/posts/mobile-pinch-zoom-viewport-trap-focal-math/cdb7d349-4e83-48fa-9004-e0e9375b0f6b.webp)

## 범인은 viewport 메타 태그였습니다

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

`user-scalable=no`(또는 `maximum-scale=1.0`)가 있으면 브라우저 기본 핀치 줌만 꺼지는 게 아닙니다. **Chrome 55 이상은 touchmove 리스너를 passive 로 강제해서 `preventDefault()` 까지 무시합니다.**

그러니 "기본 줌은 끄고 내가 직접 구현하지"가 아예 안 되는 구조입니다. JS 로 만든 커스텀 핀치가 통째로 막힙니다.

![user-scalable no 가 있으면 Chrome 이 touchmove 를 passive 로 강제해 preventDefault 가 무시되므로, viewport 에서 토큰을 걷고 컨테이너에 touch-action none 을 줘야 PointerEvents 핀치가 동작한다.](/images/posts/mobile-pinch-zoom-viewport-trap-focal-math/9020515d-63fa-4557-8b04-4ffdb8a60ffd.svg)

## 이렇게 풀었습니다

순서는 세 단계입니다.

1. viewport 에서 `user-scalable=no` 를 빼고, `maximum-scale` 은 허용치(예: 5.0)로 둡니다.
2. 페이지 전체가 다시 핀치되는 부작용은 CSS 로 막습니다. `body { touch-action: pan-x pan-y }` 면 스크롤은 되고 핀치만 금지됩니다.
3. 줌 대상 컨테이너에만 `touch-action: none` 을 주고 PointerEvents 로 핀치를 구현합니다.

## 두 손가락 중점이 고정되려면 translate 보정이 필요합니다

핀치하는 동안 두 손가락의 중점(focal point)이 화면에서 그대로인 것처럼 보이려면, scale 이 바뀔 때 translate 도 같이 보정해야 합니다. `transform-origin: center` 기준이면 이렇습니다.

```js
// ratio = nextScale / prevScale
// focal = 두 포인터의 중점, center = 컨테이너 중심 (getBoundingClientRect 기반)
nextOffset.x = (focal.x - center.x) * (1 - ratio) + prevOffset.x * ratio;
nextOffset.y = (focal.y - center.y) * (1 - ratio) + prevOffset.y * ratio;
```

## 흔히 하는 실수 두 가지

하나는 center 항을 빼먹는 겁니다. 화면 좌표(focal)만 쓰면 컨테이너가 화면 중앙에 있지 않을 때 수십 px 씩 어긋납니다. 컨테이너 중심을 기준으로 하는 좌표계로 바꿔서 계산해야 합니다.

다른 하나는 `setPointerCapture` 를 빼먹는 겁니다. 손가락이 요소 밖으로 살짝만 나가도 pointermove 가 끊겨서 핀치가 뚝뚝 끊깁니다. `pointerdown` 에서 capture 해 두면 요소 밖에서도 이벤트가 계속 들어옵니다.

## 보너스, 수치 로직은 순수함수로 뺍니다

거리에서 배율을 구하는 계산, focal 보정, 팬 클램프를 DOM 없는 순수함수로 분리하면 실기기 없이도 테스트가 됩니다. "핀치 중점은 줌 전후에 같은 콘텐츠 지점을 가리킨다" 같은 좌표 불변식을 단위 테스트로 박아 둘 수 있어서, 수식을 고칠 때 마음이 한결 편합니다.

## 참고

- [MDN Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events)
- [MDN touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)
- [Chrome Scrolling Intervention](https://developer.chrome.com/blog/scrolling-intervention)
