시간표 칸 하나 색칠하는 게 왜 이렇게 어려울까 — iOS Safari 드래그 버그 추적기
에브리프리타임에는 에타 시간표 스크린샷을 자동으로 분석해서 빈 시간/수업 시간을 판별하는 기능이 있다. 자동 인식 결과가 틀렸을 때를 위해, When2meet처럼 칸을 탭하거나 손가락으로 쓸어서(drag) 직접 수정하는 에디터를 만들어뒀다.
로컬에서 잘 돌아가길래 배포했다.
며칠 뒤 배포 후 실사용 중, 탭을 손가락으로 쓸어 수정하는 기능이 모바일 사파리에서 안 되는 것을 보고.. 빠르게 수정해야겠다고 생각했다.
여기서부터 커밋 6개, 되돌린 접근 3개, 결국 실제 원인을 찾기까지의 기록이다.
코드는 이렇게 생겼었다
// 칸 하나
<button
onPointerDown={(e) => {
e.preventDefault();
const to = !busy;
painting.current = { to };
setCell(d, s, to);
}}
onPointerEnter={() => {
if (painting.current) setCell(d, s, painting.current.to);
}}
/>
When2meet 방식 그대로다. 누른 칸에서 pointerdown으로 토글 시작, 드래그 중 지나가는 칸마다 pointerenter로 같은 상태를 칠한다. 마우스로는 완벽하게 동작했다.
1차 시도: 터치는 왜 드래그가 안 될까
첫 제보는 "탭도 드래그도 안 된다"였다. 원인을 짐작해봤다 — 터치 이벤트는 pointerdown이 일어난 요소에 암묵적으로 캡처(implicit pointer capture) 된다. 즉 손가락이 다른 칸 위로 이동해도, 그 칸의 pointerenter가 아니라 여전히 처음 눌렀던 칸에 이벤트가 묶인다. 이게 스펙에 정의된 동작이라 Chrome이든 Safari든 똑같이 이런다.
그래서 pointerenter에 기대는 대신, 컨테이너에 pointermove를 걸고 document.elementFromPoint()로 손가락이 실제로 있는 위치의 칸을 직접 찾도록 바꿨다.
onPointerMove={(e) => {
if (!painting.current) return;
const el = document.elementFromPoint(e.clientX, e.clientY);
const cell = el?.closest('[data-d]');
if (!cell) return;
setCell(Number(cell.dataset.d), Number(cell.dataset.s), painting.current.to);
}}
배포했다. -> 아직도 안됨...
2차 시도: touch-action은 상속되지 않는다
다시 코드를 봤다. touch-action: none을 그리드 컨테이너에만 걸어뒀었다. CSS 속성이니 자식 요소에 자동으로 내려갈 거라 생각했는데, touch-action은 상속 대상이 아니다. 그래서 실제로 터치를 받는 칸(<button>)은 여전히 기본값인 touch-action: auto였다.
이게 왜 문제냐면, 사람이 손가락으로 화면을 "탭"할 때 완벽하게 고정된 채로 누르는 경우는 거의 없다. 몇 픽셀은 항상 흔들린다. 부모는 스크롤을 막고 있어도 정작 손가락이 닿은 자식 요소가 스크롤을 허용하고 있으면, 브라우저는 이 애매한 제스처를 "탭"이 아니라 "스크롤 시작"으로 해석해버릴 수 있다. 결과적으로 탭 자체가 취소된다.
칸 하나하나에도 touch-action: none을 직접 넣어줬다.
<button style={{ touchAction: 'none' }} className="touch-none ..." />
이것도 배포했다. 이번엔 "크롬은 쓸어서 선택은 되고 터치는 안되고, 사파리는 선택은 되고 쓸어서 선택은 안됨."
브라우저마다 서로 다른 부분이 깨진다는 건, 브라우저 자체가 문제가 아니라 내 코드가 뭔가를 이중으로 하고 있다는 신호였다. 그런데 이때는 그걸 눈치채지 못하고 또 다른 API로 갈아탔다...
3차 시도: Pointer Events를 버리고 Touch/Mouse로
"Pointer Events가 사파리에서 불안정한가?" 싶어서 아예 native touchstart/touchmove와 mousedown/mouseenter를 따로 등록하는 방식으로 갈아엎었다. 이러면 최소한 각 API가 뭘 하는지는 명확히 통제할 수 있으니까.
문제는 터치 이벤트와 마우스 이벤트를 동시에 걸어두면, 브라우저가 터치 다음에 "호환용 마우스 이벤트"를 자동으로 한 번 더 합성해서 쏜다는 점이었다. 스펙상 터치 인터랙션이 끝나면 mousemove → mousedown → mouseup → click이 순서대로 뒤따라온다. touchstart에서 이미 칸을 토글했는데, 뒤이어 오는 합성 mousedown이 같은 로직을 한 번 더 실행하면 — 토글 → 토글로 상쇄되어 아무 일도 없었던 것처럼 보인다.
이걸 막으려면 touchstart에서 preventDefault()를 호출해서 뒤따르는 합성 이벤트 자체를 취소시켜야 한다. 그런데:
React는
touchstart/touchmove리스너를 passive로 등록한다. 그 안에서preventDefault()를 불러도 조용히 무시된다.
이건 React 16 즈음부터 있던 최적화다. 브라우저가 스크롤 성능을 위해 터치 리스너를 기본적으로 passive로 취급하는 흐름에 맞춘 것인데, 덕분에 React의 onTouchStart={...} prop 안에서 e.preventDefault()를 불러봐야 실제로는 아무 효과가 없다. 콘솔에 경고만 찍힌다.
Unable to preventDefault inside passive event listener invocation.
이 한 줄을 실제로 콘솔에서 본 게 이번 추적에서 가장 결정적인 단서였다.
제대로 된 테스트 설계의 중요성 (인간 QA를 믿지말자..)
여기까지 세 번의 수정이 전부 빗나갔다. 매번 폰을 들고 재현해봐야 했고, 당연히 화가 났다.
이때부터 방식을 바꿨다. playwright-core + 로컬 Chrome/WebKit 바이너리로 실제 브라우저에 터치 이벤트를 주입해서, 배포 전에 재현 코드로 직접 확인하기로 했다.
// 실제 터치처럼 touchstart → touchmove(연속) → touchend 를 한 프레임 안에서 쏜다
cells[0].dispatchEvent(mkTouch('touchstart', cells[0], first.x, first.y));
for (let s = 1; s < 10; s++) {
cells[0].dispatchEvent(mkTouch('touchmove', cells[0], pt(cells[s]).x, pt(cells[s]).y));
}
cells[0].dispatchEvent(mkTouch('touchend', cells[0], 0, 0));
10칸을 드래그로 쓸었을 때 실제로 몇 칸이 칠해지는지 세어보는 테스트를 만들었다. 기대값은 10.
한 태스크 내 연속 touchmove 10칸 → 1 칸 칠해짐 (기대: 10)
배열: 0000000000 0000000000 0000000001 0000000000 0000000000
1칸. 마지막 칸만 남는다.
진짜 원인
여기서 진짜 원인이 드러났다. 이벤트 API 문제가 전혀 아니었다.
function setCell(d, s, to) {
if (value[d]?.[s] === to) return;
const next = value.map((row) => row.slice()); // ← 렌더 시점의 value 를 기준으로
next[d][s] = to;
onChange(next);
}
touchmove는 리액트의 리렌더 사이클보다 훨씬 빠르게, 한 프레임에 여러 번 몰아쳐 들어올 수 있다. 그런데 setCell은 매번 그 렌더에 고정된 value prop을 기준으로 새 배열을 만들었다. 10번의 touchmove가 전부 같은 클로저 안에서 같은 낡은 value를 읽고, 각자 독립적으로 "그 위에 한 칸만 바뀐 새 배열"을 만들어서 onChange로 던진다. setOcc는 매번 마지막에 호출된 것으로 덮어써지니, 결과적으로 마지막 칸의 변경 하나만 살아남는다.
전형적인 stale closure 레이스 컨디션이다. 브라우저가 이벤트를 몰아서 쏘는 정도(디바이스 주사율, 터치 샘플링 레이트)에 따라 증상이 다르게 보였던 것도 이걸로 설명됐다 — 왜 Chrome과 Safari에서 증상이 계속 뒤바뀌었는지도.
고친 방법은 렌더와 무관하게 항상 최신값을 들고 있는 ref를 두는 것.
const valueRef = useRef(value);
valueRef.current = value;
function setCell(d, s, to) {
const cur = valueRef.current; // ← 항상 최신
if (cur[d]?.[s] === to) return;
const next = cur.map((row) => row.slice());
next[d][s] = to;
valueRef.current = next; // ← 다음 이벤트가 바로 이 위에 쌓인다
onChangeRef.current(next);
}
이벤트 리스너도 React 합성 이벤트 대신 addEventListener(..., { passive: false })로 직접 등록해서, preventDefault()가 실제로 먹히도록 바꿨다. 이러면 터치 뒤에 합성 마우스 이벤트가 아예 안 뒤따라오므로, 2차 시도에서 겪었던 이중 발화 문제도 근본적으로 해결된다.
한 태스크 내 연속 touchmove 10칸 → 10 칸 칠해짐 (기대: 10)
배열: 0000000000 0000000000 1111111111 0000000000 0000000000
판정: PASS
같은 테스트를 수정 전/후 코드에 각각 돌려서, Chromium과 WebKit 양쪽에서 "이전 코드는 실패하고 새 코드는 통과한다"는 걸 직접 확인한 뒤에야 배포했다.
부수적으로 건드린 것
원인을 찾는 과정에서 두 가지를 더 바꿨는데, 둘 다 완전히 불필요한 변경은 아니었다.
<button>→<div role="button">: iOS Safari는 네이티브 폼 컨트롤(<button>) 위에서 손가락을 누른 채 움직이면 자체 하이라이트/제스처 처리가 끼어들어touchmove가 불안정하게 뜨는 경우가 있다. 드래그 인터랙션이 핵심인 UI에서는 div +role="button"+ 키보드 핸들러로 대체하는 편이 안전했다.pointer-events계열 API를 결국 안 쓰기로: 처음엔 Pointer Events가 터치·마우스를 통합해서 더 깔끔할 거라 생각했는데, 실제로는 native touch/mouse 이벤트를 직접 다루는 쪽이preventDefault타이밍을 더 정확히 통제할 수 있었다. React의 합성 이벤트 레이어를 거치지 않는 것도 한몫했다.
남은 교훈
- 증상이 브라우저마다 다르게 나타나면, 브라우저 문제가 아니라 내 코드가 뭔가를 이중으로 하고 있다는 신호일 확률이 높다. Chrome에서 드래그는 되는데 탭이 안 되고, Safari에서는 반대인 상황 — 이건 "각 브라우저의 버그"가 아니라 "내가 두 개의 이벤트 경로를 동시에 열어놓고 서로 취소시키고 있다"는 뜻이었다.
- 연속 이벤트를 다룰 때
state/prop클로저를 직접 참조하지 말 것.touchmove,scroll,resize처럼 한 렌더 사이클에 여러 번 발화할 수 있는 이벤트 핸들러 안에서 이전 렌더의 값을 계속 읽고 있으면, 아무리 로직이 맞아도 경쟁 상태로 무너진다.ref로 최신값을 들고 다니는 게 정답이었다. - 테스트로 확실하게 확인할 것.
playwright-core로 실제 Chrome/WebKit 바이너리를 띄워 터치 이벤트를 주입하는 데는 몇 줄이면 충분했다. 이걸 처음부터 했으면 세 번의 헛발질 없이 한 번에 끝났을 문제다.
'프레임워크 · 라이브러리 > React' 카테고리의 다른 글
| 무한루핑 캐러셀 튐 현상 트러블슈팅 (0) | 2026.05.22 |
|---|---|
| Linkareer 프로젝트 Carousel 구현 (0) | 2026.05.15 |
| 두더지게임과 함께 알아보는 useRef와 타이머 (0) | 2026.05.01 |
| React 기초 정리 - 6장 (Delete 기능 구현, 마무리) (0) | 2024.04.12 |
| React 기초 정리 - 5장 (Update 기능 구현) (0) | 2024.04.12 |