REPO DEEP DIVE · 2026-07-29 · PMNDRS/REACT-SPRING · 스프링 물리 기반 React 애니메이션 엔진

pmndrs/react-spring 딥다이브
"목적지가 아니라 힘"으로 움직임을 정의하는 라이브러리

react-spring은 화면 요소를 지속시간(duration)이 아니라 장력(tension)·마찰(friction)·질량(mass) 같은 물리량으로 움직이게 하는 React 애니메이션 엔진이다. useSpring 같은 훅으로 값을 선언하면, animated.div 같은 특수 컴포넌트가 그 값을 매 프레임 DOM에 직접 써 넣는다.

가장 중요한 특징은 "React를 다시 렌더링하지 않고" 애니메이션이 돈다는 점이다. 상태가 바뀔 때마다 컴포넌트를 리렌더하는 대신, animated()가 감싼 컴포넌트는 매 프레임 값을 구독해 DOM 노드의 속성만 직접 갱신한다. 그리고 목표 지점(to)이 애니메이션 도중 바뀌어도 처음부터 다시 재생하지 않고, 현재 속도를 유지한 채 새 목표로 자연스럽게 방향을 튼다(리타겟).

(저장소 pmndrs/react-spring · 제작 Paul Henschel, 현재 유지보수 Josh Ellis 등 pmndrs 팀 · 라이선스 MIT · 레이어드 모노레포 — 코어 패키지 7개(rafz·types·shared·animated·core·mock-raf·parallax) + 렌더 타깃 2개(web·three) · npm 안정판 10.1.2 / 메인 브랜치는 11.0.0-beta.0(ESM 전용 리라이트 진행 중) · 최신 커밋 d0a6847(2026-06-23) · Node ≥24.16.0 · pnpm 11.9.0 워크스페이스 + Turborepo + tsdown)
목차
  1. 한 줄 정체
  2. 왜 지금 주목받는가
  3. 무엇을 해결하나
  4. 전체 구조
  5. 핵심 설계 · 동작 원리
  6. 기술 스택 · 의존성
  7. 설치 · 요구사항
  8. 실제 사용 흐름
  9. 함정 · 오해 방지
  10. 대안 비교
  11. 정리 · 누가 쓰면 좋은가

1한 줄 정체

"숫자(duration)가 아니라 힘(tension·friction·mass)으로 움직임을 정의하는" React 애니메이션 엔진

react-spring은 스프링 물리 시뮬레이터를 애니메이션 엔진으로 쓰는 React 라이브러리다. 보통의 CSS 애니메이션은 "0.3초 동안 ease-out으로"처럼 시간을 기준으로 움직임을 정의한다. react-spring은 대신 "장력 170, 마찰 26짜리 스프링에 매달려 있다"처럼 을 기준으로 정의한다. 매 프레임 물리 방정식을 계산해 위치·속도를 갱신하기 때문에, 목표가 중간에 바뀌어도 관성이 자연스럽게 이어진다.

API의 핵심은 두 가지다. useSpring·useSprings·useTrail·useTransition·useChain 같은 이 애니메이션 값을 만들어내고, animated.div·animated.svg 같은 특수 컴포넌트가 그 값을 실제 DOM(또는 3D 오브젝트)에 적용한다. 렌더 타깃은 현재 웹(DOM)react-three-fiber(3D) 둘로, 같은 스프링 엔진을 다른 "출력 대상"에 연결하는 구조다.

한 줄 비유

"초시계로 재는 움직임"이 아니라 "그네를 미는 손"

CSS transition은 초시계로 움직임을 재는 것과 같다 — "0초에서 시작해 0.3초에 도착"이라고 미리 정해두면, 도중에 목적지가 바뀌어도 초시계는 그 사실을 모른 채 이미 정해둔 시간표대로 도착 지점만 바꿔 다시 처음부터 잰다.

react-spring은 그네를 미는 손에 가깝다. 그네가 지금 얼마나 빠르게, 어느 방향으로 움직이고 있는지 계속 느끼면서 힘을 준다. 목표 지점이 갑자기 바뀌어도(사용자가 손을 놓거나 방향을 바꿔도) 그네는 멈췄다 다시 출발하지 않고, 지금의 속도를 그대로 살려 새 방향으로 흐른다.

용어
스프링 물리 애니메이션 (spring physics animation)
이동 거리·목표까지 남은 거리·현재 속도를 매 프레임 다시 계산해 움직임을 만드는 방식. "이만큼 시간이 지나면 여기 있어야 한다"가 아니라 "지금 이 힘을 받으면 다음 순간 여기로 움직인다"를 반복 계산한다. 실제 스프링(용수철)의 운동 방정식을 그대로 프로그램으로 옮긴 것이라 생각하면 된다.

2왜 지금 주목받는가

Motion(옛 Framer Motion)의 대중화 · react-three-fiber 3D 웹의 성장 · 대규모 v11 리라이트 진행 중

react-spring 자체는 2018년부터 있던 오래된 라이브러리지만, 최근 다시 주목받는 맥락은 크게 세 갈래다.

① "물리 기반 모션"이라는 개념 자체가 대중화됐다

최근 몇 년 사이 Motion(옛 Framer Motion)이 React 애니메이션의 사실상 표준처럼 자리 잡으면서, "스프링처럼 자연스러운 움직임"이라는 개념 자체가 일반 프론트엔드 개발자에게도 익숙해졌다. react-spring은 이 개념을 React 생태계에 가장 먼저, 가장 순수하게(물리 계산 자체가 목적) 들여온 라이브러리 중 하나였고, "원조 스프링 엔진"이라는 위치에서 다시 조명받고 있다.

② react-three-fiber와 함께 3D 웹이 커지고 있다

react-spring은 pmndrs(Poimandres) 팀이 만드는 react-three-fiber(r3f) 생태계의 표준 애니메이션 도구이기도 하다. r3f로 만드는 3D 웹사이트·인터랙티브 쇼케이스가 늘면서, @react-spring/three로 3D 오브젝트의 위치·회전·색을 스프링으로 움직이는 수요도 함께 늘었다.

③ 저장소 자체가 대대적인 현대화 리라이트 중이다

이 레포를 직접 클론해 보면, 메인 브랜치가 이미 "v11" 대규모 리팩터링을 진행 중이라는 게 바로 드러난다. 빌드 도구를 tsdown(esbuild/rolldown 기반)으로, 린트·포맷을 러스트 기반 초고속 도구 oxlint·oxfmt로, 테스트를 Playwright 브라우저 모드의 Vitest로 갈아엎었다. 오래된 라이브러리가 최신 프론트엔드 툴체인으로 스스로를 갈아입고 있는 실물 사례라 저장소 구조 자체가 학습 가치가 있다.

경쟁 지형
"원조 물리 엔진" 포지션을 재정비하는 중

Motion(Framer Motion)이 레이아웃 애니메이션·제스처·문서/생태계에서 앞서가고, GSAP은 타임라인 시퀀싱의 최강자로 남아 있다. react-spring은 그 사이에서 "진짜 물리 계산 + 훅 기반 선언형 API + 크로스 타깃(웹·3D)"이라는 원래 강점에 집중하며, ESM 전용·의존성 최소화(외부 애니메이션 라이브러리 의존 없음)로 몸집을 가볍게 다듬는 중이다.

3무엇을 해결하나

"애니메이션 도중 목표가 바뀌면 어색해지는 문제" + "매 프레임 리렌더 비용"을 함께 없앤다

react-spring이 정면으로 노리는 문제는 두 가지다.

문제 ① — duration 기반 애니메이션은 "중간에 끼어들기"에 약하다

CSS transition이나 setTimeout으로 짠 애니메이션은 시작점과 끝점, 걸리는 시간을 미리 고정한다. 그런데 사용자는 얌전히 기다려주지 않는다 — 카드를 드래그하다 도중에 손을 떼거나, 호버가 끝나기 전에 다시 마우스를 올리는 식으로 목표를 실시간으로 바꾼다. duration 기반 방식은 이럴 때 애니메이션이 뚝 끊기고 처음부터 재생되거나, 순간이동하듯 튀는 경우가 많다.

react-spring의 SpringValue는 목표(to)가 바뀌면 현재 위치와 현재 속도를 그대로 이어받아 새 목표를 향해 물리 계산을 계속한다. "재생 중인 애니메이션을 다시 시작하지 않고 방향만 튼다"는 이 성질을 리타겟(retarget)이라 부른다.

문제 ② — 애니메이션 값을 React state로 다루면 리렌더가 폭발한다

움직이는 값을 useState에 담고 매 프레임 setState를 부르면, 컴포넌트(와 그 자식들)가 초당 수십 번씩 리렌더된다. react-spring은 애니메이션 값을 React state 바깥의 별도 객체(SpringValue)로 관리하고, animated.div 같은 컴포넌트가 그 값을 구독해 DOM 노드의 스타일·속성만 직접 고쳐 쓴다. React의 리렌더 사이클은 아예 건너뛴다.

비유

손잡이를 놓으면 관성을 그대로 이어받는 짐수레를 생각하면 쉽다. duration 기반 애니메이션은 레일 위를 달리는 열차다 — 정해진 시간표대로만 움직이고, 중간에 방향을 틀려면 일단 멈춰야 한다. react-spring은 손으로 미는 짐수레다 — 밀던 중에 방향을 바꿔도 수레는 지금 속도를 유지한 채 부드럽게 방향을 튼다.

4전체 구조

"타깃 무관 물리 엔진" 4개 계층 + "얇은 타깃 어댑터" 2개 — react-spring 코어는 DOM도 3D도 모른다

저장소의 CLAUDE.md(레포 내부 개발 가이드)는 이 아키텍처를 "레이어드 모노레포"라 부른다. 핵심 문장은 "타깃(targets/*)에 도달하기 전까지 모든 계층은 렌더 대상에 무관하다"는 것이다. 즉 물리 계산을 담당하는 아래 4개 패키지는 그 값이 결국 DOM 스타일이 될지, 3D 오브젝트의 좌표가 될지 전혀 모른다.

┌─────────────────────────────────────────────────────────────┐ │ REACT-SPRING 레이어드 아키텍처 (하단→상단) │ └─────────────────────────────────────────────────────────────┘ targets/web targets/three # 타깃(얇은 어댑터) — 렌더 대상만 앎 (react-dom) (react-three-fiber) └───────────┬───────────┘ │ @react-spring/core # 선언형 API: 훅·컴포넌트·SpringValue# ·Controller·SpringRef @react-spring/animated # Animated 클래스 계층, createHost# ·withAnimated (리렌더 우회 HOC) @react-spring/shared # Globals·FrameLoop·색상 보간# ·fluid observer·내부 훅 @react-spring/rafz # 전역 requestAnimationFrame 스케줄러 # ·5개 큐 + rAF 기반 setTimeout @react-spring/types ── 런타임 코드 없는 순수 TS 타입 (전 계층이 참조) @react-spring/parallax ── @react-spring/web 위에 얹은 스크롤 패럴랙스 컴포넌트

각 타깃(targets/web/src/index.ts, targets/three/src/index.ts)은 같은 뼈대를 따른다. ① Globals.assign(...)으로 배치 업데이트 함수·색상 보간기 같은 플랫폼별 동작을 꽂아 넣고, ② primitives(만들 수 있는 엘리먼트 목록)를 정의하고, ③ createHost(primitives, hostConfig)animated 객체를 만들어 내보낸다. hostConfig가 요구하는 것은 딱 세 가지 — applyAnimatedValues(값을 실제 노드에 쓰기), createAnimatedStyle(style prop 감싸기), getComponentProps(전달 전 prop 거르기) — 뿐이다.

용어
모노레포 (monorepo) · 워크스페이스
여러 개의 작은 패키지를 하나의 저장소 안에 두고 함께 관리하는 방식. react-spring은 pnpm 워크스페이스로 packages/*(엔진 본체)와 targets/*(타깃 어댑터)를 한 저장소에서 관리하며, 서로를 workspace:~ 버전으로 참조한다.

5핵심 설계 · 동작 원리

SpringValue 상태머신 · animated()의 리렌더 우회 · rafz 프레임 스케줄러 · 훅 계층 · 물리 파라미터 · 인터폴레이션

이 섹션이 이 문서에서 가장 중요하다. 아래 6가지가 서로 맞물려 "물리 기반이면서도 빠른" 애니메이션 엔진을 이룬다.

① SpringValue — 매 프레임 물리를 적분하는 상태머신

@react-spring/coreSpringValue(packages/core/src/SpringValue.ts, 1,166줄)가 엔진의 심장이다. 애니메이션이 도는 동안 매 프레임 advance(dt) 메서드가 호출되는데, 이 메서드는 세 가지 모드 중 하나로 다음 위치를 계산한다.

애니메이션이 "멈췄다"고 판단하는 기준(precision·restVelocity)도 흥미롭다. 목표까지 남은 거리와 현재 속도가 둘 다 아주 작아지면 멈춘 것으로 본다. 실제로 부동소수점 오차(예: Math.cos(Math.PI/2)가 정확히 0이 아니라 6e-17이 되는 경우) 때문에 스프링이 영원히 안 멈추는 버그(이슈 #2208)가 있었고, 최신 코드는 이를 감안해 값 크기에 비례하는 적응형 precision을 계산하도록 고쳐져 있다.

용어
리타겟 (retarget)
애니메이션이 진행 중일 때 목표값(to)이 바뀌는 것. react-spring은 이때 처음부터 다시 시작하지 않고, 현재 위치·속도를 그대로 물려받아 새 목표를 향해 계속 움직인다. 자동차 크루즈 컨트롤의 목표 속도를 주행 중에 바꾸는 것과 비슷하다 — 차가 멈췄다 다시 가속하지 않고 지금 속도에서 이어서 조절한다.

② animated() HOC — React 리렌더를 건너뛰고 DOM을 직접 갱신

packages/animated/src/withAnimated.tsxwithAnimatedanimated.div 같은 컴포넌트를 만드는 핵심 함수다. 동작 방식은 이렇다.

const observer = new PropsObserver(callback, deps)

useIsomorphicLayoutEffect(() => {
  each(deps, dep => addFluidObserver(dep, observer)) // 값 변화를 구독
  return () => { /* 언마운트 시 구독 해제 */ }
})

class PropsObserver {
  eventObserved(event) {
    if (event.type == 'change') raf.write(this.update) // rafz의 write 큐에 예약
  }
}

animated.div style={styles}는 렌더링될 때 styles 안의 스프링 값들을 구독 대상(FluidValue)으로 등록해두고, 값이 바뀔 때마다 setState가 아니라 raf.write(callback)으로 "다음 프레임에 DOM을 직접 고쳐라"라고 예약한다. 콜백은 host.applyAnimatedValues(instance, props)를 호출해 ref로 잡아둔 실제 DOM 노드의 style·속성을 자바스크립트로 바로 써 넣는다. React의 커밋 단계를 아예 거치지 않는다 — 이게 초당 60번씩 값이 바뀌어도 리렌더가 튀지 않는 이유다. 단, 대상이 함수형 컴포넌트라 ref를 못 받는 등 직접 갱신에 실패하면 마지막 안전장치로 forceUpdate()가 호출돼 평범한 리렌더로 대체된다.

비유

우편함에 매번 새 편지를 넣는 대신, 벽에 붙은 게시판 글자를 직접 지우고 다시 쓰는 것과 비슷하다. React state로 값을 흘려보내면 컴포넌트라는 "우편함"에 매번 새 편지(리렌더)가 도착해 열어봐야 한다. animated()는 그 대신 실제 DOM(게시판)에 손을 뻗어 글자만 지우고 다시 쓴다 — 우편함(컴포넌트 트리)은 아무 일도 없었던 것처럼 조용하다.

③ rafz — 하나의 requestAnimationFrame으로 모든 것을 돌리는 스케줄러

@react-spring/rafz(packages/rafz/src/index.ts, 255줄)는 브라우저의 requestAnimationFrame단 한 번만 등록하고, 그 안에서 다섯 개의 명명된 큐(onStart·update·onFrame·write·onFinish)를 순서대로 비운다. 수십 개의 스프링이 동시에 움직여도 브라우저에는 rAF 콜백이 하나만 등록된다. 여기에 rAF 프레임을 이용해 흉내 낸 setTimeout까지 얹혀 있어, 지연(delay)이 걸린 애니메이션도 같은 시계에 맞춰 돈다.

그 위의 @react-spring/sharedframeLoop를 관리한다. 실행 중인 애니메이션들을 우선순위 순으로 정렬된 배열에 담아두고(뒤에 등록된 스프링이 앞선 스프링의 최신 값을 참조할 수 있도록), 매 프레임 animation.advance(dt)를 호출한다. 기본은 'always' 모드(항상 rAF가 돈다)지만, targets/three는 이를 'demand' 모드로 바꿔 react-three-fiber의 invalidate()를 통해 필요할 때만 프레임을 요청하도록 최적화한다 — 3D 씬에서 아무것도 안 움직일 때 GPU를 놀리지 않기 위해서다.

④ 훅 계층 — useSpring부터 useChain까지

packages/core/src/hooks/ 아래 훅들은 서로를 쌓아 올린 관계다.

비유

useTrail·useChain기차 놀이(계주)와 비슷하다. useTrail은 앞사람 뒤통수만 보고 따라 뛰는 술래잡기 기차라면, useChain은 "1번 주자가 결승선을 통과해야 2번 주자가 출발하는" 이어달리기다.

⑤ 물리 파라미터 — tension·friction·mass 대 duration

packages/core/src/constants.ts에는 자주 쓰는 프리셋이 정의돼 있다.

프리셋tension / friction느낌
default170 / 26기본값 — 무난하게 빠르고 안정적
gentle120 / 14부드럽고 느긋하게
wobbly180 / 12탱글탱글 — 도착 후에도 살짝 흔들림
stiff210 / 20빠르고 단단하게
slow / molasses280 / 60, 280 / 120천천히, 아주 천천히

mass(질량, 기본 1)는 별도로 있는데, 값이 클수록 "무거운 물체처럼" 멈추는 데 더 큰 마찰이 필요해진다. 반대로 config.duration을 지정하면 위 물리 파라미터는 전부 무시되고, "몇 밀리초 동안 어떤 easing으로"라는 전통적인 CSS 방식으로 되돌아간다 — 즉 react-spring은 물리 엔진이면서도, 필요하면 평범한 duration 애니메이션도 같은 API로 지원한다.

⑥ 인터폴레이션 — 다른 스프링 값에서 파생되는 값

Interpolation(packages/core/src/Interpolation.ts)은 하나 이상의 SpringValue를 입력받아 다른 값으로 변환하는 파생 값이다. 예를 들어 스크롤 진행도 스프링값 하나를 받아 "0~1 진행도"를 "0~360도 회전"으로 매핑할 수 있다. 원본이 바뀔 때만 다시 계산되는 메모이즈드 구조라, 관찰자(observer)가 없으면 아예 구독을 걸지 않아 불필요한 계산을 피한다.

6기술 스택 · 의존성

외부 애니메이션 라이브러리 의존 0개 · tsdown + Turborepo + pnpm · oxlint/oxfmt · Vitest 브라우저 모드

react-spring 코어는 런타임 의존성이 거의 없다. 물리 계산·프레임 스케줄링·보간을 전부 자체 구현했기 때문에, 애니메이션 로직 자체를 감싼 서드파티 라이브러리가 없다. 대신 아래처럼 계층별 패키지로 나뉜다.

패키지역할주요 의존/peer
@react-spring/rafz전역 rAF 스케줄러(5개 큐 + setTimeout)의존성 없음
@react-spring/types런타임 없는 순수 TypeScript 타입없음
@react-spring/sharedGlobals·FrameLoop·색상/문자열 보간·내부 훅rafz 참조
@react-spring/animatedAnimated 클래스 계층, createHost, withAnimatedshared 참조
@react-spring/coreSpringValue·Controller·SpringRef·훅·컴포넌트peer: react ^16.8~19
@react-spring/mock-raf테스트 전용 — rAF를 수동으로 한 프레임씩 흉내테스트 전용
@react-spring/parallax스크롤 패럴랙스 컴포넌트web 위에 얹힘
@react-spring/webDOM 타깃 — animated.divpeer: react-dom, dep: csstype
@react-spring/threereact-three-fiber 3D 타깃peer: @react-three/fiber ≥6, three ≥0.126

개발 도구 체인 — "예전 자료의 preconstruct는 이제 없다"

영역도구
패키지 매니저pnpm 11.9.0(packageManager 필드로 고정, Corepack 경유)
모노레포 태스크 러너Turborepo(turbo.json) — 빌드 캐시·의존 순서 관리
번들러tsdown(rolldown 기반) — 예전에 쓰이던 preconstruct는 현재 코드베이스에 존재하지 않는다
린트 / 포맷oxlint / oxfmt — 러스트로 작성된 oxc 프로젝트의 초고속 도구
테스트Vitest — 브라우저 모드(Playwright, Chromium)로 unit·e2e·타입 테스트 3종 실행
릴리스Changesets — 패키지별 semver 변경 로그를 PR 단위로 누적, "beta" 프리릴리스 모드 사용 중
용어
ESM 전용(ESM-only) 빌드
CommonJS(require()) 출력 없이 ES 모듈(import) 번들만 배포하는 방식. v11부터 각 패키지는 dist/이름.modern.mjs 하나만 내보내고, 예전의 CJS·Webpack4 대응 번들은 모두 제거됐다.

7설치 · 요구사항

타깃 패키지를 직접 설치 · React 16.8~19 지원 · 저장소 자체 개발 요구사항은 별개

react-spring을 쓰는 앱 입장에서 설치는 간단하다. 웹이면 @react-spring/web, react-three-fiber 3D면 @react-spring/three를 설치하면 된다.

# DOM(웹) 프로젝트
npm install @react-spring/web

# react-three-fiber 3D 프로젝트
npm install @react-spring/three

복사만 하면 끝이다 — 별도 설정 파일이나 CSS 임포트가 필요 없다. 필요한 peer dependency는 아래와 같다.

패키지필요한 peer dependency
@react-spring/webreact ^16.8 | ^17 | ^18 | ^19, react-dom(동일 범위)
@react-spring/threereact(동일 범위), @react-three/fiber ≥6, three ≥0.126
헷갈리기 쉬운 점
"저장소 개발 요구사항"과 "라이브러리 사용 요구사항"은 다르다

레포의 package.json"engines": {"node": ">=24.16.0"}, pnpm@11.9.0을 못박아 두고 있다. 이건 react-spring 저장소 자체를 클론해 빌드·기여할 때 필요한 버전이다.

내 앱에 @react-spring/web을 설치해 쓰는 것과는 별개다 — 다만 v11부터 ESM 전용이라, CommonJS로 require()해야 하는 구식 환경이면 Node ≥22.12가 필요하고(그래야 require()가 ESM 패키지를 읽을 수 있다), Webpack 4처럼 아주 오래된 번들러는 더 이상 지원되지 않는다.

8실제 사용 흐름

useSpring으로 값 선언 → animated.div가 그 값을 구독 → rafz가 매 프레임 DOM을 직접 갱신

가장 간단한 예시부터 보자. 요소가 나타날 때 투명도와 위치가 함께 움직이는 "페이드인" 애니메이션이다.

import { useSpring, animated } from '@react-spring/web'

function FadeIn({ isVisible, children }) {
  const styles = useSpring({
    opacity: isVisible ? 1 : 0,
    y: isVisible ? 0 : 24,
  })

  return (
    <animated.div style={styles}>
      {children}
    </animated.div>
  )
}

isVisible이 바뀔 때마다 useSpring은 새 목표값을 SpringValue에 넘긴다. 컴포넌트 자체는 리렌더되지 않고, animated.div가 값이 바뀔 때마다 알아서 style을 갱신한다.

여러 항목을 순서대로 밀어 넣는 useTrail, 리스트 항목의 등장/퇴장을 관리하는 useTransition도 같은 원리다.

import { useTransition, animated } from '@react-spring/web'

function List({ items }) {
  const transitions = useTransition(items, {
    from: { opacity: 0, height: 0 },
    enter: { opacity: 1, height: 40 },
    leave: { opacity: 0, height: 0 },
    keys: item => item.id,
  })

  return transitions((style, item) => (
    <animated.div style={style}>{item.label}</animated.div>
  ))
}

항목이 배열에서 사라져도 leave 스타일이 재생될 때까지 DOM에서 바로 지워지지 않는다 — useTransition이 내부적으로 키 기준으로 각 항목의 생애주기(enter → update → leave)를 추적하기 때문이다.

데이터가 실제로 흐르는 순서 (5단계)

STEP 1

useSpring 같은 훅이 @react-spring/core 안에서 Controller와 그 안의 SpringValue들을 만든다.

STEP 2

SpringValueshared가 관리하는 FluidValue(관찰 가능한 값)로 등록된다.

STEP 3

sharedFrameLooprafzupdate 큐에 "매 프레임 이 값을 계산해줘"라고 예약한다.

STEP 4

매 프레임 advance(dt)가 새 값을 계산하고 change 이벤트를 쏘면, withAnimated가 만든 관찰자가 이를 받는다.

STEP 5

관찰자가 타깃의 applyAnimatedValues(웹이면 DOM 스타일 갱신, three면 applyProps로 3D 오브젝트 갱신)를 호출해 화면에 값이 반영된다.

마지막으로, 여러 훅을 순서대로 잇는 useChain은 이렇게 쓴다.

const [springStyles, springApi] = useSpring(() => ({ opacity: 0 }))
const [transStyles, transApi] = useTransition(items, { ... })

useChain([springApi, transApi], [0, 0.4]) // springApi 먼저, 0.4 지점에 transApi

9함정 · 오해 방지

메인 브랜치와 npm 배포판의 버전 간극 · 사라진 타깃들 · 예전 자료의 빌드 도구 · 조용히 바뀐 동작

이 레포는 지금 "보이는 코드"와 "실제로 npm에 설치되는 것"이 다른 과도기다. 아래 함정들은 실제로 저장소 소스와 npm 레지스트리를 직접 대조해 확인한 내용이다.

함정 1
클론한 메인 브랜치 = v11 베타, npm의 "latest" = v10.1.2

이 레포를 지금 클론하면 모든 패키지의 package.json 버전이 11.0.0-beta.0이다. 하지만 npm 레지스트리를 직접 조회해 보면 @react-spring/web의 배포 태그는 {"latest": "10.1.2", "beta": "11.0.0-beta.0"}다.

지금 여러분이 npm install @react-spring/web을 하면 10.1.2(구 버전)가 설치되고, 이 문서와 레포 소스가 설명하는 것은 아직 베타 태그로만 받을 수 있는 v11이다. 튜토리얼이나 블로그 글이 어느 버전을 기준으로 쓰였는지 항상 확인해야 한다.

함정 2
"react-spring" 이라는 이름의 패키지는 더 이상 없다

v11의 changeset(remove-deprecated-targets.md)에는 "react-spring 우산(umbrella) 패키지를 제거한다"고 명시돼 있다. 예전 자료에서 npm install react-spring이라고 나와도, 지금은 @react-spring/web이나 @react-spring/three처럼 타깃을 직접 설치해야 한다.

함정 3
React Native·Konva·Zdog 타깃이 소스에서 아예 사라졌다

같은 changeset에서 @react-spring/native, @react-spring/konva, @react-spring/zdog제거됐다고 밝힌다. 실제로 이 레포의 targets/ 폴더에는 webthree 딱 둘만 있다. React Native 프로젝트라면 공식 안내대로 Reanimated(UI 스레드에서 도는 스프링 엔진)로 넘어가는 것이 권장된다 — react-spring의 스프링은 JS 스레드에서 계산되기 때문이다.

함정 4
"빌드 도구는 preconstruct" — 예전 정보다

과거 react-spring 관련 글·이슈에는 빌드 도구로 preconstruct가 자주 언급됐다. 하지만 현재 저장소 어디에도 preconstruct는 없다 — 지금은 tsdown(rolldown 기반)과 Turborepo로 전면 교체됐다. 예전 자료를 보고 preconstruct.config.js 같은 걸 찾으면 헛수고다.

함정 5
애니메이션 중간에 목표를 바꾸면 onRest가 더 이상 안 불린다

changeset(clever-islands-dig.md 계열)에 따르면, v11부터 진행 중인 애니메이션이 새 목표로 리타겟될 때 이전 애니메이션의 onRest가 호출되지 않는다. "멈춘 것"이 아니라 "계속 움직이는 중"이라는 게 이유다. 대신 이전 start()의 Promise는 finished: false로 여전히 resolve된다. 예전 코드에서 onRest로 "목표가 취소됐다"를 감지하던 로직이 있다면 v11에서 더는 동작하지 않는다.

그래도 믿을 만한 신호
테스트·CI·문서화가 실제로 꼼꼼하다

번들 크기 회귀를 잡는 CI(bundle-size.yml), 매일 도는 nightly.yml, 커버리지 임계값(문장 80% / 분기 74% / 함수 71% / 라인 82%)을 강제하는 설정이 실제로 존재한다. 레포 내부의 CLAUDE.md는 아키텍처·테스트 규칙을 매우 구체적으로 문서화해뒀다 — "장난감 프로젝트"가 아니라 실제로 관리되는 라이브러리라는 근거다.

10대안 비교

Motion(옛 Framer Motion) · GSAP · 순수 CSS — "물리 기반 인터럽트"가 필요한가로 갈린다
도구애니메이션 모델강점 / 약점
react-spring물리 스프링(tension·friction·mass), JS 스레드 계산, DOM/3D 직접 갱신목표 변경(리타겟)에 가장 자연스럽게 대응. 대신 레이아웃 애니메이션·제스처 편의 기능은 상대적으로 얇음
Motion(구 Framer Motion)스프링 + CSS/WAAPI 가속을 섞은 하이브리드layout prop 등 레이아웃·제스처 애니메이션이 매우 편함, 생태계·문서가 큼. 순수 물리 계산의 세밀함은 react-spring보다 덜 파고듦
GSAP프레임워크 무관 타임라인 엔진복잡한 시퀀싱·타임라인 제어에 최강. React 전용 최적화가 약해 직접 ref를 다뤄야 하고, 일부 고급 플러그인은 상용 라이선스
순수 CSS transition/animation브라우저 네이티브 duration 기반가장 가볍고 GPU 가속이 쉬움. 애니메이션 도중 목표 변경(인터럽트)과 JS 상태 동기화에는 근본적으로 약함
react-spring이 유리한 상황
"손을 놓았을 때 자연스럽게 이어지는" 인터랙션

드래그하다 손을 떼는 카드, 스와이프 제스처, 실시간으로 방향이 바뀌는 UI처럼 애니메이션 도중 목표가 계속 바뀌는 상호작용에서 가장 빛난다. react-three-fiber로 만든 3D 오브젝트를 물리적으로 움직이고 싶을 때도 마찬가지다.

다른 도구가 더 나을 수 있는 상황
레이아웃 애니메이션이나 복잡한 타임라인 시퀀싱이 중심일 때

요소 위치가 바뀔 때 자동으로 부드럽게 이어주는 "레이아웃 애니메이션"이 핵심이라면 Motion이 더 적은 코드로 해결한다. 여러 애니메이션을 정밀한 타임라인으로 지휘해야 한다면 GSAP의 타임라인 API가 더 편하다. 단순한 hover·클릭 색 전환 정도라면 CSS만으로 충분하다.

11정리 · 누가 쓰면 좋은가

"손맛 있는 인터랙션"과 "3D 애니메이션"이 필요한 팀에게

react-spring은 "움직임을 시간표가 아니라 힘으로 정의한다"는 한 가지 아이디어를 매우 깊게 판 라이브러리다. SpringValue의 물리 적분, animated()의 리렌더 우회, rafz의 단일 프레임 루프가 서로 맞물려 "물리적으로 자연스러우면서도 React 리렌더 비용이 없는" 애니메이션을 만든다. 지금은 그 위에 ESM 전용·최신 툴체인(tsdown·oxlint·Turborepo)으로 몸집을 다듬는 v11 리라이트가 진행 중이라, 소스를 열어보면 "오래된 라이브러리가 스스로 현대화하는 과정"까지 함께 볼 수 있다.

이런 팀에게 잘 맞는다

· 드래그·스와이프처럼 사용자가 애니메이션 도중에 개입하는 인터랙션을 자주 만드는 팀
· react-three-fiber로 3D 웹을 만들며 오브젝트를 물리적으로 움직이고 싶은 팀
· 훅 기반의 선언형 API로 애니메이션 상태를 관리하고 싶은 React 팀

다른 선택이 더 나을 수 있는 팀

· 카드·리스트의 레이아웃 변경 애니메이션이 핵심이라면 Motion
· 여러 애니메이션을 정밀한 타임라인으로 지휘해야 한다면 GSAP
· hover 색상 전환 정도의 단순한 효과만 필요하다면 순수 CSS로 충분