동기식 TurboModules가 자동으로 빨라지는 것은 아니다
요약
React Native의 New Architecture에서 TurboModules를 통한 동기식 통신이 반드시 성능 향상을 의미하지는 않음을 경고합니다. 동기식 호출은 네이티브 작업이 완료될 때까지 JavaScript 스레드를 차단하여 앱의 반응성을 저해할 수 있습니다.
핵심 포인트
- 동기식 API는 JavaScript 스레드를 차단(Blocking)할 위험이 있음
- 네이티브 메서드가 동기식이라도 내부 작업(복호화, 디스크 I/O 등)은 시간이 걸릴 수 있음
- 지연 시간이 가변적인 작업에 동기식 호출을 사용하면 성능 저하 유발
- 반복적인 동기식 호출은 상태 업데이트 및 사용자 상호작용을 방해함
React Native의 새로운 아키텍처 (New Architecture)는 JavaScript가 네이티브 기능에 더 직접적으로 접근할 수 있도록 해줍니다.
한 가지 중요한 능력은 TurboModules를 통한 동기식 통신 (synchronous communication)입니다.
이는 유용할 수 있습니다.
하지만 놓치기 쉬운 성능 문제를 일으킬 수도 있습니다:
네이티브 메서드가 동기식이라고 해서 네이티브 작업이 빠른 것은 아니다.
통신 메커니즘은 효율적일 수 있지만, JavaScript 스레드는 여전히 네이티브 작업이 완료될 때까지 기다려야 합니다.
실제 서비스 애플리케이션 (production applications)에서는 이 차이가 중요합니다.
동기식 API의 오해하기 쉬운 단순함
다음과 같이 노출된 네이티브 메서드를 생각해 보세요:
const token = SecureStorage.getAccessToken();
JavaScript 측면에서 보면 이는 해롭지 않아 보입니다.
await도 없고, 콜백 (callback)도 없으며, 눈에 보이는 비동기 워크플로우 (asynchronous workflow)도 없습니다.
개발자의 관점에서는 값이 즉시 반환되는 것처럼 보입니다.
하지만 네이티브 구현체는 값을 반환하기 전에 여러 작업을 수행할 수 있습니다:
- Android Keystore 또는 iOS Keychain 접근
- 저장된 데이터 복호화 (decrypt)
- 디스크에서 읽기
- JSON 파싱 (parse)
- 네이티브 객체를 JavaScript 호환 값으로 변환
- 다른 시스템 서비스와 통신
이 작업에 시간이 걸린다면, 메서드가 반환될 때까지 JavaScript는 계속할 수 없습니다.
API는 단순해 보이지만,
실행은 그렇지 않을 수 있습니다.
동기식 (synchronous)의 실제 의미
동기식 호출은 호출자가 결과를 기다린다는 것을 의미합니다.
예를 들어:
const result = NativeModule.getValue();
renderScreen(result);
getValue()가 완료될 때까지 renderScreen은 시작할 수 없습니다.
네이티브 작업이 매우 작고 예측 가능할 때는 이러한 동작이 허용될 수 있습니다.
하지만 작업의 지연 시간 (latency)이 가변적일 때는 위험해집니다.
동일한 메서드라도 다음과 같이 걸릴 수 있습니다:
- 데이터가 캐시 (cached)된 경우 1밀리초 미만
- 데이터 변환이 필요한 경우 수 밀리초
- 저장소, 암호화 (cryptography) 또는 시스템 서비스가 관여되는 경우 훨씬 더 오래 걸림
JavaScript 스레드는 네이티브 메서드가 왜 지연되는지 상관하지 않습니다.
그저 차단 (blocked)된 상태로 남아 있을 뿐입니다.
이것이 React Native 성능에 영향을 미치는 이유
React Native 애플리케이션은 JavaScript 스레드에서 중요한 작업들을 수행합니다.
여기에는 다음과 같은 작업들이 포함될 수 있습니다:
- 상태 업데이트 (state updates) 처리
- 애플리케이션 로직 실행
- 사용자 상호작용 (user interactions) 처리
- React 렌더링 (renders) 준비
- 네비게이션 (navigation) 조정
- 애니메이션 관련 JavaScript 실행
- 네이티브 모듈 (native modules)로부터의 이벤트 응답
동기식 네이티브 메서드가 해당 스레드를 차단 (block)하면, 다른 JavaScript 작업들은 대기해야만 합니다.
단 한 번의 호출은 명확한 문제를 일으키지 않을 수도 있습니다.
하지만 반복적인 호출은 문제를 일으킬 수 있습니다.
어떤 컴포넌트가 렌더링 중에 보안 값을 읽는다고 가정해 봅시다:
function AccountScreen() {
const token = SecureStorage.getAccessToken();
...
이제 이 컴포넌트가 네비게이션이나 상태 업데이트 중에 여러 번 렌더링된다고 가정해 보십시오.
매 렌더링마다 또 다른 네이티브 연산이 트리거될 수 있습니다.
만약 해당 메서드가 저장소에 접근하고 복호화 (decryption)를 수행한다면, 애플리케이션은 렌더링 경로 (render path) 내부에서 반복적으로 JavaScript 스레드를 차단하게 됩니다.
눈에 보이는 결과는 다음과 같을 수 있습니다:
- 느린 네비게이션
- 지연된 버튼 응답
- 애니메이션 프레임 드랍 (dropped animation frames)
- 일관되지 않은 화면 전환
- 일시적인 UI 프리징 (UI freezes)
- 저사양 기기에서 더 악화되는 성능
개발자는 처음에 React의 재렌더링 (re-renders)을 조사할 수도 있습니다.
하지만 진짜 문제는 해당 렌더링 중에 수행되는 동기식 네이티브 작업입니다.
TurboModules는 오버헤드를 줄이는 것이지, 실행 비용을 줄이는 것이 아니다
이것이 핵심적인 차이점입니다.
TurboModules는 JavaScript와 네이티브 코드 간의 통신 방식을 개선합니다.
이들은 기존의 브릿지 (bridge) 아키텍처와 관련된 일부 오버헤드 (overhead)를 줄일 수 있습니다.
하지만 TurboModules가 네이티브 연산 자체를 비용이 들지 않게 만들어주는 것은 아닙니다.
어떤 네이티브 메서드가 세 가지 작업을 수행한다고 가정해 봅시다:
보안 저장소 읽기 (Read secure storage)
↓
값 복호화 (Decrypt the value)
...
통신 계층은 효율적일 수 있습니다.
그럼에도 애플리케이션은 여전히 다음 비용을 지불해야 합니다:
- 저장소 접근 (storage access)
- 암호화 처리 (cryptographic processing)
- 객체 할당 (object allocation)
- 데이터 변환 (data conversion)
- 스레드 조정 (thread coordination)
JavaScript와 네이티브 코드 사이의 경로를 최적화한다고 해서 목적지에서 발생하는 작업이 사라지는 것은 아닙니다.
더 빠른 문(doorway)이 생긴다고 해서 그 뒤에 있는 방이 작아지지는 않습니다.
동기식 네이티브 메서드가 적절한 경우
동기식 메서드는 작업이 다음과 같을 때 유용합니다:
- 매우 작음 (tiny)
- 결정론적임 (deterministic)
- 메모리에 상주함 (memory-resident)
- 빈번하게 요구됨 (frequently required)
- 느린 I/O를 수행하지 않음이 보장됨
- 지원되는 기기 전반에서 예측 가능함
예시로는 캐시된 기능(capability)을 읽는 것이 포함될 수 있습니다:
const supportsBiometrics = DeviceCapabilities.hasBiometricHardware();
또는 네이티브 상수(constant)를 확인하는 것:
const environment = AppConfiguration.getEnvironment();
또는 매우 작은 계산을 수행하는 것:
const pixels = DisplayUtils.dpToPixels(16);
이러한 작업들은 일반적으로 네이티브 구현이 가벼운 상태를 유지할 때만 적합합니다.
중요한 질문은 반환 타입 (return type)이 작은지 여부가 아닙니다.
중요한 질문은 값이 어떻게 생성되는가입니다.
구현부에서 매번 시스템 서비스 (system service)를 쿼리한다면, 단 하나의 불리언 (boolean) 값을 반환하는 것조차 여전히 비용이 많이 들 수 있습니다.
일반적으로 비동기식으로 유지해야 하는 작업들
네이티브 메서드는 다음과 같은 사항을 포함할 때 보통 비동기식 (asynchronous)이어야 합니다:
저장소 (Storage)
const profile = await SecureStorage.getProfile();
디스크 및 보안 저장소 (secure-storage) 접근은 예측 불가능한 지연 시간 (latency)을 가질 수 있습니다.
암호화 (Cryptography)
const decryptedPayload = await CryptoModule.decrypt(payload);
암호화 작업은 알고리즘과 입력 크기에 따라 비용이 많이 들 수 있습니다.
네트워킹 (Networking)
const response = await NativeNetworkClient.fetchConfiguration();
네트워크 작업은 본질적으로 예측 불가능하며, 절대로 JavaScript를 동기식으로 차단 (block)해서는 안 됩니다.
대규모 데이터 변환 (Large data conversion)
const records = await NativeDatabase.getRecords();
수천 개의 네이티브 레코드 (records)를 JavaScript 객체 (objects)로 변환하는 것은 상당한 시간을 소비할 수 있습니다.
이미지 및 파일 처리 (Image and file processing)
const result = await ImageProcessor.compress(filePath);
미디어 처리 (Media processing)는 계산 비용이 많이 들며, 일반적으로 지연 시간 (latency)에 민감한 애플리케이션 작업과는 분리되어 실행되어야 합니다.
시스템 서비스 (System services)
const location = await LocationModule.getCurrentPosition();
응답 시간은 권한, 센서, 기기 상태 및 운영 체제 (operating-system)의 동작에 따라 달라질 수 있습니다.
렌더링 내부에서 동기적 작업 피하기
가장 강력한 리뷰 규칙 중 하나는 다음과 같습니다:
해당 작업이 사소하다는 것이 증명되지 않는 한, React 렌더링 경로 (render path) 내부에서 동기적 네이티브 작업 (synchronous native work)을 수행하지 마십시오.
이는 위험합니다:
function Dashboard() {
const settings = NativeSettings.getSettings();
...
더 나은 설계는 값을 비동기적으로 로드하고 이를 애플리케이션 상태 (application state)에 저장하는 것입니다:
function Dashboard() {
const [settings, setSettings] = useState<Settings | null>(null);
...
정확한 상태 관리 (state-management) 방식은 다를 수 있습니다.
원칙은 동일합니다:
렌더링이 네이티브 저장소 (native storage), 암호화 (cryptography) 또는 시스템 리소스 (system resources)를 기다리게 만들지 마십시오.
의도적으로 캐싱하고, 우연히 캐싱하지 마십시오
때때로 애플리케이션이 값에 즉각적으로 접근해야 하기 때문에 동기식 API가 생성되기도 합니다.
더 안전한 해결책은 값을 더 일찍 로드하고 인메모리 캐시 (in-memory cache)를 유지하는 것일 수 있습니다.
예를 들어:
class SessionStore {
private token: string | null = null;
...
네이티브 저장소 작업은 비동기적으로 유지됩니다:
await sessionStore.initialise();
이후의 읽기 작업은 애플리케이션 메모리에 접근하기 때문에 동기적입니다:
const token = sessionStore.getCachedToken();
이를 통해 의미 있는 구분이 생깁니다:
- 저장소로부터 로드하는 것은 비동기적 (asynchronous)입니다.
- 이미 로드된 값을 읽는 것은 동기적 (synchronous)입니다.
이는 저장소 접근을 동기식 API 뒤로 숨기는 것보다 훨씬 안전합니다.
하지만 캐싱은 그 자체의 책임(responsibilities)을 수반합니다:
- 캐시 무효화 (cache invalidation)
- 로그아웃 처리 (logout handling)
- 토큰 갱신 (token refresh)
- 메모리 생명주기 (memory lifecycle)
- 멀티 프로세스 일관성 (multi-process consistency)
- 민감한 값의 보호 (protection of sensitive values)
목표는 모든 것을 동기적으로 만드는 것이 아닙니다.
목표는 비용이 많이 드는 작업을 지연 시간(latency)에 민감한 경로 외부로 배치하는 것입니다.
네이티브 메서드 전체를 측정하세요
흔히 발생하는 성능 실수 중 하나는 JavaScript 실행 시간만을 측정하는 것입니다.
예를 들어:
const start = performance.now();
const token = SecureStorage.getAccessToken();
...
이는 JavaScript 측면에서의 차단 시간(blocking time)을 드러낼 수 있습니다.
하지만 더 깊은 조사를 위해서는 네이티브 계측(native instrumentation)이 필요할 수 있습니다.
Android에서는 개발자가 네이티브 타이밍 도구(native timing tools)나 플랫폼 프로파일러(platform profilers)를 사용하여 섹션을 측정할 수 있습니다.
iOS에서는 Instruments를 통해 스토리지(storage), 암호화(cryptography), 객체 변환(object conversion) 및 스레드 차단(thread blocking)에 소비되는 시간을 식별하는 데 도움을 받을 수 있습니다.
유용한 질문들은 다음과 같습니다:
- 메서드가 평균적으로 얼마나 걸립니까?
- 관찰된 최악의 지연 시간(latency)은 얼마입니까?
- 값이 캐시(cached)되지 않았을 때 실행 시간이 변합니까?
- 메서드가 디스크 액세스(disk access)를 수행합니까?
- 큰 객체를 할당(allocate)하거나 변환(convert)합니까?
- 렌더링(rendering) 또는 네비게이션(navigation) 중에 호출됩니까?
- 한 번의 사용자 동작 동안 몇 번 호출됩니까?
- 저사양 기기에서는 어떻게 동작합니까?
평균 타이밍만으로는 충분하지 않습니다.
보통은 빠르게 완료되지만 가끔 40밀리초(milliseconds) 동안 차단되는 메서드는 여전히 눈에 보이는 UI 문제를 일으킬 수 있습니다.
반복적인 동기 호출을 주의하세요
한 번의 메서드 호출 비용은 수용 가능한 수준으로 보일 수 있습니다.
하지만 호출 빈도가 실제 문제가 될 수 있습니다.
다음 상황을 고려해 보세요:
items.map(item => NativeFormatter.format(item));
항목이 500개라면, 애플리케이션은 500번의 동기적 네이티브 호출을 수행합니다.
각 호출이 단 1밀리초의 일부 시간만 소요하더라도, 결합된 비용은 상당해질 수 있습니다.
더 강력한 네이티브 API라면 전체 컬렉션(collection)을 한 번에 처리할 수 있을 것입니다:
const formattedItems = await NativeFormatter.formatBatch(items);
이를 통해 다음 사항들을 줄일 수 있습니다:
- 반복적인 경계 교차 (repeated boundary crossings)
- 반복적인 할당 (repeated allocations)
- 스레드 조정 (thread coordination)
- 호출당 변환 오버헤드 (per-call conversion overhead)
배칭(Batching)은 특히 다음과 같은 작업을 할 때 유용합니다:
- 데이터베이스 행 (database rows)
- 이미지 메타데이터 (image metadata)
- 센서 기록 (sensor records)
- BLE 데이터 (BLE data)
- 파일 목록 (file lists)
- 암호화 작업 (cryptographic operations)
- 네이티브 SDK에 의해 생성된 대규모 배열 (large arrays produced by native SDKs)
올바른 API 형태는 구현만큼이나 중요한 경우가 많습니다.
동기식 메서드는 아키텍처 결합을 초래합니다
즉각적인 성능 외에도 또 다른 위험이 존재합니다.
동기식 API는 나중에 느려질 수 있습니다.
버전 1은 메모리 내 값을 반환할 수 있습니다:
getUserRegion(): string;
미래의 요구사항은 구현을 다음과 같이 변경할 수 있습니다:
- 암호화된 저장소에서 지역 정보 읽기
- 오래된 값 마이그레이션
- 원격 구성 (remote configuration) 참조
- 네이티브 SDK 쿼리
- 다중 사용자 프로필 지원
JavaScript 인터페이스는 동기식으로 유지되지만, 네이티브의 책임은 더 무거워졌습니다.
이제 팀은 어려운 선택에 직면합니다:
- JavaScript를 계속 차단 (blocking)할 것인가
- 숨겨진 캐싱 (hidden caching)을 도입할 것인가
- 공개 API를 변경할 것인가
- 동기식 및 비동기식 버전을 모두 유지할 것인가
미래에 I/O가 필요할 가능성이 있는 값의 경우, 비동기 계약 (asynchronous contract)으로 시작하는 것이 종종 더 안전합니다.
API 설계는 작업이 오늘 어떻게 작동하는지뿐만 아니라, 작업이 어떻게 진화할 수 있는지를 고려해야 합니다.
실질적인 결정 규칙
동기식 TurboModule 메서드를 노출하기 전에 스스로에게 물어보세요:
이 메서드가 저장소, 네트워킹, 암호화, 대규모 변환 또는 예측 불가능한 시스템 작업 없이 항상 빠르게 완료될 수 있는가?
답이 불확실하다면, 비동기 API를 사용하세요.
유용한 분류 방식은 다음과 같습니다:
동기식 실행에 적합한 경우
- 상수 (constants)
- 캐시된 기본 타입 (cached primitives)
- 아주 작은 계산 (tiny calculations)
- 메모리 내 플래그 (in-memory flags)
- 결정론적 기능 확인 (deterministic capability checks)
비동기식 실행에 더 적합한 경우
- 파일 액세스 (file access)
- 보안 저장소 (secure storage)
- 데이터베이스 (databases)
- 네트워킹 (networking)
- 암호화 및 복호화 (encryption and decryption)
- 압축 (compression)
- 이미지 처리 (image processing)
- 대규모 데이터 변환 (large data conversion)
- 하드웨어 상호작용 (hardware interaction)
- 운영체제 서비스 (operating-system services)
메서드의 이름만으로는 충분하지 않습니다.
전체 네이티브 실행 경로를 검토하십시오.
코드 리뷰 체크리스트
동기식 TurboModule 메서드를 검토할 때는 다음 사항을 확인하십시오:
- 디스크 또는 보안 저장소 (secure-storage)에 접근하는가?
- 다른 SDK 또는 시스템 서비스 (system service)를 호출하는가?
- 암호화 (cryptography) 작업을 수행하는가?
- 큰 객체 (object)나 배열 (array)을 반환할 수 있는가?
- 데이터를 파싱 (parse)하거나 직렬화 (serialise)하는가?
- 기기 상태에 따라 지연 시간 (latency)이 변할 수 있는가?
- 렌더링 (rendering) 중에 호출되는가?
- 루프 (loop) 내부에서 반복적으로 호출되는가?
- 결과값이 이미 메모리 (memory)에 존재하는가?
- 나중에 해당 메서드의 비용 (expensive)이 더 커질 가능성이 있는가?
우려되는 답변이 하나라도 있다면, 동기식 설계를 재고하기에 충분할 수 있습니다.
최종 요약 (Final takeaway)
TurboModules는 React Native의 네이티브 통합 모델을 개선할 수 있습니다.
적절한 경우에 직접적이고 동기적인 상호작용을 가능하게 합니다.
하지만 이러한 기능이 기본 동작 (default behaviour)이 되어서는 안 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기