JSI 코드는 이미 빠르지만, 작은 아키텍처 선택에 따라 2~10배 더 느려지거나 더 빨라질 수 있습니다. 순수 JSI로 모듈을 만들 때 어떤 패턴이 코드를 최대한 효율적으로 만드는지 살펴봅니다.
JSI 코드를 작성하는 일은 이미 빠르지만, 작은 아키텍처 선택에 따라 2–10배 더 느려지거나 더 빨라질 수 있습니다.
순수 JSI로 모듈을 만들고 있다면, 어떤 패턴이 코드를 최대한 효율적으로 만드는지 아는 것이 도움이 됩니다.
이 점은 모듈이 hot path에서 실행될 때 특히 중요합니다.
본격적으로 들어가기 전에, 한 가지는 꼭 기억해 두세요: JSI는 빠르지만, 공짜는 아닙니다. 네이티브 구현이 매우 빠르더라도, 전체 비용은 여전히 다음 요소들이 지배할 수 있습니다:
JS ↔ C++ 경계 교차
프로퍼티 접근 해석
가상 디스패치
문자열 변환 및 비교
힙 할당
따라서 실제로 가장 큰 이득은 대개 먼저 올바른 아키텍처를 선택하는 데서 오고, 그다음에야 마이크로 최적화에서 옵니다.
모듈을 작성할 때는 같은 결과를 내는 여러 방법이 있습니다.
예를 들어, 어떤 로직을 실행하는 JSI 함수를 만들고 싶다고 해봅시다. 가능한 한 가지 방법은 HostObject를 만들고, JS에서 대략 이렇게 사용하는 것입니다: MyModule.process(...)
하지만 이 접근은 다음과 같은 여러 오버헤드 원인 때문에 가장 성능이 좋은 방법은 아닙니다:
HostObject::get를 통한 가상 디스패치
PropNameID에서 std::string으로의 변환
프로퍼티 이름에 대한 문자열 비교
그리고 많은 구현에서, 프로퍼티 접근 중 새 HostFunction 생성
놓치기 쉬운 한 가지 세부 사항이 있습니다:
HostObject::get()은 초기화 중 한 번만 실행되는 것이 아니라, 모든 프로퍼티 접근마다 실행됩니다.
실제로는 각 호출이 다음과 더 비슷합니다:
훨씬 적은 오버헤드로 같은 API를 노출할 수 있습니다:
JavaScript에서 보면 두 접근은 거의 동일해 보입니다. 둘 다 같은 유용한 작업을 수행하고, 같은 결과를 반환합니다:
하지만 내부적으로는 매우 다르게 동작하며, 직접 HostFunction을 사용하는 버전은 호출마다 발생하는 프로퍼티 해석 오버헤드를 피합니다.
함수가 아무 일도 하지 않고 단지 어떤 숫자만 반환하는 상황에서, 루프 안에서 단순 호출을 측정해 이 두 접근을 벤치마크해 보면 그 차이를 확인할 수 있습니다.
벤치마크 ( 1,000,000회 반복 )
결과는 분명합니다. 단순한 HostFunction 호출은 HostObject를 통해 노출한 같은 함수보다 약 5배****더 빠르며, 경우에 따라 이는 매우 눈에 띄는 향상으로 이어질 수 있습니다.
💡
또 하나 중요한 뉘앙스는, HostObject를 계속 사용하더라도 프로퍼티 디스패치 자체는 여전히 최적화할 수 있다는 점입니다.
안정적인 프로퍼티 집합의 경우, jsi::PropNameID를 캐시하고 재사용하면 매번 프로퍼티 이름을 UTF-8 문자열로 변환하고 모든 접근마다 원시 문자열을 비교하는 일을 줄일 수 있습니다.
이렇게 해도 HostObject의 모든 오버헤드가 사라지지는 않지만, 조회 비용의 일부는 줄일 수 있습니다.
NitroModules도 이 부분을 기본적으로 도와줍니다. 반복 비교를 위해 jsi::PropNameID를 캐시하므로, 특히 많은 네이티브 객체가 같은 형태를 공유할 때 유용합니다.
프로퍼티 집합이 고정되어 있다면, 또 다른 가능한 최적화는 긴 문자열 비교 체인을 생성된 디스패치 또는 해시 기반 디스패치로 대체하는 것입니다. 이렇게 하면 조회 중 분기와 반복적인 문자열 작업을 줄일 수 있습니다.
💡
Nitro Modules 역시 생성된 디스패치 로직을 통해 이러한 종류의 최적화를 기본적으로 적용하므로, 사용자는 조회 코드를 직접 작성하지 않아도 이점을 얻을 수 있습니다.
Nitro Modules의 예시
호출 사이에 네이티브 상태가 필요하다면 어떨까요?
그 경우 jsi::HostObject를 사용할 수 있지만, 또 다른 강력한 선택지인 jsi::NativeState도 있습니다.
간단한 예를 생각해 봅시다. 호출 사이에 네이티브 상태를 변경하는 객체(카운터)가 있고, 새 값을 JS로 돌려준다고 가정합니다.
HostObject를 사용하면 코드는 다음과 비슷할 수 있습니다:
jsi::NativeState를 사용해도 같은 결과를 얻을 수 있으며, 이 접근은 성능이 훨씬 더 좋습니다.
이 예시에서는 일반 JS 객체를 만들고, 여기에 NativeState를 붙인 다음, 그 JS 객체에 단순한 HostFunction인 프로퍼티 하나를 추가합니다. 그리고 그 함수 내부에서 thisVal로부터 NativeState에 접근합니다.
그런 다음, 앞과 마찬가지로 원하는 이름으로 그 객체를 런타임에 넣습니다.
JS 측에서는 이렇게 사용합니다:
JavaScript에서 보면 API는 같습니다. 이제 벤치마크를 보겠습니다.
벤치마크 (1,000,000회 반복)
벤치마크는 NativeState가 다시 약 5배****더 빠르다는 것을 보여줍니다.
💡
Nitro 참고 :
Nitro Modules를 사용 중이라면, 이 최적화는 이미 기본 객체 모델에 내장되어 있습니다.
Nitro HybridObjects는 jsi::HostObject가 아니라 jsi::NativeState를 기반으로 하므로, 이러한 종류의 성능 향상은 상당 부분 기본으로 제공됩니다.
여기에는 마법 같은 것은 없습니다. 주된 이유는 단순합니다. NativeState는 동적 프로퍼티 해석 오버헤드의 한 층 전체를 제거합니다:
HostObject::get를 통한 가상 디스패치 없음
PropNameID -> std::string을 통한 프로퍼티 이름 조회 없음
문자열 비교 없음
thisVal.asObject(rt).getNativeState(...)를 통한 네이티브 포인터 직접 접근
따라서 내부 네이티브 상태를 가진 JSI 객체가 필요하고 최대 성능을 원한다면, HostObject 대신 Object + NativeState를 고려할 가치가 있습니다.
이 패턴은 특히 다음과 같은 경우에 매력적입니다:
메서드 집합이 미리 알려져 있을 때
메서드 이름이 안정적일 때
상태가 자연스럽게 네이티브 측에 존재할 때
객체 같은 JS API를 원하지만, 모든 접근마다 HostObject 비용을 지불하고 싶지 않을 때
즉, 동적 프로퍼티 인터셉션 시맨틱이 필요하지 않다면, NativeState가 훨씬 더 잘 맞는 경우가 많습니다.
문자열의 크기를 알고 있거나, 최소한 최대 크기를 알고 있다면(예: < 512), 그리고 실행 중에 그 문자열을 만들어야 한다면, 스택에서 작업하는 편이 좋습니다.
간단한 예를 살펴봅시다.
입력 데이터로부터 문자열을 만들어야 한다고 상상해 봅시다. 무엇이든 될 수 있지만, 단순화를 위해 문자 **'A'**로 채우기만 하겠습니다.
이제 std::string을 char 배열로 바꿔 이 코드를 더 빠르게 만들어 봅시다:
로직은 같지만, 성능 특성은 다릅니다.
벤치마크 ( 1,000,000회 반복 )
결과는 char buf[256]가 약 3배 더 빠르다는 것을 보여줍니다.
주된 이유는 보통 다음과 같습니다:
힙 할당 없음
할당자 오버헤드 없음
수명이 짧은 데이터에 더 좋은 지역성
더 적은 간접 참조
스택 버퍼는 컴파일 시점에 크기가 고정된 단순한 로컬 메모리입니다. 반면 std::string은 작은 내부 버퍼를 넘어서 커지면 일반적으로 힙 할당이 필요합니다.
std::string에 대한 중요한 참고언급할 가치가 있는 한 가지 뉘앙스가 있습니다:
std::string은 항상 힙에 할당되는 것은 아닙니다. 대부분의 표준 라이브러리 구현은 SSO(Small String Optimization)를 사용하므로, 짧은 문자열은 std::string 객체 자체 내부에 직접 저장될 수 있습니다.
이 최적화는 작은 문자열에만 도움이 되며, 구현과 플랫폼에 따라 보통 15–23바이트 정도입니다. 이 예시에서는 256바이트를 사용하므로, 이 경우는 SSO 범위를 훨씬 넘어서며 일반적으로 힙 할당이 필요합니다.
createFromAscii(...) 사용💡
문자열이 ASCII 문자만 포함한다면, jsi::String::createFromAscii(...)가 보통 createFromUtf8(...)보다 더 잘 맞습니다.
ASCII는 UTF-8의 엄격한 부분집합이지만, ASCII 전용 생성자는 더 강한 불변 조건을 전달하며 일부 UTF-8 처리 작업을 피할 수 있습니다. 따라서 네이티브 코드가 이미 데이터가 ASCII 전용이라는 것을 알고 있다면, 이를 명시하는 것이 좋습니다.
이 예시에서:
버퍼는 명시적으로 길이가 제공되므로 널 종료될 필요가 없습니다. 이는 중요한 세부 사항입니다. 길이를 알고 있다면 이를 직접 전달함으로써 '\\0'를 찾기 위한 스캔을 피할 수 있기 때문입니다.
따라서 크기를 알고 있고 결과 문자열이 ASCII 전용이라면, 이 패턴이 보통 더 바람직합니다.
createFromUtf16(...) 사용네이티브 코드가 이미 문자열을 UTF-16 형태로 가지고 있다면, String::createFromUtf16(...)도 더 나은 선택일 수 있습니다.
이 경우 데이터를 먼저 UTF-8로 변환한 다음 JS 런타임이 그것을 다시 디코딩하게 만드는 과정을 피할 수 있습니다. 따라서 실제로는, 가장 좋은 생성자는 이미 메모리에 있는 표현과 일치하는 생성자인 경우가 많습니다:
ASCII 전용 데이터 ->createFromAscii(...)
UTF-8 데이터 ->createFromUtf8(...)
UTF-16 데이터 ->createFromUtf16(...)
작은 최적화 예시를 하나 더 살펴보겠습니다.
어떤 작업을 수행하고 새 문자열을 만드는 함수가 있다고 가정해 봅시다:
작은 최적화를 시도해 보고, 그 결과를 살펴볼 수 있습니다:
벤치마크 ( 1,000,000회 반복 )
여기서도 여전히 측정 가능한 개선이 있습니다: 약 1.3배입니다.
최적화된 버전은 다음을 피합니다:
임시 std::string 생성
로컬 버퍼의 데이터를 그 문자열로 복사
반환된 문자열이 로컬 스코프를 벗어날 때 발생하는 추가 힙 관련 작업
이것은 HostFunction vs HostObject 같은 큰 아키텍처 최적화는 아니지만, hot code에서는 여전히 가치가 있을 수 있습니다.
네이티브 구현이 이미 최적화되어 있더라도, JS와 C++ 사이의 교차가 너무 많으면 전체 실행 시간을 여전히 지배할 수 있습니다.
각 호출에는 보통 다음이 포함됩니다:
네이티브 런타임 진입
인자 읽기 및 검증
JS와 C++ 표현 사이의 값 변환
반환값을 다시 JS로 감싸기
따라서 실제로는, 아주 작은 호출이 많은 것보다 더 큰 호출이 적은 편이 더 나은 경우가 많습니다, 각 개별 호출이 빠르더라도 마찬가지입니다.
즉, 저수준 최적화를 적용한 뒤에 다음으로 살펴볼 가치가 있는 것은 종종 API 형태입니다:
여러 호출을 하나로 합칠 수 있는가?
작업을 배치 처리할 수 있는가?
중간 JS 가시 객체를 피할 수 있는가?
이것은 JSI만의 트릭이 아니라, 실제 시스템에서 주요 비용 유발 요인 중 하나일 뿐입니다.
JSI는 이미 빠르지만, 최대 성능은 좋은 아키텍처와 현명한 메모리 선택에서 나옵니다.
가장 큰 이득은 보통 다음과 같은 단순한 결정에서 옵니다:
상태가 필요 없을 때는 HostObject보다 HostFunction을 선호
네이티브 상태가 필요할 때는 HostObject보다 Object + NativeState를 선호
크기를 알고 있을 때는 스택 버퍼를 선호
불필요한 임시 할당을 피하기
hot path에서 JS ↔ C++ 교차를 줄이기
코드가 가끔만 실행된다면, 이러한 최적화는 중요하지 않을 수 있습니다.
하지만 코드가 hot path에 있다면, 이런 작은 결정들이 매우 눈에 띄는 차이를 만들 수 있습니다.
💡
이러한 저수준 JSI 최적화를 많이 수동으로 다루고 싶지 않다면, Nitro Modules가 그중 많은 것을 기본으로 제공할 수 있습니다.
Alex Shumihin 소프트웨어 엔지니어 @ Margelo
JSI React Native Performance C++NativeModules Nitro
이 글 공유하기