Skip to main content

Introducing Hot Reloading

· 16 min read
Martín Bigio
Software Engineer at Instagram

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를 요청한다고 가정하자. 이 시점에서 logtime의 팩토리 함수는 아직 실행되지 않았으므로 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가 요청되면, 런타임은 logtime의 exports를 캐시한다(1단계). 그런 다음 time이 수정되면, HMR 프로세스는 단순히 time의 코드를 교체하는 것으로 끝날 수 없다. 그렇게 하면 log가 실행될 때 time의 캐시된 복사본(이전 코드)을 사용하게 된다.

logtime의 변경 사항을 반영하려면, 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가 있고, 이 모듈이 MovieSearchMovieScreen 뷰에 의존하며, 이 뷰들은 이전 예제의 logtime 모듈에 의존한다고 가정해 보자.

사용자가 영화 검색 뷰에 접근했지만 다른 뷰에는 접근하지 않았다면, 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를 통해 우리는 더 나은 개발자 경험을 제공하기 위해 앱을 만드는 방식을 재고할 수 있다. 핫 리로딩은 퍼즐의 한 조각에 불과하다. 더 나은 경험을 만들기 위해 어떤 창의적인 해결책을 적용할 수 있을까?