React Native) Hermes engine에 대한 고찰

2ast·2024년 4월 11일
3

React Native JS runtime

react native는 크게 3가지 환경에서 구동되며, 환경에 따라 사용하는 runtime이 다르다.
react native를 개발하다보면 구글 크롬 디버깅을 할 때가 있다. 이때는 rn의 코드가 크롬 위에서 동작하므로 크롬의 js 엔진인 V8 engine을 runtime으로 사용한다. 반면 스마트폰 디바이스에서 구동할 때는 Apple에서 개발한 js 엔진인 JavascriptCore를 사용한다. 하지만 이제는 새로운 js 엔진이 추가됐는데, 바로 meta에서 오직 rn만을 위해서 만든 Hermes가 그것이다. 이번에는 각 js 엔진의 특징과 왜 meta는 hermes를 개발했는지에 대해 정리해보려고 한다.

정리하자면.
React Native는 크게 3가지 환경에서 구동되며, 정리하면 다음과 같다.
1. Chrome 브라우저의 runtime인 V8 Engine
2. Apple에서 개발한 JavascriptCore(JSC) Engine
3. Facebook에서 개발한 Hermes Engine

컴파일과 컴파일러

이 논의를 하기에 앞서 먼저 알고가야할 개념이 있다. 바로 JIT 컴파일러와, AOT 컴파일러다. 컴파일러는 언어를 컴파일해주는 프로그램을 가리키며, 컴파일이란 하나의 언어를 다른 언어로 번역하는 일련의 과정을 의미한다. 일반적으로 컴파일이라고 하면 우리가 사용하는 고수준 언어를 컴퓨터가 이해하는 저수준 언어로 변환하는 과정을 가리킨다고 이해해도 무방하다.
이러한 컴파일러의 종류는 컴파일되는 시점을 기준으로 JIT 컴파일러와 AOT 컴파일러로 나뉜다. js engine을 얘기하다가 왜 JIT니 AOT니하는 컴파일러 얘기를 하느냐하면, 기존에 rn 엔진으로 쓰이던 V8과 JSC는 JIT 컴파일러이며, meta가 새롭게 개발해 RN의 기본 엔진으로 탑재한 hermes는 AOT 컴파일러이기 때문이다. 이 글은 큰틀에서 JIT 컴파일러와 AOT 컴파일러에 대해서 알아본 후, 컴파일러가 앱 퍼포먼스에 어떠한 영향을 주었고, 어떤 문제를 해결하기 위해 Hermes 엔진이 탄생했는지 알아볼 예정이다.

JIT(Just-In-Time) Compiler

JIT(Just-In-Time) 컴파일러는 이름에서 알 수 있듯이 런타임에 컴파일을 실행하는 방식을 의미한다. 기존 우리가 알고 있던 interpreter와 가장 큰 차이점은 bytecode와 최적화를 적극 활용해서 성능을 극대화했다는 점이다. 런타임에 코드를 번역한다는 점은 기존 interpreter 방식과 동일하지만, 곧바로 그 코드를 실행하고 마는 interpreter와는 다르게 JIT 컴파일러는 일단 bytecode로 한단계 번역한 상태를 저장하고, 이후 인라인 캐싱 등 코드 최적화를 반복하면서 점차 성능을 개선하는 방식을 채택하고 있다. 정리하자면 런타임에 실제 코드가 동작하는 패턴을 파악해서 최적의 방식으로 코드를 최적화한다는 점이다. 이러한 특징 덕분에 "컴파일언어가 인터프리터언어보다 빠르다"라는 인식은 JIT 컴파일러의 등장과 함께 옛말이 되어버렸다.

V8 Engine

react native에서 chrome debug를 켜면 react native의 코드들은 크롬 위에서 돌아가기 때문에 크롬 엔진인 v8 engine을 사용하게 된다. 아까 언급했듯이 v8 engine은 내부적으로 JIT 방식을 채택하고 있으며, 우리에게 가장 친숙한 엔진이기 때문에 v8엔진을 기준으로 JIT에 대해 조금 더 자세히 알아보려고 한다.

JIT는 기본적으로 interpreter와 optimizing compiler를 갖고 있는데, JIT의 interpreter는 소스코드를 빠르게 bytecode로 변환해주는 역할을 수행하고, optimizing compiler는 bytecode를 최적화해주는 역할을 수행한다. v8 engine에서 interpreter의 이름은 Ignition이고, optimazing compiler의 이름은 TurboFan이다. Ignition이 처음 소스코드를 바이트코드로 변환하면서 최적화 사이클을 점화하고, 점점 과열되기 시작하면 TurboFan이 식혀준다고 이해하면 쉽다.

여기서 ‘과열’이라고 표현한 이유는 JIT가 코드를 최적화하는 방식 때문이다. JIT에서 interpreter는 처음 소스코드를 바이트코드로 변환할 때 모든 코드를 최적화하지 않는다. 하지만 이후 반복적으로 호출되는 코드가 발생하면(과열되면) 그때서야 최적화 컴파일러를 거쳐 최적화를 적용하는 것이다. 이때 이전 최적화 결과가 유효하지 않다고 판단되면 deoptimize 절차를 거쳐 원래 바이트코드를 복원하고 이를 다시 최적화하기도 한다. 우리가 영어를 번역할 때 모든 문장을 공들여서 번역하지 않고, 대강 의미만 통하도록 번역한 다음 중요한 구절이나 반복되는 구절을 공들여서 번역하는 것과 유사하다.

이와같이 최적화를 적극 활용함으로써 JIT은 런타임 성능을 대폭 향상시킬 수 있었고, V8 engine에서 JIT을 활성화 하고 안하고의 성능 차이가 5배에서 최대 17배까지 났다는 실험 결과도 찾아볼 수 있다.

JavascriptCore Engine

가볍게 JSC에 대해서도 알아보려고 한다. javascriptCore는 apple에서 만든 자바스크립트 엔진으로, 사파리에 사용된다. 비교적 최근 Hermes가 기본 엔진으로 채택되기 전까지 RN의 기존 자바스크립트 엔진이었으며, 역시 JIT 방식을 채택하고 있다. 사실 RN은 모든 자바스크립트 엔진위에서 구동될 수 있도록 설계되었는데, 그 중 javascriptCore가 기본 엔진으로 채택되었던 것은 ios의 경우 기본적으로 JSC를 탑재하고 있기 때문에 앱 번들에 추가할 필요가 없어 앱 사이즈 감소에 기여할 수 있다는 점 등 여러가지 편의성 덕분이라고 한다.
아래 그림을 보면 V8과 비교해서 최적화 엔진 갯수가 더 많은 것을 볼 수 있는데, 이는 각 엔진이 추구하는 바에 따라 인터프리터와 최적화 컴파일러 사이의 최적의 균형점을 찾은 것이라고 볼 수 있다.

AOT(ahead-of-time) Compiler

런타임에 실시간으로 컴파일과 최적화를 수행하는 JIT 컴파일러와는 달리 AOT 컴파일러는 빌드타임에 컴파일과 최적화를 수행한다. 사실 엄밀히 말하면 JIT를 포기하고 AOT로 넘어온 시점에 memory와 cpu 성능면에서는 어느정도 trade off 관계가 성립했다고 볼 수 있다. 런타임 성능을 생각하면 AOT가 JIT를 따라잡을 수 없기 때문이다. 하지만 meta는 Mobile 환경에서 CPU 성능보다는 TTI 개선과 메모리 최적화가 사용성에 훨씬 더 중요하다는 판단을 내렸고, 실제로 그 선택은 유효했다.

Hermes Engine

Hermes 엔진은 오직 React Native만을 위해 meta에서 만든, RN에 최적화된 javascript engine이다. Hermes를 통해 최종적으로 달성하고자했던 목표는 다음과 같다.

  • TTI(time to interact) 개선: TTI는 앱이 인터렉시브해질 때까지 걸리는 시간을 의미하며, ‘앱 로딩 시간’이라는 친숙한 말로도 표현할 수 있다. Hermes가 TTI를 개선하는 원리는 간단하다. 기존 JIT 컴파일러는 앱 실행 직후부터 로딩에 필요한 파일들을 읽어들이며 컴파일을 시작한다. 하지만 AOT 방식으로 동작하는 Hermes는 앱 빌드 시점에 이미 모든 파일의 컴파일을 마치기 때문에 즉시 실행이 가능하다. 또한 빌드 시점에 충분한 시간을 들여 바이트코드 최적화까지 진행되므로, 전체 코드에 대해 높은 수준의 최적화를 기대할 수 있다.

  • 앱 다운로드 사이즈 감소: 압축된 javascript code보다 압축된 byte code의 사이즈가 약간 더 크긴하지만(그래도 hermes가 생성한 bytecode는 다른 엔진 대비 컴팩트하다고 함) hermes의 native code size는 기존 javascriptCore engine size보다 작아졌기 때문에 전체 앱 사이즈는 더 작아진다고 한다.(다만 이는 js engine을 앱 번들에 포함시키는 android 한정이고, javascriptCore를 포함할 필요 없었던 ios입장에서는 오히려 앱 사이즈가 약간 더 커진다고 한다.)
  • 메모리 최적화: Hermes 엔진은 모든 코드를 빌드타임에 컴파일한 뒤, 런타임에는 메모리에 저장된 코드를 실행시키기만 하면 되기 때문에, 런타임에 활발하게 컴파일러가 돌아가며, 수시로 메모리 읽고 쓰기를 반복하는 JIT 대비 메모리 사용량이 더 적다고 한다. 또한 Hermes engine에서는 새로운 GC인 Hades가 적용되었는데, 앱의 원활한 구동에 실제로 필요한 메모리만을 사용하도록 하여 메모리 사용량을 최적화했다고 한다.
  • 결과

여담

  1. 흥미로운 사실은 IOS에서는 JIT 기반 엔진을 사용하더라도 JIT 기능을 활성화할 수 없다는 점이다. 이는 ‘쓰기 가능하고 동시에 실행가능한 메모리’를 허용하지 않는 정책 때문이다. 이러한 정책을 흔히 ‘W^X policy’라고 부르는데, 보안적인 관점에서 특정 메모리 공간에 새로운 데이터를 쓸 수 있거나, 실행할 수는 있도록 허용하지만, 쓰는 동시에 실행하지는 못하도록 막는다. 런타임에 새로운 코드를 생성하고 이를 실행할 수 있도록 허용하는 것은 그자체로 치명적인 취약점이 될 수 있기 때문이다. 대부분의 OS에서도 기본적으로 쓰기 가능하고 읽기 가능한 메모리를 허용하지 않고 있지만, JIT만은 그 예외로 두는 경우가 많다고 한다. 하지만 ios는 JIT조차도 허용하지 않고 있기 때문에 JIT compiler는 ios에서 사용이 불가능하다. 그럼에도 불구하고 react native로 제작된 ios 앱의 성능이 android보다 뒤쳐진다고 느껴지지 않는 것은 (놀랍게도)iphone의 자체적인 최적화 덕분이라고 한다.

  2. Hermes 엔진은 비교적 최근까지 거의 유일한 AOT 기반 JS engine이었다. 하지만 사례를 찾아보면 아예 없지만도 않은데, ios 뿐만 아니라 많은 콘솔 게임기들도 JIT compiler를 허용하지 않기 때문에, JS 기반 콘솔 게임 개발을 위해서 ChowJS라는 AOT JS Engine이 개발되기도 했다고 한다.

  3. 개인적으로 일부 RN 프로젝트에서 android의 hermes를 비활성화하고 JSC 환경에서 개발하고는 하는데, 구형 안드로이드 기기에서 런타임 성능 드랍이 꽤나 체감 되는 수준이기 때문이다. 특히 react-navigator의 stack navigation 전환할 때 버벅임이 두드러진다. 이런저런 시도 끝에 도저히 잡히지가 않아서 JSC로 회귀했다.

  4. JSC 환경에서는 replaceAll과 같은 비교적 최신 메서드를 사용할 수 없다. 이런 부분을 간과하고 무턱대고 hermes에서 JSC로 회귀하다간 에러를 만날 수 있다. 뿐만 아니라 reanimated와 같은 큰 라이브러리에서 더이상 JSC를 고려하지 않고 hermes만을 고려해서 업데이트하겠다고 공언한 상황에서 앞으로 점점 hermes가 공고해질거라고 생각할 수밖에 없다. 불가피한 상황이 아니라면 얌전히 hermes를 쓰는게 정신건강에 좋다.

  5. 이 글은 1년 전 사내 공유를 목적으로 작성했는데, 블로그에 옮겨야지 마음만 먹다가 드디어 정리하게 됐다. cs 기반 지식도 필요했고, 상당부분을 인터넷 서치에 기반했기 때문에 잘못된 정보가 포함되어 있을 수 있으니 자유롭게 정정해주시면 감사할 것 같다.

참고

https://betterprogramming.pub/breaking-down-the-syntax-analysis-phase-of-a-compiler-18964309f332
https://velog.io/@ru_bryunak/자바스크립트-기초-1
https://highlyscalable.blogspot.com/2020/02/hermes.html
https://pks2974.medium.com/v8-에서-javascript-코드를-실행하는-방법-정리해보기-25837f61f551
https://velog.io/@godori/JavaScript-engine-1
https://stackoverflow.com/questions/53588142/the-benefits-of-react-native-javascriptcore
https://en.wikipedia.org/wiki/W^X
https://engineering.fb.com/2019/07/12/android/hermes/
https://wormwlrm.github.io/2021/04/18/Formal-Language-and-Compiler.html
https://www.tutorialspoint.com/compiler_design/compiler_design_phases_of_compiler.htm
https://medium.com/mindful-engineering/fabric-architecture-react-native-a4f5fd96b6d2
https://www.techaheadcorp.com/blog/react-native-applications-are-now-turbocharged-with-hermes-engine-find-out-how/

profile
React-Native 개발블로그

0개의 댓글