레시피 목록

웹뷰환경에서 네이티브 UI 구현하기

2026년 5월 23일 12분 읽기
Three.jsWebView

회사 프로젝트의 메인 UI가 개편되면서 이런 동작이 필요해졌습니다.

  • 화면 폭만큼의 패널이 나란히 붙어 있고, 가로 스와이프로 패널이 전환된다.
  • 각 패널 안에서는 세로 스크롤이 독립적으로 동작한다.

네이티브에서는 흔하게 볼 수 있는 UI입니다. 그런데 이 프로젝트는 네이티브가 단순히 웹뷰를 띄우는 컨테이너 역할만 해서, 결국 웹뷰에서 직접 구현해야 했습니다.

처음엔 overflow-x: scrollscroll-snap-type: x mandatory 를 걸어봤습니다. 이 정도면 되겠지 싶었죠. 그런데 대각선으로 손가락이 움직이면 가로·세로 중 하나로 깔끔하게 잠그기가 어려웠습니다. 실제로 쓰기엔 무리가 있었습니다.

여러 방법을 시도하다 보니 결국엔 오픈소스 라이브러리까지 만들게 됐습니다. 어떻게 거기까지 갔는지 한번 풀어보겠습니다.

첫 번째 시도, 스와이프 라이브러리를 그대로 가져다 붙였다

가장 먼저 시도한 건 Swiper.js를 그대로 가져다 붙이는 방법이었습니다. 처음엔 잘 돌아갔습니다. 그런데 실제 데이터를 넣자마자 문제가 터졌습니다.

슬라이드마다 콘텐츠 높이가 다르면, 전환 타이밍에 페이지가 위아래로 튑니다.

원인은 명확했습니다. 이런 라이브러리는 거의 다 슬라이드를 display: flex 로 나란히 깔고 컨테이너 전체에 transform: translateX 를 걸어 좌우 이동을 만듭니다. 그런데 그렇게 하려면 컨테이너가 단일 높이를 가져야 하죠. 슬라이드 높이가 제각각이면 선택지가 셋뿐입니다.

  • 컨테이너 높이 = 가장 큰 슬라이드 → 짧은 슬라이드에선 빈 공간이 한가득 남습니다.
  • 컨테이너 높이 = 현재 슬라이드 → 전환 순간 컨테이너 높이가 바뀌면서 페이지 전체가 reflow, 사용자가 보고 있던 스크롤 위치가 흔들립니다.
  • 슬라이드를 position: absolute → normal flow에서 빠지니까 라이브러리가 페이지 길이를 강제로 잡아줘야 하고, 다른 페이지 요소와의 흐름 통합이 까다로워집니다.

스와이프

결국 이 방법은 포기했습니다.

두 번째 시도, 슬롯 하나만 두고 임계점에서 갈아끼웠다

다음으로 시도한 건 토스 쇼핑 UI를 레퍼런스로 삼은 방법입니다.

컨테이너에는 슬라이드를 한 번에 하나만 렌더합니다. 스와이프 중에는 그 슬라이드에만 translateX 를 걸어 손가락을 따라 밀고, 옆 슬라이드는 안 보여줍니다. 드래그가 임계점을 넘기면 그 자리에서 새 슬라이드로 교체합니다.

활성 슬라이드 하나만 DOM에 두고, 사용자가 스와이프하면 그 슬라이드에 transform: translateX 를 걸어 손가락을 따라가게 합니다. 밀려난 자리 뒤엔 아무것도 없으니 컨테이너 배경(빈 공간)만 노출되죠. 드래그가 임계점을 넘기는 순간 현재 슬라이드를 언마운트하고 새 슬라이드를 마운트합니다. 임계점에 못 미치면 현재 슬라이드는 제자리로 돌아옵니다.

장점은 분명했습니다.

  • 슬라이드마다 높이가 자유롭습니다. 한 번에 하나만 DOM에 있으니 페이지 높이가 자연스럽게 그 슬라이드를 따라갑니다. 첫 번째 시도의 핵심 문제를 우회하는 겁니다.
  • 상태 관리가 자명합니다. 활성이 아닌 슬라이드는 언마운트되니까, 외부 보존 의도가 없으면 자연스럽게 초기 상태에서 시작합니다.

슬롯

지금 회사 프로젝트에서 운영 중인 구조도 이겁니다. 대부분의 시나리오에서 잘 돌아갔습니다.

하지만 한계도 명확했습니다. 스와이프 중에 옆 슬라이드가 미리 안 보이고, 밀려난 자리엔 빈 공간만 노출되니까요. 임계점을 넘기는 순간 새 슬라이드가 갑자기 등장하는 식이라 전환의 연속감이 떨어집니다. 네이티브 페이저처럼 정지 상태에서 살짝 끌어서 옆 슬라이드를 엿보는 UX도 당연히 안 되고요.

당시엔 배포 기한도 얼마 안 남았고, 내부에서도 이 정도면 충분하다는 의견이 있어서 이 방법을 채택했습니다. 그래도 계속 아쉬움이 남았습니다.

세 번째 시도, 유튜브에서 답을 봤다

그러다 유튜브에서 우연히 토스증권 영상을 하나 봤습니다. 영상에서도 저랑 똑같은 고민을 했는데, 카메라 모델로 해결한 내용이었습니다. 이건 한번 시도해 봐야겠다 싶어서 바로 설계를 시작했습니다.

콘텐츠는 가만히 있고, 카메라가 움직인다

먼저 머릿속의 모델을 바꿔야 합니다.

콘텐츠를 옮기는 대신 카메라를 옮기는 겁니다. 결과 화면은 똑같은데, 이렇게 뒤집으면 얻는 게 많습니다.

항목콘텐츠 이동 방식카메라 이동 방식
좌표 변환줌·스크롤마다 매번 계산screen ↔ world 변환식 하나로 통일
축 제약콘텐츠 transform을 축별로 분기camera.position.y 만 고정
가상화콘텐츠 인덱스 계산 + offset 추적카메라 좌표만 보면 됨

three.js를 매트릭스 엔진으로 썼다

위 설계를 구현하기 위해 three.js를 썼습니다.

ts
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 기준)라는 직관적인 좌표 매핑이 나옵니다.

ts
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 한 줄로 끝나는 간결함 때문입니다.

스크롤

모든 계산은 두 줄의 변환식에서 시작한다

모든 계산의 출발점이 되는 매핑 식입니다.

code
좌표 규약:
 - 스크린: (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 계산이 전부 풀립니다.

ts
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,
  };
}

패널 배치는 카메라가 갈 자리를 미리 정해두는 일이다

ts
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가 화면 중앙에 오는 식이죠.

세로 모드는 두 가지 포인트가 있습니다.

  1. 패널마다 높이가 다를 수 있습니다. panelHeight(index) 콜백으로 받습니다.
  2. 음수 Y 방향으로 쌓습니다. 월드 좌표계는 +Y가 위니까, 인덱스가 커질수록 아래로 가려면 Y를 빼야 합니다.

positions 배열이 컨트롤러 전체의 진실의 원천(source of truth)입니다. 스냅 타깃, 가상화 윈도우, edge 경계가 전부 여기서 나옵니다.

살려두는 건 활성 패널과 양옆 하나씩뿐이다

ts
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 을 같이 걸어둔 이유입니다.

손가락 입력은 함수 호출이 아니라 상태다

1개 손가락이면 pan, 2개면 pinch, 핀치 중에 1개가 떨어지면 다시 pan으로. 이 전환이 자연스럽게 일어나야 합니다.

ts
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 마다 누적 델타가 아니라 시작 지점 기준 절대 델타로 카메라를 다시 계산합니다. 누적 방식의 부동소수점 오차가 쌓이지 않고, 도중에 무슨 일이 있어도 시작점으로 돌아갈 수 있습니다.

ts
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();
}

포인트는 세 가지입니다.

  1. 카메라는 손가락 반대 방향으로 움직입니다. 손가락이 오른쪽으로 100px 가면 콘텐츠가 왼쪽으로 100px 흐른 것처럼 보여야 하니까, 카메라는 왼쪽으로 100px 가야 합니다.
  2. / z (zoom 나누기)가 들어갑니다. 줌이 2배 들어가 있으면 화면 100px = 월드 50 단위니까요.
  3. Y 부호가 X와 반대입니다. 월드 Y는 +가 위니까 부호가 +.

축 제약은 두 줄로 깔끔하게 들어갑니다. 가로 모드면 Y는 시작값에서 한 발자국도 안 움직입니다. 대각선 드래그여도 X만 따라갑니다. 이게 overflow-x 로는 어렵던 "축 잠금"이 카메라 모델에서는 자명해지는 지점입니다.

경계 밖에서는 고무줄처럼 버텨야 한다

첫/마지막 패널 바깥으로 손가락이 나가면, 그대로 따라가지 않고 저항이 걸려야 합니다.

ts
/**
 * 엣지 고무줄(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 이 다음 인덱스를 결정
ts
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%만 끌어도 안 넘어가서 답답합니다. 속도만으로 결정하면 정확히 절반을 넘기고 멈춰도 원래 자리로 돌아가서 어색합니다. 둘을 가중합으로 합치면 "충분히 멀리 갔거나, 빠르게 던졌거나" 둘 중 하나면 다음 패널로 넘어가는, 손가락 감각에 가까운 동작이 나옵니다.

핀치는 "이 점은 여기 있어야 한다"는 약속이다

두 손가락이 들어오면 시작 시점의 두 손가락 사이 거리와, 두 손가락 중점 아래에 있는 월드 좌표를 기억합니다. 이게 anchor입니다.

핀치가 끝날 때까지 "이 월드 점은 두 손가락 중점에 그대로 있어야 한다" 가 불변식입니다.

ts
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();
}

앞에서 본 변환식

code
screenX = (worldX - cameraX) * zoom + rootWidth/2

cameraX 에 대해 풀면

code
cameraX = worldX - (screenX - rootWidth/2) / zoom

이 됩니다. 즉 "새 줌 레벨에서 worldAnchor가 midpoint에 보이려면 카메라가 어디 있어야 하는가"를 역산하는 거죠. 카메라 추상화의 위력이 여기서 가장 잘 드러납니다. 손으로 짠 줌 보정 코드보다 훨씬 깔끔하니까요.

마지막은 300ms의 감속이 만든다

스냅이 결정되거나 scrollTo(i, { animated: true }) 가 호출되면 RAF 루프가 돕니다.

ts
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 환경에서 비슷한 고민을 하고 계신 분이라면, overflow-x 를 붙잡고 씨름하기 전에 한 번쯤 카메라 모델을 떠올려보면 어떨까 싶습니다. 저도 아직 더 나은 방법을 찾는 중입니다.