React Native Worklets의 공유 메모리 정리

React Native Worklets의 공유 메모리 정리

2026년 08월 25일

들어가기 전에

지난 글에서 클로저가 런타임 경계를 넘을 때 값이 복사된다고 정리했습니다. 복사는 한 번만 일어나고 이후 원본을 바꿔도 반영되지 않습니다.

그럼 런타임끼리 값을 주고받는 제대로 된 방법은 무엇일까요. Worklets는 공유 메모리 원시 타입을 세 가지 제공합니다. 한 번 넘기고 마는 것부터 계속 같이 들여다보는 것까지 성격이 다릅니다. 이번 글에서는 셋이 어떻게 다르고 언제 무엇을 쓰는지 정리합니다.

이 글은 Reanimated 4 / React Native New Architecture 기준입니다. 코드는 react-native-worklets 0.12에서 확인했습니다.

애초에 왜 어려운 문제인가

JavaScript는 싱글 스레드를 전제로 설계된 언어입니다. 호출 스택 하나, 이벤트 루프 하나, 한 번에 실행되는 코드 하나입니다.

이 전제가 생각보다 많은 것을 떠받치고 있습니다. 변수를 읽고 수정하는 함수가 도중에 끊길 일이 없습니다. 데이터 레이스도, 반쯤 쓰이다 만 객체도 없습니다. 카운터를 하나 올리려고 뮤텍스를 걸 필요가 없습니다.

"None of this would be safe if two threads could touch the same heap at the same time — but in single-threaded JavaScript, it is safe by construction."
Sharing memory

여기에 스레드를 하나 더 얹으면 어떻게 될까요. 두 스레드가 같은 Runtime에서 일하게 두면 잠깐은 돌아가는 듯하다가 EXC_BAD_ACCESS로 죽습니다. 한쪽이 참조를 바꾸는 순간 다른 쪽의 가정이 깨지기 때문입니다.

그래서 병렬로 돌릴 코드에는 각자 쓸 Runtime을 따로 줘야 합니다. 1편에서 RN Runtime과 UI Runtime, Worker Runtime을 나눠 본 이유가 이것입니다. 크래시는 사라지지만 이번엔 다른 문제가 생깁니다. 두 Runtime이 서로 다른 세계가 됩니다.

객체를 그냥 건네줄 수 없는 이유

JavaScript 객체는 특정 엔진 힙을 가리키는 포인터 뭉치입니다. 프로토타입 체인, 히든 클래스, 속성 저장소, GC, 글로벌 객체까지 전부 엔진 내부 상태입니다. 다른 Runtime이 해석할 방법이 없습니다.

"Force-interpreting such a collection of pointers in another Runtime would be like trying to read a pointer from one process and dereference it in another process — it just doesn't work."
Sharing memory

세 가지 공유 메모리

여기까지 보면 런타임끼리 메모리를 나눠 쓸 방법이 없어 보입니다. 그런데 이 글의 주제는 공유 메모리입니다. 모순은 아닙니다. 런타임의 JS 힙끼리 직접 이어지는 길은 여전히 없고, 아래 세 가지는 전부 C++를 거쳐 그 벽을 우회하는 방법입니다.

Worklets는 이 우회를 세 가지로 나눕니다. 값을 어디에 두느냐가 기준입니다.

Serializable   : 어느 런타임도 값을 소유하지 않음. 복사본만 주고받음
Synchronizable : 값을 C++에 둠. 모든 런타임이 참조
Shareable      : 값을 특정 런타임(Host)에 둠. 나머지는 참조

복사만 하는 메모리: Serializable

가장 단순합니다. 값을 Runtime A에서 B로 있는 그대로 옮깁니다. 대신 어느 쪽에서 바꿔도 다른 쪽에는 보이지 않습니다. 각 런타임은 자기가 사본을 들고 있다는 사실조차 모릅니다.

2편에서 본 클로저 복사가 바로 이 동작입니다.

값은 바이트 스트림으로 직렬화되어 C++를 거쳐 건너간 뒤 반대편에서 재구성됩니다.

Serializable은 직접 만들 일이 없습니다. 라이브러리 API가 내부적으로 처리합니다. — "In all cases, this is done implicitly by the library APIs, and you never need to create Serializables directly."

런타임에 매이지 않는 메모리: Synchronizable

실제로 공유가 되는 타입입니다. Runtime A에서 바꾸면 B에서도 보입니다. 다만 바뀌었다고 알려주지는 않습니다. 값이 변한 걸 알려면 각 런타임이 직접 읽어봐야 합니다.

방법은 값을 어느 런타임에도 두지 않는 것입니다. 진짜 값은 C++에 있고 뮤텍스로 보호됩니다. 각 런타임은 그 C++ 값을 가리키는 참조만 들고 있습니다. 스레드 안전하니 어느 런타임에서 언제 접근해도 값이 깨지지 않습니다.

import { createSynchronizable, scheduleOnUI } from "react-native-worklets";
 
// RN Runtime
const synchronizable = createSynchronizable({ a: 42 });
 
scheduleOnUI(() => {
  // UI Runtime에서 같은 값에 접근한다
  console.log(synchronizable.getBlocking());
});
 
// 위 콜백보다 먼저 실행될 수도, 나중에 실행될 수도 있다
synchronizable.setBlocking({ a: 24 });

이름 그대로 getBlockingsetBlocking은 기다립니다. 값을 독점해서 잡기 때문에 다른 스레드가 읽는 중이어도 기다립니다. 읽기끼리도 서로를 막습니다. 기다리기 싫으면 getDirty를 씁니다. 락이 걸려 있어도 멈추지 않는 대신, 아직 커밋되지 않은 값을 읽을 수 있습니다.

이상적으로 들리지만 대가가 있습니다. 값이 C++에 있으니 읽고 쓸 때마다 직렬화와 역직렬화를 거칩니다. 런타임이 C++ 타입을 직접 쓸 수 없기 때문입니다. 담을 수 있는 값도 제한됩니다. 함수나 프로토타입 체인이 있는 객체처럼 특정 런타임에 매인 값은 넣어봐야 다른 런타임에서 의미가 없습니다.

특정 런타임에 매인 메모리: Shareable

Synchronizable의 비용 문제를 푸는 방식입니다. 대신 비대칭이 됩니다.

값을 Host Runtime 한 곳에 평범한 JavaScript 값으로 둡니다. 그 런타임에서는 접근이 빠릅니다. 나머지 Guest Runtime은 "값이 저쪽에 있다"는 걸 아는 참조만 들고 있습니다. Guest가 읽으려면 C++로 가서 Host를 잠그고 값을 읽어 돌아와야 합니다.

import { createShareable, UIRuntimeId } from "react-native-worklets";
 
const shareable = createShareable(UIRuntimeId, 42);
 
console.log(shareable.getSync()); // 42

Host에서는 .value로 바로 읽고 씁니다. Guest에서는 getSync / getAsync / setSync / setAsync를 씁니다. Sync 쪽은 Host를 그 자리에서 멈춰 세우고 처리합니다. Async 쪽은 Host에 할 일을 예약해 둡니다. 지금 어느 쪽인지는 isHost로 확인합니다.

타입 정의상 양쪽 속성이 전부 선택적이라 위 예제를 그대로 쓰면 isHost로 좁히기 전까지 타입 에러가 납니다.

공식 문서는 이쪽을 권합니다.

"The Shareable should be the preferred way of handling non-trivial data sharing since it comes with the least overhead when used correctly."
Sharing memory

다만 제대로 썼을 때 이야기입니다. Guest에서 자주 읽으면 왕복이 쌓여서 오히려 손해입니다. 데이터 흐름을 미리 설계해야 합니다.

어느 것을 쓸까

공식 문서의 기준을 옮기면 이렇습니다.

상황선택
한 번 넘기고 이후 변경을 볼 필요가 없다 (예: 설정 객체)Serializable
여러 런타임이 비슷한 빈도로 읽고 쓰며 서로의 변경을 봐야 한다 (예: 앱 상태)Synchronizable
한 런타임이 접근을 독점하고 나머지는 가끔 들여다본다Shareable

세 번째 항목의 예시가 재미있습니다.

"you have an animation engine that reads an animation state on every frame during a critical phase of the rendering pipeline, but sometimes you want to change that state based on user input. Basically, you're writing Reanimated."
Sharing memory

매 프레임 애니메이션 상태를 읽는 쪽이 있고 가끔 사용자 입력으로 그 상태를 바꾸는 쪽이 있습니다. 문서가 대놓고 Reanimated라고 적어둔 이유입니다.

Shared Value가 여기 있습니다

Reanimated를 써봤다면 useSharedValue가 익숙할 겁니다. 1편에서는 이게 Shareable 위에 구현되어 있다고만 짚고 넘어갔습니다. 이제 무슨 뜻인지 풀어볼 수 있습니다.

"this technique is used to implement Reanimated's Shared Values API on top of Shareables."
createShareable

여기서 "이 기법"은 Guest 쪽 참조에 value getter와 setter를 달아 두는 것을 말합니다. 속성처럼 보이지만 안에서는 Guest 메서드가 대신 호출됩니다. Host든 Guest든 똑같이 .value로 쓸 수 있는 이유입니다.

Reanimated 4의 makeMutable 구현을 열어보면 그대로 나옵니다. createShareable(UIRuntimeId, ...)로 만들고 guestDecorator에서 value를 정의합니다. Host는 UI Runtime입니다.

1편에서 짚은 주의사항의 이유도 여기서 나옵니다. JS Thread에서 .value를 읽으면 블로킹되는 건, Guest인 RN Runtime이 Host인 UI Runtime을 잠그고 값을 읽어 오기 때문입니다. 반대로 쓰기는 Host에 예약만 하고 넘어갑니다. 값을 바꾸자마자 같은 자리에서 다시 읽으면 이전 값이 나오는 게 그래서입니다.

정리

  • JavaScript는 싱글 스레드를 전제로 만들어졌습니다. 스레드를 늘리려면 런타임을 나누는 수밖에 없고 그 순간 메모리 공유가 문제가 됩니다.
  • 객체는 엔진 힙을 가리키는 포인터 뭉치라 다른 런타임에 그냥 건네줄 수 없습니다.
  • Serializable은 복사만 합니다. 직접 만들 일은 없고, 클로저 복사가 이 동작입니다.
  • Synchronizable은 값을 C++에 두고 모두가 참조합니다. 공유는 확실하지만 읽고 쓸 때마다 직렬화 비용이 듭니다.
  • Shareable은 값을 Host Runtime에 두고 Guest는 참조만 합니다. 비대칭이지만 제대로 쓰면 비용이 가장 적습니다.
  • Reanimated의 Shared Value가 Shareable 위에 만들어져 있습니다.

참고 문서