아무리 아름다운 움직임도 화면이 뚝뚝 끊기는 순간 모든 정성이 물거품이 됩니다. 오히려 버벅이는 움직임은 아무 움직임도 없는 것보다 못해서, 제품을 낡고 조악하게 보이게 하지요. 60fps를 지키는 매끄러운 애니메이션을 CSS로 어떻게 짜는지, 나쁜 코드와 좋은 코드를 나란히 놓고 보겠습니다.

87.1 transform과 opacity만 움직인다

화면이 한 장을 그리는 과정은 배치 계산, 색칠, 겹치기의 세 단계입니다. transform과 opacity는 앞의 두 단계를 건너뛰고 마지막 겹치기만 다시 하면 되어, 컴포지터가 아주 가볍게 처리합니다.


.card {
transition: transform .25s ease, opacity .25s ease;
}
.card:hover {
transform: translateY(-6px);
opacity: .92;
}


이동도 크기도 투명도도 이 두 속성으로 다루면, 아무리 빠르게 움직여도 대개 매끄럽게 흐릅니다. 무언가를 옮기거나 키우거나 나타나고 사라지게 할 일이 생기면 먼저 이 둘로 풀 수 있는지부터 봅니다. 움직임의 첫 번째 원칙은 이 둘 안에서 해결하는 것입니다.

87.2 자리와 크기를 직접 건드리지 않기

반대로 top, left, width, height, margin을 애니메이션하면 화면은 매 순간 배치를 처음부터 다시 계산합니다. 이 리플로우가 한 프레임 안에 못 끝나면 움직임이 끊깁니다. 아래는 나쁜 예입니다.


.tooltip {
position: absolute;
top: 8px;
transition: top .2s ease;
}
.tooltip.show {
top: 0;
}


겉보기는 똑같지만, 같은 이동을 transform으로 바꾸면 배치 계산 없이 겹치기만으로 끝나 훨씬 가볍습니다.


.tooltip {
transform: translateY(8px);
transition: transform .2s ease;
}
.tooltip.show {
transform: translateY(0);
}


눈에는 똑같은 8px 이동이지만 화면이 짊어지는 수고는 하늘과 땅 차이입니다. 위치를 나타내는 값을 직접 건드리면 그 요소뿐 아니라 주변, 심하면 화면 전체의 배치까지 다시 계산되지요. 위치는 translate로, 크기는 scale로 대신하는 습관을 들입니다.

87.3 will-change는 신중히

이 요소가 곧 움직일 거라고 미리 귀띔하면, 화면은 그것을 따로 떼어 준비해 둡니다. 다만 모든 것에 붙이면 준비할 짐이 너무 많아져 오히려 무거워집니다.


.card:hover .thumb {
will-change: transform;
}


정말 곧 움직일 요소에만, 그것도 곧 움직일 시점에만 붙입니다. 모든 것에 이 표시를 달면 화면이 미리 준비해 둘 짐이 너무 많아져 오히려 무거워지지요. 페이지 전체에 깔아 두는 것은 도움이 아니라 낭비입니다. 좋은 약도 지나치면 독이 됩니다.

87.4 그림자는 직접 애니메이션하지 않기

box-shadow를 직접 전환하면 매 프레임 그림자를 다시 칠해야 해서 무겁습니다. 아래는 흔한 나쁜 예입니다.


.card {
box-shadow: 0 1px 3px rgba(0,0,0,.2);
transition: box-shadow .3s ease;
}
.card:hover {
box-shadow: 0 12px 24px rgba(0,0,0,.25);
}


대신 짙은 그림자를 담은 가상 요소를 미리 깔아 두고, 그 opacity만 올렸다 내립니다. 그림자를 칠하는 대신 이미 칠해 둔 것을 흐리게만 하는 트릭입니다.


.card {
position: relative;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 24px rgba(0,0,0,.25);
opacity: 0;
transition: opacity .3s ease;
}
.card:hover::after {
opacity: 1;
}


움직이는 것은 opacity 하나뿐이라 컴포지터가 가볍게 처리합니다. 미리 깔아 둔 그림자의 투명도만 오르내리니 다시 칠할 일이 없지요. 같은 그림자 반응이지만 프레임을 그리는 수고가 확 줄어듭니다.

87.5 contain으로 영향 범위 격리

어떤 요소가 바뀔 때 그 여파가 화면 전체로 번지지 않도록 울타리를 칠 수 있습니다. contain은 이 안의 배치와 색칠은 바깥과 무관하다고 브라우저에 약속합니다.


.widget {
contain: layout paint;
}


이 울타리 덕에 위젯 안이 바뀌어도 브라우저는 바깥까지 다시 계산하지 않습니다. 자주 갱신되는 목록이나 카드에 두르면 리플로우 범위가 그 안에 갇힙니다.

87.6 별도 층으로 끌어올리기

움직임이 잦은 요소를 따로 떼어 GPU가 합성하는 별도 층으로 올리면 더 매끄러워집니다. translate로 살짝 미는 것만으로 층 분리를 유도할 수 있습니다.


.layer {
transform: translate3d(0, 0, 0);
}


다만 층이 많아지면 메모리를 먹으니 남발하면 안 됩니다. 요즘은 이 옛 수법보다 will-change로 의도를 밝히는 편이 깔끔하니, 정말 무거운 한두 곳에만 씁니다.

87.7 폭과 높이 대신 scale로 늘이기

진행 막대의 길이를 width로 늘이면 매 프레임 배치를 다시 계산합니다. 아래는 나쁜 예입니다.


.bar {
width: 40%;
transition: width .3s ease;
}
.bar.full {
width: 100%;
}


같은 길이 변화를 scaleX로 대신하면 배치 계산 없이 컴포지터가 늘려 줍니다. 기준점을 왼쪽으로 두어 왼쪽에서 오른쪽으로 차오르게 합니다.


.bar {
transform: scaleX(0.4);
transform-origin: left;
transition: transform .3s ease;
}
.bar.full {
transform: scaleX(1);
}


scale은 안의 글자까지 함께 늘이니 단순한 막대에 어울립니다. 겉모습은 같아도 화면이 짊어지는 계산은 훨씬 가볍습니다.

87.8 보이지 않는 것은 그리지 않기

화면 밖에 있어 지금 보이지 않는 부분까지 브라우저가 미리 다 그려 두면 그만큼 무겁습니다. content-visibility로 아직 안 보이는 영역은 그리기를 미룰 수 있습니다.


.section {
content-visibility: auto;
contain-intrinsic-size: 600px;
}


사용자가 스크롤해 다가오기 전까지 그 영역은 그리지 않아, 처음 화면을 띄우고 움직이는 수고가 줄어듭니다. 항목이 수백 개인 긴 목록에서 특히 효과가 크지요. 옆의 intrinsic-size는 아직 안 그린 영역의 대략적 높이를 미리 일러 주어 스크롤 막대가 튀지 않게 합니다.

87.9 정리

성능을 지키는 일은 움직임이 끊기지 않게 하는 일입니다. 가벼운 transform과 opacity만 움직이고, 배치를 다시 계산하게 만드는 무거운 속성은 피하고, will-change는 아껴 쓰고, 그림자는 가상 요소 트릭으로 돌리고, contain으로 여파를 가두는 것. 화려하지만 버벅이는 것보다 소박해도 매끄러운 편이 언제나 낫습니다.