고양이 캐릭터를 만들면서 가장 오래 붙잡고 있던 문제는 움직임 알고리즘이 아니라 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를 만드는 것보다 낫습니다.