들어가며
웹이나 앱을 사용하다 보면 자연스럽게 눈길을 끄는 애니메이션을 마주할 때가 있다. 애니메이션은 재미를 더할 뿐 아니라 사용자의 이해를 돕고 인터랙션의 결과를 명확히 전달하며 브랜드의 개성을 표현하는 등 중요한 역할을 한다.
그런데 이런 애니메이션을 직접 만들어야 하는 순간이 오면 이야기가 달라진다. 디자이너가 건네준 프로토타입 영상을 보며 "이걸 어떻게 구현하지?"라고 막막해한 경험이 한 번쯤은 있을 것이다. 혹은 머릿속에 그려지는 움직임은 있지만 코드로 옮기려니 어디서부터 손을 대야 할지 모를 때도 있다.
핵심은 애니메이션도 설계할 수 있다는 것이다. 복잡해 보이는 움직임도 잘게 쪼개면 단순한 상태 변화의 조합이고, 각각의 상태 변화는 수학적으로 표현할 수 있다. 이 글에서는 애니메이션을 체계적으로 분해하고 설계하는 방법을 다룰 것이다.
애니메이션은 그래프다
무언가를 "설계"하려면 두 가지 필요한 조건이 있다. 재현할 수 있어야 하고, 조합할 수 있어야 한다. 머릿속의 감각에 의존하면 같은 움직임을 다시 만들기 어렵고, 여러 움직임을 체계적으로 엮기도 어렵다. 따라서 움직임을 공학적으로 나타내는 방법이 필요하며, 이를 위해 그래프를 이용할 수 있다. 모든 애니메이션은 그래프로 표현할 수 있다. 이 관점을 갖게 되면 복잡한 움직임도 체계적으로 분석하고 만들어낼 수 있게 된다.
요소의 투명도가 0에서 1로 변하는 페이드인 애니메이션을 생각해보자. 이것을 그래프로 그리면 가로축은 시간, 세로축은 투명도가 된다. 예를 들면, 0초에서 시작해 2초에 끝나는 동안 투명도 값이 0에서 1로 올라가는 그래프를 그려 볼 수 있다.
이동 애니메이션도 마찬가지다. 왼쪽에서 오른쪽으로 움직이는 요소는 시간-위치 그래프로 나타낼 수 있고, 크기가 커지는 애니메이션은 시간-스케일 그래프가 된다. 가로축이 꼭 시간일 필요도 없다. 스크롤 위치에 따라 요소가 나타나는 패럴랙스 효과는 스크롤 오프셋이 가로축이 된다. 어떤 애니메이션이든 결국 어떤 입력에 따라 값이 변하는 것이고, 이 변화를 그래프로 그릴 수 있다.
여기서 중요한 것은 그래프의 모양이 곧 움직임의 느낌을 결정한다는 점이다. 같은 시작점과 끝점이라도 곡선의 형태에 따라 전혀 다른 인상을 줄 수 있다. 이 곡선을 원하는 대로 만들어내는 것이 애니메이션 설계의 핵심이며, 그 도구가 되는 것이 수학이다.
애니메이션을 위한 도구, 수학
그렇다면 원하는 모양의 그래프를 어떻게 만들 수 있을까? 여기서 수학이 등장한다. 이 섹션에서는 애니메이션에서 자주 쓰이는 수학적 도구들을 살펴보고 그것들이 어떻게 그래프의 형태를 조절하는지 알아볼 것이다. 수학이 낯설게 느껴질 수도 있지만 걱정할 필요 없다. 핵심은 수학적 개념을 이해하는 것이 아니라 애니메이션의 느낌에 어떤 영향을 주는지 파악하는 것이다.
이징과 베지어 커브
가장 단순한 애니메이션은 선형이다. 선형은 시작점에서 끝점까지 일정한 속도로 변한다. 하지만 현실 세계의 움직임은 선형이 아니다. 공을 던지면 처음에 빠르다가 점점 느려지고, 자동차는 출발할 때 천천히 시작해서 속도를 높인다. 이런 자연스러운 가감속을 표현하는 것이 이징easing 함수다.
이징 함수는 0에서 1 사이의 진행률을 입력받아 변환된 진행률을 출력한다. ease-in은 느리게 시작해서 빠르게 끝나고, ease-out은 빠르게 시작해서 느리게 끝난다. 이것을 수학적으로 표현하는 가장 널리 쓰이는 방법이 큐빅 베지어 커브cubic bezier curve다.
큐빅 베지어 커브는 네 개의 제어점으로 곡선을 정의한다. 이징에서는 시작점 (0, 0)과 끝점 (1, 1)이 고정이므로 실질적으로 두 개의 제어점 (x1, y1)과 (x2, y2)만 조절하면 된다. 제어점은 곡선이 지나가는 점이 아니라, 곡선을 끌어당기는 점이다. 마치 자석처럼 곡선의 경로에 영향을 준다.
그렇다면 제어점으로부터 곡선이 어떻게 만들어질까? 핵심은 선형 보간linear interpolation의 재귀적 적용이다. 두 점 사이의 선형 보간은 단순하다. 진행률 t가 0이면 시작점, 1이면 끝점, 0.5면 정확히 중간점이다.
function lerp(a, b, t) {
return a + (b - a) * t;
}베지어 커브는 이 보간을 여러 단계에 걸쳐 반복한다. 네 개의 제어점 P0, P1, P2, P3이 있다고 하자.
- P0-P1, P1-P2, P2-P3 사이를 각각
t만큼 보간하여 세 개의 중간점을 얻는다 - 그 세 중간점 사이를 다시
t만큼 보간하여 두 개의 점을 얻는다 - 마지막 두 점 사이를
t만큼 보간하면 최종 곡선 위의 한 점이 나온다
이 방식은 꽤나 강력한데 두 개의 제어점만으로 매우 다양한 움직임을 만들어낼 수 있기 때문이다. 그 중에서 대표적으로 많이 사용되는 제어점 조합을 예제로 살펴보면 다음과 같다.
이징을 실전에서 어떻게 선택할까? 토스트 알림이 화면 아래에서 올라오는 애니메이션을 예로 들어보자.
ease-out을 적용하면 토스트가 빠르게 나타나 사용자의 주의를 끌고, 최종 위치에서 부드럽게 감속하며 안착한다. 반면 ease-in을 적용하면 천천히 시작해서 점점 빨라지는데, 등장이 느긋해서 덜 눈에 띈다. linear는 일정한 속도로 올라오다 갑자기 멈추기 때문에 기계적인 느낌을 준다.
같은 요소, 같은 움직임이지만 이징에 따라 전달하는 인상이 완전히 달라진다. 어떤 이징이 "정답"인 것은 아니다. 중요한 것은 그 움직임이 어떤 목적을 가지는가다. 사용자의 시선을 빠르게 끌어야 한다면 초반이 빠른 커브를, 조용히 사라져야 한다면 후반이 빠른 커브를 선택할 수 있다. 아래 데모에서 같은 토스트에 서로 다른 이징을 적용했을 때 인상이 어떻게 달라지는지 비교해보자.
베지어 커브의 원리를 이해하면 도구에 의존하지 않고도 원하는 움직임을 직접 설계할 수 있다. "처음에 빠르게 튀어나왔다가 천천히 안착하는 느낌"이 필요하면 첫 번째 제어점의 y값을 크게, 두 번째 제어점을 (1, 1) 근처에 두면 된다. 그래프의 모양을 먼저 그린 뒤, 그에 맞는 제어점을 찾아가는 것이다.
지수적 접근
이징은 시작과 끝이 정해진 곡선이다. 하지만 목표값이 도중에 바뀔 수 있는 상황에서는 어떨까? 커서를 따라다니는 요소처럼 목표가 매 프레임 달라진다면, 미리 정해진 곡선으로는 대응하기 어렵다. 이때 유용한 패턴이 지수적 접근exponential approach이다.
value += (target - value) * factor; // factor: 0~1수식은 놀라울 정도로 단순하다. 위 수식을 매 프레임마다 적용하면 현재 값과 목표 값의 차이에 비례하는 만큼만 이동한다. 그 결과 빠르게 접근하다가 점점 느려지는 자연스러운 감속 곡선이 만 들어진다.
factor가 작으면 천천히 다가가고 크면 빠르게 다가간다. 수학적으로 이것은 지수 감쇠exponential decay다. 차이가 매 프레임 (1 - factor) 비율로 줄어들기 때문에 남은 거리가 지수적으로 감소한다.
위치뿐 아니라 모든 종류의 값에 동일하게 적용할 수 있다. 대시보드의 카운터가 목표 수치를 향해 빠르게 올라가다 점점 느려지며 안착하는 효과도 같은 원리다.
프로그레스 바도 마찬가지다. 실제 진행률은 네트워크 상황에 따라 뚝뚝 끊기지만 표시되는 바가 지수적 접근으로 목표를 쫓아가면 사용자에게는 부드러운 진행으로 보인다.
지수적 접근의 장점은 목표가 바뀌어도 자연스럽다는 것이다. 커서가 갑자기 반대편으로 이동해도 현재 위치에서부터 새 목표를 향해 부드럽게 방향을 전환한다.
스프링 애니메이션
이징과 지수적 접근은 목표에 단조롭게 수렴한다. 하지만 현실의 물체에는 관성이 있다. 목표를 살짝 지나쳤다가 되돌아오는 탄성이 움직임에 생동감을 더한다. 버튼을 눌렀을 때의 탄력적인 피드백, 드래그한 요소가 원래 자리로 튕겨 돌아오는 느낌을 주고 싶다면 스프링 애니메이션을 사용할 수 있다.
스프링 애니메이션은 물리의 감쇠 진동damped harmonic oscillation을 기반으로 한다. 용수철에 매달린 물체를 생각하면 된다. 물체를 잡아당겼다 놓으면 원래 위치를 중심으로 진동하고 마찰에 의해 점점 진폭이 줄어들면서 결국 멈춘다.
기반이 되는 원리는 두 가지 힘이다.
- 복원력: 현재 위치가 목표에서 멀수록 강하게 잡아당긴다.
F = -k × (현재 - 목표). 여기서k는 강성stiffness이다. - 감쇠력: 속도에 비례하여 움직임을 억제한다.
F = -c × 속도. 여기서c는 감쇠 계수damping이다.
매 프레임마다 이 두 힘을 합산하여 가속도를 구하고, 가속도로 속도를 갱신하고, 속도로 위치를 갱신한다. 코드로 표현하면 다음과 같다.
const force = -stiffness * (current - target) - damping * velocity;
velocity += force * dt;
current += velocity * dt;스프링의 느낌은 stiffness와 damping 두 값으로 결정된다. 강성이 높으면 팽팽하고 빠르게 반응하며 감쇠가 낮으면 더 오래 진동한다. 이 두 파라미터를 조절하는 것만으로 "톡 튕기는 버튼"부터 "부드럽게 안착하는 카드"까지 다양한 느낌을 만들어낼 수 있다.
좋아요 버튼을 예로 들어보자. 하트를 누르는 순간 스케일이 순간적으로 작아졌다가 목표(원래 크기)를 향해 튕겨 올라온다. 이때 스프링의 오버슛 덕분에 목표를 살짝 넘었다 돌아오며 "눌렀다"는 촉각적 피드백을 만든다.
스프링 애니메이션이 이징 기반 애니메이션과 근본적으로 다른 점은 duration이 없다는 것이다. 이징은 "0.3초 동안" 같은 고정 시간이 있지만, 스프링은 물리 시뮬레이션이 수렴할 때까지 계속된다. 그리고 또 다른 점은 목표값이 중간에 바뀌면 현재 속도를 유지한 채 새 목표를 향해 자연스럽게 전환된다는 점이다.
물리 시뮬레이션
스프링은 하나의 값이 목표를 향해 진동하는 시스템이었다. 하지만 물리를 더 넓게 적용할 수 있다. 중력, 충돌, 마찰, 관성과 같은 물리 법칙들을 조합하면 직접 설계하기 어려운 복잡한 움직임을 자연스럽게 만들어낼 수 있다.
물리 시뮬레이션의 기본 구조는 단순하다. 매 프레임마다 세 단계를 반복한다.
- 힘 계산: 각 오브젝트에 작용하는 힘들을 합산한다 (중력, 스프링, 마찰, 사용자 입력 등)
- 적분: 힘으로부터 가속도를, 가속도로부터 속도를, 속도로부터 위치를 갱신한다
- 제약 처리: 충돌 감지, 경계 제한, 연결 관계 등의 제약 조건을 적용한다
for (const obj of objects) {
// 힘 계산
const gravity = { x: 0, y: 9.8 * obj.mass };
const friction = { x: -obj.vx * drag, y: -obj.vy * drag };
const fx = gravity.x + friction.x;
const fy = gravity.y + friction.y;
// 적분
obj.vx += (fx / obj.mass) * dt;
obj.vy += (fy / obj.mass) * dt;
obj.x += obj.vx * dt;
obj.y += obj.vy * dt;
// 제약 처리 (바닥 충돌)
if (obj.y > floorY) {
obj.y = floorY;
obj.vy *= -restitution; // 반발 계수만큼 튕김
}
}이 구조가 강력한 이유는 규칙만 정의하면 움직임은 알아서 만들어진다는 점이다. 컨페티를 예로 들어보자. 각 조각의 궤적을 이징 함수로 일일이 설계하려면 비현실적이다. 하지만 중력과 공기 저항, 회전만 설정하면 수십 개의 조각이 각자 다른 궤적을 그리며 떨어진다. 개별 궤적을 신경 쓸 필요가 없다.
이런 점에서 물리 시뮬레이션은 다수의 오브젝트가 상호작용하거나, 사용자 입력에 따라 결과가 달라지는 상황에서 유용하다.
다만 물리 시뮬레이션은 예측 가능성이 떨어진다는 단점이 있다. 이징 기반 애니메이션은 언제 어떤 위치에 있을지 정확히 알 수 있지만 물리 시뮬레이션은 초기 조건에 따라 결과가 달라진다. 따라서 UI의 핵심 전환 애니메이션보다는 보조적인 효과나 인터랙티브 요소에 더 적합한 경우가 많다.
자연스러운 방향 전환
지금까지는 값이 목표를 향해 얼마나 변하는지를 다뤘다. 이번에는 어느 방향으로 변하는지를 다룰 것이다. 화면 위에서 요소가 커서를 따라다니는 애니메이션을 만든다고 하자. 위치를 따라가는 것은 어렵지 않다. 하지만 요소가 이동 방향을 바라보도록 회전까지 시키려면 어떻게 해야 할까?
이때 필요한 것이 atan2 함수다. atan2(dy, dx)는 두 점 사이의 각도를 라디안으로 반환한다. 현재 위치와 목표 위치의 차이 (dx, dy)를 넣으면, 요소가 바라봐야 할 방향이 나온다.
const dx = targetX - currentX;
const dy = targetY - currentY;
const angle = Math.atan2(dy, dx);
element.style.transform = `rotate(${angle}rad)`;단순하지만 이것만으로도 움직임의 자연스러움이 크게 달라진다. 화살표가 이동 방향을 바라보거나, 캐릭터가 목적지를 향해 고개를 돌리거나, 파티클이 퍼져나가는 방향으로 늘어나는 효과 모두 atan2가 핵심이다.
atan2의 실전 활용 사례를 하나 보자. 카드 UI 위에서 마우스를 움직이면 카드가 커서 방향으로 기우는 3D 틸트 효과다. atan2로 커서의 방향을, 중심으로부터의 거리로 기울기의 강도를 구한다.
이처럼 두 점 사이의 방향을 구할 수 있으면 표현할 수 있는 애니메이션의 폭이 크게 넓어진다.
삼각함수로 만드는 주기적 움직임
우리가 중학교 때 배운 삼각함수는 애니메이션에서 매우 넓은 쓰임새를 가진다. 핵심 특성은 주기성이다. 값이 -1에서 1 사이를 끝없이 반복하기 때문에, 반복되는 움직임을 만드는 데 최적의 도구다.
하나의 sin 함수와 위상 오프셋만으로 수십 개 요소의 협응적인 움직임이 만들어진다. 로딩 인디케이터, 이퀄라이저 바, 물결 효과 등 주기적이면서 군무처럼 보이는 애니메이션 대부분이 이 원리 위에 있다.
const y = amplitude * Math.sin(time * frequency);sin이나 cos 함수에 세 개의 파라미터를 조절하면 원하는 모양의 반복을 만들 수 있다.
- 진폭amplitude: 움직임의 크기. 값이 클수록 넓게 흔들린다
- 주파수frequency: 움직임의 빠르기. 값이 클수록 빠르게 반복한다
- 위상phase: 시작 지점의 오프셋. 여러 요소에 서로 다른 위상을 주면 파동 효과가 만들어진다
삼각함수가 특히 강력한 순간은 여러 요소에 위상 차이를 줄 때다. 예를 들어, 리스트의 각 아이템에 인덱스에 비례하는 위상을 주면 파도처럼 차례로 움직이는 효과가 된다.
items.forEach((item, i) => {
const y = amplitude * Math.sin(time * frequency + i * phaseOffset);
item.style.transform = `translateY(${y}px)`;
});실전에서는 메신저의 타이핑 인디케이터가 대표적이다. 세 개의 점이 차례로 튀어오르는 이 효과는 동일한 sin에 위상만 다르게 준 것이다.
랜딩 페이지에서 흔히 보이는 플로팅 효과도 삼각함수를 이용해 만들 수 있다. 각 요소마다 서로 다른 진폭, 주파수, 위상을 주면 동일한 sin 함수 하나로 자연스러운 배경이 만들어진다.
톱니파
sin은 값이 올라갔다 내려오는 왕복 운동에 적합하다. 하지만 모든 반복이 왕복인 것은 아니다. 알림 배지의 펄스 링처럼 값이 0에서 1로 한 방향으로 진행한 뒤 처음으로 돌아가 다시 시작하는 패 턴도 있다. 이것을 톱니파sawtooth wave라고 부른다.
const p = (t % period) / period; // 항상 0~1, 주기마다 리셋t를 period로 나눈 나머지를 구하면, 값은 0에서 1까지 선형으로 올라갔다가 즉시 0으로 떨어진다. 이 p를 스케일이나 투명도에 매핑하면 "퍼지면서 사라지는" 반복 효과가 만들어진다.
const scale = 1 + p * 0.8; // 1 → 1.8로 커짐
const opacity = 1 - p; // 1 → 0으로 사라짐톱니파는 "시작 → 끝 → 즉시 리셋"이라는 단순한 구조 덕분에 펄스, 핑, 반복 프로그레스 같은 패턴에 널리 쓰인다. sin의 부드러운 왕복과 톱니파의 단방향 리셋 두 가지만으로도 대부분의 주기적 애니메이션을 표현할 수 있다.
애니메이션 설계하기
지금까지 이징, 스프링, 삼각함수 같은 수학 도구를 살펴봤다. 하나의 도구로 해결되는 경우도 있지만, 현실의 애니메이션은 대부분 더 복잡하다. 예를 들어, 알림이 나타날 때 배경이 어두워지면서 카드가 올라오고, 내용이 드러나도록 만들고 싶다면 어떻게 해야할까?
복잡한 애니메이션을 설계하려면 먼저 두 가지를 이해해야 한다. 그래프를 어떻게 쪼개는지, 그리고 애니메이션의 상태가 무엇에 의존하는지다.
그래프를 나눠라
복잡한 움직임은 하나의 수식으로 표현하기 어렵다. 이럴 때는 그래프를 구간별로 쪼개는 방법을 사용할 수 있다.
예를 들어, 지도 앱에서 핀이 제자리에서 통통 튀다 안착하는 애니메이션을 생각해보자. 물리 시뮬레이션으로도 비슷한 결과를 낼 수 있지만 자연스러운 움직임이 항상 좋은 애니메이션은 아니다. 두 번째 바운스의 높이를 의도적으로 줄이거나, 마지막 안착을 더 부드럽게 만들고 싶을 수 있다. 이럴 때 구간을 나누면 이런 세밀한 조정이 가능해진다. 각 구간은 단순한 그래프 조각이고 이들을 이어 붙이면 전체 애니메이션이 완성된다.