고양이 캐릭터를 만들면서 가장 오래 붙잡고 있던 문제는 움직임 알고리즘이 아니라 CSS transform이었습니다. 세 가지를 동시에 적용해야 했는데, 하나를 켜면 다른 하나가 사라졌습니다.

문제 상황

고양이에게 필요한 변형은 셋이었습니다.

  • 좌우 반전 — 왼쪽으로 갈 때 scaleX(-1)
  • 통통 튀기 — 걷고 달릴 때 translateY 애니메이션
  • 기울이기 — 곁눈질할 때 JS가 rotate를 인라인으로 설정

처음에는 당연하다는 듯 한 요소에 전부 걸었습니다.

/* ❌ 이렇게 하면 안 된다 */
.cat.facing-left { transform: scaleX(-1); }

.cat.state-walk  { animation: bounce 0.4s infinite; }

@keyframes bounce {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-6px); }
}

결과는 예상과 달랐습니다. 걷기 애니메이션이 켜지는 순간 좌우 반전이 사라졌습니다. 왼쪽으로 가는데 오른쪽을 보고 있는 고양이가 된 것입니다.

왜 그런가 — transform은 하나의 속성이다

핵심은 transform단일 속성이라는 점입니다. scaleX, translateY, rotate는 각각 독립된 속성이 아니라 하나의 속성에 들어가는 값일 뿐입니다.

그래서 다음 두 줄은 합쳐지지 않습니다.

transform: scaleX(-1);
transform: translateY(-6px);   /* 앞의 것을 완전히 대체 */

같은 요소에 두 규칙이 걸리면 CSS 우선순위에 따라 하나만 살아남고 나머지는 버려집니다. 여기에 애니메이션이 끼면 더 복잡해집니다. 애니메이션이 실행 중인 동안 그 속성값은 일반 선언보다 우선하기 때문에, @keyframes 안의 transform이 정적 선언을 덮어씁니다.

JS가 인라인 스타일로 rotate를 설정하는 경우는 더합니다. 인라인 스타일은 우선순위가 매우 높아 클래스로 건 변형을 그대로 밀어냅니다.

안 되는 해결책들

1. 값을 문자열로 합치기

el.style.transform = `scaleX(${dir}) translateY(${y}px) rotate(${deg}deg)`;

이론적으로는 맞지만 실무에서는 금방 무너집니다. 모든 변형을 JS가 관리해야 하므로 CSS 애니메이션을 쓸 수 없게 됩니다. 통통 튀는 동작을 매 프레임 직접 계산해야 하고, 상태가 늘어날 때마다 문자열 조립 로직이 복잡해집니다.

2. !important로 밀어붙이기

당장은 원하는 하나가 살아나지만 다른 것이 죽습니다. 근본 원인이 "하나만 이긴다"인데 더 세게 이기는 것을 만들 뿐입니다.

3. 개별 transform 속성 쓰기

.cat {
  scale: -1 1;
  translate: 0 -6px;
  rotate: 15deg;
}

최신 CSS에는 translate, rotate, scale이 독립 속성으로 존재해 서로 덮어쓰지 않습니다. 좋은 해법이지만 적용 순서가 고정되어 있고(translate → rotate → scale), 브라우저 지원과 기존 코드와의 혼용을 고려해야 합니다. 이 프로젝트에서는 다른 방향을 택했습니다.

해결 — DOM 레이어를 나눈다

가장 확실한 방법은 변형마다 자기 요소를 주는 것입니다. 요소가 다르면 transform도 서로 다른 속성이므로 충돌이 원천적으로 사라집니다.

<div id="cat" class="cat-image-character">   <!-- 위치 (left/top) -->
  <div class="cat-image-flip">               <!-- 좌우 반전 -->
    <div class="cat-image-bounce">           <!-- 통통 튀기 -->
      <div class="cat-image-tilt">           <!-- 기울이기 -->
        <img src="cat.png" alt="" />
      </div>
    </div>
  </div>
</div>

각 레이어는 정확히 하나의 일만 합니다.

/* 바깥 — 위치만 담당. transform을 쓰지 않는다 */
.cat-image-character {
  position: fixed;
  width: 80px; height: 80px;
  will-change: left, top;
}

/* flip 레이어 — 방향 */
.cat-image-character.facing-left .cat-image-flip {
  transform: scaleX(-1);
}

/* bounce 레이어 — 상태별 애니메이션 */
.cat-image-character.state-walk .cat-image-bounce {
  animation: img-cat-bounce 0.4s ease-in-out infinite;
}
.cat-image-character.state-run .cat-image-bounce {
  animation: img-cat-bounce 0.18s ease-in-out infinite;
}
.cat-image-character.state-rest .cat-image-bounce {
  animation: img-cat-breathe 2s ease-in-out infinite;
}

/* tilt 레이어 — JS가 인라인으로 rotate를 넣는 곳 */
.cat-image-tilt {
  transition: transform 0.25s ease-out;
}

이제 JS는 tilt 레이어에만 손을 댑니다. 다른 변형을 전혀 신경 쓰지 않아도 됩니다.

function tiltEl() {
  return el.querySelector('.cat-image-tilt');
}

function performGlance() {
  const tilt = tiltEl();
  tilt.style.transform = `rotate(${sign * CONFIG.GLANCE_TILT_DEG}deg)`;

  clearTimeout(glanceResetTimer);
  glanceResetTimer = setTimeout(() => {
    tilt.style.transform = '';   // 원위치
  }, CONFIG.GLANCE_DURATION_MS);
}

중첩된 요소의 transform은 바깥에서 안쪽으로 누적 적용되므로, 결과적으로는 세 변형이 모두 합성됩니다. 원하던 그대로입니다.

레이어를 나눈 뒤에 생긴 새 문제

깔끔하게 해결된 줄 알았는데 이상한 버그가 남았습니다. 고양이가 왼쪽을 향하고 있을 때만 곁눈질이 커서 반대쪽으로 기울었습니다.

원인은 부모의 scaleX(-1)이었습니다. flip 레이어가 좌우를 뒤집으면 그 안의 모든 좌표계도 함께 뒤집힙니다. 자식이 시계 방향으로 돌면 화면에서는 반시계 방향으로 보입니다.

그래서 부호를 한 번 더 뒤집어줘야 합니다.

const catCenterX   = x + CAT_SIZE / 2;
const facingLeft   = el.classList.contains('facing-left');
const towardsRight = cursorPos.x >= catCenterX;

// flip 레이어가 scaleX(-1)로 전체를 거울처럼 뒤집으므로
// 부호도 함께 뒤집지 않으면 커서 반대쪽으로 기운다
const sign = (towardsRight ? 1 : -1) * (facingLeft ? -1 : 1);

tilt.style.transform = `rotate(${sign * CONFIG.GLANCE_TILT_DEG}deg)`;

거울 안에서는 방향의 의미가 반대가 된다는 것이 이 문제의 요점입니다. scaleX(-1)이 걸린 부모 아래에서 회전이나 좌우 이동을 다룰 때는 항상 확인해야 합니다.

드래그 상태에서만 예외를 둔 곳

한 군데 !important를 쓴 곳이 있습니다. 드래그 중에는 곁눈질 회전을 무시하고 "들어올려진" 모습으로 고정하고 싶었기 때문입니다.

.cat-image-character.dragging .cat-image-tilt {
  transform: scale(1.1) rotate(-6deg) !important;
}

같은 요소의 인라인 스타일을 이겨야 하는 상황이라, 여기서는 !important가 타당한 선택입니다. 다만 JS 쪽에서도 드래그 시작 시 인라인 값을 지우고 타이머를 정리하도록 해서 드래그가 끝난 뒤 상태가 남지 않게 했습니다.

function onPointerDown(e) {
  clearTimeout(glanceResetTimer);
  const tilt = tiltEl();
  if (tilt) tilt.style.transform = '';
  el.classList.add('dragging');
  // ...
}

성능 — will-change를 어디에 걸 것인가

이동은 left/top으로 처리하고 있어 바깥 레이어에만 will-change: left, top을 걸었습니다.

참고로 순수 성능만 보면 transform: translate()로 이동시키는 편이 낫습니다. 레이아웃 재계산 없이 합성 단계에서만 처리되기 때문입니다. 다만 그렇게 하면 바깥 레이어도 transform을 쓰게 되어 레이어를 하나 더 얹어야 합니다. 요소 하나짜리 프로젝트에서는 left/top으로도 충분히 부드러워 단순함을 택했습니다.

will-change는 남발하면 오히려 메모리를 낭비합니다. 실제로 계속 변하는 속성에만, 필요한 요소에만 거는 것이 원칙입니다.

정리

  • transform은 단일 속성이라 여러 규칙이 걸리면 하나만 살아남습니다.
  • 애니메이션과 인라인 스타일은 우선순위가 높아 정적 선언을 덮어씁니다.
  • 가장 견고한 해법은 변형마다 DOM 레이어를 하나씩 주는 것입니다.
  • scaleX(-1)이 걸린 부모 아래에서는 자식의 방향 감각이 뒤집힙니다. 부호를 보정해야 합니다.
  • 독립 속성(translate/rotate/scale)도 대안이지만 적용 순서가 고정된다는 제약이 있습니다.

레이어를 나누는 것은 DOM이 조금 깊어진다는 비용이 있지만, 그 대가로 각 레이어를 독립적으로 수정할 수 있게 됩니다. 새 변형을 추가할 때도 레이어를 하나 더 끼우면 끝입니다. 요소 몇 개를 아끼려다 손댈 수 없는 CSS를 만드는 것보다 낫습니다.