React Native Reanimated : Part 3 - SharedValue와 Animation Driver는 어떻게 동작하는가
React Native2026.08.02

React Native Reanimated : Part 3 - SharedValue와 Animation Driver는 어떻게 동작하는가

#React Native#React Native Reanimated#Skia#UI Thread

SharedValue, DerivedValue의 동작 원리

한동안 저를 고민하게 만들었던 질문이 하나 있었습니다.
withSpring(1)을 호출하면 스프링 계산은 실제로 어디에서 이루어질까요? Reanimated는 이 계산을 어떻게 초당 60번씩 실행할까요? 애니메이션의 시간은 누가 관리하고, 애니메이션은 언제 종료되는 걸까요?
저는 useSharedValue, useDerivedValue, withTiming을 몇 달 동안 사용하면서도 그 내부에서 어떤 일이 일어나는지는 충분히 이해하지 못했습니다. 하지만 그 구조를 이해하고 나니 애니메이션을 디버깅하는 방식부터 애니메이션을 조합하는 방법, 그리고 성능을 바라보는 관점까지 달라졌습니다.
이번 글은 Reanimated 내부 구조를 살펴보는 과정입니다. Part 2에서는 Worklet이 UI Thread의 런타임으로 어떻게 전달되고, 매 프레임마다 어떻게 실행되는지 살펴봤습니다.
이번에는 한 단계 더 깊이 들어가 보겠습니다. Worklet에 값을 공급하는 데이터 모델과, 그 값을 시간에 따라 변화시키는 Animation Driver 시스템까지 살펴보겠습니다.

이번 글에서 살펴볼 내용

  • SharedValue가 C++ 레벨에서 실제로 어떤 형태로 존재하며, 왜 두 런타임에서 값을 동기적으로 읽을 수 있는지
  • useDerivedValue가 React를 거치지 않고 값을 어떻게 조합하는지
  • withTiming, withSpring, withDecay가 사용하는 Animation Driver 모델
  • 매 프레임마다 실제로 이루어지는 Spring Physics 계산
  • Reanimated가 애니메이션을 언제 종료해야 하는지 어떻게 판단하는지

SharedValue: React State도, Signal도 아니다

SharedValue를 이해하기 위해 가장 먼저 알아야 할 것은 SharedValue가 무엇이 아닌가 입니다.
SharedValue는 React State가 아닙니다. 따라서 React의 Re-render를 발생시키지 않습니다. 또한 JavaScript Heap에 존재하는 일반적인 JavaScript 객체도 아닙니다.
SharedValue는 JSI Binding을 통해 연결된 C++ 객체입니다. 이를 통해 Main JS Thread와 UI Thread 런타임 양쪽에서 동일한 값을 동기적으로 읽고 쓸 수 있습니다.
C++ 레벨의 구조를 단순화하면 다음과 같은 형태로 생각할 수 있습니다.
// SharedValue — simplified internal structure class SharedValue { std::mutex mutex; AnimationState currentAnimation; // null if not animating JSValue storedValue; // the raw value (number, object, array) std::vector<WorkletHandle> listeners; // registered useAnimatedStyle callbacks };
 
Main JS Thread에서 sharedValue.value = 1을 실행하면 JSI Binding을 통해 C++ 레이어로 들어갑니다. C++에서는 Mutex를 획득하고 값을 저장합니다. 그리고 등록된 Animation Listener가 있다면 해당 값이 변경되었다는 것을 표시하고, 다음 프레임에서 다시 실행될 수 있도록 합니다.
반대로 UI Thread에서 Worklet이 sharedValue.value를 읽으면 JSI Binding을 통해 C++ 레이어로 들어가 Mutex를 획득한 뒤 저장된 값을 반환합니다. 이 과정은 동기적으로 이루어집니다. 비동기 처리도 없고, 데이터를 복사하거나 직렬화/역직렬화하는 과정도 없습니다.
Reanimated의 반응성이 빠르게 느껴지는 핵심적인 이유가 바로 여기에 있습니다. 값을 저장하는 공간이 C++에 존재하고, 두 런타임이 모두 JSI를 통해 동일한 저장소에 접근하기 때문입니다.
 
Main JS Thread UI Thread 런타임 │ │ │── write sv.value = 1 ──► │ │ (JSI → C++ mutex lock) │ │ │ │ ◄── read sv.value ──│ │ (JSI → C++ mutex lock) │ │ │ │ C++ SharedValue Store │ │ (sv.storedValue) │ └─────────────────┬─────────────┘ │ Owned by C++ layer, accessible from both sides via JSI
 
Mutex는 여러 Thread에서 동시에 값을 읽고 쓰더라도 Race Condition이 발생하지 않도록 보장합니다. 실제로 UI Thread의 읽기 작업은 애니메이션 프레임에 맞춰 예측 가능한 시점에 실행되기 때문에 Mutex에 의한 충돌도 낮은 편입니다.

useDerivedValue: React 없이 동작하는 Reactive Computation

useDerivedValue는 Reanimated에서 가장 강력하면서도 상대적으로 잘 활용되지 않는 기능 중 하나입니다. 그렇다면 실제로 내부에서는 어떤 일이 일어날까요?
다음과 같은 코드를 작성한다고 해보겠습니다.
 
const progress = useSharedValue(0); const opacity = useDerivedValue(() => { 'worklet'; return interpolate(progress.value, [0, 1], [0.2, 1]); });
 
여기서 progress를 기반으로 새로운 SharedValue를 만드는 것입니다. 이 값은 UI Thread에서 실행되는 Worklet에 의해 progress가 변경될 때마다 자동으로 계산됩니다.
여기서 opacity 역시 다른 SharedValue와 마찬가지로 사용할 수 있습니다. useAnimatedStyle에서 읽을 수도 있고, 다른 useDerivedValue에 전달할 수도 있으며, Gesture Handler에서 직접 읽을 수도 있습니다.
다만 차이가 하나 있습니다. opacity.value를 직접 변경하지 않는다는 것입니다. 이 값은 항상 progress.value를 기반으로 계산된 결과입니다.
내부적인 의존성 추적은 대략 다음과 같이 이루어집니다.
 
// DerivedValue dependency tracking Derivation Worklet이 실행되면: 1. Reanimated는 모든 `sharedValue.value` 읽기를 감지한다. 2. 각각의 읽기는 해당 SharedValue의 "subscriber"로 DerivedValue를 등록한다. 3. 구독 중인 SharedValue가 변경되면 DerivedValue를 dirty 상태로 표시한다. 4. 다음 프레임에서 Worklet을 다시 실행하고 DerivedValue를 업데이트한다. // This is reactive computation, fully on the UI thread
 
여기서 중요한 점은 이 반응형 체인(Reactive Chain) 전체가 React의 개입 없이 UI Thread에서 실행된다는 것입니다.
useDerivedValue를 여러 개 연결해 사용할 수도 있습니다. 하나의 DerivedValue에서 또 다른 DerivedValue를 만들고, 그 결과를 다시 다른 DerivedValue에서 사용하는 식으로 계속 연결할 수 있습니다. 이 과정에서 React는 다시 렌더링되지 않습니다.
복잡한 애니메이션에서 여러 값을 조합해야 할 때 특히 유용합니다.
 
const scrollY = useSharedValue(0); // Derived values — all computed on UI thread, zero React renders const headerOpacity = useDerivedValue(() => interpolate(scrollY.value, [0, 100], [1, 0], Extrapolate.CLAMP) ); const headerTranslateY = useDerivedValue(() => interpolate(scrollY.value, [0, 100], [0, -60], Extrapolate.CLAMP) ); const contentPaddingTop = useDerivedValue(() => interpolate(scrollY.value, [0, 100], [120, 60], Extrapolate.CLAMP) ); // 하나의 scrollY 변화만으로 세 값이 매 프레임마다 업데이트됩니다. // 이 과정에서 React는 아무것도 알지 못합니다.
 
이렇게 만들어진 SharedValue → DerivedValue 의존 관계는 C++ 레이어에서 DAG(Directed Acyclic Graph, 방향성 비순환 그래프) 형태로 관리되며, UI Thread에서 매 프레임 평가됩니다.

Animation Driver: withTiming, withSpring, withDecay

이제 개인적으로 가장 흥미롭게 생각하는 부분으로 들어가 보겠습니다. 바로 Animation Driver가 실제로 어떻게 동작하는가입니다.
다음과 같은 코드를 작성했다고 해보겠습니다.
 
sharedValue.value = withSpring(1);
 
이 코드는 sharedValue를 즉시 1로 변경하는 것이 아닙니다.
대신 SharedValue에 Animation Driver를 연결합니다.
Animation Driver는 현재 시간 t와 애니메이션의 내부 상태를 입력으로 받아, 해당 시점에 SharedValue가 가져야 할 다음 값을 계산하는 함수라고 생각할 수 있습니다.
애니메이션의 매 프레임마다 C++ 레이어는 Driver를 호출하고, 다음 값을 전달받아 SharedValue에 저장합니다. 그리고 DerivedValue나 useAnimatedStyle Worklet과 같은 Subscriber에게 값이 변경되었다는 사실을 알리고 다시 실행하도록 합니다.
 
// Animation driver model withSpring(target)는 다음과 같은 AnimationObject를 반환한다고 생각할 수 있다. { type: 'spring', current: 0, // 현재 값 target: 1, // 목표 값 velocity: 0, // 현재 속도 config: { ... } // stiffness, damping, mass }
 
매 프레임마다 대략 다음과 같은 과정이 진행됩니다.
 
1. C++ Frame Callback 실행 2. 활성화된 Animation을 가지고 있는 각각의 SharedValue에 대해: a. animation.nextValue(frameTime, animation.current, animation.velocity) 호출 b. 반환된 값을 SharedValue에 저장 c. Animation 상태 업데이트 (velocity, elapsed time 등) d. Animation이 종료되었다면 SharedValue에서 Driver 제거 3. Subscriber들에게 값이 변경되었음을 알림
 
여기서 중요한 점은 Animation Driver 역시 Worklet이라는 것입니다.
즉, Spring Physics 계산은 JavaScript 코드로 작성되어 있지만, 우리가 흔히 생각하는 Main JS Thread에서 실행되는 JavaScript가 아닙니다. UI Thread의 JavaScript 런타임에서 매 프레임 동기적으로 실행되는 JavaScript입니다.

withTiming: Easing Function으로 값을 변화시키기

withTiming은 Spring보다 단순합니다. 경과 시간을 Easing Function에 전달하고, 그 결과를 이용해 값을 계산합니다.
 
// withTiming driver (simplified) function timingStep(elapsed, duration, from, to, easingFn) { 'worklet'; const progress = Math.min(elapsed / duration, 1); const easedProgress = easingFn(progress); const currentValue = from + (to - from) * easedProgress; const finished = progress >= 1; return { value: currentValue, finished }; }
 
여기서 Easing Function 자체도 Worklet입니다. 따라서 UI Thread에서 실행됩니다.
Reanimated는 Easing.linear, Easing.ease, Easing.bezier와 같은 기본 Easing Function을 제공합니다. 이러한 함수들은 Worklet으로 미리 컴파일되어 있습니다.
직접 Custom Easing Function을 작성한다면 'worklet' 지시어가 필요합니다.
 
// ✅ Custom easing — must be a worklet const customEasing = (t) => { 'worklet'; return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; }; sharedValue.value = withTiming(1, { duration: 400, easing: customEasing, });

Animation Composition: withSequence, withRepeat, withDelay

Reanimated의 Animation Composition Helper들은 기존 Driver를 다른 Driver로 감싸는 방식으로 동작합니다.
  • withDelay(ms, animation) — 지정된 시간 동안 현재 값을 유지한 뒤 내부 Animation Driver에 실행을 위임합니다.
  • withSequence(...animations) — 여러 Animation을 순서대로 실행하고, 현재 Animation이 끝나면 다음 Animation으로 넘어갑니다.
  • withRepeat(animation, n) — Animation이 끝날 때마다 다시 시작하며, 지정된 횟수만큼 반복합니다. 1을 전달하면 무한 반복합니다.
 
이 모든 기능을 조합해서 사용할 수 있는 이유는 동일한 Animation Driver Interface를 따르기 때문입니다.
C++ 레이어는 현재 어떤 종류의 Animation이 실행되고 있는지 알 필요가 없습니다. 그저 nextValue(dt)를 호출하고 다음 값과 finished 상태를 전달받으면 됩니다.
 
// Animation driver composition withRepeat( withSequence( withTiming(1, { duration: 300 }), withTiming(0, { duration: 300 }) ), -1 )
 
실행 과정은 대략 다음과 같습니다.
 
Frame N: withRepeat.nextValue(dt) → withSequence.nextValue(dt)에 위임 → withTiming(1).nextValue(dt)에 위임 → { value: 0.3, finished: false } 반환 Frame N+20: withSequence가 withTiming(1)의 종료를 감지 → withTiming(0)으로 전환 Frame N+40: withSequence가 withTiming(0)의 종료를 감지 → withRepeat에 finished 전달 withRepeat: 반복 횟수가 남아 있다면 다시 시작 → withSequence 상태 초기화
 
각 Composition Layer 역시 Worklet Function입니다. 따라서 이 전체 체인이 UI Thread에서 매 프레임 실행됩니다.

SharedValue Listener: Main JS Thread로 돌아가는 방법

때로는 SharedValue의 변경을 감지해 Main JS Thread에서 후속 작업을 수행해야 하는 경우가 있습니다.
예를 들어 디자인 에디터에서 사용자가 요소를 드래그하면 위치는 SharedValue를 통해 UI Thread에서 매 프레임 업데이트됩니다. 하지만 드래그가 끝난 후 최종 위치를 Undo/Redo 히스토리에 기록하거나 서버에 저장하는 작업은 JS Thread의 애플리케이션 로직에서 처리해야 합니다.
이처럼 UI Thread에서 발생한 변화를 JS Thread의 로직과 연결해야 할 때 addListener나 Hook 형태의 useAnimatedReaction을 사용할 수 있습니다.
 
useAnimatedReaction( () => progress.value, // selector — UI thread에서 실행 (current, previous) => { // handler — 기본적으로 UI thread에서 실행 if (current >= 1 && previous < 1) { runOnJS(onAnimationComplete)(); // Main JS Thread로 알림 } } );
 
Selector와 Handler는 모두 UI Thread에서 Worklet으로 실행됩니다.
runOnJS는 Callback을 Main JS Thread에서 실행하도록 예약하는 역할을 합니다.
여기서 중요한 점은 useAnimatedReactionSelector의 결과가 변경될 때마다 실행된다는 것입니다. 따라서 Selector는 최대한 간단하게 작성하는 것이 좋습니다.
예를 들어 Selector가 sharedValue.value를 그대로 반환하고 있고 해당 값이 매 프레임 변경된다면, Handler 역시 매 프레임 실행됩니다.

정리: 이번 글에서 살펴본 내용

  • SharedValue는 React State가 아니라 JSI Binding을 가진 C++ 객체입니다. 두 런타임에서 동기적으로 읽고 쓸 수 있으며 Mutex를 통해 접근이 보호됩니다.
  • useDerivedValue는 React의 개입 없이 UI Thread에서 동작하는 Reactive Computation을 만들며, SharedValue와 DerivedValue 사이의 의존 관계는 DAG 형태로 관리됩니다.
  • Animation Driver(withSpring, withTiming, withDecay)는 경과 시간과 현재 상태를 기반으로 매 프레임마다 다음 값을 계산하는 Worklet입니다.
  • Spring Physics는 감쇠 조화 진동자 모델을 사용하며, Semi-implicit Euler 방식으로 위치와 속도를 계산합니다. 변위와 속도가 설정된 Rest Threshold 아래로 내려가면 애니메이션이 종료됩니다.
  • withSequence, withRepeat, withDelay는 동일한 Animation Driver Interface를 기반으로 여러 Animation을 조합할 수 있도록 해줍니다.
  • useAnimatedReaction은 UI Thread에서 Selector를 실행하고 그 결과가 변경되었을 때 Handler를 실행합니다. 불필요한 매 프레임 실행을 피하기 위해 Selector는 최대한 간단하게 유지하는 것이 좋습니다.

다음 글에서는

Part 4에서는 지금까지 살펴본 내용을 모두 연결해보겠습니다.
실제 프로덕션 환경에서 자주 발생하는 Reanimated의 문제들을 살펴보고, 문제가 발생했을 때 어디부터 확인해야 하는지, 그리고 복잡한 애니메이션을 안정적으로 60fps로 유지하기 위한 Performance Pattern까지 정리해보겠습니다.
Part 4는 Reanimated를 사용하다 문제가 생겼을 때 다시 찾아볼 수 있는 실전 레퍼런스가 될 것입니다.

원문