
우리 오픈런 서비스에는 다양한 모달에서 사용되는 BottomSheet 컴포넌트가 있습니다. 기존에는 framer-motion으로 구현되어 있었고, 아래에서 올라오고 배경을 누르면 닫히는 기본 동작에는 문제가 없었습니다. 하지만 iOS 네이티브 앱에서 흔히 볼 수 있는 핸들을 잡고 아래로 끌어서 닫는 제스처가 빠져 있었고, 이걸 넣으려고 하니 구조가 걸렸습니다.
framer-motion의 animate 속성은 목표 상태를 선언하는 방식이라, 드래그 중에 손가락을 따라 실시간으로 위치를 바꾸려면 transitionDuration을 0으로 만들어야 하고, 드래그가 끝나면 다시 애니메이션 값을 복원해야 합니다. 할 수는 있지만, 라이브러리가 해주는 일보다 직접 관리해야 하는 일이 더 많아지는 시점이었습니다.
네이티브와 같은 드래그 닫기 경험을 만들기 위해 framer-motion을 걷어내고, 직접 DOM을 조작하는 방식으로 회귀하기로 했습니다.
시작 — 손가락이 핸들에 닿으면 시작 Y좌표를 기록합니다. onClose가 없거나 이미 닫히는 중이면 드래그 자체를 시작하지 않습니다.
이동 — 매 프레임 시작점과의 거리를 계산해서, 시트를 그만큼 밀어내립니다. transition을 none으로 설정해야 손가락에 딱 붙어 따라옵니다. Math.max(0, delta)로 위쪽 드래그는 무시했는데, BottomSheet를 위로 끌어올리는 동작은 이 컴포넌트에서 처리할 이유가 없었습니다.
1const handleDragMove = (clientY: number) => {2 if (!isDragging || !sheetRef.current) return34 const delta = clientY - startYRef.current5 currentDeltaRef.current = Math.max(0, delta)67 sheetRef.current.style.transition = 'none'8 sheetRef.current.style.transform =9 `translateY(calc(${fullSize ? '7%' : '0%'} + ${currentDeltaRef.current}px))`10}종료 — 임계값(20px)을 넘겼으면 닫기, 못 넘겼으면 원래 자리로 복귀합니다.
별도로 handleDragCancel이라는 함수도 두었습니다. handleDragEnd와 비슷하지만 무조건 원위치 복귀만 합니다. 마우스가 시트 영역 밖으로 나가거나, 터치가 비정상적으로 취소될 때 호출되는 함수인데, 이런 상황에서 닫기를 트리거하면 사용자가 의도하지 않은 동작이 됩니다.
가장 까다로웠던 부분은 닫기 타이밍이었습니다. 서비스 구조상 onClose가 호출되면 모달이 즉시 언마운트되기 때문에, 슬라이드 아웃 애니메이션이 보이기도 전에 화면에서 뚝 끊기는 현상이 있었습니다.
그래서 닫기를 두 단계로 나눠 처리했습니다. 먼저 시트를 화면 밖으로 밀어내고, 애니메이션이 끝난 뒤에 onClose를 호출하는 구조입니다.
1const close = useCallback(() => {2 if (isClosingRef.current) return3 isClosingRef.current = true45 const el = sheetRef.current6 if (el) {7 el.style.transition = `transform ${TRANSITION_DURATION}ms ease-in-out`8 el.style.transform = 'translateY(100%)'9 }1011 timeoutRef.current = setTimeout(() => {12 onCloseRef.current?.()13 }, TRANSITION_DURATION)14}, [])isClosingRef는 중복 호출 방지용입니다. 드래그로 닫기가 시작된 상태에서 배경을 한 번 더 탭하면 close()가 두 번 불리는 케이스가 실제로 있었습니다.
timeout을 ref에 담아두는 것도 빠뜨리면 안 되는 부분이었습니다. 컴포넌트가 예상보다 빨리 언마운트될 수 있고, cleanup에서 clearTimeout을 호출하지 않으면 이미 사라진 컴포넌트에 대한 콜백이 실행됩니다.
onCloseRef 패턴도 여기서 필요해졌습니다. setTimeout 안에서 300ms 뒤에 onClose를 호출하는데, 그 사이에 부모가 새 onClose를 내려줄 수 있습니다. ref로 항상 최신 콜백을 참조해야 타이머가 걸린 시점의 오래된 클로저를 실행하는 문제를 피할 수 있습니다.
framer-motion을 걷어내면서 진입 애니메이션도 직접 만들어야 했습니다. 마운트 직후 translateY(100%)로 화면 밖에 놓고, 바로 translateY(0%)로 전환하면 될 것 같았는데 그렇게 단순하지 않았습니다.
브라우저가 첫 번째 스타일을 적용하기 전에 두 번째 스타일을 덮어쓰는 경우가 있습니다. 시작 위치와 도착 위치를 거의 동시에 알려주면 브라우저 입장에서는 두 스타일을 하나로 합쳐서 처리하게 되고, 결과적으로 시트가 올라오는 애니메이션 없이 그냥 나타나 버립니다.
처음에는 setTimeout으로 한 프레임을 지연시켜 봤는데, 타이밍이 불안정했습니다. 검색을 좀 해보니 requestAnimationFrame(이하 rAF)을 두 번 중첩하는 패턴이 있었고, 적용해보니 문제가 해결되었습니다.
1useEffect(() => {2 const el = sheetRef.current3 if (!el || hasMountedRef.current) return4 hasMountedRef.current = true56 el.style.transform = 'translateY(100%)'7 requestAnimationFrame(() => {8 requestAnimationFrame(() => {9 el.style.transition = `transform ${TRANSITION_DURATION}ms ease-in-out`10 el.style.transform = fullSize ? 'translateY(7%)' : 'translateY(0%)'11 })12 })13}, [fullSize])첫 번째 rAF에서 브라우저가 translateY(100%)를 렌더링하고, 두 번째 rAF에서 transition과 함께 목표 위치를 설정합니다. 이래야 브라우저가 시작점과 끝점을 별개 프레임으로 인식해서 애니메이션이 동작합니다.
hasMountedRef는 진입 애니메이션을 딱 한 번만 실행하기 위한 플래그입니다. fullSize 같은 의존성이 바뀔 때마다 시트가 아래에서 다시 올라오면 안 되니까요.
useImperativeHandle로 close() 메서드를 노출했기 때문에, 사용하는 쪽에서는 ref를 통해 닫기를 트리거합니다.
onClose를 직접 호출하는 게 아니라 close()를 거치도록 바꾼 것이 핵심입니다. 배경 탭, 드래그, 프로그래밍 방식 어디서 닫기를 트리거하든 애니메이션이 동일하게 재생됩니다.
드래그 시작은 핸들 영역에서만 받고, 이동과 종료는 시트 전체에서 받습니다. 이렇게 해야 드래그 중에 손가락이 핸들을 벗어나도 동작이 끊기지 않습니다.
1<div ref={sheetRef}2 onMouseMove={handleDragMove} onMouseUp={handleDragEnd}3 onMouseLeave={handleDragCancel}4 onTouchMove={handleDragMove} onTouchEnd={handleDragEnd}5 onTouchCancel={handleDragCancel}6>7 {/* 드래그 시작은 여기서만 */}8 <div onMouseDown={handleDragStart} onTouchStart={handleDragStart}>9 <div className="drag-handle" />10 </div>11 {children}12</div>onMouseLeave와 onTouchCancel에 앞서 언급한 handleDragCancel이 걸려 있습니다. 마우스가 시트 밖으로 나가면 닫기 판정 없이 원위치시킵니다. 모바일에서는 거의 발생하지 않는 케이스지만, 데스크톱에서 테스트하다 보면 은근 자주 밟힙니다.
배포하고 나서도 이중 rAF 패턴이 계속 마음에 걸렸습니다. 동작은 하는데 브라우저 렌더링 파이프라인에 의존하는 방식이라 업데이트에 따라 깨질 가능성을 완전히 배제할 수 없었습니다. Web Animations API로 교체하는 방안을 검토하고 있고, 다음 리팩토링 때 시도해볼 생각입니다.
드래그 중 지연은 framer-motion을 걷어낸 뒤 체감할 수 있을 정도로 줄었고, 앱 내 8개 BottomSheet에 같은 패턴을 적용하는 데 하루가 채 안 걸렸습니다.
아직 남은 건 제스처 임계값 튜닝입니다. 지금은 20px 고정인데, 드래그 속도를 함께 고려하는 방식이 더 자연스러울 수 있습니다. 사용자 피드백을 좀 더 모아본 뒤에 손볼 생각입니다.