들어가며
React Native로 에디터를 개발하기 전까지만 해도 Skia를 가장 많이 다루게 될 줄 알았습니다. Chrome의 렌더링 엔진과 Flutter에서도 사용될 만큼 뛰어난 성능을 가진 그래픽 엔진이라는 명성은 익히 들어왔고, RN에서도
@shopify/react-native-skia를 그래픽 렌더링으로 활용하여 에디터를 개발할 예정이었죠.하지만 실제로 에디터를 만들면서 깨달은 것은 그래픽을 그리는 것과, 그것을 부드럽게 움직이는 것은 전혀 다른 문제라는 점이었습니다. 아무리 Skia가 뛰어난 그래픽 라이브러리라도 JS Thread에 병목이 발생해 인터렉션이 버벅거리면, 사용자 경험은 저하될 수 밖에 없습니다. 아무리 뛰어난 붓이라도 덜덜 떨리는 손이라면 쓸모없는것처럼 말이죠.
최근 Medium을 읽다가 React Native Reanimated의 내부 동작을 깊이 있게 다룬 좋은 글을 발견했습니다. 국내에서는 Reanimated를 실제로 사용하는 방법이나 API를 소개하는 글은 비교적 쉽게 찾아볼 수 있지만, 내부적으로 어떻게 동작하는지 깊이 있게 다룬 글은 많지 않았죠.
이번 기회에 해당 글을 바탕으로 내용을 하나씩 따라가며 정리해보려고 합니다.
React Native 스레드 모델
React Native는 JavaScript 코드를 메인(UI) 스레드와 분리된 별도의 전용 스레드에서 실행합니다.
이는 의도적인 설계입니다. JavaScript 코드가 UI 렌더링을 막지 않도록 하고, 반대로 UI 처리 때문에 JavaScript 실행이 영향을 받지 않도록 하기 위해서입니다.
이론적으로는 꽤 훌륭한 구조처럼 보입니다. 단순화한 React Native의 스레드 모델을 살펴보면 다음과 같습니다.
┌─────────────────────────────────────────┐ │ JS Thread │ │ - Your React components │ │ - State updates, business logic │ │ - Event handlers │ │ - The old Animated API calculations │ └─────────────┬───────────────────────────┘ │ │ ◄── 브릿지 (비동기 직렬화 JSON) ──► │ ┌─────────────▼───────────────────────────┐ │ UI Thread │ │ - Layout & rendering │ │ - Native view updates │ │ - Touch event processing │ │ - What actually draws to the screen │ └─────────────────────────────────────────┘
JS Thread와 UI Thread는 Bridge라는 계층을 통해 서로 통신합니다. 하지만 이 Bridge는 애니메이션을 부드럽게 유지하는 데 있어 중요한 병목 지점이 됩니다.
Bridge : 비동기, 직렬화 그리고 피할 수 없는 비용
Bridge는 단순한 함수 호출이 아닙니다. 비동기 메시지 전달 큐입니다. JavaScript 코드에서 "이 View의 opacity를 0.5로 변경해주세요"라고 요청한다고 해서 그 즉시 적용되는 것은 아닙니다.
메시지는 JSON으로 직렬화되고, 큐에 들어간 뒤 Bridge를 통해 전달됩니다. 이후 네이티브 측에서 다시 역직렬화되고, 그 다음에야 View에 적용됩니다.
경우에 따라 이 과정에서 한두 프레임 정도의 시간이 발생할 수도 있습니다. 부하가 적은 상황에서는 이러한 지연을 거의 느끼지 못합니다. 하지만 실제 프로덕션 환경에서는 이야기가 달라집니다.
구체적인 상황을 하나 살펴보겠습니다. 손가락의 위치를 실시간으로 따라가는 Bottom Sheet를 만들고 있다고 가정해봅시다.
onGestureEvent는 터치 이벤트가 UI Thread에서 발생하기 때문에 UI Thread에서 실행됩니다. 그런데 Sheet의 위치를 업데이트하려면 이 이벤트가 다음과 같은 과정을 거쳐야 합니다.터치 발생(UI 스레드) │ ▼ onGestureEvent 발생(UI 스레드) │ │ ── JSON으로 직렬화 ──► ▼ JS 이벤트 핸들러 호출(JS 스레드) │ ▼ Animated.setValue() 호출(JS 스레드) │ │ ── 업데이트된 값을 다시 직렬화 ──► ▼ 네이티브 뷰 업데이트됨(UI 스레드) │ ▼ 프레임 렌더링됨(UI 스레드)
제스처 이벤트 하나를 처리하는 동안 Bridge를 두 번 건너야 합니다. 초당 60번의 터치 이벤트가 발생한다고 생각해보세요. 그 사이에는 비즈니스 로직을 처리하는 코드까지 실행됩니다.
실제 환경에서는 Redux dispatch, 리렌더링, 네트워크 콜백 등이 잘못된 타이밍에 실행될 수 있습니다. 그러면 JS Thread가 바빠지고, 결국 애니메이션이 끊기게 됩니다.
이것은 단순한 이론상의 문제가 아닙니다. 실제 React Native 앱에서 애니메이션이 끊기는 대표적인 원인 중 하나입니다.
저 역시 이 구조가 왜 중요한지 완전히 이해하기까지 생각보다 꽤 오랜 시간이 걸렸습니다.
기존의 Animated API는 어떻게 해결하려 했는가
React Native 개발팀 역시 이러한 문제를 알고 있었습니다. 그래서
Animated API에는 애니메이션 값을 네이티브에서 처리하도록 하는 useNativeDriver: true 옵션이 등장했습니다.네이티브 드라이버를 활성화하면 애니메이션 루프가 JS Thread에서 벗어나 UI Thread에서 직접 실행됩니다. JS Thread에서는 애니메이션을 한 번만 설정합니다.
시작 값, 종료 값, 지속 시간 등을 설정하고 나면 이후 각 프레임의 계산은 네이티브 측에서 독립적으로 처리합니다.
// 네이티브 드라이버 — JS 스레드가 애니메이션 타이밍을 설정합니다 Animated.timing (opacity, { toValue : 1 , duration : 300 , useNativeDriver : true , // ← 이것이 핵심 }). start ();
이는 분명 큰 개선이었습니다. Fade-in, Slide-in, Scale Bounce와 같은 단순한 전환 애니메이션은 JS Thread가 다른 작업으로 가득 차 있더라도 60fps로 실행될 수 있습니다.
하지만 네이티브 드라이버에는 명확한 한계가 있습니다. 애니메이션할 수 있는 속성이 제한적이기 때문입니다. 대표적으로 다음과 같은 속성만 지원합니다.
opacity
transform(translate,scale,rotate)
반면
width, height, backgroundColor, borderRadius와 같은 속성이나 레이아웃 관련 속성은 애니메이션하기 어렵습니다. 또한 제스처 입력에 따라 애니메이션을 실시간으로 변경하는 것도 어렵습니다.네이티브 드라이버는 미리 정의된 시작 값과 종료 값을 기반으로 애니메이션을 실행하는 데 적합하기 때문입니다.
예를 들어 다음과 같은 카드 인터랙션을 만든다고 생각해봅시다.
- 손가락을 따라 카드가 움직이고
- 손을 떼면 스프링 물리 효과로 특정 위치에 정착하고
- 얼마나 멀리 드래그했는지에 따라 색상이 변경되고
- 손가락의 속도에 따라 카드를 닫을지 결정하는
이런 동작은 기존 네이티브 드라이버만으로 구현하기 어렵습니다. 실시간 제스처 데이터를 기반으로 임의의 애니메이션 로직을 UI Thread에서 실행할 수 있는 무언가가 필요합니다.
그리고 바로 그것을 위해 탄생한 것이 Reanimated입니다.
Reanimated의 아키텍쳐
Reanimated의 핵심 아이디어는 놀라울 정도로 간단합니다. 애니메이션 값뿐만 아니라 애니메이션 로직 자체를 UI Thread로 옮기는 것입니다.
단순히 렌더링만 옮기는 것이 아닙니다. 계산도 포함됩니다. 조건문도 포함됩니다. 스프링 물리 계산도 포함됩니다. 제스처 콜백도 포함됩니다. 즉, 애니메이션과 관련된 모든 로직을 UI Thread에서 처리하는 것입니다.
이를 위해 Reanimated는 크게 두 가지를 도입했습니다.
- UI Thread에서 실행되는 별도의 JavaScript Runtime - JSI(JavaScript Interface)를 통해 동작하는 별도의 JavaScript 엔진입니다. 메인 JS Thread와 완전히 독립적으로 실행되며, 네이티브 렌더링 루프와 동기적으로 실행될 수 있습니다.
- Worklet - UI Thread Runtime으로 전달되어 실행되는 작은 JavaScript 함수입니다. Worklet은 변환된 형태로 UI Thread Runtime에 전달되고, 각 프레임에 맞춰 동기적으로 실행됩니다.
Reanimated가 적용된 구조를 단순화하면 다음과 같습니다.
┌─────────────────────────────────────────────┐ │ Main JS Thread (React) │ │ - Your components, state, business logic │ │ - Defines worklets and shared values │ │ - Communicates via SharedValue refs │ └──────────────────┬──────────────────────────┘ │ │ JSI (동기식, 직렬화 없음) │ ┌──────────────────▼──────────────────────────┐ │ UI Thread (Reanimated Runtime) │ │ - Second JS engine (Hermes / V8) │ │ - Worklets execute here │ │ - Direct access to native view props │ │ - Runs in sync with the render loop │ │ - Zero bridge crossings during animation │ └─────────────────────────────────────────────┘
Bridge 자체가 사라지는 것은 아닙니다. 하지만 Reanimated의 애니메이션 루프는 Bridge를 거치지 않습니다.
Worklet은 UI Thread의 별도 JavaScript Runtime에서 실행되며, 각 화면 프레임에 맞춰 동기적으로 계산됩니다. 이것이 Reanimated의 나머지 기능들을 가능하게 만든 핵심적인 아키텍처 변화입니다.
JSI가 이 구조를 어떻게 바꾸는가
Worklet과 SharedValue를 더 깊이 살펴보기 전에, 먼저 JSI가 왜 Reanimated의 아키텍처를 가능하게 만든 핵심 기술인지 이해할 필요가 있습니다.
JSI(JavaScript Interface)는 JavaScript와 네이티브 코드 사이의 통신을 담당하는 C++ 인터페이스입니다. 기존 Bridge와 가장 큰 차이는 다음과 같습니다.- Bridge : 비동기 메세지 큐 기반 통신, JSON 직렬화를 통한 데이터 전달, 서로 다른 스레드 간 비동기 통신 및 1~2프레임 이상의 지연 발생 가능
- JSI : 동기 함수 호출, C++ 객체를 직접 참조, 동일한 스레드에서 직접 접근, 함수 호출 수준의 지연(사실상 없음)
JSI를 사용하면 JavaScript 함수가 C++ 객체를 직접 참조할 수 있습니다. 반대로 C++ 객체가 JavaScript를 동기적으로 호출하는 것도 가능합니다.
즉, 데이터를 직렬화할 필요도 없고, 메시지를 큐에 넣을 필요도 없으며, 비동기적으로 기다릴 필요도 없습니다. 그리고 이것이 바로 Reanimated가 SharedValue를 구현할 수 있게 해준 기반입니다.
SharedValue는 C++에 존재하면서도 메인 JS Thread와 UI Thread Runtime 양쪽에서 동기적으로 읽고 쓸 수 있는 객체입니다.
SharedValue<number> (C++ heap, one object two readers) ▲ ▲ │ JSI read/write │ JSI read/write │ │ Main JS Thread UI Thread Runtime (React components) (Worklets / animation loop)
Worklet에서
sharedValue.value를 읽는다고 해서 메인 JS Thread에서 메시지를 전달받아 역직렬화하는 것이 아닙니다.C++ 객체를 직접 참조하는 것입니다. 즉, 밀리초 단위가 아니라 나노초 단위의 비용에 가까운 작업입니다. 이 구조를 이해하고 나면 Reanimated의 API가 왜 이런 형태로 설계되었는지도 자연스럽게 이해할 수 있습니다.
흔한 오해: "Reanimated는 단순히 네이티브로 옮기는 것이다"
Reanimated에 대해 자주 발생하는 오해가 하나 있습니다. Reanimated는 React Native
Animated API에서 useNativeDriver: true를 사용하는 것과 같은 방식이 아닙니다.네이티브 드라이버는 미리 정의된 애니메이션을 네이티브 영역으로 넘겨 실행합니다. 애니메이션의 값과 타이밍은
.start()가 호출되는 시점에 결정됩니다.반면 Reanimated는 실행 중인 JavaScript 로직 자체를 UI Thread에서 매 프레임 실행합니다. 제스처 상태, 물리 시뮬레이션, 보간값 등을 실시간으로 확인하면서 애니메이션을 계산할 수 있습니다.
다만 렌더링 루프와 동기적으로 실행되고 네이티브 View를 즉시 업데이트할 수 있는 완전히 다른 실행 환경에서 동작하는 것입니다.
이 차이는 성능 문제를 디버깅할 때 매우 중요합니다. Reanimated를 사용했는데도 애니메이션이 끊긴다면 가장 먼저 다음과 같은 질문을 던져봐야 합니다.
"메인 JS Thread에서 실행되는 무언가가 Worklet 안으로 들어오고 있는 것은 아닐까?"
이 문제를 어떻게 진단하는지는 Part 4에서 자세히 살펴보겠습니다.
Part 1 정리
이번 글에서는 Reanimated가 왜 등장했으며, 기존 React Native의 구조에서 어떤 문제를 해결하는지 살펴봤습니다.
- React Native는 JavaScript를 UI Thread와 분리된 별도의 JS Thread에서 실행합니다.
- JS Thread와 UI Thread는 기존에 Bridge를 통해 통신했으며, 비동기적인 메시지 전달과 JSON 직렬화 과정에서 비용이 발생했습니다.
- 이러한 구조는 제스처 기반 애니메이션처럼 매 프레임 빠르게 응답해야 하는 상황에서 병목이 될 수 있습니다.
useNativeDriver: true는 애니메이션을 UI Thread로 옮겨 이 문제를 어느 정도 해결했지만, 애니메이션할 수 있는 속성과 동작에 한계가 있었습니다.
- Reanimated는 애니메이션 값뿐만 아니라 애니메이션 로직 자체를 UI Thread로 옮기는 방식을 선택했습니다.
- 이를 위해 UI Thread에서 동작하는 별도의 JavaScript Runtime과 Worklet을 사용합니다.
- JSI는 JavaScript와 C++ 사이에서 동기적인 직접 접근을 가능하게 하며, Reanimated의 SharedValue와 UI Thread 기반 실행 구조를 가능하게 하는 핵심 기술입니다.
다음 글에서는
Part 2에서는 Reanimated Runtime 내부로 한 단계 더 깊이 들어가 보겠습니다.
Worklet이 실제로 어떻게 변환되고 UI Thread Runtime으로 전달되는지, Reanimated의 C++ 레이어가 어떤 역할을 하는지, 그리고
runOnUI와 runOnJS를 통해 서로 다른 런타임 사이를 어떻게 오갈 수 있는지 살펴보겠습니다.Part.1에서 Reanimated가 왜 이런 구조를 선택했는지 이해했다면, Part.2에서는 그 구조가 실제로 어떻게 동작하는지 알아보겠습니다.

