[TIL] 물리적 브라우저? Window Inertia와 Cross-Window Fluid 예제

2026. 05. 13.Yeji Kim
Frontend

들어가며

요즘 입력의 물성에 대해 자주 생각하게 된다. (사실 자주는 아님)

나온지 좀 지난 기능이긴 하지만, 아이폰에서 볼륨 조정을 할 때 실제로 볼륨 버튼 근처 화면이 살짝 들어가는 느낌을 받을 수 있다. 물리적 동작이 입력으로 그대로 번역되는 느낌이 들었고, 또 듀얼 모니터를 쓰면서 창을 한 모니터에서 다른 모니터로 끌어 옮길 때 실제 기기는 분리되어 있지만 공간이 이어져 있는 것 같다고 생각했다.

요런 감각들을 브라우저 위에서 만들어 볼 수 있지 않을까 싶어서 가볍게 예제로 만들어본다~!

  • cross-window-fluid - 브라우저 창들을 맞붙이면 입자가 한 쪽에서 다른 쪽으로 흐름
  • window-inertia- 브라우저 창을 움직이면 안쪽 입자가 관성으로 출렁임

1. Cross Window Fluid

Cross-Window Fluid와 Window Inertia 데모 - 브라우저 창을 끌면 안쪽 입자가 출렁이고, 두 창을 맞붙이면 입자가 흘러가는 모습

소개

브라우저 창 사이를 넘나드는 인터랙션에 대한 예제다. 듀얼 모니터 환경에서 창을 끌고 다니는 예시를 생각하면 이해하기 편하다.

가장 큰 고민은 두 브라우저 창이 서로 다른 프로세스일 수 있다는 것이었다. JS 객체나 메모리를 직접 공유할 수 없을 수 있으니..

해결 방법은 간단했는데, BroadcastChannel라는 web API를 사용했다.

BroadcastChannel

같은 출처(origin)의 여러 브라우저 문서/탭/창/worker가 같은 채널 이름을 구독하고, 거기에 메시지를 뿌리는 브라우저 내장 pub/sub API다. JS 메모리를 공유하지 않고, 메시지를 복사해서 브라우저가 다른 문서의 이벤트 큐로 전달하는 구조라고 한다. 사실 메시지를 주고 받는다는 지점에서 WebSocket이나 SharedWorker와 유사한데, 이 둘 보다 가볍다. 메시지 버스, 중앙 채널 정도로 생각하면 되겠다.

ts
const channel = new BroadcastChannel("cross-window-fluid");
 
// 메시지 발송 
channel.postMessage({ type: "state", x, y, width, height });
 
// 다른 창의 메시지 수신
channel.onmessage = (event) => {
  // ...
};

제약 조건

BroadcastChannel은 가볍지만, 그만큼 통신이 닿는 범위에 경계가 있다.

  1. 같은 origin
    scheme + host + port가 일치해야 한다.
  2. 같은 브라우저 엔진 / 인스턴스
    채널은 사실상 한 브라우저 프로세스 안에서 굴러간다.
  3. 같은 브라우저 프로필 / 시크릿 세션
    Chrome의 프로필 A와 프로필 B, 일반 창과 시크릿 창은 다른 storage 컨텍스트라서 채널이 안 닿는다. 시크릿 창끼리도 세션이 분리되면 못 듣는 경우가 있다고 한다.
  4. Storage partitioning (top-level site 기준)
    요즘 브라우저는 storage를 top-level site 단위로 파티셔닝한다. 예를 들어 a.comb.com iframe을 임베드하고 있고, 동시에 b.com을 별도 탭으로 열어 두어도, 그 iframe과 별도 탭의 b.com은 origin은 같지만 partition이 달라서 채널을 공유하지 못한다.

요약하면 같은 브라우저, 같은 프로필, 같은 origin의 탭/창들 사이에서만 메시지가 흐른다. 더 넓은 범위로 가고 싶으면 server-side relay(WebSocket, SSE 등)가 필요하다.

구현

구체적인 구현을 살펴보면 아래와 같다. 먼저, 각 창이 매 33ms마다 자기 정보를 broadcast한다.

ts
channel.postMessage({
  type: "state",
  x: window.screenX,
  y: window.screenY,
  width: window.innerWidth,
  height: window.innerHeight,
});

다른 창은 이걸 받아 remotes 맵에 저장한다. 내 오른쪽 모서리가 다른 창의 왼쪽 모서리와 가까운지 등 매 프레임 portal을 계산한다.

ts
const touching =
  Math.abs(myRight - otherLeft) < THRESHOLD &&
  Math.max(myTop, otherTop) < Math.min(myBottom, otherBottom);

이 조건이 true면 portal을 열림 처리하고, 입자가 그 경계에 닿으면 각 위치에 따라 좌표를 변환한 후 다른 창으로 전송한다.

ts
// local → screen → 상대 창의 local
const screenX = myBounds.x + particle.x;
const targetLocalX = screenX - targetBounds.x;
 
channel.postMessage({
  type: "particle-transfer",
  to: targetWindowId,
  x: targetLocalX,
  y: targetLocalY,
  vx: particle.vx,
  vy: particle.vy,
});

내 쪽에선 입자를 제거하고, 받은 쪽은 받은 좌표/속도로 새 입자를 뿌려준다. 입자 입장에선 다른 창의 같은 screen 좌표에서 같은 속도로 계속 움직이는 셈이라고 할 수 있겠다.

Trouble shooting

처음에 viewport 모서리만으로 distance를 계산했는데, 창이 시각적으로 맞붙어 있어도 portal이 안 열렸다.

이유는 단순했는데, viewport(우리가 그리는 영역)는 브라우저 chrome(주소창 + 탭바, Mac Chrome은 약 110px) 안쪽에서 시작한다. 두 창의 외곽이 맞붙어도 viewport끼리는 chrome 두께만큼 떨어져 있다.

text
┌─────────────────┐ ← 창 외곽 (사용자 눈에 보이는 경계)
│ ━━ chrome ~110px│
├─────────────────┤ ← viewport 시작 (screenY)
│                 │
│   canvas (내가   │
│   그리는 영역)     │
│                 │
└─────────────────┘

window.outerHeight도 같이 broadcast해서, chrome 두께를 빼고 외곽 edge로 proximity 판정해서 해결했다.

ts
const chromeTop = window.outerHeight - window.innerHeight;
const outerTop = window.screenY - chromeTop;

이러면 사용자 눈에 창이 맞붙은 그 순간 portal이 열리게 된다.

레퍼런스

세상엔 참 많은 webAPI가 있다는 걸 느끼며.. 더 자세한 설명을 담고 있는 문서들을 첨부한다.


2. Window Inertia

Window Inertia 데모 - 브라우저 창을 좌우로 흔들면 안쪽 입자가 관성으로 출렁이는 모습

소개

물리적 입력 → 화면 반응의 아이디어가 출발점이다. 브라우저가 콘텐츠를 담고 있는 그릇이라는 말을 어디서 본 적 있는데 약간 그런 느낌으로 브라우저 창을 빠르게 끌면 안쪽 컨텐츠도 따라 흔들리면 자연스럽지 않을까 싶었다. (사실 요런 비슷한 예제들은 세상에 많은듯?)

그렇다면

창의 이동 자체를 어떻게 외력으로 바꿀까?

API - window.screenX / screenY

브라우저는 생각보다 많은 걸 제공해준다.

ts
window.screenX; // viewport 왼쪽 모서리의 모니터 위 X (CSS px)
window.screenY; // 같은 것, Y

사용자가 창을 끌면 이 값이 실시간으로 바뀌는데, requestAnimationFrame으로 매 프레임 폴링하면 창의 이동 속도를 알아낼 수 있다.

구현

기본 아이디어는 한 줄이다.

ts
const dx = window.screenX - lastWindowX;
const dy = window.screenY - lastWindowY;

이 delta가 곧 이번 프레임에 사용자가 창을 끌어당긴 거리다. 그릇을 오른쪽으로 흔들면 물은 왼쪽으로 쏠리듯이, 내부 입자에는 반대 방향으로 가속도를 더한다.

ts
particle.vx += (-dx) * 0.2;
particle.vy += (-dy) * 0.2;

여기에 중력, drag, 입자 간 충돌 풀이까지 얹으면 의외로 되게 액체스럽다(?) 그냥 매 프레임 위치를 업데이트하고, 너무 가까이 붙은 입자끼리 살짝 밀어내는 정도를 구현하면 물리 엔진 먹인 걸 시뮬레이션 할 수 있다. (사실 물리 엔진들이 그렇게 구현되어 있으니까)

text
[Each Frame]
 
1) screenX/Y 폴링 → delta 계산 (관성력)
2) 입자에 외력 + 중력 적용
3) drag로 속도 감쇠 (마찰 비슷한 감쇠력)
4) N×N 충돌 풀이 (2회 iter 정도면 충분, 입자 간 반발력 mocking)
5) 벽 반사
6) 그리기

Reality Force Layer

만들면서 한 가지를 추상화하고 싶어졌는데, 윈도우 이동뿐 아니라 포인터, 스크롤 같은 다양한 입력도 결국 force라는 공통 인터페이스로 묶을 수 있지 않을까 싶어 작은 훅을 하나 만들었다.

ts
const force = useRealityForce({
  sources: ["window"],
  damping: 0.9,
  max: 6,
});
 
// force.current.x, force.current.y, force.current.energy

컴포넌트는 물리엔진을 몰라도 표준화된 force만 읽어 반응한다. 다른 craft에서도 그대로 재사용 가능하다.


마무리

두 craft 모두 결국 같은 질문에서 출발한다.

현실 세계 인터랙션을 화면 속으로 가져오자

브라우저는 의외로 이런 처리에 필요한 정보를 충분히 노출하고 있다. screenX/Y로 창의 위치, BroadcastChannel로 창 사이 통신 등등. 적절히 이용하면 직관적인 동작들이 뚝딱 만들어지는 것 같다. (+AI야 고마워)

물론 더 복잡하고 정교하게 현실세계를 흉내내려면 기성품을 (시중 여러 물리엔진 오픈소스) 쓰는게 베스트겠지만, delta + 가속도 + drag + 충돌 풀이, 이 네 가지 조합만으로도 간단한 예제에서는 충분히 그럴듯한 느낌이 나오는 것 같다.

다음엔 useRealityForce를 좀 더 다듬어서 포인터, 스크롤, 디바이스 기울기 등 다양한 입력을 통일된 force로 다루는 작은 레이어로 추출해보려 한다. 입력을 물리량으로 바꾸는 얇은 레이어 하나가 있으면, 어떤 UI에든 약간의 물성을 입힐 수 있을 것 같다. (최적화가 관건)


참고