개발 블로그로 돌아가기 함정 보고서

SwiftUI의 LongPressGesture는 손가락을 떼기 전에 끝난다

SharkTTY의 플로팅 키보드에 눌러서 말하는 마이크를 넣고 있습니다. 꾹 누르고 말하면 실시간으로 인식 결과가 보이고, 떼면 멈추고, 결과를 탭해야 터미널에 들어갑니다. 상호작용 자체는 무전기만큼 오래됐죠. 그런데 그 SwiftUI 구현에는 함정이 있어서, 우리 테스트 스위트를 전부 통과한 채 「절대 동작할 수 없는 기능」을 멀쩡한 얼굴로 내보낼 뻔했습니다 — 녹음이 시작 후 3분의 1초 만에 매번 스스로 멈추는 겁니다. 이 글은 우리가 처음에 검색해서 찾고 싶었던 바로 그 글입니다.

가장 당연해 보이는 API가 틀린 API

SwiftUI는 눌러서 녹음하는 버튼에 필요한 걸 그대로 주는 것처럼 보입니다:

.onLongPressGesture(minimumDuration: 0.35, maximumDistance: 30) { pressing in
    if !pressing { stopRecording() }   // ← 0.35초에 호출된다. 손가락을 뗄 때가 아니라
} perform: {
    startRecording()
}

대부분이 읽는 방식대로 읽으면 — pressing은 손가락이 닿아 있는지 알려주고, perform은 길게 누르기가 시작될 때 호출된다」 — 완벽해 보입니다. perform에서 녹음을 시작하고, pressing이 false가 되면 멈추면 되죠.

시그니처가 말해 주지 않는 부분은 이겁니다. LongPressGestureminimumDuration이 지나는 순간 성공합니다 — 그리고 성공한 제스처는 끝난 제스처입니다. pressing 클로저의 뒤에는 gesture state가 있고, gesture state는 제스처가 끝나면 리셋됩니다. 그래서 pressing(false)는 약 0.35초 시점, 손가락이 아직 화면에 딱 붙어 있는 채로 도착합니다. 정지 처리가 시작 처리 바로 뒤에 실행되고, 실제로 손가락이 떨어지는 순간은 이미 아무도 듣지 않는 이벤트가 됩니다.

우리 경우 피해는 한층 조용했습니다. 끊긴 녹음은 약 0.35초짜리였고, 「실수 탭 버리기」 가드가 300ms 미만의 오디오를 전부 삼켰으며, 스트립은 아무 말 없이 원래대로 돌아왔습니다. 에러도, 크래시도, 부분 결과도 없음. 동작하는 척하는 UI를 입은, 한 번도 동작한 적 없는 기능.

테스트가 왜 못 잡았나

테스트할 수 있는 건 전부 통과했습니다. 녹음 세션의 상태 기계는 단위 테스트가 있었고, 오디오 파이프라인은 정상이었고, 빌드는 초록불이었습니다. 결함은 온전히 런타임에서의 제스처 인식기 의미론 — 어느 콜백이 어느 물리적 순간에 호출되는가 — 에 살고 있는데, 그곳이 바로 단위 테스트가 닿지 않고 시뮬레이터로 확인하기도 귀찮은 층입니다. 잡아낸 건 코드 리뷰였고, 모든 콜백에 같은 질문 하나를 던졌습니다. 「이 코드는 물리적으로 정확히 언제 실행되는가?」 pressing에 대한 정직한 답은 「당신이 생각하는 그때가 아니다」였습니다.

해결: 길게 누르기 뒤에 드래그를 연결한다

정석 패턴은 — 우리가 아는 한 유일하게 믿을 만한 패턴은 — LongPressGesture 뒤에 거리 0의 DragGesture를 연결(sequence)하는 것입니다. 길게 누르기의 성공이 결합 제스처를 드래그 단계로 넘기고, 드래그가 손가락이 진짜로 떨어질 때까지 제스처를 살려 둡니다:

.onTapGesture { switchToVoiceBoard() }   // 짧은 탭 = 다른 동작
.gesture(
    LongPressGesture(minimumDuration: 0.35, maximumDistance: 30)
        .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
        .onChanged { value in
            // 드래그 단계에선 반복 호출된다. 시작은 정확히 한 번만.
            if case .second(true, _) = value, !isRecording { startRecording() }
        }
        .onEnded { value in
            // 길게 누르기가 성공했을 때만 도달한다 — 진짜 손가락 떼기.
            if case .second = value { stopRecording() }
        }
)

이 조합을 지탱하는 성질은 셋입니다. 누르는 단계는 오해의 여지가 없습니다: .second(true, _)는 「길게 누르기가 성공했고 손가락은 아직 붙어 있다」는 뜻. 떼는 순간은 진짜입니다: 그 시점에 돌고 있는 건 드래그라서, 결합 제스처의 onEnded는 손가락이 떨어질 때 호출됩니다. 탭도 깨끗합니다: 짧은 탭은 길게 누르기를 실패시키고, 시퀀스는 .second에 도달하지 못하며, 별도의 onTapGesture가 그것을 맡습니다.

실무 메모 두 가지. 첫째, 누르는 동안 손가락이 움직일 때마다 onChanged가 호출되므로, 시작은 자체 플래그로 지켜서 정확히 한 번만 실행되게 하세요. 둘째, 시스템은 onEnded를 한 번도 호출하지 않고 제스처를 취소할 수 있습니다(전화 수신, 누른 채로 백그라운드 전환). 그러니 scenePhase 관찰자를 붙여서 앱이 포그라운드를 벗어나면 녹음을 취소하세요. 벨트를 매고, 멜빵도 하나 더.

다음 사람을 위한 경고를 남겨라

깨진 버전이 올바른 버전보다 더 짧고 더 읽기 좋습니다. 이게 위험한 부류의 버그입니다. 미래의 리팩터링이 sequenced 제스처를 보고 「이거 onLongPressGesture 한 줄이면 되잖아」라고 생각하고는, 결함을 충실하게 복원할 테니까요. 그래서 우리 코드 위의 주석은 코드가 무엇을 하는지 설명하지 않습니다. 더 단순한 쪽이 왜 틀렸는지를 한 문장으로 적습니다: LongPressGestureminimumDuration에 손가락이 붙은 채로 성공하고 끝나므로, 그 pressing 콜백으로는 떼는 순간을 감지할 수 없다. 코드의 형태가 어떤 함정 때문에 정해졌다면, 그 함정을 코드 옆에 적어 두세요.

눌러서 말하는 키보드는 iPhone·iPad용 SharkTTY의 다음 업데이트로 나갑니다. 당신만의 제스처 함정을 찾았거나 이 글의 허점을 발견했다면? 피드백 보드가 열려 있습니다.