Posts

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

2026-05-23

회사 프로젝트에서 매인 UI가 개편되면서 다음의 동작이 필요했습니다.

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

위 동작은 네이티브에선 쉽게 볼 수 있는 UI입니다. 해당 프로젝트는 네이티브는 단순히 웹뷰를 띄우는 컨테이너 역할만 하고 있어서 해당 기능은 웹뷰에서 구현해야 했습니다.

처음엔 overflow-x: scrollscroll-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가 화면 중앙에 옵니다.

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

  1. 패널마다 높이가 다를 수 있다panelHeight(index) 콜백으로 받습니다.
  2. 음수 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();
}

세 가지를 포인트가 있습니다.

  1. 카메라는 손가락 반대 방향으로 움직입니다. 손가락이 오른쪽으로 100px 가면 콘텐츠가 왼쪽으로 100px 흐른 것처럼 보여야 하므로, 카메라는 왼쪽으로 100px 가야 합니다.
  2. / z (zoom 나누기)가 들어갑니다. 줌이 2배 들어가 있으면 화면 100px = 월드 50 단위입니다.
  3. 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 환경에서 저와 비슷한 고민을 하고 계신 분들에게 도움이 되길 바랍니다.