시중에 있는 많은 서비스들이 업데이트 할 때 스토어 심사에 대한 병목을 줄이기 위해 네이티브 앱과 WebView를 혼용하고 있습니다. 오픈런도 마찬가지로 화면 대부분을 WebView로 렌더링하고, React Native는 푸시 알림이나 위치 권한 같은 앱 기능을 메시지 통신으로 처리하면서 가볍게 감싸는 구조이고요.
하지만 모든 기술이 그렇듯, 장점이 있으면 약점도 따라오기 마련입니다. 예를 들어, WebView가 화면 전체를 감싸는 앱인 경우, 별다른 처리 없이 Next.js 라우터로 라우팅을 하면 유저는 쉽게 모바일을 고려하지 않은 웹뷰 서비스라는 것을 눈치채게 됩니다.
요즘 서비스들의 UI/UX 수준이 워낙 높아져서, 자칫 잘못하면 완성도가 낮은 서비스로 각인되기 쉽습니다. 이를 대비하여 오픈런을 만들면서 WebView를 앱과 동일한 경험으로 제공하기 위해 고민했던 과정들 몇 가지를 공유하고자 합니다.
iOS에서 화면 왼쪽 끝을 잡고 오른쪽으로 스와이프하면 이전 화면으로 돌아가는 사용자 경험을 익숙하게 느낄 수 있습니다. 이 제스처는 iOS 7부터 지원이 되어 워낙 오래돼서 사용자 몸에 완전히 배어 있으니까요. 어떤 앱이든 무의식적으로 "스와이프하면 뒤로 가겠지"라는 기대가 깔려 있는 건 이제 당연하죠.
하지만, 간헐적으로 WebView 기반 서비스 중에서 스와이프 제스처가 동작하지 않는 경험을 해본 적이 있을 거에요. 기대한 제스처가 동작을 안 하면 사용자는 귀찮음과 함께 왼쪽 상단에 작게 붙어 있는 뒤로가기 아이콘을 찾아, 파지하고 있던 손까지 고쳐 잡아서 클릭을 해야 하죠.
별것 아닌 것 같지만 이런 불편이 반복되면 사용자는 "이 서비스는 불편하다"는 걸 학습하고 들어오는 걸 꺼리게 됩니다. 기껏 만들어놓은 페이지의 리텐션이 동작 하나 때문에 떨어지는 연쇄작용이 생기는 거죠. 그래서 이 부분은 반드시 신경써야 한다고 생각했습니다.
첫 번째 시도 : WebView를 여러 장 띄우기
처음 시도한 방법은 React Native 쪽에 화면(Stack)을 여러 개 만들어서 전환하는 방식을 사용하는 것이었어요. 바텀 네비게이션이 있는 메인 화면은 첫 번째 WebView가 담당하고, URL을 감지해서 벙 상세로 넘어가면 두 번째 WebView를 스택에 푸시하는 방식입니다. RN 네비게이터의 화면 전환 애니메이션은 이미 잘 최적화돼 있으니, WebView끼리 전환만 매끄러우면 꽤 괜찮은 방법이라고 생각했거든요.
하지만 이 방법에는 두 가지 문제가 있었습니다.
첫 번째는 로딩 딜레이입니다. 두 번째 스택 스크린으로 넘어갈 때마다 WebView를 새로 띄워야 하는데, 그리 무겁지 않은 우리 서비스인데도 새 WebView가 뜨는 데 1~2초가 걸렸어요. 이는 양쪽 스크린을 왔다갔다하는 경험이 누적될수록 상당히 좋지 않은 경험이었습니다. 그나마 다행인 건, WebView끼리 쿠키는 공유돼서 로그인 토큰이 끊기는 문제는 없었어요.
두 번째는 메모리 상태가 공유되지 않는다는 점입니다. 쿠키는 공유되지만 WebView는 그와 다르게 각각 독립된 JS 컨텍스트에서 돌아가니까요. 따라서, react-query 캐시나 zustand 스토어처럼 메모리에 올라가 있는 상태는 WebView마다 완전히 별개입니다. 메인에서 이미 받아둔 유저 정보와 벙 목록을 상세 WebView에서는 처음부터 다시 fetch해야 하고, 상세 화면에서 벙에 참여해도 메인 WebView의 캐시는 그 사실을 몰라요. 이는 치명적인 이슈로 이어지지는 않을 수도 있지만, 열심히 설계해둔 캐싱전략들이 무용지물이 될 수 있으며, 장기적으로 유지보수가 굉장히 어려워집니다.
해결 : 네이티브 제스처를 웹 히스토리에 연결하기
사실 첫 번째 시도에서 지속할 지 여부에 대한 고민이 있었습니다. 사이드 프로젝트인데 오버 엔지니어링인가 싶어서 포기하기도 했다가, 그럼에도 다시 마음을 잡기를 몇 번 반복했던 게 사실입니다.
그러다가 문득 iOS Safari에서 힌트를 얻었습니다. 여느 때와 같이 Safari로 웹을 보다가, 자연스럽게 스와이프 뒤로가기를 쓰고 있다는 걸 깨달았거든요. Safari도 결국 WKWebView 계열 엔진 위에서 돌며, WKWebView에는 이 동작을 활성화해주는 속성이 이미 있었고, react-native-webview에서 해당 기능을 제공하고 있었던 것입니다..!
기쁜 마음으로 allowsBackForwardNavigationGestures를 달았습니다. 이 속성은 스와이프 제스처를 WebView의 back/forward 히스토리에 연결하는 방법이에요. 물론 히스토리 기반이라는 건 제약으로 다가오기도 했습니다. 라우트 이동 없이 로컬 상태로 화면을 전환하는 케이스에서는 여전히 제스처가 먹히지 않으니까요.
다행히 그런 화면이 몇 개 없기도 했고, 이번 기회에 라우트 단위로 바꿨더니 모든 페이지에서 스와이프 뒤로가기가 동작하게 됐습니다. 스택 네비게이션을 직접 구현하지 않고도 성능 저하 없이 네이티브와 같은 경험을 얻은 거죠.
페이지 push도 자연스럽게 전환하자
뒤로가기를 할 때는 네이티브 제스처가 스냅샷 애니메이션까지 그려주지만, 페이지에 들어갈 때는 여전히 화면이 뚝 바뀌는 어색함이 남아 있었어요. 이 부분은 View Transitions API로 iOS 푸시 전환을 흉내 내봤습니다. 새 화면만 오른쪽에서 밀려 들어오게 하고, 이전 화면은 제자리에 고정하는 방식이죠.
이제 화면이 푸시될 때는 View Transitions가, 뒤로 나올 때는 WKWebView의 네이티브 제스처가 화면을 밀어주게 되었습니다. 양방향 모두 마치 네이티브 앱처럼 라우팅 애니메이션이 동작하게 됐습니다!
다음은 회원가입 화면에서 평균 페이스를 입력받을 때 사용되고 있는 Picker 에 대한 내용인데요. iOS의 휠 Picker를 기대하고 만들었으나, 막상 드래그해 보니 체이닝되듯 부드럽게 도는 게 아니라 뚝뚝 끊기면서 돌아가는 게 참 마음에 들지 않았어요. 물론 사용자 경험을 크게 해치는 문제는 아니지만, 개인적인 욕심으로 완성도를 높이고 싶었습니다.
원하는 느낌이 무엇인지는 알지만, 현재 동작이 어색한 이유를 정의하기가 어려웠습니다. 마치 물리 엔진 같은 장치가 필요할 것 같은 막연한 느낌이었거든요.
그럼에도 네이티브 Picker의 동작을 분리해서 구체화시켜봤더니 다음과 같이 정리할 수 있었습니다.
이 세 구간을 하나씩 다뤄보면 비슷한 느낌을 낼 수 있겠다 싶어서 코드로 구체화시켜보기로 했습니다.
1단계 : 드래그를 놓는 순간의 속도 구하기
관성을 구현하려면 먼저 손을 뗀 순간 휠이 얼마나 빠르게 움직이고 있는지를 알아야 합니다. 이를 구하기 위해 드래그하는 동안 손가락의 y축 위치인 clientY 값을 시간과 함께 배열에 쌓아뒀어요. 문제는 그 다음이었습니다. 이 배열에 쌓아둔 데이터를 가지고 어느 구간을 골라 속도를 계산할지에 대한 기준이 마땅치 않았거든요.
먼저 떠올린 방법은 배열의 마지막 몇 개 샘플로 속도를 계산하는 것이었어요. 하지만 이 방법은 touchmove가 호출되는 빈도가 기기 주사율에 따라 다르다는 문제가 있었습니다. 개수를 기준으로 잘라버리면 같은 제스처인데도 기기마다 속도가 다르게 읽히게 되는 거죠. 그래서 개수 대신 시간을 기준으로, 최근 100ms의 샘플만 쓰는 방법으로 변경했습니다.
구간을 100ms로 짧게 설정한 데는 나름의 이유가 있습니다. 만약 드래그 전체로 범위를 넓혀 평균을 내게 되면, 천천히 돌리다가 마지막에 빠르게 튕기는 동작이 뭉개져 버립니다. 즉, 사용자는 빠른 휠 회전을 기대할텐데 마지막에 튕긴 힘이 평균에 희석돼서 기대하는 동작과 다르게 반영되는 거죠. 반대로 빠르게 움직이다가 잠깐 멈춘 뒤 손을 떼면, 거의 0이어야 할 속도가 크게 잡히기도 하고요.
위와 같은 여러 번의 테스트를 통해, 관성에 필요한 건 "전체 드래그의 평균"이 아니라 놓기 직전의 빠르기라는 것을 깨달을 수 있었습니다.
마지막의 클램프도 같은 맥락입니다. 코드에 나와있는 Math.max(-MAX, Math.min(MAX, v))는 계산된 속도를 허용 범위 안으로 잘라내는 처리에요. 손가락을 아주 세게·짧게 튕기면 Δy / Δt가 과도하게 커질 수 있고, 그걸 그대로 관성에 넣으면 휠이 과도하게 돌아가는 현상이 발생하는 것을 막아주는 역할입니다.
2단계 : 서서히 감속하기
1단계에서 속도를 구했으니, 이제 그 값으로 휠을 실제로 굴려야 합니다. 이 부분이 Picker가 자연스럽게 보이도록 하는 핵심 중에 하나에요. requestAnimationFrame 루프에서 매 프레임 휠을 돌리고, 속도에 마찰 계수를 곱해 줄여 나가도록 구현했어요. 손 뗀 뒤에도 잠깐 돌다가 휠이 점점 느려지는 구간이 바로 여기입니다.
루프 내에서 한 프레임 내에 동작하는 로직은 생각보다 단순해요. 계산된 위치로 이동하고(pos), 속도를 조금 줄이고(velocity), 아직 여전히 빠른 속도면 다음 프레임도 루프를 태우고, 충분히 느리게 되면 마무리를 합니다.
위 코드에서 핵심 로직 중 하나는 위치를 구하는 방식 pos += velocity * dt 인데요.
예를 들어, 만약 프레임마다 pos += 5처럼 고정 거리를 더하게 되면, 120Hz 기기는 60Hz 기기보다 같은 시간에 두 배 가까이 움직이는 문제가 발생합니다. 그 이유는 requestAnimationFrame 호출 횟수가 화면 주사율에 비례하기 때문인데요. 60Hz는 초당 약 60번, 120Hz는 약 120번 호출이 되니까요. 그래서 프레임마다 고정 거리를 더하게 되면 호출을 많이 할수록 같은 시간에 더 멀리 이동하게 되는 겁니다.
반면, dt는 이전 프레임과 지금 프레임 사이에 실제로 지난 시간이라, 이동량을 여기에 묶으면 한 프레임 당 이동 거리는 달라도 같은 시간 동안의 총거리는 비슷해집니다. 이 방법으로 기기의 주사율에 상관없이 비슷하게 휠이 굴러가는 효과를 구현할 수 있었어요.
마찰(velocity *= 0.95) 효과는 복잡하지 않게 프레임마다 상수를 곱하는 방식을 사용했습니다. 이 또한 깊게 파고들면 프레임마다 다르게 동작하긴 하지만, 휠이 서서히 힘 빠지는 느낌을 구현하는데는 큰 문제는 없었습니다. 0.95라는 값도 여러 값으로 테스트 해보다가 눈에 보기 좋은 값을 정한 것 뿐이에요.
예상 외로, 감속을 끝내고 스냅으로 넘기는 부분에서도 고려할 게 있었습니다. 처음엔 속도가 충분히 낮아지면(slowEnough) 무조건 넘기는 식으로 단순하게 구현했는데요. 그랬더니 겉보기엔 멈춘 것 같은데, slowEnough가 되기 직전까지 휠이 찔끔씩 미끄러지는 구간이 남았습니다. 그래서 속도가 꽤 낮고(nearZero), 동시에 눈금까지 4px 안일 때(nearBoundary)는 조금 일찍 스냅으로 넘기도록 조건을 하나 더 뒀습니다.
3단계 : 눈금에 자석처럼 붙는 효과
마찰로 인한 감속까지 작동을 하니, 이제 남은 건 휠의 타겟 부분을 가운데로 위치시키는 동작입니다. 즉, 휠이 눈금 사이에 애매하게 멈추면 안 되니, 가장 가까운 칸으로 끌어다가 딱 맞추는 과정인거죠.
이는 매 프레임마다 아직 도달하지 못한 남은 거리의 20%씩 좁히는 방식으로 타겟 위치에 점점 가까이 가도록 구현을 했어요. 멀리 있으면 한 번에 많이 가고, 가까워질수록 이동량이 줄어들어서 서서히 멈추듯이 부드럽게 위치가 맞춰지는 느낌을 받을 수 있습니다.
다만 거리를 줄이는 로직인 next = current + (target - current) * 0.2는 10 → 8 → 6.4 → … 처럼 값에 천천히 수렴만 할 뿐, 실제 값과 영원히 일치할 수 없는 문제가 발생하는데요. 이는 0.5px 안으로 들어오면 도착으로 보고 targetPosition에 붙인 뒤 루프를 끝내는 안전장치를 달아놨습니다.
트러블 슈팅 : passive 이벤트 이슈
휠을 돌리면 페이지 전체가 같이 스크롤되는 이슈가 작업 중에 있었습니다. 이는 세로 스와이프의 기본 동작이 페이지 스크롤인데, touchmove가 { passive: true }로 붙어서 React onTouchMove의 preventDefault()가 무시되기 때문인데요.
touchmove는 React 핸들러를 쓰지 않고, 피커 컨테이너에 { passive: false }로 네이티브 리스너를 직접 걸어서 해결했습니다. 이렇게 해야 preventDefault()가 동작하고, 페이지 스크롤은 막힌 채로 휠만 움직이기 때문이죠.
뚝뚝 끊겨서 어색했던 기존 Picker가 드디어 iOS Picker의 경험과 거의 일치하는 상태로 동작하기 시작했습니다..!
애플, 안드로이드의 앱 정책이 바뀌지 않는 이상 모바일의 배포는 당분간 병목이 될 수 밖에 없을 것이라 생각이 듭니다. 그런 만큼 WebView에 대한 수요는 꾸준할 것이라고 보는데요.
결과적으로는 삽질이었지만, 모바일에서의 경험을 그대로 유지하기 위해 노력했던 경험들과 구체적인 동작을 가설을 세워서 하나씩 해결해나가고 만족스러운 경험을 직접 구현했다는 성취감이 굉장히 재미로 다가왔던 프로젝트였습니다.
이와 같이 웹에서 사용할 수 있는 모바일 네이티브에서의 경험을 유지할 수 있는 가벼운 제품들이 이처럼 많이 나와서 대중화가 되었으면 하는 바람을 가지고 다음 글을 이어가보려고 합니다.