애니메이션 라이브러리를 만들면 피할 수 없는 문제들이 있습니다. GSAP, motion(motion.dev), React Spring. 이름만 들어도 알 만한 라이브러리들이 전부 같은 고민을 합니다. 저사양 모바일에서도 버벅거리지 않아야 합니다. 동시에 움직임이 자연스러워야 합니다. 이 두 가지를 동시에 잡는 게 생각보다 어렵습니다.
motion은 무겁다는 이야기가 많습니다. 번들 사이즈를 제외해도, 런타임 성능 역시 그렇습니다. React Spring도 비슷한 이야기를 듣습니다. 왜 그럴까요? 이 라이브러리들을 만든 사람들이 최적화를 못 해서 그런 걸까요? 아닙니다. 구조적인 이유가 있습니다.
CSS 애니메이션은 안 버벅거립니다. 브라우저의 컴포지터 스레드에서 실행되기 때문입니다. 메인 스레드가 아무리 바빠도 애니메이션은 독립적으로 돌아갑니다. 반면, motion이나 React Spring은 JS로 매 프레임마다 DOM을 조작합니다. requestAnimationFrame 루프 안에서 스타일을 계속 바꿉니다. 메인 스레드를 점유하니까, 다른 JS 작업이 밀리면 애니메이션도 같이 밀립니다. 버벅거리는 겁니다.
그럼 의문이 생깁니다. 왜 다들 JS로 할까요? CSS 애니메이션만 쓰면 되는 거 아닌가요? 이유가 있습니다. CSS 애니메이션은 속도를 모릅니다.
이번 글에서는 제가 애니메이션 라이브러리를 만들면서 마주친 문제들과 해결 과정을 다뤄보겠습니다.
애니메이션 라이브러리의 내부 동작이 궁금했던 분, 혹은 직접 만들어보고 싶은 분들에게 도움이 될 겁니다.

사용자가 버튼을 빠르게 토글하는 상황을 생각해 봅시다. CSS keyframe 애니메이션으로 구현하면 어떻게 될까요?
@keyframes slideRight {
from { transform: translateX(-100px); }
to { transform: translateX(100px); }
}
.box {
animation: slideRight 0.5s ease-out forwards;
}
요소가 오른쪽으로 이동하는 도중에 방향을 바꾸면, 현재 위치와 속도는 완전히 무시됩니다. 새 애니메이션이 시작점에서 처음부터 다시 시작합니다. 중간 지점에 있던 요소가 시작점으로 순간 이동한 뒤 다시 움직입니다.

CSS transition은 조금 낫습니다. 현재 위치에서 이어서 가긴 합니다. 하지만 현재 속도를 모릅니다. 빠르게 이동 중이던 요소가 방향을 바꾸면, 그 속도는 무시되고 새로운 duration으로 처음부터 가속을 시작합니다. 관성이 없습니다.
스프링 물리는 다릅니다. 현재 속도를 기억하고, 그 속도를 기반으로 방향을 전환합니다. 공을 던졌다가 반대로 치면 공이 즉시 멈추지 않고 관성을 가지고 움직이는 것처럼요.

스프링을 JS로 구현하면, requestAnimationFrame 루프 안에서 매 프레임마다 물리 계산을 합니다.
function animate() {
const now = performance.now();
const dt = (now - lastTime) / 1000;
// 스프링 물리 계산
const force = -stiffness * (position - target) - damping * velocity;
velocity += force * dt;
position += velocity * dt;
// DOM 업데이트
element.style.transform = `scale(${position})`;
lastTime = now;
requestAnimationFrame(animate);
}
데스크톱에서는 문제없습니다. 16.67ms마다 한 번씩 계산하면 됩니다. 하지만 모바일은 다른 세계입니다. 저사양 안드로이드에서는 프레임 간격이 50ms, 100ms까지 벌어지기도 합니다. dt가 커지면 물리 시뮬레이션이 불안정해집니다. 스프링이 과도하게 튀거나, 수렴하지 않고 발산하기도 합니다. 그래서 애니메이션 라이브러리들은 온갖 최적화를 합니다.
1. dt clamping: 델타 타임을 100ms로 제한해서 시뮬레이션 폭발을 방지합니다.
const dt = Math.min((now - lastTime) / 1000, 0.1);
2. lag smoothing: 프레임이 크게 밀렸을 때 지연 시간을 여러 프레임에 분산합니다.
// 500ms 밀렸다고 가정
// 한번에 500ms 처리 → 애니메이션이 점프 ????
// 33ms로 무시 → 너무 느림, 슬로우모션 ????
// 40ms씩 분산 → 약간 빠르게 따라잡으면서 부드러움 ✅
if (elapsed > lagThreshold) {
// 지연을 여러 프레임에 나눠서 처리
dt = adjustedLag; // 예: 40ms
}
3. Ticker 싱글톤: 애니메이션이 10개 돌아가도, requestAnimationFrame은 한 번만 호출합니다.
class Ticker {
private listeners = new Set<(dt: number) => void>();
private tick = (timestamp: number) => {
const dt = (timestamp - this.lastTime) / 1000;
this.listeners.forEach(cb => cb(dt));
requestAnimationFrame(this.tick);
};
}
4. 수치해석 방법 선택: Explicit Euler는 불안정하니까 Semi-Implicit Euler를 씁니다. RK4는 고정 timestep에서만 정확하니까 가변 FPS 환경에선 오히려 안 좋습니다.

GSAP 소스코드를 뜯어보면 이런 최적화들이 전부 들어있습니다. SSGOI도 마찬가지입니다. 이 모든 걸 적용하면 저사양 모바일에서도 꽤 부드럽게 돌아갑니다. 하지만 본질적인 한계가 있습니다. 아무리 최적화해도 JS는 메인 스레드를 씁니다. CPU가 다른 작업으로 바쁘면 애니메이션이 밀립니다. css 애니메이션처럼 컴포지터 스레드에서 독립적으로 도는 게 아닙니다.
그래서 Web Animation API가 떠오릅니다. WAAPI는 element.animate()로 애니메이션을 실행합니다. css 애니메이션처럼 컴포지터 스레드에서 돌아가기 때문에 메인 스레드가 바빠도 60fps를 유지합니다.
element.animate([
{ transform: 'scale(1)', opacity: 0 },
{ transform: 'scale(1.1)', opacity: 1 }
], {
duration: 300,
easing: 'ease-out'
});
성능 문제가 해결된 것 같습니다. 그런데 왜 motion이나 React Spring은 WAAPI를 안 쓸까요?
WAAPI도 속도를 알 수 없습니다. animation.currentTime으로 현재 시간은 알 수 있습니다. 하지만 현재 속도를 가져오는 API는 스펙에 없습니다. 결국 WAAPI를 쓰면 css 애니메이션과 같은 문제가 생깁니다. 자연스러운 스프링 연속성을 포기해야 합니다.
정리하면 이렇습니다. css 애니메이션은 컴포지터 스레드에서 돌아가니까 성능은 좋지만, 속도를 모릅니다. JS 스프링은 속도를 추적하니까 자연스럽지만, 메인 스레드를 씁니다. WAAPI도 컴포지터 스레드라 성능은 좋지만, 역시 속도를 모릅니다. 성능과 자연스러움, 둘 중 하나를 포기해야 하는 것처럼 보입니다.
SSGOI 3.0에서 이 문제를 해결했습니다. 핵심 아이디어는 단순합니다. 스프링 시뮬레이션을 미리 돌려서, 결과를 키프레임 배열로 만들어 WAAPI에 넘긴다.
// 1. 스프링 시뮬레이션을 미리 계산
const frames: SimulationFrame[] = [];
let position = 0, velocity = initialVelocity;
while (!isSettled(position, velocity)) {
const force = -stiffness * (position - 1) - damping * velocity;
velocity += force * dt;
position += velocity * dt;
frames.push({ time: frames.length * dt, position, velocity });
}
// 2. 키프레임 배열로 변환
const keyframes = frames.map(f => ({
transform: `scale(${f.position})`,
offset: f.time / totalDuration
}));
// 3. WAAPI로 실행 (컴포지터 스레드에서 돌아감)
element.animate(keyframes, { duration
이렇게 하면 애니메이션은 컴포지터 스레드에서 돌아가면서, 스프링 물리를 그대로 표현할 수 있습니다. 하지만 문제가 남아있습니다. 애니메이션 도중에 방향이 바뀌면 어떻게 하죠? WAAPI에서 현재 속도를 못 가져온다고 했으니까요.
여기서 핵심 트릭이 나옵니다. 시뮬레이션 데이터를 보관합니다.

// 시뮬레이션 결과 저장
const frames: SimulationFrame[] = [
{ time: 0, position: 0, velocity: 12 },
{ time: 16, position: 0.3, velocity: 8 },
{ time: 32, position: 0.7, velocity: 3 },
// ...
];
// 애니메이션 도중 현재 상태 조회
function
}
}
WAAPI 스펙에는 속도를 가져오는 API가 없지만, 우리는 시뮬레이션 데이터를 가지고 있으니까 직접 계산할 수 있습니다. 방향이 바뀌면 현재 속도를 조회하고, 기존 애니메이션을 취소합니다. 그다음 현재 속도를 초기값으로 새 시뮬레이션을 실행하고, 새 키프레임 배열을 생성해서 WAAPI로 실행합니다.
// 방향 전환 시
const currentVelocity = getVelocity(); // 저장해둔 데이터에서 조회
animation.cancel();
// 현재 속도를 이어받아 새 시뮬레이션
const newFrames = simulateSpring({
from: currentPosition,
to: newTarget,
initialVelocity: currentVelocity // 이게 있어서 자연스러움!
});
element.animate(toKeyframes(newFrames), options);
컴포지터 스레드에서 돌아가니까 성능이 좋고, 시뮬레이션 데이터를 보관하니까 속도도 추적할 수 있습니다.CPU 6x slowdown 테스트에서 기존 JS 방식은 100ms씩 끊겼지만, 이 방식은 60fps를 유지했습니다.

성능 문제는 해결했습니다. 하지만 애니메이션 라이브러리를 만들면 또 다른 문제를 마주칩니다. DOM이 사라질 때 애니메이션을 어떻게 넣을까요?

React에서 조건부 렌더링을 생각해 봅시다.
function App() {
const [show, setShow] = useState(true);
return (
<div>
{show && <Card />}
<button onClick={() => setShow(!show)}>Toggle</button>
</div>
);
}
show가 false가 되면 <Card />는 DOM에서 완전히 사라집니다. 그냥 사라집니다. 페이드 아웃 같은 건 없습니다. 퇴장 애니메이션을 구현하려면 요소가 DOM에서 제거되는 시점을 알아야 합니다. 제거되는 걸 감지하면, 요소를 복제해서 원래 위치에 다시 삽입하고, 그 복제본으로 퇴장 애니메이션을 실행합니다. 애니메이션이 끝나면 복제본을 제거합니다. 문제는 DOM이 소멸되는 시점을 알려주는 프레임워크가 많지 않다는 겁니다.
DOM이 생성되는 시점은 대부분의 프레임워크가 알려줍니다. React는 ref 콜백으로 알려줍니다.
<div ref={(element) => {
// element가 DOM에 마운트됨
console.log('mounted', element);
}}>
하지만 DOM이 사라지는 시점은요? React 19에서야 ref에 리턴 콜백을 정의하면 언마운트 시 호출되는 기능이 추가됐습니다.
// React 19+
<div ref={(element) => {
console.log('mounted', element);
return () => {
console.log('unmounting'); // 이제 소멸 시점을 알 수 있음
};
}}>
하지만 Solid나 Qwik 같은 프레임워크는 이런 기능을 지원하지 않습니다. 프레임워크마다 다르고, 버전마다도 지원 여부가 다릅니다. motion은 이 문제를 AnimatePresence로 해결합니다.
import { AnimatePresence, motion } from 'motion/react';
function App() {
const [show, setShow] = useState(true);
return (
<AnimatePresence>
{show && (
<motion.div
key="card"
initial={{ opacity: 0 }}
);
}
AnimatePresence가 부모에서 자식들을 감시합니다. 자식이 사라지려고 하면 즉시 DOM에서 제거하지 않고, 퇴장 애니메이션이 끝날 때까지 잡아둡니다. 잘 동작합니다. 하지만 매번AnimatePresence로 감싸야 합니다. 까먹으면 퇴장 애니메이션이 안 됩니다.
범용적으로 적용하려면 프레임워크에 의존하면 안 됩니다. 그래서 소멸 감지를 자체적으로 구현했습니다.
MutationObserver로 DOM 변화를 직접 감시합니다.
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach((node) => {
if (node instanceof HTMLElement) {
// 이 요소가 제거됨을 감지
handleElementRemoval(node);
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
document.body에 MutationObserver를 걸어서, 어떤 요소든 DOM에서 제거되면 감지합니다. 프레임워크가 알려주든 말든 상관없이 DOM 레벨에서 직접 잡아냅니다. React든 Solid든 Qwik이든 래퍼 없이 퇴장 애니메이션이 가능합니다.
// SSGOI 방식 - 래퍼 없음
<div ref={transition(fade())}>Content</div>
퇴장 애니메이션은 해결했습니다. 하지만 더 까다로운 문제가 있습니다. DOM이 사라졌다가 다시 나타날 때, 애니메이션 상태를 어떻게 유지할까요? <Card />가 스케일 아웃 애니메이션을 하는 도중에 다시 show가 true가 되면 어떻게 해야 할까요?
언마운트 애니메이션으로 scale이 0.5까지 내려갔다고 해봅시다. 0.5까지 내려가는 중에 다시 마운트가 됩니다. 자연스럽게 하려면 0.5에서 이어서 스케일 인을 해야 합니다. 하지만 언마운트되는 순간 컴포넌트 내부의 애니메이션 상태(scale 0.6, 속도 얼마)는 전부 사라집니다. 다시 마운트되면 처음부터 시작할 수밖에 없습니다.

상태를 바깥에서 관리해야 합니다.애니메이션 상태를 컴포넌트 내부가 아니라 외부에 저장해야 합니다. 그래야 언마운트/리마운트 사이클에서 살아남습니다. 그리고 외부에서 상태를 관리하려면 "이 요소가 어떤 요소인지" 식별할 수 있어야 합니다.
이게 키가 필요한 이유입니다. motion의 AnimatePresence도 key를 통해 어떤 요소인지 식별하고 상태를 추적합니다. 개발자가 직접 key를 써야 합니다.
<motion.div key="card" ... /> // 이 key로 식별
SSGOI도 키가 필요합니다. 근데 매번 쓰기 귀찮습니다.
// 이렇게 매번 key를 쓰고 싶지 않음
<div ref={transition({ key: 'card-1', ...fade() })}>Card 1</div>
<div ref={transition({ key: 'card-2', ...fade() })}>Card 2</div>
<div ref={transition({ key: 'card-3', ...fade() })}>Card 3</div
그래서 빌드 타임에 키를 자동 생성합니다. 소스 코드의 위치(파일명:라인:컬럼)를 키로 씁니다.
// 개발자가 작성한 코드
<div ref={transition(fade())}>Content</div>
// 빌드 후 변환된 코드
<div ref={transition({ ...fade(), key: "Card.tsx:15:6" })}>Content</div>
같은 파일, 같은 위치에 있는 코드는 항상 같은 키를 갖습니다. 개발자가 신경 쓸 필요 없습니다. .map()으로 리스트를 렌더링할 때는 JSX의 key를 조합합니다.
// 개발자가 작성한 코드
{items.map((item) => (
<li key={item.id} ref={transition(fade())}>
{item.name}
</li>
))}
// 빌드 후 변환된 코드
{items.map((item) => (
<li key={item.id} ref={transition({ ))}
이 변환을 하는 게 빌드 플러그인입니다. 여러 번들러를 지원해야 하니까 unplugin을 썼습니다. unplugin은 하나의 플러그인 코드로 여러 번들러를 지원하게 해주는 도구입니다.

// 하나의 코드로
import { createUnplugin } from 'unplugin';
const unplugin = createUnplugin((options) => ({
name: 'SSGOI-auto-key',
transform(code, id) {
// transition() 호출을 찾아서 key 주입
return transformCode(code, id);
}
}));
// 여러 번들러 지원
export const vite = unplugin.vite;
export const webpack = unplugin.webpack;
export
Vite 쓰는 사람, Next.js(Webpack) 쓰는 사람, Rollup 쓰는 사람 전부 같은 플러그인을 쓸 수 있습니다.
// vite.config.ts
import SSGOIAutoKey from "@SSGOI/react/unplugin/vite";
export default defineConfig({
plugins: [react(), SSGOIAutoKey()],
});
// next.config.ts
import SSGOIAutoKey from "@SSGOI/react/unplugin/webpack";
const nextConfig = {
webpack: (config) => {
config.plugins.push(SSGOIAutoKey());
return config;
},
};
솔직히 말하면, 이 플러그인은 Claude Code가 짰습니다.요구사항만 정의했습니다. "transition() 호출을 찾아서, 소스 위치 기반으로 key를 주입해라. .map() 안에 있으면 JSX key도 조합해라." 이 정도만 말했더니 AST 파싱부터 코드 변환까지 다 구현해 줬습니다.
이제 라이브러리 만들 때 고민의 무게가 달라졌습니다. 요구사항만 명확하면 구현체는 AI가 해줍니다. 어떤 문제를 풀어야 하는지 정의하는 게 핵심이 됐습니다.
애니메이션 라이브러리를 만들면서 마주친 문제들을 돌아보면, 결국 트레이드오프를 어떻게 풀어내느냐의 문제였습니다. css 애니메이션은 빠르지만 속도를 모릅니다. JS 스프링은 자연스럽지만 메인 스레드를 씁니다. 이 딜레마를 스프링을 미리 계산해서 Web Animation API로 실행하고, 시뮬레이션 데이터를 보관해서 속도를 추적하는 방식으로 풀었습니다.
DOM 생명주기도 비슷한 문제였습니다. 언마운트되면 내부 상태가 사라지니까, 상태를 외부에서 관리해야 합니다. 외부에서 관리하려면 키가 필요하고요. motion은 AnimatePresence로 해결했고, 저는 MutationObserver와 빌드 타임 키 자동 생성으로 해결했습니다.
DX도 마찬가지입니다. 개발자가 신경 쓸 게 적을수록 좋습니다. unplugin으로 여러 번들러를 지원하고, 반복적인 코드 변환은 빌드 타임에 자동으로 처리합니다. 요즘은 AI가 구현체를 짜주는 시대입니다. 어떤 문제를 풀어야 하는지 정의하는 게 핵심이 됐습니다. 이 글에서 다룬 문제들이 그 정의를 하는 데 도움이 됐으면 합니다. 이 글에서 다룬 내용은 제가 만든 SSGOI라는 라이브러리에 적용되어 있으니, 관심 있으신 분들은 참고해 주세요.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.