canvas 위에서 레이어를 비교할 때 opacity 조절이 핵심이다. 문제는 이 UI가 원래 좌측 하단에 고정되어 있었다는 것.
canvas 작업은 상호작용이 많다. 레이어를 드래그하고, 확대/축소하고, 위치를 맞추는 동안 시선은 canvas 중앙에 머문다. 그런데 opacity 값을 확인하려면 시선을 좌측 하단으로 옮겨야 했다. 작업 흐름이 끊긴다.
해결책은 opacity 숫자를 슬라이더 thumb 바로 위에 표시하는 것이었다. 드래그하는 손가락 근처에서 값을 바로 확인할 수 있다. 화면도 덜 가린다.
그런데 단순히 숫자만 띄우면 밋밋했다. 드래그하는 방향으로 살짝 기울어졌다가 손을 떼면 다시 원위치로 돌아오는 애니메이션을 추가했다. 오뚜기처럼. 실용적인 이유도 있지만, 솔직히 만들면서 재밌었다. 슬라이더를 빠르게 움직이면 숫자가 휘청거리는 게 자꾸 해보고 싶어진다.
Spring Physics란
오뚜기가 흔들리다 멈추는 동작은 스프링 물리로 구현한다. 핵심 공식은 하나다.
F = -kx - cv
k(stiffness): 복원력. 높을수록 빠르게 원위치로 돌아온다.x(displacement): 현재 위치와 목표 위치의 차이.c(damping): 저항력. 낮을수록 오래 흔들린다.v(velocity): 현재 속도.
스프링이 늘어나면 -kx가 원래 위치로 당기고, 움직이면 -cv가 브레이크를 건다. 이 두 힘의 균형으로 "흔들리다 멈추는" 자연스러운 동작이 나온다.
여기에 mass(질량)를 추가하면 가속도가 결정된다. a = F / mass. 무거울수록 느리게 반응하고, 가벼울수록 민첩하게 움직인다.
구현 방식 선택
스프링 애니메이션을 구현하는 방법은 크게 두 가지다.
| 방식 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
| Framer Motion `useSpring` | 코드 간결, 검증된 구현 | DOM 기반만 가능 | React 컴포넌트 |
| 직접 구현 | 어디서든 사용 가능 | 물리 공식 이해 필요 | Canvas, WebGL |
pixelDiff는 기본적으로 PixiJS(WebGL)로 canvas를 렌더링한다. 성능 때문이다. 그런데 WebGL을 지원하지 않는 환경도 있다. 이 경우 DOM 기반 렌더링으로 폴백한다.
결국 같은 오뚜기 애니메이션을 두 번 구현해야 했다. PixiJS용은 스프링 물리를 직접 계산하고, DOM 폴백용은 Framer Motion을 사용한다.
구현: Framer Motion
Framer Motion의 useSpring은 스프링 물리를 알아서 계산해준다. 파라미터만 넘기면 된다.
const rotateSpring = useSpring(0, {
stiffness: 40, // 복원력
damping: 3, // 저항력
mass: 0.6, // 질량
});
오뚜기 동작의 핵심은 세 단계다:
- 드래그 시작: 속도와 가속도로 회전 각도 계산
- 즉시 기울이기:
rotateSpring.set(angle)로 목표 각도 설정 - 놓으면 복귀: 짧은 딜레이 후
rotateSpring.set(0)
useEffect(() => {
// 속도 + 가속도 → 회전 각도
const angle = velocity * 0.3 + acceleration * 0.02;
const clamped = Math.max(-90, Math.min(90, angle));
rotateSpring.set(clamped);
// 50ms 후 원위치로
const timer = setTimeout(() => rotateSpring.set(0), 50);
return () => clearTimeout(timer);
}, [velocity, acceleration]);
회전축은 배지 하단 중앙이다. transformOrigin으로 설정한다.
<motion.div
style={{
rotate: rotateSpring,
transformOrigin: 'center calc(100% + 16px)', // thumb 중심
}}
>
{value}
</motion.div>
구현: PixiJS 직접 계산
PixiJS에서는 Framer Motion을 쓸 수 없다. 스프링 물리를 직접 계산해야 한다.
매 프레임마다 호출되는 update 함수에서 세 가지를 계산한다:
const SPRING_CONFIG = {
stiffness: 70, // 복원력
damping: 2.5, // 저항력
mass: 0.5, // 질량
};
public update(deltaTime: number) {
// 1. 현재 위치와 목표의 차이
const displacement = this.currentRotation - this.targetRotation;
// 2. 스프링 힘 = -kx - cv
const springForce = -SPRING_CONFIG.stiffness * displacement;
const dampingForce = -SPRING_CONFIG.damping * this.rotationVelocity;
// 3. 가속도 = 힘 / 질량
const acceleration = (springForce + dampingForce) / SPRING_CONFIG.mass;
// 4. 속도와 위치 업데이트
this.rotationVelocity += acceleration * deltaTime;
this.currentRotation += this.rotationVelocity * deltaTime;
// 5. 실제 회전 적용
this.container.rotation = (this.currentRotation * Math.PI) / 180;
}
이 함수를 PixiJS의 Ticker에 등록한다.
constructor() {
this.tickerBound = this.onTick.bind(this);
Ticker.shared.add(this.tickerBound);
}
private onTick() {
const deltaTime = Ticker.shared.deltaMS / 1000;
this.update(deltaTime);
}
Framer Motion이 내부적으로 하는 일을 직접 하는 셈이다. 코드가 길어지지만, canvas 환경에서는 이 방법밖에 없다.
파라미터 튜닝
세 파라미터의 조합이 애니메이션 느낌을 결정한다.
| 파라미터 | 낮으면 | 높으면 |
|---|---|---|
| stiffness | 느리게 복귀, 부드러움 | 빠르게 복귀, 탱탱함 |
| damping | 오래 흔들림 | 빨리 멈춤 |
| mass | 가볍고 민첩 | 무겁고 둔함 |
pixelDiff에서 쓴 설정:
// React (Framer Motion)
{ stiffness: 40, damping: 3, mass: 0.6 }
// PixiJS (직접 구현)
{ stiffness: 70, damping: 2.5, mass: 0.5 }
React 쪽이 조금 더 부드럽고 느리다. PixiJS 쪽은 더 탱탱하고 빠르다. 같은 공식인데 왜 다를까?
Framer Motion은 내부적으로 deltaTime을 고정값으로 처리한다. 직접 구현할 때는 실제 프레임 간격을 쓴다. 같은 파라미터라도 결과가 다르다. 눈으로 보면서 맞춰야 한다.
튜닝 팁:
- stiffness 먼저: 복귀 속도를 정한다. 40~100 사이에서 시작.
- damping 다음: 흔들림 횟수를 조절한다. 낮추면 2-3번 더 흔들린다.
- mass 마지막: 전체 느낌을 미세 조정한다. 대부분 0.5~1.0 사이.
언제 직접 구현해야 하나
라이브러리를 쓸 수 있으면 쓰는 게 낫다. Framer Motion은 검증된 구현이고, 코드도 짧다.
직접 구현이 필요한 경우:
- 렌더링 환경이 다를 때: Canvas, WebGL, PixiJS, Three.js 등
- 라이브러리가 닿지 않을 때: Web Worker, 게임 엔진, 네이티브 브릿지
- 성능이 중요할 때: DOM 조작은 비싸다. PixiJS는 GPU로 렌더링해서 수십 개 레이어도 60fps를 유지한다.
pixelDiff가 PixiJS를 쓰는 이유도 성능이다. 여러 레이어를 동시에 드래그하고, 확대/축소하고, opacity를 조절한다. DOM으로는 버벅인다. 애니메이션까지 PixiJS 안에서 처리하면 모든 렌더링이 GPU에서 끝난다.

직접 구현하면 좋은 점도 있다. 물리 공식을 이해하게 된다. F = -kx - cv가 무슨 뜻인지 몸으로 알게 된다. 다음에 비슷한 상황이 오면 라이브러리 없이도 해결할 수 있다.
스프링 애니메이션의 핵심은 공식 하나다. F = -kx - cv. 이것만 알면 어떤 환경에서든 오뚜기를 만들 수 있다. Framer Motion이 있으면 편하게 쓰고, 없으면 직접 계산하면 된다. 어렵지 않다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd' 카테고리의 다른 글
| shadcn/ui 도입기: Headless UI로 컴포넌트 제어권 되찾기 (0) | 2026.02.24 |
|---|---|
| Marquee Selection (범위 선택) 구현하기 (0) | 2026.02.21 |
| Pixi.js 다중 선택 드래그 구현하기 (0) | 2026.02.19 |
| OffscreenCanvas로 Service Worker에서 이미지 처리하기 (0) | 2026.02.10 |
| next-intl 없이 i18n 직접 만들기 (0) | 2026.02.07 |
