들어가기 전에
지난 글에서 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된 함수를 다룰 때 걸리는 제약들을 다룹니다.
참고 문서
- https://docs.swmansion.com/react-native-worklets/docs/fundamentals/closures
- https://docs.swmansion.com/react-native-worklets/docs/fundamentals/runtimeKinds
- https://docs.swmansion.com/react-native-worklets/docs/guides/troubleshooting
- https://docs.swmansion.com/react-native-worklets/docs/threading/runOnUISync
- https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue