---
title: "롱프레스 드래그가 안 되는 이유 — touch-action 은 제스처가 시작될 때 확정된다"
description: "모바일 웹에서 롱프레스 드래그 구현 시 터치 동작이 스크롤로 취소되는 원인과 해결책을 다룹니다. touch-action 속성은 제스처 시작 시점에 확정되므로, 드래그 시작 순간에 preventDefault를 호출해 스크롤을 차단하고 포인터 이벤트를 제어하는 올바른 구현 방법을 상세히 설명합니다"
date: 2026-07-31
updated: 2026-07-31T10:13:35.279Z
tags: [touch-action, pointer-events, mobile, frontend]
canonical: https://blog.wooncloud.com/posts/touch-action-too-late-for-long-press-drag
---

![이미 지나간 뒤에 닫히는 차단봉](/images/posts/touch-action-too-late-for-long-press-drag/9bc3a731-2477-4ccc-b925-c092f6243720.webp)

## 상황

모바일 웹에서 "길게 눌러 드래그" 를 직접 구현했다. HTML5 Drag and Drop API 는 터치를 지원하지 않으니 Pointer Events 로 만드는 게 정석이다.

```js
// 500ms 눌러 있으면 드래그 모드로 진입
onPointerDown = (e) => {
  if (e.pointerType !== 'touch') return;
  timer = setTimeout(() => {
    setDragging(true);
    document.body.style.touchAction = 'none'; // 스크롤 잠금 (이라고 믿었다)
    createGhost(e);
  }, 500);
};
```

코드는 완벽해 보였다. 롱프레스 감지도 되고, 고스트 엘리먼트도 뜨고, `elementsFromPoint` 로 드롭 대상도 잘 찾는다. 그런데 실기기에서 **손가락을 움직이는 순간 드래그가 통째로 취소됐다.** 고스트는 사라지고 목록만 스크롤됐다.

## 원인

`touch-action` 은 **제스처가 시작되는 시점에 값이 확정된다.**

브라우저는 `touchstart`(= 첫 `pointerdown`) 시점에 해당 지점의 `touch-action` 계산값을 읽어 "이 제스처를 스크롤로 처리할지" 를 미리 정한다. 그 뒤에 값을 바꿔도 **이미 진행 중인 제스처에는 반영되지 않는다.**

그래서 위 코드의 순서가 치명적이다.

1. 손가락이 닿는다 → 브라우저: "여긴 `touch-action: auto`, 스크롤 가능한 제스처군"
2. 500ms 뒤 우리가 `touchAction = 'none'` 으로 바꾼다 → **이미 늦음**
3. 손가락이 움직인다 → 브라우저가 예정대로 스크롤 시작
4. 스크롤이 시작되면 브라우저는 포인터를 회수한다 → **`pointercancel` 발생**
5. `pointercancel` 핸들러가 드래그를 정리한다 → 드래그 종료

즉 내가 짠 정리 로직이 정상 동작한 결과였다. 범인은 `pointercancel` 이 아니라 **한 발 늦은 `touch-action`** 이었다.

> **그럼 처음부터 `touch-action: none` 을 걸면 되지 않나?**
> 그러면 그 요소 위에서 평소 스크롤이 아예 안 된다. 목록 아이템이 드래그 소스인데 리스트를 스크롤할 수 없게 되는 셈이라, 조건부로 켜는 것 외엔 방법이 없다.

## 해결

진행 중인 제스처를 막을 수 있는 건 `touch-action` 이 아니라 **이벤트 취소**다. 드래그가 실제로 시작된 순간에만 `touchmove` 를 가로채 `preventDefault()` 한다.

```js
let touchGuard = null;

const blockNativeScroll = () => {
  if (touchGuard) return;
  touchGuard = (e) => { if (e.cancelable) e.preventDefault(); };
  // passive: false 가 핵심 — 기본값(passive)에서는 preventDefault 가 무시된다
  document.addEventListener('touchmove', touchGuard, { passive: false });
};

const unblockNativeScroll = () => {
  if (!touchGuard) return;
  document.removeEventListener('touchmove', touchGuard);
  touchGuard = null;
};
```

그리고 롱프레스가 발동하는 **바로 그 순간, 다른 무엇보다 먼저** 호출한다.

```js
timer = setTimeout(() => {
  blockNativeScroll();   // 가장 먼저
  setDragging(true);
  createGhost(e);
}, 500);
```

이러면 첫 `touchmove` 가 취소되어 **스크롤이 애초에 시작되지 않고**, 따라서 `pointercancel` 도 오지 않는다. 드래그가 끝나거나 취소될 때 리스너를 반드시 해제한다.

`touchmove` 리스너를 걸 때 `{ passive: false }` 를 빼먹으면 아무 효과가 없다. 브라우저는 문서 레벨 터치 리스너를 기본 passive 로 취급하고, passive 리스너의 `preventDefault()` 는 조용히 무시된다(콘솔 경고만 뜬다).

## 같이 걸린 것들

터치 드래그를 직접 만들 때 함께 밟게 되는 지뢰가 몇 개 더 있다.

### 드롭 직후 클릭이 한 번 더 발생한다

터치는 `pointerup` 뒤에 마우스 이벤트를 합성하므로, "탭 = 열기" 인 UI 라면 옮기자마자 항목이 열린다. 드래그로 끝난 제스처의 다음 클릭 하나를 캡처 단계에서 삼킨다.

```js
function suppressNextClick() {
  const onClick = (ev) => { ev.preventDefault(); ev.stopPropagation(); };
  document.addEventListener('click', onClick, { capture: true, once: true });
  // click 이 합성되지 않는 경우도 있으니 반드시 회수
  setTimeout(() => document.removeEventListener('click', onClick, { capture: true }), 400);
}
```

### iOS 는 롱프레스에 자체 메뉴를 띄운다

복사/공유 팝업이 우리 드래그가 시작되는 바로 그 제스처를 가로챈다. `user-select: none` 만으로는 안 되고 별도 속성이 필요하다.

```css
[data-draggable] { -webkit-touch-callout: none; }
```

### 드래그 중엔 손가락으로 스크롤할 수 없다

방금 우리가 막았기 때문이다. 화면 밖 대상으로 옮기려면 가장자리 자동 스크롤을 직접 구현해야 한다. 없으면 "지금 보이는 곳으로만" 옮길 수 있는 반쪽 기능이 된다.

```js
const tick = () => {
  const r = container.getBoundingClientRect();
  const EDGE = 72, MAX = 16;
  let dv = 0;
  if (pointer.y < r.top + EDGE)         dv = -((r.top + EDGE - pointer.y) / EDGE) * MAX;
  else if (pointer.y > r.bottom - EDGE) dv =  ((pointer.y - (r.bottom - EDGE)) / EDGE) * MAX;
  if (dv) {
    container.scrollTop += dv;
    highlightDropTargetAt(pointer.x, pointer.y); // 스크롤로 대상이 바뀌므로 같이 갱신
  }
  raf = requestAnimationFrame(tick);
};
```

### 하이라이트를 DOM 속성으로만 토글했다면 CSS 규칙이 실제로 있는지 확인한다

목록 전체 리렌더를 피하려고 프레임워크 상태 대신 `setAttribute('data-drop-active', 'true')` 를 쓰는 건 좋은 최적화지만, 대응하는 스타일이 없으면 **기능은 도는데 화면엔 아무 변화가 없다.** 행 배경이 인라인 스타일이라면 `!important` 도 필요하다.

## 정리

- `touch-action` 은 **선언적 사전 설정**이다. 이미 시작된 제스처를 되돌리지 못한다.
- 진행 중인 제스처를 막는 유일한 방법은 `touchmove` + `{ passive: false }` + `preventDefault()`.
- 드래그가 "취소되는" 증상을 보면 `pointercancel` 을 의심하기 전에 **브라우저가 스크롤을 시작한 것 아닌지** 부터 확인한다. 그게 대부분의 원인이다.

## 참고

- [MDN — touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)
- [MDN — pointercancel event](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointercancel_event)
- [Chrome — Making touch scrolling fast by default (passive listeners)](https://developer.chrome.com/blog/passive-event-listeners)
- [MDN — Element.setPointerCapture()](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture)
