프론트엔드·모바일
- 제스처 우선순위 · 페이저 · iOS 엣지 백 설계
- 웹·데스크톱(PWA) 버전, 2단 레이아웃
- 컴포넌트 개발 하네스(Dev 탭) 구축
- 프론트·백 공유 계약(spec) 저장소 운영
- 스토어 출시 · GA4 계측 · 랜딩(Astro)
빌드·배포
- GitHub Actions 워크플로 12종
- EAS 빌드 · OTA 배포 · 릴리스 스모크 테스트
문제 해결 기록
손가락 하나에 뜻이 다섯이던 화면, 제스처의 우선순위를 설계하자
배경
한 화면 위에 세로 당김 새로고침, 하단 탭 가로 페이저, 방↔달력 서브탭 플링, 집 화면 핀치 확대·자리 드래그가 겹쳐 있었고, 아이폰 사용자 습관에 맞춰 직접 만든 가장자리 뒤로가기까지 같은 터치를 두고 경합했습니다.
집을 확대해 두면 옆 탭으로 못 넘어갔고, 같은 가로 스와이프가 위치에 따라 '달력으로'와 '집으로'로 갈렸으며, 시뮬레이터에선 되던 것이 실기기·다른 OS에서 달랐습니다. JS 스레드에서 처리하니 손을 놓은 뒤에야 화면이 따라와 '손맛'도 죽었습니다.
시도한 방법
판정 문법을 하나로 — 축과 임계
가로 제스처는 activeOffsetX ±24 / failOffsetY ±36, 세로 당김은 activeOffsetY ±12 / failOffsetX ±12. 한쪽의 실패 조건이 다른 쪽의 활성 조건이 되게 맞췄습니다.
누가 먼저 판정하는지 그래프로
더 깊은 자식 디텍터가 동률에서 이기고, iOS는 자식 ScrollView가 requireExternalGestureToFail로 페이저의 방향 판정을 기다립니다. 새로고침 팬은 스크롤과 동시 인식.
UI 스레드로 옮기고 상태 커밋만 JS로
worklet에서 판정·이동을 처리하고 runOnJS는 탭 선택·저장 같은 커밋에만 씁니다. 제스처 객체는 마운트 시 1회만 만들어 드래그 중 리렌더에도 살아남게 했습니다.
관련 코드
// 가로 우세 + 최소 이동일 때만 활성 — 세로 스크롤·탭은 살려 둔다
export const SWIPE_CLAIM_DX = 24;
export const SWIPE_FAIL_DY = SWIPE_CLAIM_DX * 1.5;
export const horizontalFlingGesture = (id, onFling) =>
Gesture.Pan().withTestId(id).runOnJS(true)
.activeOffsetX([-SWIPE_CLAIM_DX, SWIPE_CLAIM_DX])
.failOffsetY([-SWIPE_FAIL_DY, SWIPE_FAIL_DY]);// iOS: 자식 ScrollView는 페이저의 방향 판정을 기다린다
export function usePagerNativeGesture(pager) {
const native = Gesture.Native();
return pager ? native.requireExternalGestureToFail(pager) : native;
}
// pager && Platform.OS === 'ios'
// ? <GestureDetector gesture={native}>{scroll}</GestureDetector>
// : scroll// 집 화면: 카메라가 이동을 가져가는 조건 (UI 스레드)
export function cameraClaimsMove(touchCount, zoomed, draggingSeat, dx, dy) {
'worklet';
if (draggingSeat) return false;
if (touchCount >= 2) return true;
return zoomed && Math.hypot(dx, dy) > CAM_PAN_SLOP;
}- 임계는 상수로, 한 곳에
- 가로 우세 판정(24px)과 세로 실패 임계(×1.5)를 utils/gesture.ts에 모아, 집 전환·친구 방 순회·온보딩 슬라이드가 같은 문법을 씁니다.
- iOS만 스크롤 중재
- iOS는 ScrollView가 페이저보다 먼저 터치를 잡아 가로 스와이프가 죽었습니다. 자식 스크롤을 Gesture.Native로 감싸 페이저가 '아니다'라고 할 때까지 기다리게 했고, Android는 그대로 둡니다.
- 집 화면 잠금
- 두 손가락이거나 확대 상태의 이동은 카메라가 가져가고(worklet), 그동안 페이저를 잠급니다. 단, 집 페이지가 활성일 때만.
결과
세로 당김·가로 페이저·핀치 확대가 한 화면에서 서로를 방해하지 않고, iOS와 Android가 같은 손동작에 같은 결과를 냅니다.
판정과 이동이 UI 스레드에서 돌아 손가락을 따라오는 반응이 즉시 나옵니다.
만났던 이슈 — 다 맞춰도, 같은 손동작에 뜻이 셋
임계와 우선순위를 다 맞춰도 '나의 방'에서는 가로 스와이프가 손가락 위치에 따라 다른 뜻이었습니다. 달력 위에서는 '월 이동', 방 영역에서는 '방↔달력 서브탭 전환', 그 밖에서는 '하단 탭 이동'.
세 제스처가 모두 정상 동작해도 사용자는 무엇이 일어날지 예측할 수 없었습니다. 기술적으로는 풀 수 있는 충돌이었지만, 풀수록 규칙이 늘어 사용자에게 설명이 안 되는 화면이 됐습니다.
해결 — 충돌을 푸는 대신 뜻을 하나로 줄였습니다
방↔달력 서브탭 플링과 달력 월 스와이프를 제거하고(monthSwipe=false), 가로 스와이프는 어디서 시작하든 하단 탭 이동 하나만 뜻하게 했습니다. 서브탭은 탭 버튼으로만 바꿉니다. (#825)
제거한 코드는 남겨 두지 않고 주석에 이유를 적어, 나중에 누가 다시 붙이려 할 때 왜 뺐는지 알 수 있게 했습니다.
// monthSwipe=false 유지 (#825) — 달력 위 가로 스와이프가
// '월 이동'이라는 또 다른 뜻을 갖지 않게 한다.
<Calendar monthSwipe={false} ... />
// 스와이프는 전부 셸 탭 페이저 몫 — 나의 방 안에서 같은 손동작이
// 손가락 위치에 따라 '달력으로'와 '집으로'로 갈리던 것을 하나로 통일.만났던 이슈 — iOS 실기기에서만, 스와이프가 갑자기 멈춤
iOS 실기기에서만, 탭 사이를 스와이프하다 갑자기 멈추고 그 뒤로 스와이프가 전혀 먹지 않았습니다. 시뮬레이터에선 재현이 안 됐습니다.
원인은 뒤로가기였습니다. 앱이 네이티브 스택이 아니라 셸 상태로 화면을 바꾸는 구조라 아이폰 사용자 습관에 맞춰 엣지 백을 직접 만들었는데(#564), 이 팬이 페이저와 같은 터치를 두고 경합했습니다.
형제 디텍터가 리렌더로 재부착되며 활성 팬이 취소되면 onFinalize가 JS에 닿지 않아 swiping 래치가 true로 굳고, 이후 활성화가 영영 건너뛰어졌습니다. (2026-09-08 분석)
해결 — 인식 단계에서 끄고, 래치는 매 터치마다 리셋
탭 루트에선 엣지 백 인식 자체를 끔 — JS 가드가 아니라 enabled로, 페이저의 터치를 먼저 잡을 수 없게 (#1143)
래치를 매 터치 시작·외부 탭 전환에서 리셋 — onFinalize가 누락돼도 다음 스와이프가 살아남게
엣지 백은 탭/서브화면 경계를 넘을 때만 재구성 — 데이터 리렌더가 활성 제스처를 갈아끼우지 않게. 서브화면은 전폭 스와이프 백(#1135), 가로 제스처를 쓰는 화면은 왼쪽 28px만
// 탭 루트에선 인식 자체를 끈다 (#1143)
const edgeBackEnabled =
Platform.OS === 'ios' && TAB_FOR_SCREEN[screen] == null;
const edgeBackPan = useMemo(() => Gesture.Pan()
.enabled(edgeBackEnabled).runOnJS(true).maxPointers(1)
.activeOffsetX(24).failOffsetY([-16, 16])
.onTouchesDown((e, mgr) => {
// 서브화면은 전폭(#1135), 가로 제스처 화면은 왼쪽 28px만
const ok = fullSwipeRef.current || e.allTouches[0].x <= EDGE_BACK_WIDTH;
if (!ok) mgr.fail();
})
.onEnd((e) => {
if (e.translationX > 64 || e.velocityX > 700) goBackRef.current();
}),
[edgeBackEnabled]); // 경계를 넘을 때만 재구성.onTouchesDown((e, mgr) => {
// iOS: 활성 팬이 형제 디텍터의 재부착·취소로 드롭되면
// onFinalize가 JS에 닿지 않아 true로 굳는다 (2026-09-08 분석)
swiping.value = false;
...배운 것
제스처 충돌은 대부분 기술로 풀 수 있지만, 풀었다고 사용자가 편해지는 건 아닙니다. 손동작 하나에 뜻이 여럿이면 규칙을 아무리 잘 세워도 사용자는 그 규칙을 모릅니다.
실기기에서 iOS·Android를 번갈아 눌러 보는 것을 검증 절차에 넣었습니다. 시뮬레이터가 통과시키는 제스처 버그가 생각보다 많았습니다.
그 밖에 만든 것
같은 코드로 웹·데스크톱까지
expo export --platform web으로 PWA 빌드, app.rougether.com에 배포. .web.ts 모듈 6개로 네이티브 SDK를 웹 번들에서 제외하고, 카카오 로그인은 REST 인가 코드 리다이렉트를 직접 구현.
데스크톱은 480px 폰 컬럼, 960px 이상은 [캔버스 | 할 일] 2단으로 최대 1200px.
정적 export의 서버 렌더는 창 폭을 몰라 첫 렌더 마크업이 어긋나면 React가 className 차이를 고치지 않아 프레임이 영영 안 붙었습니다. 첫 렌더는 서버와 같게 두고 마운트 뒤에 프레임을 적용하고, 루트 타입이 Fragment↔View로 바뀌어 리마운트되지 않도록 항상 같은 트리를 그립니다.
곰 발바닥 당겨서 새로고침
RN RefreshControl은 인디케이터 커스텀이 안 돼, RNGH 팬 + Reanimated로 직접 구현.
맨 위에서 아래로 끌면 콘텐츠가 절반 저항으로 따라오고 발바닥이 자라남 — iOS 바운스는 이중 동작이라 끔.
하단바 드래그 선택
알약 안에서 8px 넘게 끌면 선택 표시가 손가락을 따라오고, 놓을 때 한 번만 전환.
실제 탭 너비·중심을 측정해 글꼴·화면 폭이 달라도 좌표를 가정하지 않음.
배포가 사용자에게 닿았는지까지 검증
GitHub Actions 워크플로 12종 — CI, EAS 빌드, dev·production OTA, 핫픽스 OTA, iOS 릴리스 스모크 테스트.
배포 후 번들 지문을 대조해 OTA 도달 여부를 확인하고 Sentry 소스맵을 함께 올려, 릴리스 성공 여부를 사람이 확인하지 않아도 되게.






