주소 입력 UI를 갈아엎다
이틀 동안 열 가지 방법을 시도한 끝에, 문제를 해결하는 대신 문제 자체를 없애기로 했습니다.
주소 입력 UI를 갈아엎다
간단한 기능이었습니다. 주소를 입력하면 제안 목록이 나타나고, 탭하면 선택됩니다. 지도 앱에서 매일 보는 그 UI. 구현도 간단할 거라고 생각했습니다.
만드는 데 이틀이 걸렸습니다. 그리고 결국 다 버렸습니다.
시작은 순조로웠다
Apple Maps 서버 API를 연동해서 자동완성을 만들었습니다. TextInput 아래에 드롭다운을 absolute positioning으로 띄우고, 사용자가 2글자 이상 입력하면 300밀리초 디바운스 후 API를 호출합니다. 결과를 리스트로 보여주고, 탭하면 주소와 좌표가 입력됩니다.
시뮬레이터에서 테스트했을 때 깔끔하게 동작했습니다. 검색도 빠르고, 선택도 잘 되고, 좌표까지 정확하게 들어옵니다. 만족스러웠습니다.
실기기에서 테스트하는 순간 문제가 시작됐습니다.
보이지 않는 적: 키보드
주소를 입력하려면 TextInput을 탭해야 합니다. 그러면 소프트 키보드가 올라옵니다. 키보드가 올라간 상태에서 제안된 주소를 탭하면 — 아무 일도 일어나지 않습니다.
왜? 모바일 운영체제는 키보드가 올라가 있을 때 TextInput 밖을 터치하면 키보드를 먼저 내립니다. 이 과정에서 터치 이벤트가 소비됩니다. 제안 항목에는 터치가 전달되지 않습니다.
시뮬레이터에서는 왜 됐을까? 맥의 하드웨어 키보드를 쓰니까 소프트 키보드 자체가 올라오지 않았습니다. 문제가 존재하지 않는 환경에서 테스트한 셈입니다. 실기기 테스트의 중요성을 다시 한 번 느꼈습니다.
첫 번째 접근: 타이밍으로 해결하기
가장 직관적인 해결책이었습니다. 키보드가 내려갈 때 onEndEditing이 호출되면서 드롭다운이 사라지는데, 이걸 300밀리초 지연시키면 됩니다. 키보드가 내려가는 동안 드롭다운이 남아 있으니, 사용자가 다시 탭할 수 있을 겁니다.
결과: 안 됐습니다. 키보드가 내려가면서 레이아웃이 바뀌고, 터치 좌표가 어긋났습니다. onPressIn(터치 다운 시점)을 써봤지만 이것도 호출되지 않았습니다. 터치 이벤트 자체가 시스템에 의해 소비되는 거라, 타이밍 문제가 아니었습니다.
두 번째 접근: React Native 설정
React Native의 ScrollView에는 keyboardShouldPersistTaps라는 옵션이 있습니다. "handled"로 설정하면 Pressable이 처리하는 탭은 키보드를 유지합니다. 이론적으로는 완벽한 해결책입니다.
드롭다운을 탭하면 키보드가 유지된 채로 선택이 됐습니다. 성공!
…이라고 생각했는데, 페이지의 다른 버튼을 누르니 키보드가 안 내려갔습니다. 상태 변경 버튼, 날짜 선택, 카테고리 변경 — 어디를 눌러도 키보드가 화면을 반이나 가린 채로 버티고 있습니다.
keyboardShouldPersistTaps는 ScrollView 전체에 적용됩니다. 드롭다운에만 선택적으로 걸 수 없습니다. 드롭다운이 보일 때만 "handled", 아닐 때는 기본값으로 동적으로 전환해 봤지만, 값이 바뀌는 순간 re-render가 발생하면서 진행 중인 터치가 취소됐습니다.
세 번째 접근: 네이티브 모듈 (iOS)
JS 레벨에서 해결이 안 되니 네이티브로 내려갔습니다. Expo 모듈로 KeyboardPersistView를 만들었습니다. 이 뷰 안의 터치는 키보드를 내리지 않도록 하는 게 목표였습니다.
Swift에서 hitTest를 override해서 터치 타겟을 자기 자신으로 설정했습니다. React Native의 터치 핸들러 대신 우리 뷰가 터치를 처리합니다. touchesEnded에서 어떤 자식 뷰가 탭됐는지 찾아서 JS에 이벤트로 전달합니다.
이 방식으로 선택은 동작했습니다. 하지만 키보드는 여전히 내려갔습니다.
키보드를 아예 유지하는 것도 시도했습니다. touchesBegan에서 super를 안 부르는 것, gesture recognizer에 require(toFail:)을 거는 것, 별도 UIWindow에 드롭다운을 띄우는 것 — 다섯 가지가 넘는 iOS 네이티브 접근을 시도했지만, React Native 내부의 키보드 dismiss 메커니즘을 완전히 막을 수는 없었습니다.
결국 “키보드는 내려가되, 선택은 확실히 동작”하는 hitTest 방식이 iOS에서의 최선이었습니다.
네 번째 접근: Android의 벽
Android로 넘어갔습니다. 같은 네이티브 모듈을 Android에도 만들었습니다. onInterceptTouchEvent, onTouchEvent, dispatchTouchEvent — 세 가지 터치 메서드를 모두 override하고 로그를 찍었습니다.
로그가 안 찍혔습니다. 단 하나도.
Android에서는 두 가지 문제가 겹쳤습니다. 첫째, position: "absolute"로 부모 영역 밖에 위치한 뷰는 보이기는 하지만 터치를 받지 못합니다. iOS와 다릅니다. 둘째, 설사 일반 flow로 렌더해도 Android OS가 키보드 dismiss 시 터치를 소비해 버립니다.
FOCUS_BLOCK_DESCENDANTS, isFocusable = false — 모두 시도했습니다. Portal로 FlatList 밖에 렌더링하는 것도 시도했지만, 위치 추적과 키보드 높이 계산에서 또 다른 버그가 쏟아졌습니다.
Android에서는 드롭다운 방식 자체가 불가능하다는 결론에 도달했습니다. 슬픈 결론이었습니다.
발상의 전환
이틀째 되는 날 오후, 코드를 쳐다보다가 한 발 물러서서 생각했습니다.
왜 드롭다운이어야 하지?
Google Maps 앱을 켜 봤습니다. 주소 입력 칸을 탭하면 검색 전용 화면이 올라옵니다. 거기서 주소를 입력하고, 제안을 탭하면 선택됩니다. 키보드가 올라가 있는 화면에서 제안 목록을 탭하는 건데 — 잘 됩니다.
이유는 간단합니다. 별도 화면이니까요. 그 화면의 TextInput이 포커스를 가지고 있고, 제안 목록은 같은 화면 안의 일반 리스트입니다. TextInput 밖을 터치하는 게 아니라, 같은 화면 안의 다른 뷰를 터치하는 겁니다.
이틀 동안 싸웠던 문제가 설계를 바꾸는 것으로 증발했습니다.
만들어 보니 더 나았다
처음에는 솔직히 “차선책”이라고 생각했습니다. 드롭다운이 안 되니까 어쩔 수 없이 별도 화면으로 가는 거라고. 타협이라고.
하지만 만들어 보니 오히려 더 나은 UX였습니다.
주소를 선택한 뒤에도 편집할 수 있습니다. 드롭다운 방식에서는 선택하면 드롭다운이 사라지고 끝이었는데, 별도 화면에서는 선택 후에도 자유롭게 텍스트를 수정할 수 있습니다. “야탑역, 13506 대한민국 경기도 성남시 분당구 성남대로 903” 뒤에 ”, 2층 카페”를 붙이고 싶을 때, 그냥 이어서 치면 됩니다.
맵 프리뷰도 자연스럽게 추가됐습니다. 주소를 선택하면 좌표가 함께 옵니다. 좌표가 있으면 미니맵을 띄워서 “내가 선택한 위치가 맞나?” 확인할 수 있습니다. 검색 화면에서도, 원래 화면으로 돌아와서도 미니맵이 보입니다.
iOS와 Android가 같은 UX가 됐습니다. 드롭다운 방식에서는 iOS만 네이티브 모듈로 동작하고 Android는 비활성화해야 했는데, 별도 화면은 양쪽 모두 동일하게 동작합니다.
그리고 코드가 극적으로 줄었습니다. 200줄이 넘던 AddressInput 컴포넌트가 50줄이 됐습니다. iOS 네이티브 모듈 분기, Android 분기, 전체선택 감지 로직, 드롭다운 위치 계산 — 전부 사라졌습니다. 대신 Pressable 하나와 Text 하나. 탭하면 검색 화면으로 이동합니다.
이 경험에서 배운 것
기술적 문제에 빠지면 터널 비전이 옵니다. “이 터치 이벤트를 어떻게 가로챌 수 있을까?” “이 gesture recognizer를 어떻게 무력화할 수 있을까?” 문제를 정면으로 돌파하려는 본능이 작동합니다.
하지만 때로는 한 발 물러서서 물어봐야 합니다. “왜 이 터치 이벤트를 가로채야 하지?” “드롭다운이 아닌 다른 방법은 없나?”
이틀을 허비한 것 같지만, 그 이틀이 없었으면 “별도 화면으로 가자”는 결정을 확신할 수 없었을 겁니다. 열 가지 방법을 시도하고 전부 실패한 뒤에야, 문제 자체를 없애는 게 맞다는 확신이 생겼습니다. 그리고 막상 바꾸고 나니 UX도 코드도 더 나아졌습니다.
문제를 해결하는 것보다 문제를 없애는 게 더 나은 답일 때가 있습니다. 다만, 그 판단은 충분히 시도해 본 뒤에만 내릴 수 있습니다.