화면 위를 자유롭게 돌아다니는 캐릭터를 만드는 데 라이브러리가 필요할까요. 결론부터 말하면 필요 없습니다. 이 사이트의 고양이는 requestAnimationFrame과 CSS 애니메이션만으로 움직이고, 전체 코드는 300줄이 되지 않습니다. 실제로 쓰인 코드를 기준으로 설명하겠습니다.

구조 나누기

가장 먼저 정한 것은 책임의 분리였습니다. 한 파일에 다 넣으면 금방 손댈 수 없게 됩니다.

  • config.js — 속도, 지속 시간, 반응 문구 같은 상수만
  • cat-movement.js — 움직임과 인터랙션 로직
  • uiManager.js — DOM 갱신 (상태 텍스트, 로그)
  • main.js — 엔트리포인트, 연결만 담당

특히 config.js를 분리한 것이 실제로 유용했습니다. "조금 더 빠르게", "쉬는 시간을 줄여보자" 같은 조정이 전부 한 파일의 숫자 하나를 바꾸는 일이 되기 때문입니다.

export const CONFIG = Object.freeze({
  CAT_SIZE: 80,
  MARGIN:   10,
  SPEED: Object.freeze({ walk: 1.4, run: 3.2 }),
  STATE_DURATION: Object.freeze({
    walk: [3000, 7000],
    run:  [1500, 4000],
    rest: [2000, 5000],
  }),
  // ...
});

Object.freeze()를 쓴 이유는 실수로 런타임에 값이 바뀌는 것을 막기 위해서입니다. 상수는 상수여야 디버깅이 쉽습니다.

상태 머신 — 살아 있는 느낌의 핵심

캐릭터가 그냥 일정한 속도로 움직이면 아주 빠르게 기계처럼 보입니다. 살아 있는 느낌을 만드는 것은 예측할 수 없는 상태 전환입니다.

세 가지 상태를 두었습니다.

  • walk — 느리게 이동 (속도 1.4)
  • run — 빠르게 이동 (속도 3.2)
  • rest — 정지

각 상태는 무작위 시간이 지나면 다른 상태로 전환됩니다. 고정 간격이 아니라 범위를 두는 것이 중요합니다.

function scheduleNextState() {
  clearTimeout(stateTimer);
  const [min, max] = STATE_DURATION[state];
  stateTimer = setTimeout(() => {
    if (dragging) return; // 드래그 중이면 건너뛴다
    applyState(STATES[randInt(0, STATES.length - 1)]);
  }, randInt(min, max));
}

여기서 놓치기 쉬운 것이 clearTimeout입니다. 클릭 반응 등으로 상태가 강제 전환될 때 이전 타이머가 살아 있으면 예약된 전환이 뒤늦게 끼어들어 움직임이 튀는 현상이 생깁니다. 상태를 바꾸는 모든 경로에서 기존 타이머를 반드시 정리해야 합니다.

속도 재조정에서 만난 버그

상태가 바뀌면 속도가 달라져야 하는데, 여기서 초기 구현이 틀렸습니다. 처음에는 vxvy에 그냥 새 속도를 대입했는데, 그러면 진행 방향이 함께 바뀌어버립니다. 걷다가 달리기 시작할 때 갑자기 엉뚱한 쪽으로 꺾이는 것처럼 보였습니다.

올바른 방법은 방향은 유지하고 크기만 바꾸는 것입니다. 벡터를 정규화한 뒤 새 속도를 곱합니다.

if (newState !== 'rest') {
  const speed = SPEED[newState];
  const len   = Math.hypot(vx, vy) || 1;   // 0으로 나누기 방지
  vx = (vx / len) * speed;
  vy = (vy / len) * speed;
}

|| 1은 정지 상태에서 len이 0이 되어 NaN이 퍼지는 것을 막는 안전장치입니다. 이런 방어는 나중에 원인을 찾기 어려운 버그를 미리 없애줍니다.

메인 루프 — setInterval이 아니라 rAF

애니메이션 루프는 setInterval이 아니라 requestAnimationFrame을 씁니다. 이유는 세 가지입니다.

  • 브라우저의 화면 갱신 주기에 맞춰 호출되므로 화면 찢어짐이 없습니다.
  • 탭이 백그라운드로 가면 자동으로 멈춰 배터리와 CPU를 아낍니다.
  • 모니터 주사율에 맞춰 부드럽게 동작합니다.
function tick() {
  if (dragging || state === 'rest') {
    rafId = requestAnimationFrame(tick);
    return;                     // 루프는 유지하되 위치 계산만 건너뛴다
  }

  x += vx;
  y += vy;
  // ... 경계 처리 ...

  el.style.left = `${x}px`;
  el.style.top  = `${y}px`;

  rafId = requestAnimationFrame(tick);
}

rest 상태에서 루프를 중단하지 않고 계속 돌리는 것이 포인트입니다. 멈췄다가 다시 시작하는 방식은 재개 시점을 관리해야 해서 상태가 꼬이기 쉽습니다. 계산만 건너뛰는 편이 훨씬 단순합니다.

벽 반사 — 각도에 흔들림 주기

화면 가장자리에 닿으면 방향을 바꿔야 합니다. 단순히 부호만 뒤집으면 되지만, 그렇게만 하면 문제가 생깁니다. 같은 경로를 무한히 반복하게 되어 몇 초만 보고 있어도 패턴이 읽힙니다.

그래서 반사할 때마다 각도에 약간의 무작위 흔들림을 넣었습니다.

if (x <= MARGIN)    { x = MARGIN; vx =  Math.abs(vx); bounced = true; }
else if (x >= maxX) { x = maxX;   vx = -Math.abs(vx); bounced = true; }

if (bounced) {
  const tweak = rand(-0.3, 0.3);                  // 라디안
  const angle = Math.atan2(vy, vx) + tweak;
  vx = Math.cos(angle) * speed;
  vy = Math.sin(angle) * speed;
}

vx = -vx가 아니라 Math.abs()로 부호를 명시적으로 지정한 것도 의도적입니다. 창 크기가 갑자기 줄어들어 캐릭터가 경계 밖에 놓이면, 단순 부호 반전은 경계 안쪽으로 들어오지 못하고 계속 떨리는 상태에 빠질 수 있습니다.

클릭과 드래그 구분하기

캐릭터를 클릭하면 놀라고, 붙잡아 옮기면 항의하도록 만들고 싶었습니다. 문제는 둘 다 pointerdown으로 시작한다는 것입니다. 마우스를 뗄 때가 되어야 어느 쪽인지 알 수 있습니다.

해결책은 이동 거리로 판정하는 것입니다.

function onPointerMove(e) {
  if (!dragging) return;
  const dist = Math.hypot(
    e.clientX - dragStartClientX,
    e.clientY - dragStartClientY
  );
  if (dist > CONFIG.DRAG_CLICK_THRESHOLD_PX) dragMoved = true;
  // ...
}

function onPointerUp() {
  dragging = false;
  if (dragMoved) reactToDragRelease();  // 드래그였다
  else           reactToClick();        // 클릭이었다
}

임계값은 6px로 잡았습니다. 사람이 클릭할 때 손이 완전히 고정되지 않기 때문에 0px으로 두면 대부분의 클릭이 드래그로 판정됩니다. 반대로 너무 크게 잡으면 짧은 드래그가 클릭으로 처리됩니다. 실제로 만져보며 조정할 값입니다.

Pointer Events를 쓴 이유

mousedown/touchstart를 각각 처리하는 대신 Pointer Events 하나로 통일했습니다. 마우스, 터치, 펜을 같은 코드로 다룰 수 있어 분기가 사라집니다.

여기에 setPointerCapture()를 더하면 중요한 문제 하나가 해결됩니다. 드래그 중 포인터가 요소 밖으로 나가도 이벤트를 계속 받을 수 있어 캐릭터가 손에서 떨어지지 않습니다.

try {
  el.setPointerCapture(dragPointerId);
} catch { /* 지원하지 않는 환경에서도 동작은 유지 */ }

CSS에서 touch-action: none도 함께 필요합니다. 이게 없으면 모바일에서 드래그가 페이지 스크롤로 가로채집니다.

연속 추적 대신 '가끔의 곁눈질'

처음에는 캐릭터가 커서를 계속 바라보게 만들었습니다. 그런데 실제로 써보니 기괴했습니다. 살아 있다기보다 감시당하는 느낌에 가까웠습니다.

그래서 연속 추적을 버리고 가끔 슬쩍 쳐다보는 방식으로 바꿨습니다. 두 가지 조건 중 하나가 만족될 때만 반응합니다.

  • 마지막 곁눈질 후 무작위 시간(4~9초)이 지났을 때
  • 커서가 갑자기 260px 이상 크게 움직였을 때
function checkGlanceTriggers() {
  if (!dragging) {
    const now = performance.now();
    const idleReady = now - lastGlanceAt > nextGlanceIdleMs;
    const jump = Math.hypot(
      cursorPos.x - lastCheckedCursor.x,
      cursorPos.y - lastCheckedCursor.y
    );
    if (idleReady || jump > CONFIG.GLANCE_CURSOR_JUMP_PX) {
      performGlance();
      lastGlanceAt = now;
      nextGlanceIdleMs = rand(...CONFIG.GLANCE_IDLE_MS_RANGE);
    }
  }
  lastCheckedCursor = { ...cursorPos };
}

다음 대기 시간을 매번 새로 뽑는 것(nextGlanceIdleMs)이 자연스러움의 핵심입니다. 고정 간격이면 금세 리듬이 읽힙니다.

체크는 setInterval로 400ms마다 돕니다. 이런 저빈도 판정까지 rAF에 넣으면 매 프레임 불필요한 계산이 들어갑니다. 화면 갱신은 rAF, 주기적 판정은 setInterval로 나누는 편이 낫습니다.

정리 함수를 반드시 만들 것

타이머와 이벤트 리스너를 등록했으면 해제하는 경로도 있어야 합니다. 지금은 한 페이지에 하나뿐이라 당장 문제가 없지만, 나중에 여러 마리를 띄우거나 SPA로 옮길 때 이 함수가 없으면 메모리 누수가 생깁니다.

function destroy() {
  cancelAnimationFrame(rafId);
  clearTimeout(stateTimer);
  clearTimeout(glanceResetTimer);
  clearInterval(glanceCheckTimer);
  document.removeEventListener('mousemove', onMouseMove);
  el.removeEventListener('pointerdown', onPointerDown);
  // ...
}

컨트롤러를 팩토리 함수로 만든 것도 같은 이유입니다. createCatController(el){ init, destroy, getState }를 돌려주므로 여러 인스턴스를 독립적으로 관리할 수 있습니다. 전역 변수에 상태를 두면 두 마리째부터 서로 간섭합니다.

배운 것

  • 무작위성이 생명감을 만든다. 상태 지속 시간, 반사 각도, 곁눈질 간격 — 고정값을 범위로 바꾸는 것만으로 인상이 크게 달라집니다.
  • 과한 반응은 오히려 부자연스럽다. 커서 연속 추적을 뺀 것이 가장 큰 개선이었습니다.
  • 상수는 한곳에 모을 것. 튜닝 속도가 완전히 달라집니다.
  • 타이머는 반드시 정리할 것. 원인 모를 튐 현상의 대부분이 여기서 옵니다.
  • Pointer Events + setPointerCapture는 드래그 구현의 기본 조합입니다.

다음 글에서는 이 캐릭터에서 가장 까다로웠던 문제 — 여러 CSS transform이 서로를 덮어쓰는 문제를 레이어 분리로 푼 방법을 다룹니다.