Introducing Hot Reloading
React Native의 목표는 최고의 개발자 경험을 제공하는 것이다. 그중 중요한 부분은 파일을 저장하고 변경 사항을 확인할 때까지 걸리는 시간이다. 앱이 커질수록 이 피드백 루프를 1초 미만으로 단축하는 것이 목표이다.
이 목표에 가까워지기 위해 세 가지 주요 기능을 도입했다:
- 자바스크립트를 사용해 컴파일 시간을 줄인다.
- es6/flow/jsx 파일을 VM이 이해할 수 있는 일반 자바스크립트로 변환하는 Packager 도구를 구현했다. 이 도구는 서버로 설계되어 중간 상태를 메모리에 유지해 빠른 증분 변경을 가능하게 하고, 멀티코어를 활용한다.
- 파일 저장 시 앱을 다시 로드하는 Live Reload 기능을 구축했다.
현재 개발자들의 병목 현상은 앱을 다시 로드하는 시간이 아니라 앱 상태를 잃는 문제이다. 일반적인 시나리오는 런치 스크린에서 여러 화면을 거쳐야 하는 기능을 작업하는 경우이다. 매번 다시 로드할 때마다 동일한 경로를 반복해 클릭해야 하기 때문에 피드백 루프가 몇 초씩 길어지게 된다.
핫 리로딩
핫 리로딩의 핵심 개념은 앱을 계속 실행한 상태에서 수정한 파일의 새 버전을 런타임에 주입하는 것이다. 이 방식을 사용하면 앱의 상태를 잃지 않으면서 UI를 조정할 수 있다.
동영상 하나가 수많은 설명보다 효과적이다. 라이브 리로딩(기존 방식)과 핫 리로딩(새 방식)의 차이를 확인해 보자.
자세히 보면, 빨간색 박스에서 복구가 가능하고, 이전에 없던 모듈을 가져오더라도 전체 리로드 없이 시작할 수 있다는 것을 알 수 있다.
주의 사항: 자바스크립트는 상태를 많이 사용하는 언어이기 때문에 핫 리로딩을 완벽하게 구현할 수 없다. 실제로 현재 설정은 대부분의 일반적인 사용 사례에서 잘 작동하며, 문제가 발생할 경우 언제든지 전체 리로드를 사용할 수 있다.
핫 리로딩은 0.22 버전부터 사용 가능하며, 다음과 같이 활성화할 수 있다:
- 개발자 메뉴를 연다.
- "핫 리로딩 활성화"를 탭한다.
핵심 구현 과정
이제 핫 리로딩이 왜 필요한지, 어떻게 사용하는지 알았으니 실제 동작 원리를 살펴보자.
핫 리로딩은 Hot Module Replacement(HMR)라는 기능 위에 구축되었다. 이 기능은 webpack에서 처음 도입했고, React Native Packager 내부에 구현했다. HMR은 Packager가 파일 변경을 감지하고, 앱에 포함된 간단한 HMR 런타임으로 HMR 업데이트를 전송한다.
간단히 말해, HMR 업데이트는 변경된 JS 모듈의 새 코드를 포함한다. 런타임이 이를 받으면, 이전 모듈의 코드를 새 코드로 대체한다:
HMR 업데이트는 단순히 변경할 모듈의 코드만 포함하지 않는다. 코드를 교체하는 것만으로는 런타임이 변경 사항을 반영하기에 충분하지 않기 때문이다. 문제는 모듈 시스템이 이미 업데이트하려는 모듈의 _exports_를 캐시했을 가능성이다. 예를 들어, 다음과 같은 두 모듈로 구성된 앱이 있다고 가정하자:
// log.js
function log(message) {
const time = require('./time');
console.log(`[${time()}] ${message}`);
}
module.exports = log;
// time.js
function time() {
return new Date().getTime();
}
module.exports = time;
log
모듈은 time
모듈이 제공하는 현재 날짜를 포함해 메시지를 출력한다.
앱이 번들링될 때, React Native는 __d
함수를 사용해 각 모듈을 모듈 시스템에 등록한다. 이 앱의 경우, 여러 __d
정의 중 log
에 대한 정의가 하나 있을 것이다:
__d('log', function() {
... // 모듈의 코드
});
이 호출은 각 모듈의 코드를 익명 함수로 감싸며, 이를 일반적으로 팩토리 함수라고 부른다. 모듈 시스템 런타임은 각 모듈의 팩토리 함수, 이미 실행되었는지 여부, 그리고 실행 결과(exports)를 추적한다. 모듈이 필요할 때, 모듈 시스템은 이미 캐시된 exports를 제공하거나 모듈의 팩토리 함수를 처음 실행하고 결과를 저장한다.
앱을 시작하고 log
를 요청한다고 가정하자. 이 시점에서 log
와 time
의 팩토리 함수는 아직 실행되지 않았으므로 exports가 캐시되지 않았다. 그런 다음 사용자가 time
을 수정해 날짜를 MM/DD
형식으로 반환하도록 변경한다:
// time.js
function bar() {
const date = new Date();
return `${date.getMonth() + 1}/${date.getDate()}`;
}
module.exports = bar;
Packager는 time
의 새 코드를 런타임으로 전송한다(1단계). 그리고 log
가 결국 요청되면, time
의 변경 사항이 반영된 상태로 exported 함수가 실행된다(2단계):
이제 log
코드가 time
을 최상위 require로 요청한다고 가정하자:
const time = require('./time'); // 최상위 require
// log.js
function log(message) {
console.log(`[${time()}] ${message}`);
}
module.exports = log;
log
가 요청되면, 런타임은 log
와 time
의 exports를 캐시한다(1단계). 그런 다음 time
이 수정되면, HMR 프로세스는 단순히 time
의 코드를 교체하는 것으로 끝날 수 없다. 그렇게 하면 log
가 실행될 때 time
의 캐시된 복사본(이전 코드)을 사용하게 된다.
log
가 time
의 변경 사항을 반영하려면, log
의 캐시된 exports를 지워야 한다. 왜냐하면 log
가 의존하는 모듈 중 하나가 핫 스왑되었기 때문이다(3단계). 마지막으로 log
가 다시 요청되면, 팩토리 함수가 실행되면서 time
을 요청하고 새 코드를 가져온다.
HMR API
React Native의 HMR(Hot Module Replacement)은 hot
객체를 도입해 모듈 시스템을 확장한다. 이 API는 webpack의 HMR API를 기반으로 한다. hot
객체는 accept
라는 함수를 제공하는데, 이 함수를 사용해 모듈이 핫 스왑될 때 실행될 콜백을 정의할 수 있다. 예를 들어, time
의 코드를 다음과 같이 변경하면, time
을 저장할 때마다 콘솔에 "time changed"가 출력된다:
// time.js
function time() {
... // 새로운 코드
}
module.hot.accept(() => {
console.log('time changed');
});
module.exports = time;
이 API를 직접 사용해야 하는 경우는 드물다. 대부분의 일반적인 사용 사례에서는 핫 리로딩이 기본적으로 동작한다.
HMR 런타임
앞서 살펴봤듯이, HMR 업데이트를 단순히 수락하는 것만으로는 충분하지 않다. 이미 실행된 모듈이 핫 스왑된 모듈을 사용하고 있을 수 있고, 해당 모듈의 임포트가 캐시된 상태일 수 있다. 예를 들어, 영화 앱 예제의 의존성 트리에서 최상위에 MovieRouter
가 있고, 이 모듈이 MovieSearch
와 MovieScreen
뷰에 의존하며, 이 뷰들은 이전 예제의 log
와 time
모듈에 의존한다고 가정해 보자.
사용자가 영화 검색 뷰에 접근했지만 다른 뷰에는 접근하지 않았다면, MovieScreen
을 제외한 모든 모듈의 익스포트가 캐시된다. time
모듈에 변경이 발생하면, 런타임은 log
의 익스포트를 지워 time
의 변경 사항을 반영해야 한다. 이 프로세스는 여기서 끝나지 않는다. 런타임은 모든 부모 모듈이 업데이트를 수락할 때까지 이 과정을 재귀적으로 반복한다. 따라서 log
에 의존하는 모듈들을 가져와 업데이트를 시도한다. MovieScreen
의 경우 아직 필요하지 않았기 때문에 중단할 수 있다. MovieSearch
의 경우 익스포트를 지우고 부모 모듈을 재귀적으로 처리해야 한다. 마지막으로 MovieRouter
에 대해 동일한 작업을 수행하고, 더 이상 의존하는 모듈이 없으므로 프로세스를 종료한다.
런타임은 의존성 트리를 탐색하기 위해 HMR 업데이트 시 Packager로부터 역의존성 트리를 받는다. 이 예제에서 런타임은 다음과 같은 JSON 객체를 받게 된다:
{
"modules": [
{
"name": "time",
"code": /* time's new code */
}
],
"inverseDependencies": {
"MovieRouter": [],
"MovieScreen": ["MovieRouter"],
"MovieSearch": ["MovieRouter"],
"log": ["MovieScreen", "MovieSearch"],
"time": ["log"]
}
}
리액트 컴포넌트
리액트 컴포넌트는 핫 리로딩과 함께 사용하기가 조금 더 까다롭다. 문제는 기존 코드를 새로운 코드로 단순히 교체할 수 없다는 점이다. 그렇게 하면 컴포넌트의 상태를 잃어버리기 때문이다. 리액트 웹 애플리케이션의 경우, Dan Abramov가 이 문제를 해결하기 위해 웹팩의 HMR API를 사용하는 바벨 트랜스폼을 구현했다. 간단히 말해, 그의 솔루션은 _트랜스폼 시점_에 모든 리액트 컴포넌트에 대한 프록시를 생성하는 방식으로 동작한다. 프록시는 컴포넌트의 상태를 유지하고, 실제 컴포넌트에 라이프사이클 메서드를 위임한다. 이 실제 컴포넌트가 핫 리로딩의 대상이 된다:
프록시 컴포넌트를 생성하는 것 외에도, 이 트랜스폼은 리액트가 컴포넌트를 리렌더링하도록 강제하는 코드를 포함한 accept
함수를 정의한다. 이 방식을 통해 앱의 상태를 잃지 않고 렌더링 코드를 핫 리로딩할 수 있다.
리액트 네이티브에 기본으로 포함된 트랜스포머는 babel-preset-react-native
를 사용한다. 이 프리셋은 웹팩을 사용하는 리액트 웹 프로젝트와 동일한 방식으로 react-transform
을 사용하도록 설정되어 있다.
Redux 스토어
Redux 스토어에서 핫 리로딩을 활성화하려면, webpack을 사용하는 웹 프로젝트에서와 유사하게 HMR API를 사용하면 된다:
// configureStore.js
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import reducer from '../reducers';
export default function configureStore(initialState) {
const store = createStore(
reducer,
initialState,
applyMiddleware(thunk),
);
if (module.hot) {
module.hot.accept(() => {
const nextRootReducer = require('../reducers/index').default;
store.replaceReducer(nextRootReducer);
});
}
return store;
};
리듀서를 변경하면, 해당 리듀서를 수락하는 코드가 클라이언트로 전송된다. 그런 다음 클라이언트는 리듀서가 스스로를 수락하는 방법을 모른다는 것을 인식하고, 해당 리듀서를 참조하는 모든 모듈을 찾아 수락하려고 시도한다. 결국, 이 흐름은 단일 스토어인 configureStore
모듈에 도달하여 HMR 업데이트를 수락하게 된다.
결론
핫 리로딩 기능을 개선하는 데 관심이 있다면, Dan Abramov가 쓴 핫 리로딩의 미래에 관한 글을 읽고 기여해 보길 권한다. 예를 들어, Johny Days는 여러 연결된 클라이언트와 함께 작동하도록 만드는 작업을 진행 중이다. 이 기능을 유지하고 발전시키는 데 여러분의 도움이 필요하다.
React Native를 통해 우리는 더 나은 개발자 경험을 제공하기 위해 앱을 만드는 방식을 재고할 수 있다. 핫 리로딩은 퍼즐의 한 조각에 불과하다. 더 나은 경험을 만들기 위해 어떤 창의적인 해결책을 적용할 수 있을까?