수백 번도 넘게 작성했지만, 정작 이 코드가 정확히 무엇을 하는지는 제대로 이해하지 못했던 코드가 하나 있습니다.
'worklet';
두 단어로 이루어진 이 문자열 지시문은 얼핏 보면 아무것도 아닌 것처럼 보입니다. 때로는 주석처럼 보이기도 합니다.
하지만 이 한 줄은 단순한 주석이 아닙니다. Reanimated의 Babel 플러그인이 해당 함수를 별도의 JavaScript 런타임에서 실행할 수 있는 형태로 변환하도록 지시하는 역할을 합니다. 변환된 함수는 UI Thread의 런타임으로 전달되어, 렌더링 루프와 동기화된 상태로 각 프레임에 맞춰 실행됩니다.
Part.1에서는 왜 Reanimated가 UI Thread에 별도의 JavaScript 런타임을 필요로 하는지 살펴봤습니다. 이번 글에서는 Worklet이 실제로 어떻게 동작하는지 알아보겠습니다.
Babel 변환부터 Reanimated의 C++ 레이어를 거쳐, 함수가 UI Thread에서 실행되고, 메인 JS Thread의 개입 없이 Native View를 업데이트하는 순간까지 그 과정을 하나씩 살펴보겠습니다.
참고:runOnJS와runOnUI는 현재 deprecated되었으며,scheduleOnRN과scheduleOnUI가 이를 대체합니다. 다만 기존 문서와 예제에서runOnJS와runOnUI가 여전히 널리 사용되고 있는 만큼, 이 글에서도 개념 설명을 위해 기존 명칭을 그대로 사용하겠습니다.
이번 글에서 살펴볼 내용
'worklet'지시문이 Reanimated의 Babel 플러그인에서 실제로 어떤 변환을 일으키는지
- Worklet이 어떻게 직렬화되어 UI Thread 런타임으로 전달되는지
- Reanimated의 C++ 레이어는 무엇을 관리하고, 왜 필요한지
runOnUI와runOnJS는 어떻게 런타임 사이를 이동하는지
- Worklet의 Closure가 일반적인 JavaScript Closure와 다르게 동작하는 이유
'worklet'은 실제로 무엇을 하는가
함수에
'worklet'을 작성한다는 것은 Reanimated의 Babel 플러그인에게 다음과 같이 지시하는 것과 같습니다."이 함수를 UI Thread 런타임에서 실행할 수 있는 형태로 변환해 주세요."
간단한 함수를 예로 들어보겠습니다.
function calculateOffset(progress) { 'worklet'; return progress * 200 - 100; }
Babel 플러그인은 이 함수에 대해 여러 작업을 수행합니다.
- 함수의 소스 코드를 추출합니다. 실제 함수 본문의 코드를 가져옵니다.
- 클로져를 캡처합니다. 외부 스코프에서 참조하는 변수들을 찾아 직렬화합니다.
__workletHash를 생성합니다. 소스 코드를 기반으로 해당 Worklet을 식별하기 위한 고유한 값을 생성합니다.
__initData객체를 추가합니다. 소스 코드 문자열, 캡처한 Closure 값, 각종 메타데이터를 담습니다.
변환된 결과를 단순화하면 대략 다음과 같은 형태가 됩니다.
function calculateOffset(progress) { 'worklet'; return progress * 200 - 100; } calculateOffset.__closure = {}; // 이 경우 캡처한 외부 변수가 없음 calculateOffset.__workletHash = 1234567; calculateOffset.__initData = { code: `function calculateOffset(progress){return progress*200-100;}`, location: 'MyComponent.js:3', };
여기서
__initData가 UI Thread 런타임으로 전달되는 데이터입니다. 함수 객체 자체가 전달되는 것은 아닙니다. 함수 객체는 직렬화할 수 없기 때문입니다.대신 함수의 소스 코드가 문자열 형태로 전달되고, UI Thread의 JavaScript 엔진에서 다시 평가됩니다. 그렇게 만들어진 함수가 해당 런타임에서 실제로 호출 가능한 함수가 됩니다.
이 차이를 이해하는 것이 매우 중요합니다.
UI Thread에서 실행되는 Worklet은 원래 함수에 대한 참조가 아닙니다. 원래 함수를 그대로 가져가는 것이 아니라, 원래 함수의 코드를 다시 평가해서 만들어낸 복사본입니다.
Reanimated C++ 레이어: 전체 동작을 조율하는 오케스트레이터
두 JavaScript 런타임 사이에는 Reanimated의 C++ 레이어가 존재합니다.
실제로 Reanimated의 많은 핵심 동작이 바로 이곳을 통해 연결되고 처리됩니다. 어떤 역할을 하는지 살펴보겠습니다.
┌────────────────────────────────────────────────────┐ │ Main JS Thread │ │ ┌──────────────────────────────────────────────┐ │ │ │ Reanimated JS (worklet factory, API layer) │ │ │ └─────────────────────┬────────────────────────┘ │ └────────────────────────┼───────────────────────────┘ │ JSI bindings ┌────────────────────────▼───────────────────────────┐ │ C++ Core (NativeReanimated) │ │ - SharedValue storage & synchronisation │ │ - Worklet runtime management │ │ - Animation frame scheduling │ │ (Choreographer / CADisplayLink) │ │ - View prop updater │ │ (direct native prop mutation) │ │ - Event handler registration │ └────────────────────────┬───────────────────────────┘ │ JSI bindings ┌────────────────────────▼───────────────────────────┐ │ UI Thread JS Runtime │ │ ┌──────────────────────────────────────────────┐ │ │ │ Evaluated worklets (animation logic) │ │ │ │ useAnimatedStyle callbacks │ │ │ │ gesture handlers │ │ │ └──────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────┘
C++ 레이어는 전체 구조를 연결하는 중심 역할을 합니다. 구체적으로는 다음과 같은 작업을 담당합니다.
- SharedValue 저장소를 관리합니다. 두 런타임에서 접근할 수 있는 애니메이션 값을 저장하고 동기화합니다.
- 애니메이션 콜백을 스케줄링합니다. Android에서는
Choreographer, iOS에서는CADisplayLink와 연결하여 렌더링 과정에서 각 프레임마다 Worklet이 실행되도록 합니다.
- UI Thread Runtime을 관리합니다. JavaScript 엔진 인스턴스를 생성하고 Worklet 코드를 평가하며, Worklet이 JSI를 통해 SharedValue를 읽고 쓰거나 Native 메서드를 호출할 수 있도록 연결합니다.
- View Prop Updater를 관리합니다.
opacity,transform등의 Native View 속성을 React의 Reconciler를 거치지 않고 직접 변경할 수 있는 경로를 제공합니다.
마지막 부분이 특히 중요합니다.
Worklet이 SharedValue를 변경하고, 그 결과
useAnimatedStyle이 실행되었다고 가정해봅시다. 이때 만들어진 스타일 객체는 React의 Reconciler를 거치지 않습니다.C++ 레이어가 스타일 변경을 직접 Native View에 적용합니다. 즉, React는 이러한 변경이 일어났다는 사실조차 알지 못합니다.
이것이 Reanimated 애니메이션이 React 입장에서 사실상 렌더링 비용 없이 동작할 수 있는 이유입니다. 애니메이션의 변화가 React Reconciler의 영역 밖에서 일어나기 때문입니다.
runOnUI와 runOnJS: Runtime 경계를 넘나들기
이제 두 개의 서로 다른 런타임이 존재한다는 사실을 이해했으니,
runOnUI와 runOnJS가 왜 필요한지도 자연스럽게 이해할 수 있습니다.runOnUI는 Worklet을 받아 UI Thread 런타임에서 실행되도록 스케줄링합니다. 메인 JS Thread에서 UI Thread의 로직을 실행해야 할 때 사용합니다.반대로
runOnJS는 일반 JavaScript 함수를 받아 메인 JS Thread에서 실행되도록 스케줄링합니다. Worklet에서 React 쪽으로 다시 호출해야 할 때 사용합니다. 예를 들어 상태를 업데이트하거나 Callback Prop을 호출하는 경우입니다.// runOnUI: main thread → UI thread function triggerHaptic() { 'worklet'; // This runs on the UI thread runOnJS(fireHapticFeedback)(); // schedule back to main thread } function fireHapticFeedback() { // This is a regular function — runs on main JS thread HapticFeedback.trigger('impactLight'); } // From your component: runOnUI(triggerHaptic)();
여기서 반드시 이해해야 할 중요한 부분이 있습니다.
runOnJS는 동기적으로 실행되지 않습니다.runOnJS는 전달받은 함수를 메인 JS Thread의 다음 실행 가능한 이벤트 루프에서 실행하도록 스케줄링합니다.따라서 Worklet에서
runOnJS(fn)()을 호출한 시점과 실제로 fn이 실행되는 시점 사이에는 필연적으로 지연이 발생합니다. 경우에 따라 한 프레임 정도의 지연이 생길 수도 있습니다.이 때문에 Worklet 내부에서
runOnJS를 사용해 메인 Thread의 값을 동기적으로 읽을 수는 없습니다.Worklet은 계속 실행되고,
fn은 나중에 실행됩니다.// ❌ 흔히 하는 오해 function onGestureUpdate(event) { 'worklet'; let currentState; runOnJS((s) => { currentState = s; })(someReactState); // currentState는 여전히 undefined // runOnJS는 비동기적으로 실행되기 때문입니다. if (currentState === 'locked') return; }
그렇다면 올바른 방법은 무엇일까요?
초기화 시점에 JavaScript에서 데이터를 전달하거나, SharedValue를 사용해 두 런타임 사이에서 상태를 공유하는 것입니다.
// ✅ 올바른 방법 const isLocked = useSharedValue(false); function onGestureUpdate(event) { 'worklet'; if (isLocked.value) return; // SharedValue 읽기는 동기적으로 수행됩니다. // ... handle gesture }
단순한 개념처럼 보이지만, 실제 Reanimated 코드를 작성할 때 발생하는 미묘한 버그의 흔한 원인 중 하나입니다.
Worklet의 Closure: Snapshot 문제
Worklet은 외부 스코프의 값을 Closure로 캡처합니다. 하지만 여기에는 중요한 차이가 있습니다.
Worklet이 UI Thread에 등록되는 시점의 값이 Snapshot으로 캡처됩니다. 이는 일반적인 JavaScript의 Closure 동작과는 근본적으로 다릅니다.
일반적인 JavaScript Closure에서는 변수를 캡처하면 해당 변수의 최신 값을 참조할 수 있지만, Worklet에서는 등록 시점의 값을 캡처하여 사용합니다.
구체적인 예를 살펴보겠습니다.
function MyComponent({ threshold }) { const offset = useSharedValue(0); // ❌ 주의해야 할 패턴 const style = useAnimatedStyle(() => { // threshold는 Closure로 캡처됩니다. // 하지만 UI Thread Runtime에는 어떤 threshold 값이 전달되어 있을까요? return { transform: [ { translateX: offset.value > threshold ? 100 : 0, }, ], }; }); // threshold prop이 변경되더라도 // UI Thread의 Worklet은 다시 등록되기 전까지 // 이전 threshold 값을 가지고 있습니다. }
threshold가 Prop 변경으로 바뀌면 React가 컴포넌트를 다시 렌더링하고 useAnimatedStyle Worklet을 다시 등록합니다.Reanimated는 변경된 Closure Snapshot과 함께 Worklet을 UI Thread로 다시 전달해야 합니다.
이 재등록이 완료되기 전까지 UI Thread의 Worklet은 이전
threshold 값을 사용합니다.설정값처럼 자주 변경되지 않는 값이라면 크게 문제가 되지 않습니다.
하지만 매 프레임마다 변경되는 값이라면 Closure로 캡처해서는 안 됩니다. 이런 경우에는 SharedValue를 사용해야 합니다.
// ✅ 자주 변경되는 값을 위한 안전한 패턴 const thresholdShared = useSharedValue(threshold); // Prop과 SharedValue를 동기화 useEffect(() => { thresholdShared.value = threshold; }, [threshold]); const style = useAnimatedStyle(() => { // SharedValue는 항상 최신 값을 동기적으로 읽을 수 있습니다. return { transform: [ { translateX: offset.value > thresholdShared.value ? 100 : 0, }, ], }; });
Worklet의 Closure가 Snapshot이라는 사실을 이해하고 나니, 그동안 해결하지 못했던 애니메이션 버그의 상당수가 왜 발생했는지 명확하게 이해할 수 있었습니다.
Worklet은 매 프레임 어떻게 실행되는가
이번에는
useAnimatedStyle Worklet이 실행될 때 내부적으로 어떤 일이 일어나는지 살펴보겠습니다.애니메이션 프레임 하나가 처리되는 전체 흐름은 다음과 같습니다.
// 매 프레임의 실행 과정 Android Choreographer / iOS CADisplayLink 실행 │ ▼ Reanimated C++ 프레임 콜백 │ ▼ 활성화된 애니메이션이 있는 SharedValue 각각에 대해: │ ├── 애니메이션 시계에 따라 SharedValue 업데이트 │ (spring, timing 등) │ ▼ useAnimatedStyle이 등록된 각 컴포넌트에 대해: │ ├── UI Thread Runtime에서 Style Worklet 실행 │ ├── Worklet이 SharedValue를 동기적으로 읽음 │ ├── 새로운 Style 객체 반환 │ ▼ C++ ViewPropUpdater가 Style을 Native View에 직접 적용 │ ▼ 프레임 렌더링 — 사용자는 변경된 애니메이션을 확인
프레임이 시작되는 순간부터 Native View가 업데이트되는 순간까지의 전체 과정은 UI Thread에서 동기적으로 처리됩니다.
메인 JS Thread의 개입은 없으며, 브릿지를 거치는 과정도 없습니다. 메인 JS Thread는 이 과정이 발생했다는 사실조차 알지 못합니다.
React는 다시 렌더링되지 않고, Reconciler도 실행되지 않으며, 컴포넌트 트리 역시 변경되지 않습니다.
이것이 바로 Reanimated 애니메이션이 JS Thread에서 발생하는 병목의 영향을 받지 않는 이유입니다.
물론 Worklet이 충분히 빠르고 부수 효과 없이 동작한다는 전제하에 말이죠. Worklet이 빠르게 실행된다면 UI Thread는 약 16ms의 프레임 예산 안에서 모든 작업을 처리할 수 있습니다.
Worklet이 너무 느리다면 어떻게 될까?
여기서 자주 언급되지 않는 중요한 사실이 하나 있습니다.
Worklet도 결국 애니메이션을 버벅이게 만들 수 있습니다. 다만 JS Thread에서 발생하는 것이 아니라 UI Thread에서 발생합니다.
Worklet에서 무거운 작업을 수행하면 UI Thread가 사용할 수 있는 프레임 시간을 소모하게 됩니다.
예를 들어 다음과 같은 작업이 있습니다.
- 큰 배열을 순회하는 작업
- 복잡한 문자열 처리
- UI Thread를 블로킹하는 동기적인 JSI 함수 호출
60fps에서는 프레임 하나를 처리할 수 있는 시간이 약 16.7ms입니다. 하지만 UI Thread는 Worklet만 실행하는 것이 아닙니다. 레이아웃 처리, 터치 이벤트 처리, 렌더링 등도 함께 수행해야 합니다.
따라서 Worklet이 이 16.7ms를 전부 사용할 수 있는 것은 아닙니다. 실제로 Worklet이 해야 할 일은 최대한 단순해야 합니다.
SharedValue를 기반으로 Style 객체를 계산하는 것. 이 정도가 이상적인 Worklet의 역할입니다.
Worklet은 Stateless하고, 빠르게 실행되며, Side Effect가 없어야 합니다.
// ✅ 이상적인 Worklet — 순수하고 빠르며 상태가 없음 const style = useAnimatedStyle(() => { return { opacity: interpolate( progress.value, [0, 1], [0, 1], Extrapolate.CLAMP ), transform: [{ scale: progress.value }], }; });
// ❌ 위험한 Worklet — 너무 많은 일을 수행함 const style = useAnimatedStyle(() => { const result = heavyArray.reduce((acc, item) => { // 이렇게 하지 않는 것이 좋습니다. // UI Thread에서 초당 60번 실행될 수 있습니다. return acc + item.value; }, 0); return { opacity: result }; });
Worklet 정리
이번 글에서는
'worklet'이 어떻게 함수를 변환하고, UI Thread 런타임에서 어떤 과정을 거쳐 실행되는지 전체적인 흐름을 살펴봤습니다.정리하자면 다음과 같습니다.
'worklet'지시문은 Babel 변환을 일으켜 함수의 소스 코드와 Closure Snapshot을 추출하고, 이를 UI Thread Runtime에서 다시 평가할 수 있도록 합니다.
- UI Thread에서 실행되는 Worklet은 원래 함수에 대한 참조가 아니라 복사본입니다. 따라서 실시간으로 변하는 값을 참조하는 것이 아니라 등록 시점의 Snapshot을 캡처합니다.
- Reanimated의 C++ 레이어는 SharedValue 저장소, Worklet Runtime 관리, 프레임 스케줄링, Native View 직접 업데이트 등 전체 동작을 조율합니다.
runOnJS는 비동기적으로 동작하므로 Worklet 내부에서 메인 JS Thread의 값을 동기적으로 읽는 용도로 사용할 수 없습니다.
- Worklet의 Closure는 등록 시점의 값을 캡처합니다. 애니메이션 도중 변경되는 값이라면 Closure 대신 SharedValue를 사용해야 합니다.
- 매 프레임의 실행 과정은 UI Thread에서 처리됩니다. SharedValue에 대한 JSI 읽기, Worklet 실행, Native View 변경까지 React의 개입 없이 이루어집니다.
다음 글에서는
Part 3에서는 한 단계 더 깊이 들어가 SharedValue, DerivedValue, 그리고 실제 애니메이션 드라이버를 살펴보겠습니다.
spring, timing, decay가 어떤 방식으로 애니메이션을 계산하는지, DerivedValue가 React의 리렌더링 없이 어떻게 값을 조합하는지, 그리고 Spring 물리 계산이 매 프레임 어떤 방식으로 이루어지는지 자세히 알아보겠습니다.Part 3부터는 본격적으로 애니메이션의 수학적인 동작 원리를 살펴보게 됩니다.

