웹뷰환경에서 네이티브 UI 구현하기
2026-05-23회사 프로젝트에서 매인 UI가 개편되면서 다음의 동작이 필요했습니다.
- 화면 폭만큼의 패널이 나란히 붙어 있고, 가로 스와이프로 패널이 전환된다.
- 각 패널 안에서는 세로 스크롤이 독립적으로 동작한다.
위 동작은 네이티브에선 쉽게 볼 수 있는 UI입니다. 해당 프로젝트는 네이티브는 단순히 웹뷰를 띄우는 컨테이너 역할만 하고 있어서 해당 기능은 웹뷰에서 구현해야 했습니다.
처음엔 overflow-x: scroll 에 scroll-snap-type: x mandatory 를 사용했지만, 대각선으로 손가락이 움직일 때 가로/세로 중 하나로 깔끔하게 잠그기 어려움이 발생하는등 실제 적용하기엔 무리가 있었습니다.
결국 여러 방법을 시도해 보았고, 최종적으로는 오픈소스 라이브러리까지 만들게 되었습니다.
첫 번째 시도 — 스와이프 라이브러리
가장 먼저 시도한 건 swipe 라이브러리(Swiper.js)를 그대로 가져다 붙이는 것이었습니다. 처음엔 잘 동작했지만, 실제 데이터를 넣은 환경에서 문제가 발생했습니다.
슬라이드마다 콘텐츠 높이가 다르면, 슬라이드 전환 타이밍에 페이지가 위아래로 튀었습니다.
원인은 명확했습니다. 이런 라이브러리는 거의 다 슬라이드를 display: flex 로 나란히 깔고 컨테이너 전체에 transform: translateX 를 적용해 좌우 이동을 만듭니다. 그러면 컨테이너는 단일 높이를 가져야 하는데, 슬라이드 높이가 제각각이면 선택지가 셋이었습니다.
- 컨테이너 높이 = 가장 큰 슬라이드 → 짧은 슬라이드에서 빈 공간이 한가득 남는다.
- 컨테이너 높이 = 현재 슬라이드 → 전환 순간 컨테이너 높이가 바뀌면서 페이지 전체가 reflow, 사용자가 보고 있던 스크롤 위치가 흔들린다.
- 슬라이드를
position: absolute→ normal flow에서 빠지므로 라이브러리가 페이지 길이를 강제로 잡아 줘야 하고, 다른 페이지 요소와의 흐름 통합이 까다로워진다.
결국 이런 문제 때문에 첫 번째 방법은 포기했습니다.
두 번째 시도 — 슬롯 하나 + 스와이프 착시
다음 시도는 토스 쇼핑 UI를 레퍼런스로 삼아 구현했습니다.
컨테이너에는 슬라이드를 한 번에 하나만 렌더하되, 좌우 스와이프 애니메이션 으로 옆 슬라이드가 들어오는 것처럼 보이게 한다.
구현 골자는 단순했습니다. 활성 슬라이드 컴포넌트 하나만 렌더 트리에 두고, 사용자가 스와이프하면 트랜지션 동안에만 다음 슬라이드를 함께 마운트해서 둘을 transform: translateX 로 한 묶음씩 옆으로 미는 애니메이션을 띄웁니다. 트랜지션이 끝나면 새 슬라이드만 남기고 이전 슬라이드는 언마운트합니다.
장점이 분명했습니다.
- 슬라이드마다 높이가 자유롭다 — 한 번에 하나만 DOM에 있으니 페이지 높이는 자연스럽게 그 슬라이드를 따라간다. 첫 번째 시도의 핵심 문제를 우회한다.
- 상태 관리가 자명하다 — 활성이 아닌 슬라이드는 언마운트되므로, 외부 보존 의도가 없으면 자연스러운 초기 상태에서 시작한다.
- 번들이 매우 작다 — DOM 노드도 거의 1개분만 메모리에 있고, 라이브러리 자체도 트랜지션 로직만 있으면 되니 가볍다.
지금 회사 프로젝트에서 운영 중인 구조도 이것입니다. 대부분의 시나리오에서 잘 동작했습니다.
하지만 네이티브 처럼 인접 슬라이드를 미리 볼 수 없는 즉, 정지 상태에서 살짝 끌어서 옆 슬라이드를 엿보는 UX는 불가능합니다.
당시 배포 기한도 얼마 남지 않았고, 내부에서도 이 정도면 충분하다는 의견으로 결정되서 현재 이 방법을 채택했지만 계속 아쉬움이 남았습니다.
세 번째 시도 — 카메라 모델
유튜브를 보다가 우연히 토스증권 영상을 하나 봤습니다. 그 영상에서도 저와 똑같은 고민을 했는데 카메라 모델을 사용해서 해결한 내용이였습니다. 해당 영상을 보고 이건 한 번 시도해 봐야겠단 생각으로 바로 설계를 시작했습니다.
핵심 아이디어 — "콘텐츠는 가만히 있고, 카메라가 움직인다"
먼저 머릿속의 모델을 바꿔야 합니다.
콘텐츠를 옮기는 대신 카메라를 옮기는 거죠. 결과 화면은 똑같지만, 이렇게 뒤집으면 얻는 것이 많습니다.
| 항목 | 콘텐츠 이동 방식 | 카메라 이동 방식 |
|---|---|---|
| 좌표 변환 | 줌·스크롤마다 매번 계산 | screen ↔ world 변환식 하나로 통일 |
| 축 제약 | 콘텐츠 transform을 축별로 분기 | camera.position.y 만 고정 |
| 가상화 | 콘텐츠 인덱스 계산 + offset 추적 | 카메라 좌표만 보면 됨 |
three.js
위의 설계를 구현하기 위해 three.js를 사용했습니다.
import * as THREE from "three";
import {
CSS3DObject,
CSS3DRenderer,
} from "three/examples/jsm/renderers/CSS3DRenderer.js";
CSS3DRenderer 는 three.js scene graph를 WebGL이 아니라 CSS transform: matrix3d(...) 로 렌더링 합니다. 각 CSS3DObject 는 실제 HTMLElement 를 감싼 wrapper이고, 매 프레임 그 element에 matrix3d 가 박힌 transform이 적용됩니다.
결과적으로:
- DOM 노드가 그대로 살아있습니다 —
<input>,<button>, 스크롤 바, 텍스트 선택, 접근성 트리, 이벤트 버블링이 전부 정상 동작합니다. - 단지 그 노드들이 화면에서 차지하는 위치만 카메라/scene 변환에 따라 결정됩니다.
즉 three.js는 여기서 "DOM 노드들의 위치를 카메라/scene 추상화로 관리하기 위한 매트릭스 엔진" 으로 쓰입니다. 셰이더나 렌더 파이프라인은 안 씁니다.
CSS3DRenderer는 GPU에 그리는 게 아니라, 계산된 4×4 변환 행렬을 matrix3d(a, b, c, ...) CSS 문자열로 직렬화해서 호스트 앱이 만든 진짜 <div> 의 style.transform 에 박아 넣을 뿐입니다. DOM 노드는 손대지 않습니다 — 그 안의 <input>, <button>, 스크롤바, 텍스트 노드 전부 평상시처럼 동작합니다.
OrthographicCamera 인 이유
원근(perspective) 카메라를 쓰면 멀리 있는 패널이 작아 보이는 왜곡이 생깁니다. UI에는 이게 거의 항상 거슬립니다. 직교(orthographic) 카메라는 깊이에 따라 크기가 변하지 않습니다.
frustum을 컨테이너 크기와 동일하게 잡으면 1 월드 단위 = 1 px (zoom=1 기준) 라는 직관적인 좌표 매핑이 나옵니다.
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(
-width / 2, // left
width / 2, // right
height / 2, // top
-height / 2, // bottom
0.1,
2000,
);
camera.position.set(0, 0, 1000);
frustum 폭이 width 이므로, 카메라가 x = 0 에 있으면 [-width/2, width/2] 월드 좌표가 화면에 잡힙니다. 이 매핑이 머릿속에 박혀 있으면 뒤의 모든 계산이 자연스러워집니다.
솔직히 말하면 three.js가 맞는 선택인지는 아직도 잘 모르겠습니다. 같은 결과를 wrapper element에 transform: translate3d(...) scale(...) 을 거는 200줄짜리 vanilla 코드로 만들 수 있습니다. 그래도 끌어온 이유는 검증된 매트릭스 합성, scene graph의 표현력, 그리고 camera.zoom *= factor 한 줄로 끝나는 간결함 때문입니다.
좌표계와 변환 공식
모든 계산의 출발점이 되는 매핑 식입니다.
좌표 규약:
- 스크린: (0,0) = 좌상단, x→오른쪽, y→아래
- 월드/카메라: Three.js 표준 (+y 위)
매핑 식 (OrthographicCamera 기준):
screenX = (worldX - cameraX) * zoom + rootWidth/2
screenY = -(worldY - cameraY) * zoom + rootHeight/2
⇔
worldX = (screenX - rootWidth/2) / zoom + cameraX
worldY = cameraY - (screenY - rootHeight/2) / zoom
스크린 Y는 아래로 갈수록 커지고 월드 Y는 위로 갈수록 커지므로, Y에는 부호 반전이 들어갑니다. 이 두 식만 손에 쥐고 있으면 뒤의 pan/pinch 계산이 전부 풀립니다.
export function screenPointToWorld(
screenX: number,
screenY: number,
cameraX: number,
cameraY: number,
zoom: number,
rootWidth: number,
rootHeight: number,
): { x: number; y: number } {
const cx = rootWidth / 2;
const cy = rootHeight / 2;
return {
x: (screenX - cx) / zoom + cameraX,
y: cameraY - (screenY - cy) / zoom,
};
}
패널 배치 — 카메라가 갈 자리를 미리 정해둔다
const positions: Array<{ x: number; y: number }> = [];
function computePositions(): void {
positions.length = 0;
if (direction === "horizontal") {
for (let i = 0; i < panelCount; i++) {
positions.push({ x: i * width, y: 0 });
}
} else {
let cursor = 0;
for (let i = 0; i < panelCount; i++) {
const h = options.panelHeight?.(i) ?? height;
positions.push({ x: 0, y: -(cursor + h / 2) });
cursor += h;
}
}
}
가로 모드는 단순합니다. 패널 i 는 월드 좌표 (i * width, 0) 에 놓입니다. 카메라가 x = 2 * width 에 있으면 패널 2가 화면 중앙에 옵니다.
세로 모드는 두 가지 포인트가 있습니다.
- 패널마다 높이가 다를 수 있다 —
panelHeight(index)콜백으로 받습니다. - 음수 Y 방향으로 쌓는다 — 월드 좌표계는 +Y가 위이므로, 인덱스가 커질수록 아래로 가려면 Y를 빼야 합니다.
이 positions 배열이 컨트롤러 전체의 진실의 원천(source of truth)입니다. 스냅 타깃, 가상화 윈도우, edge 경계가 전부 여기서 나옵니다.
가상화 — overscan 윈도우
function applyVirtualization(): void {
for (let i = 0; i < cssObjects.length; i++) {
const obj = cssObjects[i];
const panel = options.panels[i];
if (!obj || !panel) continue;
const inWindow = Math.abs(i - activeIndex) <= overscan;
if (obj.visible !== inWindow) {
obj.visible = inWindow;
// CSS3DRenderer는 visible=false 객체를 일관되게 숨기지 않으므로
// display 토글 병행.
panel.style.display = inWindow ? "" : "none";
}
}
}
활성 패널에서 좌우(또는 위아래)로 overscan 칸까지만 살려둡니다. 기본값은 1이라 활성 + 양옆 = 총 3개만 렌더링됩니다.
three.js의 일반적인 WebGL 렌더링에서는 object.visible = false 면 그 객체가 GPU 패스에서 빠집니다. 하지만 CSS3DRenderer 는 매 프레임 모든 객체의 transform을 갱신하는 패스가 따로 있고, 버전에 따라 visible=false 인 노드의 DOM도 그대로 둘 수 있습니다. 안전벨트로 display: none 을 같이 걸어두었습니다.
Pan — 손가락을 따라 카메라 옮기기
손가락 입력은 단순한 함수 호출이 아니라 상태 입니다.
1개 손가락이면 pan, 2개면 pinch, 핀치 중에 1개가 떨어지면 다시 pan으로 — 이 전환이 자연스럽게 일어나야 합니다.
function startPan(pointerId: number): void {
const p = pointers.get(pointerId);
if (!p) return;
panStart = {
cameraX: camera.position.x,
cameraY: camera.position.y,
pointerX: p.x,
pointerY: p.y,
pointerId,
activeIndex: currentActiveIndex(),
lastMoveTime: performance.now(),
lastDelta: 0,
};
}
시작 시점의 카메라 위치와 포인터 위치를 모두 스냅샷으로 잡아둡니다.
이후 매 pointermove 마다 누적 델타가 아니라 시작 지점 기준 절대 델타 로 카메라를 다시 계산합니다. 누적 방식의 부동소수점 오차가 쌓이지 않고, 도중에 무슨 일이 있어도 시작점으로 돌아갈 수 있습니다.
function updatePan(): void {
if (!panStart) return;
const p = pointers.get(panStart.pointerId);
if (!p) return;
const dx = p.x - panStart.pointerX;
const dy = p.y - panStart.pointerY;
const z = camera.zoom;
// 화면 드래그 → 카메라 이동 (월드 anchor: 손가락 아래 월드 점 고정)
// cameraX_new = cameraX_start - dx / zoom
// cameraY_new = cameraY_start + dy / zoom (스크린 Y↓ vs 월드 Y↑ 부호 반전)
let targetX = panStart.cameraX - dx / z;
let targetY = panStart.cameraY + dy / z;
// 축 제약
if (axis === "x") targetY = panStart.cameraY;
else targetX = panStart.cameraX;
// 엣지 저항
const bounds = panBoundsAlongAxis();
if (axis === "x") {
targetX = applyResistance(targetX, bounds.min, bounds.max, resistance);
} else {
targetY = applyResistance(targetY, bounds.min, bounds.max, resistance);
}
camera.position.x = targetX;
camera.position.y = targetY;
onChange();
}
세 가지를 포인트가 있습니다.
- 카메라는 손가락 반대 방향으로 움직입니다. 손가락이 오른쪽으로 100px 가면 콘텐츠가 왼쪽으로 100px 흐른 것처럼 보여야 하므로, 카메라는 왼쪽으로 100px 가야 합니다.
/ z(zoom 나누기)가 들어갑니다. 줌이 2배 들어가 있으면 화면 100px = 월드 50 단위입니다.- Y 부호가 X와 반대입니다. 월드 Y는 +가 위이므로 부호가 +.
축 제약은 두 줄로 깔끔하게 들어갑니다. 가로 모드면 Y는 시작값에서 한 발자국도 안 움직입니다. 대각선 드래그여도 X만 따라갑니다. 이게 overflow-x 로는 어렵던 "축 잠금"이 카메라 모델에서는 자명해지는 지점입니다.
엣지 저항 — 고무줄
첫/마지막 패널 바깥으로 손가락이 나가면 그대로 따라가지 않고 저항이 걸려야 합니다.
/**
* 엣지 고무줄(rubber band). `[min, max]` 밖의 값은 `resistance ∈ [0,1]` 배율로 감쇠.
*
* value=110, max=100, resistance=0.2 → 100 + 10*0.2 = 102
*/
export function applyResistance(
value: number,
min: number,
max: number,
resistance: number,
): number {
if (max < min) return clamp(value, min, max);
if (value < min) return min - (min - value) * resistance;
if (value > max) return max + (value - max) * resistance;
return value;
}
resistance = 0.2 면 경계 밖 100px 오버슈트가 화면에서는 20px로 줄어듭니다. 손가락은 100px 갔는데 화면은 20px만 따라온 셈이라 자연스럽게 "당기고 있구나" 하는 느낌이 납니다. 그리고 손을 떼면 트윈이 경계로 다시 끌어당겨 옵니다.
손을 뗐을 때 — 스냅 결정
dragRatio= 패널 크기 대비 드래그한 비율 (예: 0.4면 패널의 40% 만큼 움직임)velocityRatio= 마지막 이동의 속도를 패널/100ms 단위로 환산- 둘을 합쳐
decideSnapTarget이 다음 인덱스를 결정
export function decideSnapTarget(
startIndex: number,
dragRatio: number,
velocityRatio: number,
snapThreshold: number,
panelCount: number,
velocityWeight = 0.3,
): number {
if (panelCount <= 0) return 0;
const effective = dragRatio + velocityRatio * velocityWeight;
let target = startIndex;
if (effective > snapThreshold) target = startIndex + 1;
else if (effective < -snapThreshold) target = startIndex - 1;
return clamp(target, 0, panelCount - 1);
}
"거리만으로 결정" 하면 천천히 30%만 끌어도 안 넘어가서 답답하고, "속도만으로 결정" 하면 정확히 절반을 넘기고 멈춰도 원래 자리로 돌아가서 어색합니다. 둘을 가중합으로 합치면 "충분히 멀리 갔거나, 빠르게 던졌거나" 둘 중 하나면 다음 패널로 넘어가는, 손가락 감각에 가까운 동작이 나옵니다.
Pinch Zoom
두 손가락이 들어오면 시작 시점의 두 손가락 사이 거리와, 두 손가락 중점 아래에 있는 월드 좌표 를 기억합니다. 이게 anchor입니다.
핀치가 끝날 때까지 "이 월드 점은 두 손가락 중점에 그대로 있어야 한다"는 게 불변식.
function updatePinch(): void {
// ... 두 포인터 위치 a, b 읽기
const distance = Math.hypot(b.x - a.x, b.y - a.y) || 1;
const midpoint = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const zoomFactor = distance / pinchStart.distance;
const newZoom = clamp(pinchStart.zoom * zoomFactor, minZoom, maxZoom);
// 손가락 중점 아래 월드 점이 그대로 머물도록 카메라 위치 보정
const { width, height } = getRootSize();
let newCameraX =
pinchStart.worldAnchor.x - (midpoint.x - width / 2) / newZoom;
let newCameraY =
pinchStart.worldAnchor.y + (midpoint.y - height / 2) / newZoom;
camera.position.x = newCameraX;
camera.position.y = newCameraY;
camera.zoom = newZoom;
camera.updateProjectionMatrix();
onChange();
}
앞에서 본 변환식
screenX = (worldX - cameraX) * zoom + rootWidth/2
을 cameraX 에 대해 풀면
cameraX = worldX - (screenX - rootWidth/2) / zoom
이 됩니다. 즉 "새 줌 레벨에서 worldAnchor가 midpoint에 보이려면 카메라가 어디 있어야 하는가" 를 역산하는 거죠. 카메라 추상화의 위력이 여기서 가장 잘 드러납니다 — 손으로 짠 줌 보정 코드보다 훨씬 깔끔합니다.
트윈 — 부드러운 RAF 애니메이션
스냅이 결정되거나 scrollTo(i, { animated: true }) 가 호출되면 RAF 루프가 돕니다.
function stepTween(): void {
if (!tween) return;
const now = performance.now();
const t = Math.min(1, (now - tween.start) / tween.duration);
const k = easeOutCubic(t);
camera.position.x = tween.fromX + (tween.toX - tween.fromX) * k;
camera.position.y = tween.fromY + (tween.toY - tween.fromY) * k;
if (tween.fromZoom !== tween.toZoom) {
camera.zoom = tween.fromZoom + (tween.toZoom - tween.fromZoom) * k;
camera.updateProjectionMatrix();
}
onChange();
if (t < 1) {
rafId = requestAnimationFrame(stepTween);
} else {
tween = null;
rafId = null;
}
}
export function easeOutCubic(t: number): number {
const u = 1 - t;
return 1 - u * u * u;
}
easeOutCubic(t) = 1 - (1-t)^3 — 빠르게 출발해서 부드럽게 감속합니다. 300ms 짧지만 답답하지 않은 길이고, 시작 직후의 가속이 손가락 놓는 동작의 운동성을 이어받는 느낌을 줍니다. t=0.5에서 이미 87.5%가 완료되고, 마지막 절반의 시간은 작은 12.5%를 부드럽게 정착시키는 데 씁니다. iOS 네이티브 페이저와 비슷한 감각이 여기서 나옵니다.
마치며
사실 전 아이디어를 가지고 설계를 진행했고 코드는 클로드코드가 구현했습니다.
중간중간 수식과 수학적(?) 논리가 필요한 부분 모두 클로드코드가 구현했고 저는 이후 설명을 다시 요청하면서 이해하는 방식으로 작업을 진행했죠.
라이브러리는 GitHub - Guksu/wvkit 에 공개해 두었습니다.
이 라이브러리에는 스크롤컨테이너뿐 아니라 웹뷰환경에서 필요한 기능들을 계속해서 추가하고 있습니다.
WebView 환경에서 저와 비슷한 고민을 하고 계신 분들에게 도움이 되길 바랍니다.