테크
2025. 07. 09
웹 기술로 구현한, 네이티브에 가까운 바텀시트
제스처부터 성능, UX까지 세세한 터치를 더한 개발기
2024년 9월, 비누랩스는 ‘에브리타임 베트남’ MVP 버전을 론칭하며 해외 시장에 첫 발을 내디뎠습니다. 글로벌 버전은 빠른 실험과 배포를 위해 웹 기술 기반의 구조를 선택했고, 강의평 관련 페이지는 HTML, CSS, JavaScript를 사용해 웹뷰 환경에서 구현했습니다.

최근에는 하이브리드 앱이나 PWA(Progressive Web App)처럼 웹 기술을 활용해 모바일 화면을 개발하는 사례가 늘고 있습니다. 하나의 코드로 여러 플랫폼에 대응할 수 있고, 네이티브 앱에 비해 높은 생산성을 제공한다는 점이 큰 장점입니다.
하지만 모바일에서 웹 기술을 사용할 경우, 데스크톱과는 본질적으로 다른 사용 환경을 고려해야 합니다. 데스크톱에서는 넓은 화면에서 마우스와 키보드와 같은 입력기기를 사용하고, 고성능 기기가 일반적인 반면, 모바일은 작은 화면에서 손가락 터치를 사용하고 상대적으로 낮은 성능의 기기에서 동작해야 합니다.
이러한 차이로 인해, PWA나 웹뷰 기반의 모바일 UI에서 자연스러운 사용자 경험을 제공하려면 개발자의 추가적인 고민과 노력이 필요합니다. 강의평 작성 기능을 구현하면서 이 점을 크게 체감했는데요. 그중에서도 바텀시트처럼 모바일에서 특화된 UI를 웹 기술로 구현하는 데 특히 많은 공을 들였습니다.
이번 글에서는 네이티브 앱 수준의 사용자 경험을 목표로 바텀시트를 구현하면서 고민했던 기술적 선택과 성능 개선 과정을 공유해보려고 합니다.
Part 1:: 웹 기술로 네이티브처럼 바텀시트 구현하기
바텀시트는 화면 하단 영역을 활용해 기존 화면의 컨텍스트를 유지하면서 새로운 정보를 제공하는 컴포넌트입니다. 모바일처럼 화면이 작은 환경에서는 사용자의 작업 흐름을 방해하지 않고 필요한 정보를 자연스럽게 보여줄 수 있다는 점에서 유용합니다. 또한 사용자는 화면을 쓸어 올리거나 내리는 간단한 동작만으로 바텀시트를 열고 닫을 수 있어, 더 쉽고 자유롭게 사용할 수 있습니다.
1. 바텀시트 기본 구성
1.1 바텀시트 컴포넌트 동작 설계
바텀시트 동작 구현의 핵심은, 바텀시트의 현재 상태와 위치를 어떻게 관리하고, 이를 어떻게 화면에 반영할지 결정하는 것입니다. 에브리타임 글로벌 앱에서는 top
이라는 이름의 JavaScript 상태값(state)을 사용해 바텀시트의 위치와 활성화 상태를 관리합니다.

이러한 방식에는 세 가지 주요 장점이 있습니다. 첫째, top
값을 기준으로 바텀시트의 위치를 코드상에서 직관적으로 파악할 수 있습니다. 둘째, 상태 값을 변경하는 것만으로 바텀시트의 위치를 손쉽게 지정할 수 있습니다. 셋째, 특정 상태값에 제한되지 않기 때문에 다양한 상황에 유연하게 대응이 가능합니다.
바텀시트를 부드럽게 이동시키는 애니메이션도 간단히 구현할 수 있습니다. top
상태 값이 변경될 때마다 해당 값을 기반으로 CSS의 transform
속성을 갱신하고, 여기에 transition
속성을 함께 적용하면 부드러운 움직임을 손쉽게 구현할 수 있습니다.
아래는 바텀시트의 기본 구조를 보여주는 코드 예시입니다. 에브리타임 글로벌 앱의 웹뷰는 모두 Vue로 작성되어 있기 때문에, 예시 코드 역시 Vue 기반으로 작성했습니다.
<!-- BottomSheet.vue -->
<template>
<!-- scrim: 바텀시트 바깥 배경, 클릭하면 바텀시트를 닫도록 함-->
<div
class="scrim"
:class="{deactive: isBottomSheetActive}"
@click.prevent="() => close()"
></div>
<div
class="bottom-sheet-container"
:style="{
'--bottomsheet-translateY': translateYValue // CSS var로 자바스크립트 state를 css로 전달
}"
>
<!-- 컨텐츠 영역 -->
<div class="bottom-sheet-content">
<!-- 바텀시트 컨텐츠 주입 슬롯 -->
<slot name="contentSlot"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useBottomSheet } from './useBottomSheet';
const { store, close } = useBottomSheet();
// 바텀시트 활성화 여부 (store.top 값으로 판단)
const isBottomSheetActive = computed(() => store.top !== null);
const translateYValue = computed(() => {
// 열려있으면 top 위치로, 닫혀있으면 화면아래 그대로 유지
return store.top !== null
? `-${window.innerHeight - store.top}px`
: '0%';
});
</script>
<style scoped lang="scss">
.scrim {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 10;
&.deactive {
opacity: 0;
pointer-events: none;
}
}
.bottom-sheet-container {
position: fixed;
top: 100%; /* 초기 위치: 화면 아래 */
left: 0;
width: 100%;
background-color: white;
transition: transform 0.4s ease-in-out;
transform: translateY(var(--bottomsheet-translateY)); /* css var 사용 */
}
</style>
// useBottomSheet.ts
import { defineStore } from 'pinia';
import { reactive } from 'vue';
// 바텀시트 상태(위치, 열림/닫힘 등) 관리를 위한 전역 store
export const useBottomSheet = defineStore('bottomSheet', () => {
const store = reactive({
top: null as number | null, // 바텀시트 상단 위치 (px, 화면 최상단 기준). `null`일때 닫힘.
});
// 바텀시트를 여는 함수
// targetTop은 바텀시트를 열었을때의 목표 top 위치(px). 예시는 기본값을 화면 50% 지점으로 임의로 정함
const open = (targetTop?: number) => {
store.top = targetTop ?? (window.innerHeight * 0.5)
};
// 바텀시트 닫는 함수
const close = () => {
store.top = null;
};
return {
store,
open,
close
};
})
<!-- 바텀시트를 사용하는 컴포넌트 예시 -->
<script setup lang="ts">
import BottomSheet from './BottomSheet.vue';
import { useBottomSheet } from './useBottomSheet';
const { open } = useBottomSheet();
</script>
<template>
<div class="app-container">
<button @click="() => open()">
바텀시트 열기버튼 (화면 중간 높이)
</button>
<button @click="() => open(100)">
바텀시트 열기버튼 (상단에서 100px)
</button>
<BottomSheet>
<template #contentSlot>
<div class="example-content">
<h2>바텀시트 내용</h2>
<p>바텀시트의 contentSlot에 추가됩니다.</p>
</div>
</template>
</BottomSheet>
</div>
</template>
1.2 핸들러 영역 확보
바텀시트 상단에 보통 ‘핸들러’라고 불리는 조작 UI가 있지만, 실제로 사용자가 터치해서 조작하기에는 그 범위가 충분하지 않을 수 있습니다. 따라서 바텀시트를 편리하게 조작할 수 있도록, 적절한 핸들러 영역을 확보하는 것이 중요합니다.
바텀시트를 다룰 때 흔히 생각할 수 있는 방식은 바텀시트 전체를 스와이프하여 조작하는 것입니다. 하지만 이 경우 내부에 스크롤 가능한 콘텐츠가 있다면 문제가 생깁니다. 사용자가 콘텐츠를 스크롤하려고 할 때 바텀시트 자체가 함께 스와이프되는 상황이 발생할 수 있기 때문입니다.
YouTube 모바일 웹의 바텀시트를 살펴보면, 이런 상황이 발생하지 않도록 조작 가능한 핸들러 영역이 명확히 구분되어 있는 것을 확인할 수 있습니다.

에브리타임 글로벌 앱의 강의평 페이지에 사용된 바텀시트에는 긴 스크롤 영역이 포함되어 있기 때문에, 핸들러와 콘텐츠 영역을 명확히 분리하여 제스처로 인한 동작에 간섭이 발생하지 않도록 구현했습니다. 바텀시트 컴포넌트는 헤더(핸들러)와 바디(콘텐츠)로 나뉘며, 바텀시트 이동과 관련된 제스처는 오직 헤더 영역에서만 인식하도록 설정해 스크롤 동작과의 충돌을 방지했습니다. 또한, 헤더 영역에도 slot
을 추가해 원하는 UI 요소를 자유롭게 배치하면서도 충분한 터치 영역을 확보할 수 있도록 설계했습니다.
아래는 바텀시트의 핸들러 영역과 콘텐츠 영역을 구분하여 구성한 컴포넌트 구조 예시입니다. 실제 앱에서는 handle 슬롯에 UI 요소를 삽입해 충분한 터치 영역을 확보할 수 있습니다.
<!-- BottomSheet.vue -->
<template>
...
<div
class="bottom-sheet-container"
...
>
<!-- 핸들러 영역 추가 -->
<!-- 터치 이벤트 핸들러는 이 요소에 추가됩니다-->
<div
class="bottom-sheet-handle-area"
>
<div class="default-handle-bar"></div> <!-- 기본 핸들 바 UI -->
<slot name="handle"></slot>
</div>
<div class="bottom-sheet-content">
<slot name="contentSlot"></slot>
</div>
</div>
</template>
...

2. 바텀시트에 스와이프 제스처 구현하기
2.1 터치 이벤트 핸들러 구조화
스와이프 제스처를 구현하기 앞서, 터치 이벤트를 처리할 핸들러 함수부터 살펴보겠습니다. 이벤트 핸들러는 컴포넌트 파일의 복잡도를 낮추기 위해 별도의 파일로 분리했으며, 고차 함수 패턴을 활용해 외부에서 선언한 store
를 주입 받을 수 있도록 구성했습니다. 이를 통해 핸들러 내부에서 상태 값을 유연하게 사용할 수 있도록 했습니다.
<!-- BottomSheet.vue -->
<template>
...
<!-- 제스쳐 구현에 사용할 터치 핸들러 추가 -->
<div
class="bottom-sheet-handle-area"
ref="bottomSheetRef"
@touchstart="handleTouchStart(bottomSheetContext)"
@touchmove="handleTouchMove(bottomSheetContext)"
@touchend="handleTouchEnd(bottomSheetContext)"
@touchcancel="handleTouchEnd(bottomSheetContext)"
>
<div class="default-handle-bar"></div>
<slot name="handle"></slot>
</div>
...
</template>
<script>
import {useBottomSheet} from "./useBottomSheet"
import {handleTouchStart, handleToucheMove, handleTouchEnd} from "./eventHandler"
// 이벤트 핸들러로 넘겨줄 값들
const bottomSheetContext = useBottomSheet();
const {store, close} = bottomSheetContext
// 바텀시트 mount 이후 store에 Ref를 할당해서, 이벤트 핸들러에서 사용
onMounted(()=>{
store.bottomSheetRef = bottomSheetRef
})
...
</script>
// eventHandler.ts
export const handleTouchMove =
({store}) => // bottomSheetContext의 store 값
(e: TouchEvent) => {
const currentY = e.touches[0].clientY;
const {bottomSheetRef} = store;
// store.top을 바꾸지 않고 DOM을 직접 다루는 이유는 3.1 랜더링 최적화 섹션에서 설명합니다
bottomSheetRef.style.setProperty(
'transform', `translateY(-${window.innerHeight - currentY}px)`
);
};
2.2 터치 위치 기준점 보정
사용자가 바텀시트의 헤더 영역을 터치한 후 위아래로 스와이프할 때, 터치 이벤트에서 얻는 Y좌표(e.touches[0].clientY
)는 화면의 가장 위를 기준으로 측정됩니다. 이 Y좌표 값을 그대로 바텀시트의 위치로 사용하면, 터치하는 순간 바텀시트가 갑자기 점프하듯이 튀어 움직이는 현상이 발생합니다.

이유는 간단합니다. 현재 바텀시트의 위치와 터치한 지점 사이의 간격(=offset)을 고려하지 않았기 때문입니다. 이 문제를 해결하기 위해 터치가 시작될 때의 Y좌표를 저장해두고, 그 시점의 바텀시트 위치(top)와의 차이를 계산합니다. 이후 스와이프가 진행되는 동안에는 현재 터치 좌표에 이 offset을 더해 바텀시트의 위치를 보정합니다.
이렇게 계산된 위치 값을 translateY
CSS 속성에 적용하면, 바텀시트가 손가락 움직임을 자연스럽게 따라오는 것처럼 보입니다.
아래는 이 위치 보정 로직을 실제 코드로 구현한 예시입니다.
// eventHandler.ts
export const handleTouchStart =
({store}) =>
(e: TouchEvent) => {
// 터치 시작 Y 좌표를 store.startY에 기록
store.startY = e.touches[0].clientY;
};
export const handleTouchMove =
({store}) =>
(e: TouchEvent) => {
const currentY = e.touches[0].clientY;
// 기록된 터치 시작 Y 좌표와 초기 top 값을 store에서 가져옴
const { startY, top: initialTop, bottomSheetRef} = store;
// 1. 터치 시작점과 바텀시트 초기 top 값의 차이(offset) 계산
const offset = initialTop - startY;
// 2. 현재 터치 Y 좌표에 offset을 더해, 바텀시트의 새로운 top 위치 계산
const newTopPosition = currentY + offset;
// 3. 계산된 newTopPosition을 사용해 보정된 값으로 바텀시트 위치 업데이트
bottomSheetRef.style.setProperty(
'transform', `translateY(-${window.innerHeight - newTopPosition}px)`
);
}
2.3 스와이프 시 Transition 처리 방식 개선
앞서 바텀시트를 구현했을때, 기본적으로 transition
속성을 통해 애니메이션을 구현했다고 말씀드렸습니다. 바텀시트가 활성화되거나 비활성화될 때는 transition
속성을 이용해 간편하고 부드러운 전환 효과를 줄 수 있습니다. 하지만 transition
의 duration
이 적용된 상태에서는 사용자가 스와이프할 때, 바텀시트가 손가락 움직임을 즉각적으로 따라오지 못하고, 다소 지연되는 듯한 느낌을 줍니다.
이 문제를 해결하기 위해, 스와이프가 시작되는 시점에 바텀시트 DOM 요소의 transition-duration
속성을 0초로 설정해 일시적으로 애니메이션을 제거합니다. 이를 통해 사용자의 손가락 움직임에 지연 없이 즉각적으로 반응할 수 있도록 합니다. 이후 스와이프가 종료되는 시점에는 transition-duration
속성을 다시 원래 값으로 복구해, 바텀시트가 최종 위치로 부드럽게 이동하며 애니메이션이 마무리되도록 합니다.
// eventHandler.ts
export const handleTouchMove =
({store}) =>
(e: TouchEvent) => {
const currentY = e.touches[0].clientY;
const { startY, top: initialTop, bottomSheetRef } = store;
const offset = initialTop - startY;
const newTopPosition = currentY + offset;
// 스와이프하는 중에는 transition-duration을 0초로 설정
bottomSheetRef.style.setProperty(
'transition-duration', '0s'
);
bottomSheetRef.style.setProperty(
'transform', `translateY(-${window.innerHeight - newTopPosition}px)`
);
};
export const handleTouchEnd =
({store}) =>
(e: TouchEvent) => {
const { bottomSheetRef } = store;
// transition-duration 다시 원래대로 복귀
bottomSheetRef.style.setProperty(
'transition-duration', '0.4s'
);
};
2.4 제스처 후 바텀시트 닫기 처리
사용자가 바텀시트를 스와이프한 뒤 별도의 처리를 하지 않으면, 바텀시트가 중간 위치에 어정쩡하게 멈춰있는 상태로 남게 됩니다. 이를 방지하기 위해 스와이프가 끝난 시점의 바텀시트 위치를 기준으로, 닫을지 아니면 원래 위치로 복귀시킬지를 판단해야 합니다.
또한 transition-duration
속성은 스와이프 중에는 0초로 설정되었다가, 터치가 끝난 뒤 다시 0.4초로 복원되어야 다음 바텀시트 활성화/비활성화 시 부드러운 애니메이션이 적용됩니다.
// eventHandler.ts
export const handleTouchMove =
({store}) =>
(e: TouchEvent) => {
const currentY = e.touches[0].clientY;
const { startY, top: initialTop, bottomSheetRef } = store;
const offset = initialTop - startY;
const newTopPosition = currentY + offset;
// touchEnd에는 터치좌표가 주어지지 않으므로 store에 저장
store.currentY = currentY;
bottomSheetRef.style.setProperty(
'transition-duration', '0s'
);
bottomSheetRef.style.setProperty(
'transform', `translateY(-${window.innerHeight - newTopPosition}px)`
);
};
export const handleTouchEnd =
({store, close}) =>
(e: TouchEvent) => {
const { startY, currentY, bottomSheetRef, top:initialTop } = store;
const offset = initialTop - startY;
const newTopPosition = currentY + offset;
// transition-duration 다시 0.4초로 복구
bottomSheetRef.style.setProperty(
'transition-duration', '0.4s'
);
// ex) 스와이프가 끝난 시점에 바텀시트가 화면 1/2 보다 덜 차지할때 닫는 처리
if (
(window.innerHeight - newTopPosition) * 2 < window.innerHeight
) {
close();
} else {
// 아니면 초기 top 값으로 복귀
bottomSheetRef.style.setProperty(
'transform', `translateY(-${window.innerHeight - initialTop}px)`
);
}
};
2.5 바텀시트의 최대 높이 설정
바텀시트를 화면 상단까지 완전히 확장하는 플로우가 없다면, 바텀시트가 올라갈 수 있는 위치의 최댓값을 제한해주는 것이 좋습니다. 사용자가 바텀시트를 화면 끝까지 밀어 올릴 경우, 보정값(offset) 때문에 바텀시트가 화면 위로 벗어나는 현상이 발생할 수 있고, 내부 콘텐츠 영역을 충분히 확보하지 않았다면 바텀시트 하단에 의도치 않은 빈 공간이 드러날 수 있습니다.

에브리타임 글로벌 앱에서는 이러한 상황을 방지하기 위해 바텀시트가 초기 위치보다 더 위로 올라가지 않도록 제한하는 처리를 적용했습니다.

3. 플링(Fling) 제스처 구현하기
스와이프만으로는 네이티브 앱 수준의 사용성을 완벽하게 구현했다고 보기엔 아쉬운 점이 있습니다. 사용자가 바텀시트를 빠르게 닫거나 열고 싶을 때, 화면을 순간적으로 위아래로 쓸어 올리거나 내리는 동작 역시 구현해야 합니다. 이러한 동작을 플링(fling) 제스처라고 합니다. 플링은 터치스크린에서 손가락을 빠르게 쓸어 넘기는 동작으로, 바텀시트의 경우에는 바텀시트를 즉시 닫거나 펼치는 데 유용하게 쓰일 수 있습니다.
3.1 어떤 동작을 플링으로 인식할까?
처음에는 플링을 인식하기 위해 터치 시작 지점의 타임스탬프를 저장하고, 짧은 시간 동안의 Y축 이동 거리를 기준으로 판단하는 방식부터 시도했습니다. 예를 들어, 터치 시작 직후 0.1초 안에 약 40픽셀 이상 움직이면 플링으로 간주하는 식입니다.
하지만 이 방식은 다양한 사용자의 터치 패턴을 제대로 반영하지 못했습니다. 예를 들어 어떤 사용자는 화면을 길게 누른 후 마지막 순간에만 빠르게 움직이거나, 상하가 아닌 대각선 방향으로 손가락을 움직이는 경우도 있습니다. 이런 상황에서는 플링 동작을 정확히 인식하지 못하는 문제가 생깁니다.
우리가 목표한 것은 네이티브 앱 수준의 제스처 구현이기 때문에, 네이티브 바텀시트 구현 방식을 참고했습니다. 네이티브 구현에서는 손가락을 화면에 댄 시점의 순간 속도가 아닌, 손가락을 화면에서 떼는 바로 그 순간의 ‘순간 속도’와 ‘방향’을 기준으로 플링 여부를 판단하고 있었습니다.
즉, 스와이프 도중의 속도와 관계 없이, 손을 떼는 순간의 속도가 빠르면 플링으로 간주하는 것입니다.
🔗 관련 코드 참고 🔻
1️⃣ 손을 떼는 순간 velocity 측정 (Android)
2️⃣ y축 velocity를 판단해서 바텀시트 위치 지정 (Android)
이런 네이티브 방식을 참고하여, touchStart 이벤트가 발생하면 초기 터치 정보를 기록하고, 이후 발생하는 모든 touchMove 이벤트마다 타임스탬프와 좌표값을 업데이트해 순간 속도(Velocity)를 계산하도록 구현했습니다. 이렇게 매 이동 시점마다 속도를 계산해두면, touchEnd 시점에 직전의 속도를 기준으로 플링 여부를 즉시 판단할 수 있습니다.
또한 바텀시트는 손가락을 위로 밀면 펼쳐지고 아래로 내리면 닫히는 수직 동작 중심의 UI이기 때문에, 수평(x축) 이동이 수직(y축)보다 더 큰 경우는 플링으로 인식하지 않도록 제한했습니다.
3.2 제스처에 맞춘 트랜지션 속도 조절
바텀시트가 열리거나 닫힐 때, 일반적인 동작과 플링 제스처에 따른 동작에 각각 다른 transition-duration
을 적용하면 보다 자연스럽고 직관적인 사용자 경험을 만들 수 있습니다. 예를 들어, 평소에는 약 0.4초 정도의 부드러운 애니메이션을 사용하지만, 플링처럼 빠르게 닫히거나 열릴 때는 0.15초 정도로 더 빠른 속도를 적용해 즉각적인 반응성을 높일 수 있습니다.
// eventHandler.ts
export const handleTouchStart =
({store}) =>
(e: TouchEvent) => {
const {clientX: currentX, clientY: currentY} = e.touches[0];
store.startY = currentY;
// touchStart에서 시작 시간, 현재 좌표도 추가로 저장
store.currentX = currentX;
store.currentY = currentY;
store.timeStamp = performance.now();
};
export const handleTouchMove =
({store}) =>
(e: TouchEvent) => {
const {clientX: currentX, clientY: currentY} = e.touches[0];
const {
currentX: lastX,
currentY: lastY,
timeStamp: lastTimestamp,
startY,
top:initialTop
} = store;
const offset = initialTop - startY;
const newTopPosition = currentY + offset;
const now = performance.now();
// touchMove가 일어날 때 마다 속도를 계산
const lastVelocity =
Math.abs(lastX - currentX) > Math.abs(lastY - currentY)
? 0 // y축보다 x축으로 많이 이동한 경우 속도는 0
: (currentY - lastY) / (now - lastTimestamp) * 1000; // velocity 계산
// 계산한 velocity 저장
store.lastVelocity = lastVelocity;
// 다음 touchMove 이벤트에서 계산을 위해 currentX, currentY 업데이트
store.currentX = currentX
store.currentY = currentY
bottomSheetRef.style.setProperty(
'transform', `translateY(-${window.innerHeight - newTopPosition}px)`
);
};
export const handleTouchEnd =
({store, close}) =>
(e: TouchEvent) => {
const { startY, currentY, top: initialTop, lastVelocity, bottomSheetRef} = store;
const offset = initialTop - startY;
const newTopPosition = currentY + offset;
const FLING_VELOCITY_THRESHOLD = 1000;
// velocity 경계값을 넘은 경우를 체크 (예시는 닫는 케이스만 고려함)
if (FLING_VELOCITY_THRESHOLD < lastVelocity) {
// fling 동작으로 바텀시트가 닫히는 경우 transition-duration을 0.15s로
bottomSheetRef.style.setProperty('transition-duration', '0.15s');
close();
} else if (
(window.innerHeight - newTopPosition) * 2 < window.innerHeight
) {
bottomSheetRef.style.setProperty('transition-duration', '0.4s');
close()
} else {
bottomSheetRef.style.setProperty('transition-duration', '0.4s');
bottomSheetRef.style.setProperty(
'transform', `translateY(-${window.innerHeight - newTopPosition}px)`
);
}
};
Part 2:: 디테일을 다듬어 퀄리티 높이기
여기까지 구현했다면, 이제 바텀시트의 활성화와 비활성화뿐 아니라 스와이프와 플링 동작까지 인식하는 인터렉션을 만들 수 있게 되었습니다. 하지만 이게 끝이 아닙니다. 성능을 극대화하고 브라우저의 기본 동작과의 충돌을 정교하게 제어해야 진짜 ‘네이티브 같은’ 바텀시트가 됩니다. 디테일을 챙겨봅시다.
1. 랜더링 성능 최적화
1.1 상태(state) 변경 대신 DOM 직접 조작하기
Part 1의 2.1 터치 이벤트 핸들러 구조화를 다룬 섹션에 store.top
같은 상태 값을 직접 수정하는 대신, setProperty를 통해 바텀시트의 DOM 요소 스타일을 조작하는 방식을 사용했습니다. 이는 특히 touchMove처럼 이벤트가 매우 빈번하게 발생하는 상황에서 중요합니다.
스와이프 중에는 바텀시트 내부 콘텐츠가 변하지 않기 때문에, 상태를 변경하여 리랜더링하는 것은 불필한 작업입니다. 이럴 경우 DOM 요소를 미리 참조해두고 이벤트 핸들러 내부에서 transform
스타일 속성을 직접 조정하는 방식이 훨씬 효율적입니다. 이를 통해 불필요한 렌더링 사이클을 줄이고 부드러운 동작을 유지할 수 있습니다.
1.2 transform
속성을 활용한 위치 제어
앞서 바텀시트의 위치는 transform
속성을 통해 관리한다고 말씀드렸습니다. 일반적으로 position: fixed
나 position: absolute
가 적용된 요소는 CSS의top
속성을 수정하여 위치를 제어하는 경우가 많습니다. 그러나 이는 렌더링 퍼포먼스 측면에서 비효율적일 수 있습니다.
top
값을 바꾸면 브라우저는 전체 레이아웃을 다시 계산하는 ‘리플로우(reflow)’ 과정을 거치게 되고, 이는 성능 저하로 이어질 수 있습니다. 반면 transform: translateY()
는 레이아웃 계산 없이 ‘리페인트(repaint)’만 발생하므로 훨씬 가볍고 빠르게 동작합니다.
따라서 위치를 자주 업데이트해야 하는 바텀시트 같은 컴포넌트에는 transform
속성을 활용하는 것이 최적의 선택입니다.
✅ 성능 테스트

👉 top 속성 변경 시: 리플로우 발생으로 성능 저하


👉 transform: translate
변경 시: 성능 부담 최소화


1.3 브라우저 최적화를 위한 will-change: transform;
활용
will-change
는 특정 CSS 속성이 앞으로 변경될 것임을 브라우저에 미리 알려주는 힌트 역할을 합니다. 보통은 브라우저가 자동으로 최적화를 잘 수행하지만, 바텀시트처럼 transform
속성이 자주 변경되는 경우에는 개발자가 명시적으로 will-change: transform;
을 선언해주는 것이 효과적입니다.
브라우저는 해당 요소를 별도 레이어로 분리하고 GPU 가속 준비 등을 사전에 수행하므로, 랜더링 성능이 개선됩니다. 특히 이미지나 시각적 요소가 많은 경우, 성능 향상이 더 뚜렷하게 나타납니다. 아래는 크롬 퍼포먼스 탭에서 측정한 비교 결과입니다.
✅ 성능 테스트

👉 will-change
미적용 시


👉 will-change
적용 시


1.4 requestAnimationFrame
을 통한 프레임 최적화
window.requestAnimationFrame
은 브라우저가 다음 프레임을 그리기 직전에 등록된 콜백 함수를 실행하는 API입니다. 이 메서드를 사용하면 DOM 업데이트를 브라우저의 렌더 타이밍에 맞춰 수행할 수 있어, 불필요한 렌더링을 줄이고 성능을 높일 수 있습니다.
예를 들어, 바텀시트의 위치를 transform
으로 조정할 때 requestAnimationFrame
안에서 처리하면 프레임 단위의 최적화가 가능합니다.
또한, 프레임마다 여러 번 호출되는 것을 방지하기 위해, 이미 예약된 프레임이 있을 경우 중복 예약을 막는 로직도 필요합니다. 아래 코드는 이를 적용한 예시입니다.
let rafId = null;
const draw = (bottomSheetRef, topPosition) => {
if (rafId !== null) {
return; // 이미 예약된 프레임이 있으면 건너뛰기
}
// requestAnimationFrame을 이용한 실제 DOM 업데이트
rafId = window.requestAnimationFrame(() => {
bottomSheetRef.style.setProperty(
'transform', `translateY(-${window.innerHeight - newTopPosition}px)`
);
// 프레임 처리 완료시 rafId 초기화
rafId = null;
});
};
export const handleTouchMove =
({store}) =>
(e: TouchEvent) => {
//... 이전 작업들
draw(store.bottomSheetRef, store.top)
};
2. 더 나은 사용성을 위한 디테일 체크
2.1 브라우저 핀치 줌 방지
모바일에서 두 손가락으로 확대/축소하는 핀치 줌(Pinch Zoom)은 바텀시트와 같은 UI 컴포넌트에 의도하지 않은 동작을 유발해 사용자 경험을 저해할 수 있습니다. 이를 방지하려면 HTML의 태그에
user-scalable=0
, maximum-scale=1.0
, minimum-scale=1.0
와 같은 속성을 설정해 사용자의 화면 확대/축소 기능을 비활성화합니다.
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1.0, minimum-scale=1.0, user-scalable=0"
>
2.2 터치 이벤트의 기본 동작 제어
브라우저가 기본 제공하는 API가 아닌 제스처를 수동으로 직접 구현할 경우에는 터치 이벤트와 연결된 브라우저의 기본 동작들(예: 스크롤, 당겨서 새로고침 등)이 의도치 않게 함께 실행될 수 있습니다. 이런 경우에는 e.preventDefault()
메서드를 사용하여 브라우저의 기본 동작들을 차단합니다.
<!-- BottomSheet.vue -->
<template>
...
<!-- touchmove에 .prevent 추가로 이벤트핸들러 기본 동작 막기 -->
<div
class="bottom-sheet-handle-area"
ref="bottomSheetRef"
@touchstart="handleTouchStart(bottomSheetContext)"
@touchmove.prevent="handleTouchMove(bottomSheetContext)"
@touchend="handleTouchEnd(bottomSheetContext)"
@touchcancel="handleTouchEnd(bottomSheetContext)"
>
...
</template>
2.3 가상키보드 자동 닫기
바텀시트 내에 input
이나 textarea
요소가 있을 경우, 사용자가 텍스트를 입력하면 가상 키보드가 화면에 나타납니다. 이때 바텀시트가 닫히면서 가상 키보드가 그대로 남아 있으면 어색한 사용자 경험을 유발할 수 있습니다. 이를 방지하기 위해, 바텀시트가 닫히는 시점에 내부에서 포커스된 input
또는 textarea
요소의 blur()
메서드를 호출해 포커스를 해제하고, 가상 키보드를 함께 내리는 처리가 필요합니다.
// useBottomSheet.ts
const close = () => {
store.top = null;
// handle 내부의 input blur 처리
if (store.bottomSheetRef) {
const inputElements = Array.from(store.bottomSheetRef.querySelectorAll('input, textarea')) as (HTMLInputElement | HTMLTextAreaElement)[];
inputElements.forEach(element => {
element.blur();
});
}
}
이처럼 바텀시트의 기본 동작부터 스와이프 제스처 구현, 성능 최적화, 사용성 개선까지 전반적인 구현 과정을 살펴보았습니다. 완벽하진 않더라도, 웹뷰 환경에서도 네이티브 앱 수준에 가까운 사용자 경험을 구현할 수 있다는 가능성을 확인할 수 있었고, 유사한 UI를 고민 중인 개발자에게 하나의 참고 사례가 되기를 기대합니다.
-
Written by 이상준 | 비누랩스 소프트웨어 엔지니어
웹 기술을 통해 사용자와 개발자의 문제를 해결합니다.