React Native Worklets의 클로저 동작 정리

React Native Worklets의 클로저 동작 정리

2026년 08월 24일

들어가기 전에

지난 글에서 Worklet이 여러 Runtime 사이를 옮겨 다니며 실행되는 함수라는 것과, 런타임마다 자기만의 글로벌 스코프와 힙이 있다는 것을 정리했습니다.

이번 글에서는 그 구조가 실제로 코드에 어떻게 드러나는지 봅니다. Worklet이 바깥 변수를 캡처할 때, 어느 런타임에서 실행되느냐에 따라 동작이 달라집니다.

이 글은 Reanimated 4 / React Native New Architecture 기준으로 설명합니다.

클로저는 Worklet에도 있습니다

Worklet은 일반 JavaScript 함수처럼 주변 스코프의 변수를 캡처할 수 있습니다. 이렇게 캡처된 변수를 클로저라고 부릅니다.

const a = 1;
const b = 2;
 
function addAB() {
  "worklet";
  return a + b; // a, b는 바깥 스코프에서 캡처된 클로저
}

"What makes worklets special is their ability to capture variables from their surrounding scope, similar to regular JavaScript functions."
Understanding closures

여기까지는 일반 함수와 같습니다. 차이는 이 함수를 어디서 실행하느냐에서 생깁니다.

같은 런타임에서 실행할 때

Worklet을 정의한 런타임과 같은 런타임(RN Runtime)에서 호출하면, 클로저는 원본 변수의 참조 복사본을 받습니다.

원시값은 서로 영향을 주지 않습니다

복사본이기 때문에 바깥에서 재할당해도 Worklet 안에는 반영되지 않습니다.

let count = 0;
 
function logCount() {
  "worklet";
  console.log(count);
}
 
count = 1;
logCount(); // 0

반대 방향도 같습니다. Worklet 안에서 바꿔도 원본은 그대로입니다.

let count = 0;
 
function increment() {
  "worklet";
  count += 1; // 복사본을 수정할 뿐
}
 
increment();
console.log(count); // 0

객체는 참조가 복사됩니다

객체는 조금 다릅니다. 참조가 복사되므로 같은 객체를 가리킵니다. 그래서 속성을 바꾸면 양쪽에 반영됩니다.

let obj = { value: 0 };
 
function mutate() {
  "worklet";
  obj.value += 1;
}
 
mutate();
console.log(obj.value); // 1 — 같은 객체를 가리키므로 원본도 바뀜

바깥에서 속성을 바꿔도 마찬가지입니다.

let obj = { value: 0 };
 
function logValue() {
  "worklet";
  console.log(obj.value);
}
 
obj.value = 1;
logValue(); // 1

다만 재할당은 반영되지 않습니다. 참조 자체가 바뀌기 때문에 Worklet은 여전히 예전 객체를 가리킵니다.

let obj = { value: 0 };
 
function logValue() {
  "worklet";
  console.log(obj.value);
}
 
obj = { value: 1 }; // 새 객체로 재할당
logValue(); // 0 — 예전 객체를 보고 있음

정리하면 이렇습니다.

조작Worklet에 반영?이유
원시값 재할당값이 복사됨
객체 속성 변경 (obj.value = 1)같은 객체를 참조
객체 재할당 (obj = {...})참조 복사본이라 원본의 재할당이 반영 안 됨

다른 런타임에서 실행할 때

여기서부터가 Worklet만의 동작입니다. 런타임이 다르면 메모리를 공유할 수 없으므로, 클로저 변수가 완전히 복사됩니다.

"The closure in the worklet becomes an exact copy of the variables from the surrounding scope at the moment of its invocation. This is a necessity due to the fact that different runtimes cannot share references to the same objects, they cannot share memory directly."
Understanding closures

같은 코드를 UI Runtime에서 실행하면 결과가 달라집니다.

import { runOnUISync } from "react-native-worklets";
 
let obj = { value: 0 };
 
function logValue() {
  "worklet";
  console.log(obj.value);
}
 
obj.value = 1;
runOnUISync(logValue); // 0 — 같은 런타임이었다면 1

같은 런타임이었다면 앞서 본 대로 obj.value = 1이 반영돼 1이 나옵니다. 하지만 UI Runtime으로 보내는 순간 obj완전한 복사본이 만들어집니다. 속성 변경조차 건너오지 못합니다.

복사는 한 번만 일어납니다

여기서 한 걸음 더 들어가면, 객체는 런타임으로 한 번만 복사됩니다. Worklet을 여러 번 호출해도 처음 복사된 값이 계속 쓰입니다.

import { scheduleOnUI } from "react-native-worklets";
 
const obj = { prop: 1 };
 
function worklet() {
  "worklet";
  console.log(obj.prop);
}
 
scheduleOnUI(worklet); // 1
obj.prop = 2;
scheduleOnUI(worklet); // 2가 아니라 1

"obj is not a shared value and is only copied to UI runtime once. Therefore, in development builds, we make the object immutable and add this warning after copying it"
Troubleshooting

개발 빌드에서는 복사가 끝난 객체를 아예 얼려버립니다. 수정을 시도하면 경고를 띄웁니다.

Tried to modify key `prop` of an object which has been already passed to a worklet.

이 경고를 만났다면 런타임 경계를 넘긴 객체를 나중에 수정하려 한 것입니다. 계속 변하는 값이라면 Shared Value를 써야 한다는 신호입니다.

왜 이렇게 되는가

지난 글에서 정리한 구조가 그대로 원인입니다. 런타임은 각자 독립된 Hermes 인스턴스라 자기만의 힙이 있습니다. 힙이 분리되어 있으면 같은 객체를 가리키는 참조를 주고받을 방법이 없습니다. 그래서 값을 통째로 복사하는 것 외에 선택지가 없습니다.

같은 런타임에서는 힙이 하나라 참조가 통합니다. 다른 런타임에서는 힙이 둘이라 복사만 가능합니다. 이 한 줄이 위의 모든 차이를 설명합니다.

두 경우를 나란히 놓으면

같은 런타임 (RN → RN)
  원시값 : 복사본        → 이후 재할당 무관
  객체   : 참조 복사     → 속성 변경은 반영, 재할당은 반영 안 됨

다른 런타임 (RN → UI/Worker)
  원시값 : 완전 복사     → 이후 변경 무관
  객체   : 완전 복사     → 속성 변경도, 재할당도 반영 안 됨
  복사는 한 번만 일어남 → 이후 호출도 첫 복사본을 씀

글로벌 스코프도 런타임마다 다릅니다

앞선 예제들에서 Worklet 안에 console을 썼습니다. 이 console은 어디서 온 걸까요.

JavaScript는 지역 스코프에 없는 식별자를 만나면 바깥 스코프로, 최종적으로는 글로벌 스코프까지 거슬러 올라갑니다. 그런데 Worklet Runtime은 각자 별도의 글로벌 스코프가 있습니다.

"The accessed console object inside a worklet is different on each Runtime! It's available on every runtime because each Worklet Runtime defines its own console object in its global scope."
Understanding closures

RN Runtime의 console과 UI Runtime의 console은 서로 다른 객체입니다. 두 곳 모두에서 동작하는 이유는 각 런타임이 자기 console을 따로 정의해두기 때문입니다.

그래서 글로벌 변수는 런타임 사이에 공유되지 않습니다.

global.someValue = 42;
 
function logSomeValue() {
  "worklet";
  console.log(global.someValue);
}
 
logSomeValue(); // 42 — RN Runtime의 global
runOnUISync(logSomeValue); // undefined — UI Runtime의 global에는 없음

Worklet 안에서 global.xxx에 접근하면 실행되는 런타임의 글로벌 스코프를 봅니다. 원래 런타임의 글로벌이 아닙니다.

다른 런타임에 글로벌 값 넘기기

굳이 넘겨야 한다면 로컬 변수로 한 번 받아서 클로저에 태워야 합니다.

global.someValue = 42;
const localSomeValue = global.someValue; // 로컬 변수로 캡처
 
function assignGlobal() {
  "worklet";
  global.someValue = localSomeValue; // UI Runtime의 global에 할당
}
runOnUISync(assignGlobal);
 
function logSomeValue() {
  "worklet";
  console.log(global.someValue);
}
runOnUISync(logSomeValue); // 42

로컬 변수는 클로저에 포함되어 복사되지만 global은 그렇지 않습니다. 그 차이를 이용한 방법입니다.

실무에서 걸리는 지점

정리한 내용이 실제로 어떻게 드러나는지 두 가지만 짚어두겠습니다.

값이 갱신되지 않는 것처럼 보일 때 — 애니메이션 콜백이 옛날 값을 참조한다면 클로저 복사를 의심해볼 만합니다. 런타임 경계를 넘는 순간 값은 그 시점에 고정됩니다. 계속 변하는 값을 주고받아야 한다면 클로저가 아니라 Shared Value를 써야 합니다.

속성 하나만 쓰더라도 객체 전체가 복사됩니다 — Worklet 안에서 obj.foo 하나만 읽어도 캡처되는 건 obj 전체입니다. 그래서 쓰지도 않은 다른 속성을 바꿔도 위의 경고가 뜹니다.

"Since we copy the whole object obj instead its accessed properties, it's immutable."
Troubleshooting

해법은 필요한 값을 미리 변수로 빼두는 것입니다. 복사량도 줄고 경고도 사라집니다.

// 필요한 값만 추출해서 캡처
const primaryColor = theme.colors.primary;
 
const style = useAnimatedStyle(() => {
  return { backgroundColor: primaryColor };
});

정리

  • Worklet도 바깥 스코프의 변수를 캡처합니다. 다만 어느 런타임에서 실행하느냐에 따라 동작이 갈립니다.
  • 같은 런타임이면 참조가 복사됩니다. 객체 속성 변경은 양쪽에 반영되고 재할당은 반영되지 않습니다.
  • 다른 런타임이면 완전 복사됩니다. 속성 변경조차 건너오지 않고 복사는 한 번만 일어납니다.
  • 원인은 하나입니다. 런타임마다 힙이 분리되어 있어 참조를 공유할 수 없습니다.
  • 속성 하나만 써도 객체 전체가 복사되므로, 필요한 값은 미리 변수로 빼두는 게 좋습니다.
  • 글로벌 스코프도 런타임마다 별개입니다. console이 런타임마다 다른 객체인 이유가 여기 있습니다.

다음 편에서는 Worker Runtime을 직접 만들 때 드는 비용과, workletize된 함수를 다룰 때 걸리는 제약들을 다룹니다.

참고 문서