LazyPad 개발기 2: 터치 입력감은 왜 배율 하나로 해결되지 않았나
LazyPad 1편에서는 처음 다루는 Swift/Xcode 환경에서 iPhone-Mac 입력 앱을 배포 후보까지 끌고 간 전체 흐름을 정리했습니다.
이번 글은 그중 입력감을 따로 풉니다. 입력감은 손가락을 움직였을 때 Mac cursor가 얼마나 자연스럽게 따라오는지에 대한 체감입니다.
처음에는 감도 숫자 하나를 바꾸면 해결될 것처럼 보였습니다. 하지만 실제로는 손가락 sample, 움직임 속도, 작은 떨림, 지연, gesture 경계, Mac pointer gain이 함께 섞여 있었습니다.
그래서 이 글에서는 디버깅 기록으로 바로 들어가기 전에, LazyPad에서 사용한 핵심 용어를 먼저 설명합니다. 다음 글의 cursor reacquire 문제도 이 용어를 알아야 더 쉽게 읽을 수 있습니다.
입력 파이프라인은 손가락 움직임을 cursor 움직임으로 번역하는 과정입니다
입력 파이프라인은 손가락이 iPhone 화면에 닿은 뒤 Mac cursor가 움직이기까지의 처리 흐름입니다.
LazyPad에서는 다음 흐름으로 생각했습니다.
iPhone touch sample
-> sample timing 정리
-> gesture 의도 판단
-> gain curve 적용
-> smoothing 적용
-> move/scroll message 생성
-> Mac Companion 전달
-> macOS cursor event 실행
이 흐름을 나눈 이유는 문제를 작게 보기 위해서였습니다. Cursor가 튄다고 해서 곧바로 "감도를 낮추자"로 가면 다른 문제가 생깁니다.
예를 들어 첫 재터치 jump는 줄어들 수 있습니다. 하지만 느린 이동까지 둔해지고, 긴 이동은 답답해질 수 있습니다. 그래서 각 단계가 맡은 문제를 분리했습니다.
| 계층 | 쉬운 설명 | 담당한 문제 |
|---|---|---|
| Sample intake | 손가락 위치와 시간을 작은 기록으로 받는 단계입니다 | 입력을 얼마나 촘촘히 읽을지 정합니다 |
| Gesture boundary | tap, move, scroll, drag가 언제 시작되는지 나누는 단계입니다 | 클릭하려던 동작이 이동으로 오해되는 일을 줄입니다 |
| Gain curve | 손가락 이동량을 cursor 이동량으로 바꾸는 단계입니다 | 느린 이동과 빠른 이동의 비율을 다르게 조정합니다 |
| Smoothing | 작은 떨림을 줄이는 단계입니다 | cursor가 떨리지 않게 하되 늦게 따라오지 않게 조정합니다 |
| Message cadence | Mac으로 입력을 어느 간격으로 보낼지 정하는 단계입니다 | 너무 많이 보내거나 늦게 몰아서 보내는 일을 줄입니다 |
| Mac injection | Mac에서 실제 cursor event로 실행하는 단계입니다 | 권한, safety release, pointer state와 연결됩니다 |
Touch sample은 손가락 움직임을 아주 작은 점으로 나눈 기록입니다
Touch sample은 특정 시각에 손가락이 화면 어디에 있었는지 나타내는 기록입니다.
손가락 움직임을 영상으로 보면 선처럼 보입니다. 하지만 앱은 그 선을 한 번에 받지 않습니다. 여러 개의 작은 점을 시간순으로 받고, 그 점들의 차이를 계산해서 이동량을 만듭니다.
예를 들어 손가락이 오른쪽으로 움직였다면 앱은 대략 다음과 같은 정보를 받습니다.
10:00:00.000 x: 100, y: 300
10:00:00.008 x: 104, y: 300
10:00:00.016 x: 109, y: 301
여기서 timestamp는 sample이 발생한 시각입니다. 같은 10px 이동이라도 0.01초에 움직인 것과 0.2초에 움직인 것은 느낌이 다릅니다. 그래서 위치와 시간은 함께 봐야 했습니다.
coalescedTouches는 중간 sample을 더 촘촘히 보기 위한 단서였습니다
iOS의 touchesMoved callback은 손가락이 움직일 때 호출됩니다. 여기서 callback은 시스템이 앱에 "움직임이 들어왔습니다"라고 알려주는 호출이라고 보면 됩니다.
문제는 callback 하나가 sample 하나만 뜻하지 않는다는 점입니다. iOS는 한 번의 move callback 안에 여러 touch sample을 묶어 줄 수 있습니다. Apple의 coalescedTouches(for:)는 이 묶인 sample을 가져오는 API입니다.
이 방식은 장점이 있습니다. 중간 sample을 더 볼 수 있으므로 손가락 움직임을 더 부드럽고 정확하게 복원할 수 있습니다.
하지만 LazyPad에서는 이 특성이 디버깅 포인트가 되었습니다. 손을 뗐다 다시 올린 직후, 첫 callback 안에 여러 sample이 들어오면 첫 output이 예상보다 커질 수 있었습니다.
| 용어 | 쉬운 설명 | LazyPad에서 본 의미 |
|---|---|---|
touchesMoved | 손가락이 움직였다고 iOS가 앱에 알려주는 callback입니다 | 입력 처리의 시작점입니다 |
coalescedTouches | 한 callback 안에 묶여 있는 실제 touch sample 목록입니다 | 중간 움직임을 복원할 수 있지만 첫 batch가 커질 수 있습니다 |
| Batch | 한 번에 묶여 들어온 sample 묶음입니다 | 첫 batch 전체를 제한해야 하는 이유가 되었습니다 |
| First output | 새 gesture에서 Mac으로 처음 나가는 이동 결과입니다 | 사용자가 가장 민감하게 느끼는 순간입니다 |
이후 3편에서 다룰 first batch clamp는 여기서 나옵니다. 첫 sample 하나만 제한하는 것이 아니라, 처음 외부로 나가는 batch 전체를 제한해야 했습니다.
predictedTouches는 미래를 조금 당겨 보는 도구지만 확정 입력은 아니었습니다
Apple의 predictedTouches(for:)는 앞으로 이어질 touch 위치를 예측해서 제공합니다.
쉽게 말하면 "손가락이 이 방향과 속도로 계속 움직이면 다음 위치는 이쯤일 가능성이 있습니다"라고 미리 알려주는 값입니다.
이 값은 지연을 줄이는 데 도움이 될 수 있습니다. 특히 그림 그리기 앱이나 빠른 터치 반응이 중요한 UI에서는 화면이 늦게 따라오는 느낌을 줄이는 데 사용할 수 있습니다.
다만 LazyPad에서는 조심해야 했습니다. LazyPad는 화면 안의 그림을 미리 그리는 앱이 아니라 Mac cursor를 실제로 움직이는 앱입니다. 예측이 틀리면 cursor가 목표보다 앞서가거나, 되돌아오는 느낌이 생길 수 있습니다.
그래서 predicted touch는 매력적인 후보였지만, v0.1에서는 Mac으로 확정 입력처럼 보내기보다 보수적으로 판단했습니다.
| 구분 | coalesced touch | predicted touch |
|---|---|---|
| 의미 | 이미 발생한 실제 sample입니다 | 앞으로 발생할 것처럼 예측한 sample입니다 |
| 장점 | 실제 움직임을 더 촘촘히 볼 수 있습니다 | 지연을 줄이는 데 도움이 될 수 있습니다 |
| 위험 | batch가 커지면 첫 출력이 커질 수 있습니다 | 예측이 틀리면 cursor overshoot가 생길 수 있습니다 |
| LazyPad 판단 | timestamp와 함께 정규화해 사용했습니다 | 확정 cursor event로 보내는 것은 보류했습니다 |
Jitter와 lag는 서로 반대 방향으로 움직이는 경우가 많았습니다
Jitter는 손가락이 거의 멈춰 있어도 입력값이 미세하게 흔들리는 현상입니다. Cursor가 아주 조금씩 떠는 느낌으로 나타납니다.
Lag는 손가락 움직임보다 cursor 반응이 늦게 따라오는 느낌입니다. 사용자는 "손보다 커서가 뒤에 온다"고 느낄 수 있습니다.
이 둘은 자주 충돌합니다. Jitter를 줄이려고 smoothing을 강하게 걸면 cursor가 안정적으로 보입니다. 대신 반응이 늦어져 lag가 커질 수 있습니다.
반대로 lag를 줄이려고 smoothing을 약하게 걸면 cursor는 빨리 반응합니다. 대신 작은 떨림까지 그대로 보일 수 있습니다.
| 목표 | 조정 방향 | 생길 수 있는 부작용 |
|---|---|---|
| Jitter 줄이기 | smoothing을 강하게 적용합니다 | cursor가 늦게 따라올 수 있습니다 |
| Lag 줄이기 | smoothing을 약하게 적용합니다 | cursor가 떨릴 수 있습니다 |
| 둘 다 줄이기 | 움직임 속도에 따라 smoothing을 바꿉니다 | 구현과 튜닝 기준이 복잡해집니다 |
이 지점에서 1 Euro Filter를 참고했습니다.
1 Euro Filter는 느릴 때와 빠를 때를 다르게 보는 기준이 되었습니다
1 Euro Filter는 noisy input을 다루기 위한 speed-based low-pass filter입니다.
Low-pass filter는 갑자기 튀는 값을 완만하게 만들어주는 필터라고 이해하면 됩니다. 다만 고정된 필터는 항상 같은 강도로 값을 부드럽게 만들기 때문에, 빠르게 움직일 때는 늦게 따라오는 문제가 생길 수 있습니다.
1 Euro Filter의 핵심은 속도에 따라 smoothing 강도를 바꾸는 것입니다.
| 움직임 상태 | 필요한 처리 | 이유 |
|---|---|---|
| 손가락이 느리거나 거의 멈춤 | smoothing을 더 강하게 적용합니다 | 작은 떨림을 줄여야 합니다 |
| 손가락이 빠르게 움직임 | smoothing을 약하게 적용합니다 | cursor가 늦게 따라오면 안 됩니다 |
LazyPad에서 중요한 것은 논문 알고리즘을 그대로 옮기는 일이 아니었습니다. 더 중요한 점은 jitter와 lag를 같은 문제로 보지 않는 관점이었습니다.
그래서 "감도 배율을 낮추면 떨림도 줄고 튐도 줄어들 것"이라고 단순화하지 않았습니다. 느린 구간, 빠른 구간, 첫 재터치 구간을 따로 봤습니다.
Control-display gain은 손가락 거리와 cursor 거리의 비율입니다
Control-display gain은 입력 장치의 움직임과 화면 cursor 움직임 사이의 비율입니다.
여기서 control은 사용자가 움직이는 쪽입니다. LazyPad에서는 iPhone 화면 위의 손가락 움직임입니다. display는 결과가 보이는 쪽입니다. LazyPad에서는 Mac 화면의 cursor 움직임입니다.
예를 들어 손가락을 1cm 움직였을 때 cursor가 3cm만큼 이동한다면 gain이 높다고 볼 수 있습니다. 손가락을 1cm 움직였는데 cursor가 0.5cm만 이동한다면 gain이 낮다고 볼 수 있습니다.
| Gain 상태 | 장점 | 단점 |
|---|---|---|
| 낮은 gain | 작은 버튼을 정밀하게 가리키기 쉽습니다 | 화면 멀리 이동하려면 손가락을 많이 움직여야 합니다 |
| 높은 gain | 먼 곳까지 빠르게 이동할 수 있습니다 | 작은 목표를 지나치기 쉽습니다 |
| 동적 gain | 느릴 때와 빠를 때를 다르게 조정할 수 있습니다 | 곡선을 잘못 잡으면 예측하기 어려운 느낌이 됩니다 |
이 때문에 pointer movement는 단순 배율 하나로 보기 어려웠습니다.
Transfer function은 입력을 출력으로 바꾸는 곡선입니다
Transfer function은 입력값을 출력값으로 바꾸는 규칙입니다.
입력 장치에서는 손가락 이동 거리, 이동 속도, 시간 간격 같은 값이 입력입니다. 결과는 Mac cursor가 실제로 얼마나 움직이는지입니다.
단순 배율은 가장 쉬운 transfer function입니다.
cursor 이동량 = 손가락 이동량 x 2
하지만 LazyPad에는 이 방식이 부족했습니다. 느린 이동, 빠른 이동, 첫 재터치 이동이 서로 다른 문제를 만들었기 때문입니다.
그래서 더 현실적인 구조는 다음에 가까웠습니다.
느린 이동: 정밀하게 움직입니다
중간 이동: 자연스럽게 가속합니다
빠른 이동: 너무 멀리 튀지 않게 상한을 둡니다
첫 재터치: 첫 output을 더 보수적으로 제한합니다
Control-display gain 연구와 libpointing은 이 판단에 근거를 주었습니다. 특히 libpointing은 운영체제의 pointing transfer function을 측정하고 비교하려는 연구입니다. 이 관점 덕분에 Mac cursor 움직임을 "감으로 맞추는 배율"이 아니라 "입력과 출력 사이의 곡선"으로 볼 수 있었습니다.
Gesture boundary는 클릭과 이동이 섞이지 않도록 나누는 선입니다
Gesture는 사용자의 손동작을 뜻합니다. Tap, move, scroll, drag가 모두 gesture입니다.
Gesture boundary는 한 gesture가 끝나고 다른 gesture가 시작되는 경계입니다. LazyPad에서는 손가락을 뗐다가 다시 올리는 순간이 특히 중요했습니다.
경계가 모호하면 문제가 생깁니다.
| 상황 | 잘못 판단하면 생기는 문제 |
|---|---|
| Tap하려고 살짝 움직였습니다 | cursor move로 오해할 수 있습니다 |
| Move하려고 다시 손을 올렸습니다 | 이전 gesture의 잔여 움직임처럼 처리될 수 있습니다 |
| Scroll하려고 두 손가락을 올렸습니다 | 한 손가락 move와 섞일 수 있습니다 |
| Drag 중 연결이 끊겼습니다 | mouse-down이 남는 위험이 생길 수 있습니다 |
그래서 touch slop, deadzone, hysteresis 같은 개념을 함께 봤습니다.
| 용어 | 쉬운 설명 | LazyPad에서의 역할 |
|---|---|---|
| Touch slop | 작은 움직임을 아직 gesture로 보지 않는 허용 범위입니다 | tap과 move를 구분하는 데 필요했습니다 |
| Deadzone | 입력을 무시하는 작은 구간입니다 | 손가락 떨림이 cursor move가 되는 일을 줄입니다 |
| Hysteresis | 한 번 상태가 바뀐 뒤에는 다시 되돌아가기 어렵게 하는 규칙입니다 | tap, move, scroll 상태가 흔들리지 않게 합니다 |
| Clamp | 값이 너무 커지지 않도록 상한을 두는 처리입니다 | 첫 재터치 output이 과하게 나가는 일을 줄였습니다 |
Scroll은 cursor move와 다른 감각으로 봐야 했습니다
Scroll은 cursor move와 다릅니다.
Cursor move는 손가락을 움직인 만큼 pointer 위치가 바뀌는 문제입니다. Scroll은 콘텐츠가 움직이는 문제입니다. 사용자는 스크롤에서 속도, 관성, 멈춤 느낌을 함께 기대합니다.
그래서 scroll feel은 별도 계층으로 보았습니다.
| 용어 | 쉬운 설명 |
|---|---|
| Velocity tracker | 손가락 움직임의 속도를 추적하는 장치입니다 |
| Residual wheel | 작은 스크롤 잔여값을 모아 다음 출력에 반영하는 처리입니다 |
| Friction | 손을 뗀 뒤 움직임이 서서히 줄어드는 느낌을 만드는 요소입니다 |
| Momentum | 손을 뗀 뒤에도 잠깐 이어지는 관성입니다 |
LazyPad v0.1에서는 모든 scroll physics를 완성하려고 하지 않았습니다. 먼저 two-finger vertical scroll과 residual 처리를 분리해서 확인했습니다.
AI는 후보를 넓혔고, 용어의 채택은 근거로 좁혔습니다
AI는 입력감 문제를 계층으로 나누는 데 도움이 됐습니다.
처음 다루는 Swift/Xcode 환경에서는 무엇을 검색해야 하는지도 막힐 수 있습니다. AI는 coalescedTouches, predictedTouches, adaptive smoothing, control-display gain, transfer function 같은 후보를 빠르게 모아주었습니다.
하지만 후보가 많아지는 것과 채택할 수 있는 것은 다릅니다.
| AI가 넓힌 후보 | 직접 확인한 기준 |
|---|---|
| iOS touch API 후보 | Apple 문서와 실제 UIKit 입력 흐름 |
| smoothing 알고리즘 후보 | jitter와 lag tradeoff |
| pointer gain 후보 | HCI 연구와 LazyPad 체감 문제 |
| 네트워크 원인 후보 | ordinary Wi-Fi, hotspot, build별 physical smoke |
| 배포 전 입력감 기준 | build 10에서 반복 확인한 사용자 행동 |
AI 시대의 개발에서 중요한 능력은 빠르게 물어보는 능력만은 아니었습니다. 어떤 답이 현재 문제에 맞는지, 어떤 답은 보류해야 하는지 판단하는 능력이 함께 필요했습니다.
이번 글에서 남는 기준
LazyPad 입력감에서 가장 중요한 결론은 감도 숫자 하나로 문제를 닫지 않았다는 점입니다.
다음 기준을 남겼습니다.
- 손가락 sample과 cursor output을 같은 것으로 보지 않습니다.
- 실제 sample과 예측 sample을 구분합니다.
- Jitter와 lag를 분리해서 봅니다.
- Gain을 단순 배율이 아니라 transfer function으로 봅니다.
- Tap, move, scroll, drag는 gesture boundary로 나눕니다.
- AI가 찾은 후보는 문서, 논문, build, 실기기 smoke로 다시 좁힙니다.
이 기준이 있었기 때문에 다음 글의 cursor reacquire 문제도 네트워크 지연 하나로 단정하지 않을 수 있었습니다.
다음 글은 첫 재터치 cursor jump를 build별로 좁힙니다
다음 글에서는 손을 뗐다가 다시 올린 직후 Mac cursor가 크게 움직이던 문제를 다룹니다.
이 글에서 설명한 coalescedTouches, batch, clamp, gesture boundary, gain이 실제 디버깅에서 어떻게 쓰였는지 build 4부터 build 10까지의 흐름으로 정리하겠습니다.
이어 읽기
시리즈는 순서대로, 편집 추천은 맥락대로, 비슷한 주제는 태그 기준으로 정리합니다.
시리즈 전체
LazyPad 개발기: 아이폰을 맥 입력면으로 만들기2/4편- 1.LazyPad 개발기 1: 처음 쓰는 Swift와 Xcode로 iPhone-Mac 앱 배포 후보 만들기
- 2.LazyPad 개발기 2: 터치 입력감은 왜 배율 하나로 해결되지 않았나
- 3.LazyPad 개발기 3: 첫 재터치에서 드러난 커서 입력감 디버깅
- 4.LazyPad 개발기 4: TestFlight와 Mac Companion 배포 후보 검증
함께 읽으면 좋은 글
편집 추천비슷한 주제의 글
태그가 겹치는 글입니다. 시리즈와 편집 추천에 이미 나온 글은 제외합니다.
코드트리 직접 코딩 감각 유지기 3: 알림톡과 GitHub 잔디로 루틴을 유지했습니다
CodeTree 알림톡과 GitHub 연동을 활용해 직접 문제를 풀어보는 루틴을 유지한 경험을 정리하고, Trail 0 완료와 Trail 1 진행률, 매일 발송되는 학습 리마인더, 잔디 기록이 학습 지속에 준 영향을 돌아본 코드트리 직접 코딩 감각 유지기 3편.
AI라는 다른 종 앞에서, 나는 나를 더 보여주기로 했다
AI를 다른 종처럼 느낀 두려움과 안도를 출발점으로, 흩어진 취향과 반복 자동화 작업을 AI에게 나를 더 잘 알 수 있는 지도로 건네며 나와 AI가 함께 특징화되는 과정을 쓴 개인 에세이.
코드트리 직접 코딩 감각 유지기 1: 갭체크로 백트래킹 약점을 확인했습니다
CodeTree 갭체크 결과를 바탕으로 AI 시대에도 직접 문제를 읽고 코드로 밀어붙이는 감각을 유지하기 위해, 조건문·시뮬레이션·완전탐색은 풀었지만 백트래킹에서 손이 멈춘 이유와 다음 학습 순서를 정리한 코드트리 직접 코딩 감각 유지기 1편.