CSS 애니메이션 — Transition, Keyframes, 그리고 성능 최적화
웹 페이지에서 요소가 부드럽게 움직이는 건 어떻게 가능한 걸까요? 그리고 왜 어떤 애니메이션은 버벅이고, 어떤 건 매끄러울까요?
CSS 애니메이션 — Transition, Keyframes, 그리고 성능 최적화
CSS 애니메이션은 프론트엔드 개발에서 빠질 수 없는 영역입니다. 버튼에 호버 효과를 넣는 것부터 로딩 스피너, 페이지 전환 효과까지 — 결국 다 CSS 애니메이션이에요. 공부하다 보니 "그냥 움직이게만 하면 되는 거 아닌가?" 싶었는데, 성능까지 고려하면 알아야 할 게 꽤 있었습니다.
1. transition — 상태 변화를 부드럽게
transition은 CSS 속성이 변할 때 그 변화를 부드럽게 보간해주는 기능입니다. 가장 흔한 예가 :hover에서 색상이나 크기가 서서히 바뀌는 효과예요.
기본 문법
/* 개별 속성 */
.box {
transition-property: background-color; /* 어떤 속성을 */
transition-duration: 0.3s; /* 얼마나 걸려서 */
transition-timing-function: ease; /* 어떤 속도 곡선으로 */
transition-delay: 0s; /* 얼마 뒤에 시작할지 */
}
/* 단축 속성 — 실무에서는 이 방식을 더 많이 씁니다 */
.box {
transition: background-color 0.3s ease 0s;
}
핵심은 transition이 상태 변화가 트리거 되어야 동작한다는 점입니다. hover, focus, class 추가/제거 같은 이벤트가 없으면 아무 일도 일어나지 않아요.
여러 속성을 동시에 전환
.card {
/* 각 속성별로 다른 duration 지정 가능 */
transition: transform 0.3s ease,
box-shadow 0.3s ease 0.1s, /* 0.1초 지연 */
opacity 0.2s linear;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
opacity: 0.95;
}
transition: all 0.3s ease도 동작하지만, 의도하지 않은 속성까지 전환될 수 있어서 구체적으로 명시하는 게 좋습니다. 성능 측면에서도all보다 개별 속성 지정이 유리해요.
2. timing-function — 속도 곡선 이해하기
애니메이션이 "자연스럽다"고 느끼는 건 속도 곡선 덕분입니다. CSS는 cubic-bezier 곡선을 기반으로 속도를 제어해요.
주요 키워드
| 키워드 | cubic-bezier 값 | 특징 |
|---|---|---|
linear | (0, 0, 1, 1) | 일정한 속도 — 기계적인 느낌 |
ease | (0.25, 0.1, 0.25, 1) | 기본값. 부드럽게 시작하고 부드럽게 끝남 |
ease-in | (0.42, 0, 1, 1) | 느리게 시작, 빠르게 끝남 |
ease-out | (0, 0, 0.58, 1) | 빠르게 시작, 느리게 끝남 |
ease-in-out | (0.42, 0, 0.58, 1) | 느리게 시작, 느리게 끝남 |
커스텀 cubic-bezier
.bounce-effect {
/* cubic-bezier로 바운스 느낌 구현 */
transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
cubic-bezier의 네 값은 베지어 곡선의 두 제어점 좌표입니다. cubic-bezier.com에서 시각적으로 조정하면서 원하는 곡선을 찾을 수 있어요. 직접 만져보면 각 값이 어떤 영향을 주는지 감이 빨리 옵니다.
steps() — 프레임 단위 애니메이션
/* 스프라이트 시트 애니메이션에 유용 */
.sprite {
animation: walk 0.6s steps(6) infinite;
}
steps()는 부드러운 보간 대신 뚝뚝 끊어지는 프레임 전환을 만듭니다. 스프라이트 애니메이션이나 타이핑 효과를 만들 때 유용해요.
3. @keyframes 애니메이션
transition이 A → B 두 상태 사이의 전환이라면, @keyframes는 여러 단계를 거치는 복잡한 애니메이션을 정의할 수 있습니다.
기본 구조
/* 키프레임 정의 */
@keyframes fadeIn {
from { /* 0%와 같음 */
opacity: 0;
transform: translateY(20px);
}
to { /* 100%와 같음 */
opacity: 1;
transform: translateY(0);
}
}
/* 적용 */
.element {
animation-name: fadeIn;
animation-duration: 0.6s;
animation-timing-function: ease-out;
animation-delay: 0s;
animation-iteration-count: 1; /* infinite면 무한 반복 */
animation-direction: normal; /* reverse, alternate 등 */
animation-fill-mode: forwards; /* 종료 후 상태 유지 */
}
/* 단축 속성 */
.element {
animation: fadeIn 0.6s ease-out forwards;
}
여러 단계가 있는 키프레임
@keyframes pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
주요 속성 정리
animation-iteration-count:1,3,infinite— 반복 횟수animation-direction:normal(정방향),reverse(역방향),alternate(정↔역 교대)animation-fill-mode:none(기본, 애니메이션 전후 원래 상태),forwards(마지막 프레임 유지),backwards(시작 전 첫 프레임 적용),both(둘 다)animation-play-state:running/paused— JS로 일시정지할 때 유용
fill-mode를 모르면 "애니메이션 끝나고 원래대로 돌아가는데 왜 그런 거지?" 하는 상황을 겪게 됩니다. forwards를 빼먹은 거예요.
4. transform — 레이아웃을 건드리지 않는 변환
transform은 요소의 시각적 표현만 바꿉니다. 문서 흐름에 영향을 주지 않기 때문에 ** 애니메이션에 가장 적합한 속성 **이에요.
주요 함수들
.box {
/* 이동 — 원래 위치에서 상대적으로 */
transform: translate(50px, 20px); /* X, Y */
transform: translateX(50px);
transform: translateY(-20px);
/* 회전 */
transform: rotate(45deg); /* 시계 방향 45도 */
transform: rotate(-90deg); /* 반시계 방향 */
/* 크기 조정 */
transform: scale(1.5); /* 1.5배 확대 */
transform: scale(0.8, 1.2); /* X축 0.8배, Y축 1.2배 */
/* 기울이기 */
transform: skew(10deg, 5deg); /* X축, Y축 */
}
복합 변환
.card:hover {
/* 여러 변환을 한 줄에 — 순서가 결과에 영향을 줍니다 */
transform: translateY(-8px) rotate(2deg) scale(1.02);
}
복합 변환에서 순서가 중요한 이유는, CSS가 변환을 오른쪽에서 왼쪽으로 적용하기 때문입니다. rotate 후 translate하면 회전된 좌표계 기준으로 이동하게 돼요.
transform-origin
/* 변환의 기준점 설정 — 기본값은 요소의 중심 */
.door {
transform-origin: left center; /* 왼쪽 가운데를 축으로 */
transition: transform 0.5s;
}
.door:hover {
transform: rotateY(-90deg); /* 문이 열리는 효과 */
}
transform이 레이아웃에 영향을 주지 않는다는 건, 요소를translate로 이동시켜도 주변 요소들이 밀리지 않는다는 뜻입니다. 시각적으로만 이동하고 원래 자리는 그대로 남아 있어요. 이게 바로 성능상 유리한 이유이기도 합니다.
5. will-change와 GPU 가속
브라우저는 특정 CSS 속성의 변경을 감지하면 해당 요소를 별도의 Composite Layer(합성 레이어)로 분리해서 GPU에서 처리합니다. 이게 "GPU 가속"이에요.
자동으로 GPU 가속되는 속성
transformopacityfilter
이 속성들은 요소의 레이아웃이나 페인팅을 다시 하지 않고 GPU가 레이어를 합성하는 것만으로 처리할 수 있어서 매우 빠릅니다.
will-change — 브라우저에게 미리 알려주기
/* 호버 전에 부모에서 미리 힌트를 줌 */
.card-container:hover .card {
will-change: transform;
}
.card:active {
transform: scale(0.98);
}
will-change는 브라우저에게 "이 속성이 곧 바뀔 거야"라고 알려주는 힌트입니다. 브라우저는 이 정보를 바탕으로 미리 합성 레이어를 만들어 둡니다.
will-change 남용 주의
/* 이렇게 하면 안 됩니다 */
* {
will-change: transform, opacity; /* 모든 요소에 적용 — 메모리 폭발 */
}
.element {
will-change: all; /* 'all'은 의미 없는 값 */
}
남용하면 안 되는 이유:
- 합성 레이어 하나당 GPU 메모리를 추가로 소모합니다
- 너무 많은 레이어를 만들면 오히려 성능이 악화 됩니다
- 이미
transform이나opacity를 사용 중이면 브라우저가 알아서 최적화하므로will-change가 불필요한 경우가 많습니다
가장 좋은 패턴은 애니메이션 직전에 추가하고, 끝나면 제거하는 것 입니다.
// JS에서 동적으로 관리하는 패턴
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('animationend', () => {
element.style.willChange = 'auto'; // 원복
});
6. Reflow vs Repaint vs Composite — 렌더링 파이프라인
브라우저가 화면을 그리는 과정을 이해하면 왜 transform이 left보다 빠른지 알 수 있습니다.
렌더링 파이프라인
JavaScript → Style → Layout(Reflow) → Paint(Repaint) → Composite
각 단계에서 하는 일:
- Layout(Reflow): 요소의 크기와 위치를 계산합니다
- Paint(Repaint): 계산된 레이아웃을 바탕으로 픽셀을 그립니다
- Composite: 그려진 레이어들을 합성하여 최종 화면을 만듭니다
속성별 비용 차이
| 변경 속성 | 트리거되는 단계 | 비용 |
|---|---|---|
width, height, margin, padding | Layout → Paint → Composite | 높음 |
color, background, box-shadow | Paint → Composite | 중간 |
transform, opacity | Composite만 | 낮음 |
같은 결과, 다른 성능
/* 나쁜 예 — Layout을 매 프레임마다 다시 계산 */
.box-slow {
transition: left 0.3s, top 0.3s;
position: absolute;
left: 0;
}
.box-slow:hover {
left: 100px;
top: 50px;
}
/* 좋은 예 — Composite만 발생 */
.box-fast {
transition: transform 0.3s;
}
.box-fast:hover {
transform: translate(100px, 50px);
}
결과는 같지만 성능 차이는 큽니다. left를 바꾸면 브라우저가 해당 요소와 주변 요소들의 레이아웃을 모두 다시 계산하는 반면, transform은 GPU가 레이어만 이동시키면 끝이에요.
이 차이가 데스크톱에서는 잘 안 느껴질 수 있지만, 모바일이나 저사양 기기에서는 체감이 확실합니다.
7. 실전 예제
버튼 호버 효과
.button {
padding: 12px 24px;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
/* transform과 box-shadow만 전환 — 성능 최적화 */
transition: transform 0.2s ease,
box-shadow 0.2s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
로딩 스피너
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6; /* 위쪽만 색상 변경 */
border-radius: 50%;
/* transform만 사용 — GPU 가속 */
animation: spin 0.8s linear infinite;
}
페이드인 애니메이션
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeInUp 0.6s ease-out forwards;
}
/* 순차적으로 나타나게 하려면 delay 활용 */
.fade-in:nth-child(1) { animation-delay: 0s; }
.fade-in:nth-child(2) { animation-delay: 0.1s; }
.fade-in:nth-child(3) { animation-delay: 0.2s; }
순차 페이드인에서 animation-fill-mode: forwards를 빼먹으면 delay 동안 요소가 보였다가 사라졌다 나타나는 깜빡임이 발생합니다. backwards나 both를 함께 쓰면 delay 동안에도 from 상태를 유지할 수 있어요.
/* 깜빡임 방지: delay 동안 from 상태 유지 + 종료 후 to 상태 유지 */
.fade-in {
animation: fadeInUp 0.6s ease-out both; /* both = forwards + backwards */
}
정리
- transition 은 상태 변화(hover, focus, class 토글)에 반응하는 단순한 애니메이션에 적합합니다
- @keyframes 는 여러 단계를 거치는 복잡한 애니메이션이나 자동 실행이 필요할 때 사용합니다
- transform과 opacity 를 우선적으로 사용하세요 — Composite 단계만 거치므로 성능이 가장 좋습니다
left,top,width같은 Layout 속성 애니메이션은 피하고,transform: translate()로 대체하세요- will-change 는 꼭 필요한 곳에만 — 남용하면 메모리 낭비로 역효과가 납니다
animation-fill-mode를 이해하면 "애니메이션 끝나면 원래대로 돌아가는" 문제를 해결할 수 있습니다