프론트엔드 개발을 하다 보면 성능 최적화를 피할 수 없는 순간이 온다. 특히 애니메이션은 성능 문제가 눈에 바로 보이기 때문에 더 신경 쓰이는 영역이기도 하다.
최근에 페이지 전환 라이브러리(https://ssgoi.dev)
를 만들면서 흥미로운 문제를 만났고, 해결하는 과정에서 브라우저 렌더링에 대해 더 깊이 이해하게 됐다. 그 경험을 공유해보려 한다.
ssgoi라는 페이지 전환 라이브러리를 만들고 있었다. Spring 물리 기반으로 페이지가 부드럽게 전환되는 게 핵심 기능이었다.
데스크톱 Chrome에서는 만족스러웠다. 60fps로 부드럽게 동작했다. 그런데 아이폰 13에서 테스트해보니 뭔가 미세하게 끊기는 느낌이 들었다.
"기분 탓인가?" 싶어서 Chrome DevTools에서 CPU 성능을 6배 느리게 설정하고 다시 테스트해봤다.

확실히 버벅였다. 페이지가 마운트되는 순간마다 애니메이션이 100ms씩 뚝뚝 끊겼다.
원인을 찾기 위해 브라우저의 렌더링 구조를 다시 살펴봤다.
브라우저에서 우리가 작성하는 대부분의 코드는 메인쓰레드에서 실행된다.
이 모든 작업이 하나의 쓰레드에서 순차적으로 처리된다. 메인쓰레드가 바쁘면 다른 작업들은 기다려야 한다.
반면 컴포지터 쓰레드는 다른 일을 한다.
transform, opacity 같은 속성의 애니메이션컴포지터 쓰레드는 메인쓰레드와 분리되어 동작한다. 그래서 JavaScript가 무거운 작업을 하고 있어도 스크롤은 부드럽게 동작하는 것이다.
┌─────────────────────────────────────────────────────────┐
│ Renderer Process │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ Main Thread │ │ Compositor Thread │ │
│ │ │ │ │ │
│ │ - JavaScript │ │ - Scroll │ │
│ │ - DOM │ │ - Animation │ │
│ │ - Style Calc │ │ - Layer Composite│ │
│ │ - Layout │ │ │ │
│ │ - Paint │ │ │ │
│ └───────────────────┘ └─────────┬─────────┘ │
│ │ │
└─────────────────────────────────────│───────────────────┘
│ Compositor Frame
▼
┌───────────────────┐
│ GPU Process │
│ │
│ - Rasterization │
│ - Draw to Screen │
└───────────────────┘
이 두 쓰레드의 관계를 이해하면, 왜 어떤 애니메이션은 부드럽고 어떤 애니메이션은 끊기는지 설명할 수 있다.
브라우저가 화면을 그리는 과정을 간단히 정리하면 이렇다:
JavaScript → Style → Layout → Paint → Composite
여기서 중요한 건, 1~4단계는 메인쓰레드에서, 5단계는 컴포지터 쓰레드에서 처리된다는 점이다.
transform이나 opacity를 변경하면 Layout과 Paint를 건너뛰고 바로 Composite 단계로 갈 수 있다. 그래서 이 속성들이 애니메이션에 좋다고 알려진 것이다.
내 코드는 requestAnimationFrame(rAF)으로 매 프레임 Spring 물리를 계산하고 스타일을 업데이트하고 있었다.
function tick() {
// Spring 물리 계산
state = stepSpring(state, target, constants, deltaTime);
// 스타일 업데이트
element.style.transform = `translateX(${state.position}px)`;
requestAnimationFrame(tick);
}
transform만 바꾸니까 괜찮을 것 같지만, 함정이 있다.
transform 값의 변경 자체는 컴포지터에서 처리되지만, "이 값으로 바꿔라"라는 명령은 메인쓰레드에서 내려야 한다. rAF 콜백이 메인쓰레드에서 실행되기 때문이다.
페이지 전환 상황을 생각해보면:
컴포지터는 일할 준비가 되어 있는데, 정작 일감을 전달하는 통로가 막혀버린 것이다.
"그러면 CSS 애니메이션이나 Web Animation API를 쓰면 되지 않나요?"
맞는 말이다. CSS 애니메이션은 컴포지터 쓰레드에서 독립적으로 실행될 수 있다. 하지만 문제가 있다.
Spring 애니메이션은 시간 기반이 아니라 상태 기반이다. 현재 위치(position)와 속도(velocity)라는 상태를 가지고 있다.
이게 중요한 이유는, 애니메이션 도중에 목표가 바뀌어도 자연스럽게 이어진다는 점이다.

드래그하다가 놓으면 원래 위치로 돌아가는 UI를 생각해보자. 돌아가는 중에 다시 드래그하면? Spring은 현재 속도를 유지하면서 새로운 목표로 부드럽게 방향을 튼다.
CSS의 ease-in-out이나 cubic-bezier 같은 시간 기반 이징으로는 이런 자연스러움을 만들기 어렵다. 중간에 끊고 새로 시작하면 속도가 갑자기 바뀌면서 부자연스러워진다.
motion.dev 같은 유명 라이브러리들이 Web Animation API를 두고도 여전히 rAF 기반으로 Spring을 구현하는 이유가 여기에 있다.
며칠을 고민하다가 Svelte의 transition 코드를 살펴보게 됐다.
Svelte는 CSS transition을 사용하면서도 JavaScript로 keyframe을 미리 생성하는 방식을 쓰고 있었다. 동적으로 keyframe을 만들어서 CSS에 주입하고, 브라우저에게 실행을 맡기는 것이다.
"이 방식을 Spring에도 적용할 수 있지 않을까?"
Spring 물리 계산은 결정론적이다. 시작 위치, 시작 속도, 목표값, Spring 설정값이 같으면 결과는 언제나 같다.
그렇다면:
메인쓰레드는 맨 처음에 시뮬레이션 한 번만 돌리고, 이후 애니메이션은 컴포지터가 알아서 처리하게 하는 것이다.
1단계: Spring 시뮬레이션
function simulateSpring(from, to, spring, initialVelocity = 0) {
const frames = [];
let state = { position: from, velocity: initialVelocity };
for (let i = 0; i < MAX_FRAMES; i++) {
// 현재 상태 기록
frames.push({
time: i * FRAME_TIME,
position: state.position,
velocity: state.velocity
});
// 다음 프레임으로
state = stepSpring(state, to, constants, FRAME_TIME / 1000);
// 목표에 충분히 가까워지면 종료
if (isSettled(state, to)) break;
}
return frames;
}
Spring 물리를 프레임 단위로 돌리면서 각 시점의 위치와 속도를 기록한다. 이 시뮬레이션은 동기적으로 실행되고, 보통 수 밀리초면 끝난다.
2단계: Keyframes로 변환
function framesToKeyframes(frames, styleFn) {
return frames.map(frame => styleFn(frame.position));
}
// 사용 예시
const keyframes = framesToKeyframes(frames, (pos) => ({
transform: `translateX(${pos}px)`
}));
시뮬레이션 결과를 Web Animation API가 이해하는 형식으로 변환한다.
3단계: Web Animation API로 실행
const animation = element.animate(keyframes, {
duration: totalDuration,
fill: 'forwards',
easing: 'linear' // Spring 물리가 이미 적용되어 있으므로
});
easing: 'linear'가 포인트다. Spring의 가속과 감속이 이미 keyframes에 담겨 있기 때문에, 브라우저는 프레임 사이를 선형으로 보간하기만 하면 된다.
애니메이션 도중에 새로운 목표로 바꿔야 할 때는 어떻게 할까?
시뮬레이션할 때 각 프레임의 속도도 함께 기록해뒀기 때문에, 경과 시간으로 현재 상태를 계산할 수 있다.
function interpolateFrame(frames, elapsedTime) {
// 이진 탐색으로 해당 시점의 프레임을 찾고
// 두 프레임 사이를 선형 보간
return { position, velocity };
}
애니메이션을 멈추는 순간 interpolateFrame으로 현재 위치와 속도를 구하고, 그 상태에서 새 목표를 향하는 시뮬레이션을 다시 돌리면 된다. Spring의 연속성이 그대로 유지된다.

CPU를 6배 느리게 해도 애니메이션이 끊기지 않는다. React 컴포넌트가 마운트되든, JavaScript가 무거운 작업을 하든, 애니메이션은 컴포지터 쓰레드에서 독립적으로 실행된다.
이 아이디어는 사실 내가 처음 생각한 게 아니다. Svelte의 transition 코드를 읽다가 "keyframe을 미리 생성한다"는 패턴을 발견했고, 그걸 Spring에 적용해본 것뿐이다.
문제를 풀다 막힐 때, 비슷한 문제를 겪었을 선배 개발자들의 코드를 찾아보는 습관이 도움이 되는 것 같다. 오픈소스 라이브러리 코드에는 그런 노하우가 많이 담겨 있다.
"어, 이거 어디서 본 패턴인데?" 하는 순간이 오면 꽤 뿌듯하다.
이 아이디어는 사실 내가 처음 생각한 게 아니다. Svelte의 transition 코드를 읽다가 "keyframe을 미리 생성한다"는 패턴을 발견했고, 그 NJ E-ZPass