guksulog
포스트 목록
#Three.js#WebView

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

2026년 5월 23일 12분 읽기

회사 프로젝트의 메인 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 경계가 전부 여기서 나와요.

가상화 — overscan 윈도우

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 을 같이 걸어둔 이유예요.

Pan — 손가락을 따라 카메라 옮기기

손가락 입력은 단순한 함수 호출이 아니라 상태예요.

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

Pinch Zoom

두 손가락이 들어오면 시작 시점의 두 손가락 사이 거리와, 두 손가락 중점 아래에 있는 월드 좌표를 기억해요. 이게 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에 보이려면 카메라가 어디 있어야 하는가"**를 역산하는 거죠. 카메라 추상화의 위력이 여기서 가장 잘 드러나요 — 손으로 짠 줌 보정 코드보다 훨씬 깔끔하거든요.

트윈 — 부드러운 RAF 애니메이션

스냅이 결정되거나 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 환경에서 저와 비슷한 고민을 하고 계신 분들에게 도움이 되길 바라요.