Reanimated에서 자주 발생하는 실수와 성능 최적화
라이브러리의 내부 동작을 이해하면서 가장 크게 느끼게 되는 것은, 그동안 얼마나 많은 실수를 아무렇지 않게 하고 있었는가를 깨닫는 순간입니다.
Part 1부터 Part 3까지 Reanimated의 내부 구조를 살펴보면서 Thread 모델, Worklet 런타임, SharedValue 데이터 모델, Animation Driver가 어떻게 동작하는지 알아봤습니다. 이제 마지막으로 조금 더 실용적인 이야기를 해보려 합니다.
실제 프로덕션 환경에서 문제를 일으키는 대표적인 패턴과 이를 방지하기 위한 사고방식, 그리고 라이브 환경에서 Reanimated 애니메이션을 안정적으로 동작시키기 위해 알아두어야 할 내용을 하나로 정리해보겠습니다.
이번 글에서 살펴볼 내용
- Reanimated에서 가장 흔하게 발생하는 7가지 실수와 각각의 문제가 발생하는 내부적인 이유
- Reanimated를 제대로 활용하기 위해 필요한 성능에 대한 사고방식
- 애니메이션이 끊기거나 버벅일 때 어떻게 원인을 찾아야 하는지
useAnimatedStyle의 동작 규칙과 내부에서 할 수 있는 것과 할 수 없는 것
- 이번 시리즈 전체에서 기억해야 할 핵심 내용 정리
실수 1. Worklet 내부에서 React State를 읽는 경우
가장 흔하게 발생하는 실수이면서, 개발 환경에서는 정상적으로 동작하다가 프로덕션에서 문제가 발생할 수 있어 더욱 위험한 패턴입니다.
// ❌ 문제가 되는 코드 const [isExpanded, setIsExpanded] = useState(false); const height = useSharedValue(0); const style = useAnimatedStyle(() => { // `isExpanded`는 React state에서 가져온 값 // Worklet이 마지막으로 등록될 당시의 값을 Closure로 캡처합니다. // State가 빠르게 변경되면 오래된 값을 사용할 수 있습니다. return { height: isExpanded ? height.value : 0, }; });
이 코드는 위험할까요? Part 2에서 살펴봤듯이 Worklet의 Closure는 특정 시점의 값을 Snapshot으로 캡처합니다.
isExpanded가 변경되면 React가 다시 렌더링되고 Worklet도 다시 등록됩니다. 하지만 State가 변경된 순간부터 새로운 Worklet이 UI Thread 런타임에 등록되기 전까지는 UI Thread가 여전히 이전 Closure를 사용하고 있을 수 있습니다.애니메이션이 빠르게 진행되거나 State가 짧은 시간에 여러 번 변경되는 상황에서는 새로운 값이 반영되기 전에 한 프레임 이상 이전 값을 사용하면서, 애니메이션이 한 박자 늦게 따라오는 것처럼 보이는 현상이 발생할 수 있습니다.
해결 방법은
isExpanded를 SharedValue로 옮기는 것입니다.// ✅ 올바른 패턴 const isExpanded = useSharedValue(false); const style = useAnimatedStyle(() => { // SharedValue는 항상 최신 값을 동기적으로 읽을 수 있습니다. return { height: isExpanded.value ? height.value : 0, }; }); // 이벤트 핸들러에서 const handlePress = () => { isExpanded.value = !isExpanded.value; };
하나의 기준만 기억하면 됩니다. 애니메이션이 진행되는 동안 변경되는 값이라면 React State가 아니라
SharedValue를 사용해야 합니다.실수 2. runOnJS를 호출하고 동기적인 결과를 기대하는 경우
Part 2에서도 살펴봤지만, 실제 개발에서 여러 번 문제를 일으키는 부분이기 때문에 다시 한번 짚고 넘어가겠습니다.
// ❌ 동작하지 않는 코드 — runOnJS는 비동기입니다. function onGestureEnd(event) { 'worklet'; let shouldDismiss = false; runOnJS((result) => { shouldDismiss = result; // 나중에 실행됩니다. })(evaluateDismissLogic(event.velocityY)); if (shouldDismiss) { // 첫 실행에서는 항상 false offset.value = withSpring(DISMISS_TARGET); } }
runOnJS는 전달받은 함수를 JS Thread의 다음 이벤트 루프에서 실행하도록 스케줄링합니다.따라서
shouldDismiss에 실제 값이 할당될 때는 이미 Worklet의 실행이 끝난 이후입니다. 결국 조건문은 초기화된 false 값을 기준으로 실행됩니다.올바른 방법은 애니메이션의 실행 여부를 결정하는 로직 자체를 Worklet 내부에서 처리하는 것입니다. 그리고 필요한 값은
SharedValue를 통해 공유합니다.// ✅ 올바른 패턴 const shouldDismiss = useSharedValue(false); function onGestureEnd(event) { 'worklet'; // UI Thread에서 바로 판단합니다. const willDismiss = Math.abs(event.velocityY) > VELOCITY_THRESHOLD || offset.value > POSITION_THRESHOLD; if (willDismiss) { offset.value = withSpring(DISMISS_TARGET); // 애니메이션에 직접 영향을 주지 않는 부수 작업이라면 // runOnJS를 사용해도 괜찮습니다. runOnJS(onDismissAnimationStart)(); } else { offset.value = withSpring(0); } }
핵심은 다음과 같습니다. 애니메이션에 영향을 주는 판단은 UI Thread에서, UI Thread가 가지고 있는 값을 사용해 처리해야 합니다.
runOnJS는 analytics, haptics, React State 업데이트, 애니메이션 완료 후 콜백처럼 애니메이션 자체의 실행에 직접적인 영향을 주지 않는 부수 작업에 사용하는 것이 좋습니다.실수 3. Layout 속성을 애니메이션하는 경우
CSS 애니메이션이나 웹 개발 경험이 있다면 특히 혼동하기 쉬운 부분입니다.
// ❌ 부드럽게 애니메이션되지 않을 수 있습니다. const style = useAnimatedStyle(() => ({ width: interpolate(progress.value, [0, 1], [100, 200]), height: interpolate(progress.value, [0, 1], [50, 100]), marginTop: progress.value * 20, }));
width, height, margin, padding, top, left 등의 Layout 속성은 실제 레이아웃 계산을 다시 수행해야 적용됩니다.Layout 계산이 발생하면 React Native의 Yoga Layout Engine이 전체 Layout Tree를 다시 계산해야 합니다. 이는 비용이 큰 작업이며, UI Thread에서 매 프레임마다 동기적으로 처리하기에는 부담이 될 수 있습니다.
Reanimated의
useAnimatedStyle을 통해 직접 애니메이션하기에 적합한 속성은 네이티브 View에 직접 적용할 수 있는 속성입니다. 대표적으로 opacity와 transform이 있습니다.따라서 Layout과 유사한 효과를 만들 때는 다음과 같은 방식으로 접근할 수 있습니다.
// ✅ scaleX를 사용해 width 변화처럼 보이게 만들기 const style = useAnimatedStyle(() => ({ transform: [ { scaleX: interpolate(progress.value, [0, 1], [0.5, 1]) } ], }));
// ✅ translateX / translateY를 사용해 위치 변화 표현하기 const style = useAnimatedStyle(() => ({ transform: [ { translateY: interpolate(progress.value, [0, 1], [-20, 0]) } ], }));
실제 Layout의 변화가 필요한 경우에는
useAnimatedStyle로 억지로 처리하기보다 Layout Animation 시스템을 사용하는 것이 적절합니다.실수 4. 컴포넌트가 제거될 때 애니메이션을 취소하지 않는 경우
Reanimated의 Animation Driver는 애니메이션이 자연스럽게 종료되거나 명시적으로 취소될 때까지 계속 실행됩니다.
만약 애니메이션이 진행 중인 상태에서 컴포넌트가 제거된다면 어떻게 될까요?
Animation Driver는 계속 실행되면서 더 이상 연결된 View가 없는
SharedValue를 업데이트할 수 있습니다.대부분의 경우 큰 문제가 발생하지는 않지만, 애니메이션 완료 Callback에서
runOnJS를 사용해 State를 업데이트하고 있다면 이야기가 달라집니다.컴포넌트가 이미 제거된 상태에서 State를 업데이트하려고 할 수 있기 때문입니다.
// ❌ 빠르게 unmount되는 상황에서 문제가 될 수 있습니다. useEffect(() => { progress.value = withSpring(1, {}, () => { // 애니메이션이 끝나면 실행됩니다. runOnJS(setIsVisible)(true); }); }, []);
따라서 컴포넌트가 제거될 때 실행 중인 애니메이션도 함께 정리해주는 것이 좋습니다.
// ✅ unmount 시 애니메이션 취소 useEffect(() => { let mounted = true; progress.value = withSpring(1, {}, () => { if (mounted) { runOnJS(setIsVisible)(true); } }); return () => { mounted = false; cancelAnimation(progress); }; }, []);
cancelAnimation(sharedValue)는 해당 SharedValue에서 실행 중인 Animation Driver를 즉시 중단합니다.따라서 애니메이션이 실행되는
SharedValue가 있다면 cleanup 함수에서 애니메이션을 정리하는 습관을 들이는 것이 좋습니다.실수 5. 조건문이나 반복문 안에서 useSharedValue를 생성하는 경우
SharedValue 역시 React의 Hooks 규칙을 따라야 합니다.내부적으로 Hook 형태로 구현되어 있기 때문에 조건문이나 반복문 안에서 생성해서는 안 됩니다.
// ❌ 잘못된 사용 function MyComponent({ isAnimated }) { if (isAnimated) { const progress = useSharedValue(0); } }
// ✅ 항상 최상위에서 생성 function MyComponent({ isAnimated }) { const progress = useSharedValue(0); // Hook 호출 여부가 아니라 // 애니메이션 로직에서 isAnimated를 사용합니다. }
이는
useState, useEffect와 동일한 규칙입니다.Reanimated는 렌더링 사이에서
SharedValue에 대한 안정적인 참조를 유지하고, UI Thread 런타임에서 해당 SharedValue의 생명주기를 관리해야 하기 때문입니다.실수 6. useAnimatedStyle의 규칙을 무시하는 경우
useAnimatedStyle에는 쉽게 놓칠 수 있는 몇 가지 규칙이 있습니다.Worklet은 기본적으로 SharedValue와 정적인 값에 의해서만 결과가 결정되는 순수 함수여야 합니다.
따라서
runOnJS를 호출해서도 안 되고, 부수 효과를 발생시켜서도 안 되며, 항상 유효한 Style 객체를 반환해야 합니다.// ❌ useAnimatedStyle의 규칙을 위반하는 코드 const style = useAnimatedStyle(() => { console.log('frame!'); // 부수 효과 — 개발 환경에서는 초당 60번 실행될 수 있음 runOnJS(logProgress)(progress.value); // 여기서 사용하면 안 됩니다. if (progress.value > 0.5) { someRef.current = true; // Worklet에서 Ref를 변경하는 것은 위험합니다. } // 특정 조건에서 return이 없음 if (someCondition) { return { opacity: 1 }; } // undefined가 반환되어 UI Thread 런타임에서 문제를 일으킬 수 있음 });
올바른
useAnimatedStyle은 최대한 단순하게 유지하는 것이 좋습니다.// ✅ 깔끔한 useAnimatedStyle const style = useAnimatedStyle(() => { const p = progress.value; return { opacity: interpolate(p,[0, 1],[0, 1],Extrapolate.CLAMP), transform: [ { scale: interpolate(p,[0, 1],[0.8, 1],Extrapolate.CLAM), }, ], }; });
값의 변화에 반응해서 별도의 작업을 수행해야 한다면
useAnimatedStyle이 아니라 useAnimatedReaction을 사용하는 것이 적절합니다.실수 7. DerivedValue를 지나치게 많이 만드는 경우
useDerivedValue는 매우 강력한 기능이지만, DerivedValue 하나를 만들 때마다 매 프레임 실행되는 Worklet이 하나씩 추가됩니다.특히 각각의 변환 과정이 단순한데도 여러 개의 DerivedValue를 연속적으로 연결하면, 실제 계산보다 Worklet을 호출하는 데 더 많은 프레임 예산을 사용하게 될 수 있습니다.
// ❌ 불필요하게 깊은 DerivedValue 체인 const a = useDerivedValue(() => progress.value * 2); const b = useDerivedValue(() => a.value + 10); const c = useDerivedValue(() => b.value * 0.5); const d = useDerivedValue(() => c.value - 5);
이런 경우라면 하나의 DerivedValue로 합칠 수 있습니다.
// ✅ 하나의 계산으로 합치기 const result = useDerivedValue(() => { const p = progress.value; return (p * 2 + 10) * 0.5 - 5; });
다만 무조건 하나로 합쳐야 한다는 의미는 아닙니다. 여러 곳에서 공유되는 값이거나 각각의 계산을 분리하는 것이 구조적으로 더 적절하다면
useDerivedValue를 나누는 편이 좋습니다.핵심은 여러 DerivedValue가 항상 함께 사용된다면 하나로 합치는 것입니다.
애니메이션이 끊길 때 원인을 찾는 방법
애니메이션이 버벅거릴 때 제가 가장 먼저 확인하는 순서는 다음과 같습니다.
애니메이션이 끊기는 현상이 항상 발생하는가? │ ├── 항상 발생한다 │ ├── Layout 속성을 애니메이션하고 있는가? │ │ └── width / height / margin 등 │ │ │ ├── 기존 Animated API에서 useNativeDriver를 사용하고 있는가? │ │ │ └── Style Worklet에서 무거운 계산을 수행하고 있는가? │ └── 특정 상황에서만 발생한다 ├── 애니메이션 중에 React가 다시 렌더링되고 있는가? │ ├── State 업데이트나 네트워크 이벤트와 함께 문제가 발생하는가? │ └── 해결: 애니메이션 상태를 SharedValue로 이동하고 │ React의 리렌더링과 분리한다. │ ├── JS Thread가 과부하 상태인가? │ ├── 도구: Flipper > Performance > JS Thread CPU │ └── 해결: 중요하지 않은 JS 작업을 지연시키고 │ 애니메이션 실행을 Worklet으로 이동한다. │ └── UI Thread가 과부하 상태인가? ├── 도구: Flipper > Performance > UI Thread CPU └── 해결: Worklet을 단순화하고 DerivedValue 체인의 깊이를 줄이며 useAnimatedStyle 내부의 무거운 작업을 확인한다.
가장 중요한 진단 도구 중 하나는 Flipper의 Frame Rate Monitor입니다. 이를 통해 JS Thread와 UI Thread의 프레임률을 각각 확인할 수 있습니다.
만약 UI Thread는 60fps를 안정적으로 유지하는데 JS Thread의 프레임률만 떨어진다면, Reanimated의 애니메이션 자체는 정상적으로 동작하고 있는 것입니다. 이 경우 문제는 React 코드 쪽에 있을 가능성이 높습니다.
반대로 UI Thread의 프레임률까지 떨어진다면, Worklet의 성능을 확인해야 합니다.
성능을 이해하기 위한 핵심 사고방식
지금까지의 내용을 모두 살펴보고 나니, 애니메이션을 구현할 때 항상 머릿속에 두고 있는 몇 가지 기준이 생겼습니다.
1. JS Thread 비용을 0에 가깝게 만든다
잘 작성된 Reanimated 애니메이션은 애니메이션이 시작된 이후 JS Thread에 거의 영향을 주지 않아야 합니다.
애니메이션이 진행되는 동안 컴포넌트가 계속 리렌더링되고 있다면 데이터 구조를 다시 살펴볼 필요가 있습니다. 애니메이션과 관련된 상태를
SharedValue로 옮기는 것을 고려해야 합니다.2. SharedValue를 경계로 생각한다
UI Thread 쪽에서 사용하는 데이터는
SharedValue와 Worklet에 존재하고, JS Thread 쪽의 데이터는 React State와 Props에 존재한다고 생각하면 이해하기 쉽습니다.두 영역을 오가는 대표적인 방법은 다음과 같습니다.
- UI → JS:
runOnJS— 비동기
- JS → UI: JS에서
SharedValue에 값을 작성 — JSI를 통한 동기적인 접근
3. Worklet은 Closure가 아니라 함수라고 생각한다
Worklet은 SharedValue와 명시적으로 전달된 인자만 사용할 수 있다고 생각하고 설계하는 것이 좋습니다.
외부 값을 Closure로 캡처하면 해당 값은 Worklet이 등록된 시점의 Snapshot입니다. 그 값이 변경되더라도 Worklet이 다시 등록되기 전까지는 변경된 값을 알지 못합니다.
4. Layout이 아니라 Transform을 애니메이션한다
"이 요소의 크기나 위치를 애니메이션하고 싶다"고 생각했다면 다음 질문을 먼저 해보는 것이 좋습니다.
이걸 Layout 변경(width, height, margin)이 아니라 Transform(scale, translate)으로 표현할 수 있을까?
대부분의 경우 Transform을 이용해 비슷한 효과를 만들어낼 수 있습니다.
5. Spring은 duration이 아니라 물리적인 종료 조건으로 조절한다
withSpring은 기본적으로 duration을 기준으로 종료되는 애니메이션이 아닙니다. 물리적인 계산 결과가 충분히 안정되었다고 판단되는 시점에 종료됩니다.Spring이 너무 오래 실행된다면
restDisplacementThreshold와 restSpeedThreshold를 조정해볼 수 있습니다.반대로 애니메이션이 너무 둔하게 느껴진다면
stiffness를 높이거나 mass를 낮추는 방식으로 조정할 수 있습니다.Reanimated 핵심 정리
지금까지의 내용을 한눈에 확인할 수 있도록 정리해보겠습니다.
SharedValue - C++에 존재하며 JSI를 통해 JS Thread와 UI Thread에서 접근 가능 - 읽기와 쓰기는 동기적으로 이루어지며 Mutex로 보호됨 - 값이 변경되면 등록된 Animation Worklet에 알림 - React 리렌더링을 발생시키지 않음 useAnimatedStyle - UI Thread에서 Worklet으로 실행됨 - 구독하고 있는 값이 변경되면 실행 - 반드시 Style 객체를 반환하는 순수 함수여야 함 - 부수 효과나 runOnJS 호출을 수행하면 안 됨 - 결과는 React Reconciler를 거치지 않고 Native View에 직접 적용 useDerivedValue - UI Thread에서 동작하는 반응형 계산 - 참조하고 있는 SharedValue를 구독하고 값이 변경되면 다시 실행 - 결과 역시 SharedValue - 다른 DerivedValue와 연결해서 사용할 수 있음 - React의 개입 없이 동작 withSpring / withTiming / withDecay - 매 프레임 다음 값을 계산하는 Animation Driver - withSpring: 감쇠 조화 진동 모델을 사용하는 물리 기반 애니메이션 - withTiming: Easing을 이용해 일정 시간 동안 값을 변화 - withDecay: 속도를 기반으로 점차 감속 - cancelAnimation(sv): 실행 중인 Animation Driver를 즉시 중단 runOnJS(fn)(args) - fn을 Main JS Thread에서 실행하도록 스케줄링 - 비동기적으로 동작하므로 JS 측 값을 동기적으로 읽는 용도로 사용할 수 없음 - State 업데이트, Callback, Analytics, Haptic 등에 사용 runOnUI(fn)(args) - fn을 UI Thread 런타임에서 실행하도록 스케줄링 - JS 측에서는 비동기적으로 실행 요청 - UI Thread에서 실행될 때는 해당 런타임에서 동기적으로 실행 useAnimatedReaction(selector, handler) - selector의 결과가 변경되면 UI Thread에서 handler 실행 - 서로 다른 SharedValue 간의 반응이나 조건부 애니메이션 실행에 사용 - JS 측 부수 효과가 필요하다면 handler 내부에서 runOnJS 사용 Animatable Properties - opacity - transform (translate, scale, rotate, skew) - 그 외의 Layout 속성은 Layout 계산이 필요하므로 Layout Animation 사용 성능 확인 기준 - UI Thread FPS 하락 → Worklet이 너무 무거운지 확인 - JS Thread FPS 하락 + 애니메이션 정상 → React / JS 코드가 느린 것 - 두 Thread 모두 하락 → Layout 재계산을 발생시키는 애니메이션이나 전체적인 CPU 부하 확인
마무리하며
Reanimated는 React Native 생태계에서도 상당히 정교하게 설계된 라이브러리 중 하나입니다.
두 개의 JavaScript 런타임, JSI 기반의 Shared State, 프레임 단위로 실행되는 Animation Driver와 Worklet을 조합해 애니메이션이라는 어려운 문제를 해결하고 있습니다.
하지만 내부 구조를 제대로 이해하지 못하면 오히려 미묘하고 재현하기 어려운 버그를 만들어내기 쉬운 라이브러리이기도 합니다.
Worklet의 Closure가 Snapshot으로 동작한다는 점,
runOnJS가 비동기라는 점, useAnimatedStyle에는 지켜야 할 규칙이 있다는 점은 API만 바라봐서는 쉽게 알기 어렵습니다.이번 시리즈를 통해 제가 전달하고 싶었던 것도 바로 이 부분입니다.
SharedValue가 왜 React State와 다른지, Worklet이 왜 원본 함수에 대한 참조가 아니라 복사본인지, 그리고 왜 UI Thread에 별도의 JavaScript 런타임이 필요한지를 이해한다면 Reanimated의 API들이 왜 지금과 같은 형태로 만들어졌는지도 자연스럽게 이해할 수 있습니다.
결국 중요한 것은 특정 API를 외우는 것이 아니라, 어떤 코드가 어느 Thread에서 실행되고, 어떤 값이 어디에 존재하며, 그 값이 어떤 경로를 통해 전달되는지를 이해하는 것입니다.
이제 JS Thread가 아무리 바쁜 상황에서도 60fps를 유지할 수 있는 애니메이션을 만들어보세요. Reanimated의 내부 구조를 이해했다면, 이제 그 원리를 실제 코드에 적용할 차례입니다.

