개요
Hot Module Replacement(HMR)의 기본적인 동작 메커니즘에 대해 살펴보고, 간단한 예제를 통해 살펴보려고 한다.
예제와 설명은 esbuild 번들러에서 HMR 기능을 구현하는 데모를 기반으로 한다. 해당 구현 사항은 아래 저장소에서 확인 가능하니 참고하길 바란다.
https://github.com/leegeunhyeok/esbuild-hmr
좋은 개발 경험이란
제품을 개발하는데 중요시 되는 것 중 하나는 사용자 경험(User Experience, UX)이다. 물론 제품을 사용하게 되는 엔드 유저는 사용자이기 때문에 가장 우선적으로 고려되어야 한다. 그렇다면 제품은 누가 만들어내는 것인가? 물론 기획, 디자인 등 여러 결과물들이 종합되어야 하지만, 이를 만들어내는 것은 결국 개발자의 몫이다.
그렇기에 좋은 개발자 경험(Developer Experience, DX)에도 신경을 써야 한다고 생각한다. 개발자 경험에 따라 개발자들의 업무 효율이 크게 차이가 날 것이기 때문이다.
이번 글의 주제인 Hot Module Replacement(HMR)은 이 개발자 경험을 증진시키기 위한 개발 편의 기능이다. 이에 대해 자세히 살펴보도록 하자.
Hot Module Replacement(HMR)
Hot Module Replacement(HMR)는 특정 모듈(코드)의 변경이 발생했을 때 해당 변경 사항을 런타임(Runtime)에 교체하는 편의 기능이다. 비슷한 말로 핫 리로드(Hot Reload)가 있는데 이는 같은 기능이라고 봐도 무방하다.
아마 많은 개발자들은 해당 편의 기능을 사용해본 경험이 있을 것이다. React 웹 애플리케이션을 예로 들면, 컴포넌트를 수정했을 때 페이지 전체가 새로고침 되는 것이 아니라 해당 컴포넌트만 변경되는 것 말이다.
한 번 스스로에게 질문을 해보자. 만약 특정 컴포넌트를 수정했는데 페이지 전체가 새로고침 된다면? 별로 생각하고 싶지 않을 것이다.
HMR 기능은 주요 번들러(Webpack, Snowpack, Vite 등)에 구현되어있고, 우리는 별다른 구현 혹은 구성 없이 개발 편의 기능을 누리고 있다.
동작 원리 살펴보기
HMR의 동작 원리를 살펴보기에 앞서 자바스크립트 모듈과 번들링에 대해 간략히 살펴보도록 하겠다.
모듈과 번들링
자바스크립트의 모듈 체계를 통해 코드를 분리하고 재사용할 수 있다. 이를 통해 궁극적으로는 유지보수성을 향상할 수 있는 중요한 개념 중 하나이다. 일반적으로 소스코드 파일이 곧 모듈이 되는데, 모듈은 전역 범위와 달리 개별적인 네임스페이스를 갖는다.
이러한 모듈 체계는 단일화 되어있는 것이 아니라 여러 가지 유형으로 나누어져 있다. 대표적으로 CommonJS
, ESM(ECMAScript Modules)
, AMD
등이 있다. (이번 글에서는 ESM 기준으로 설명할 예정이다)
아래와 같은 자바스크립트 애플리케이션이 존재한다고 가정하자. 시작점(Entry Point)은 main.js
파일로 가정한다.
모듈은 개별적인 네임스페이스를 갖고 있기 때문에 다른 모듈의 내부를 알 수 없다. 단, 모듈에서 외부로 노출시키고 싶은 대상을 export
하면 외부에서도 참조가 가능하다.
애플리케이션은 총 3개의 모듈들(constant
, sub
, main
)로 구성되어있는 모습을 볼 수 있는데, 외부로 노출할 대상을 export
하고 import
하여 참조하고 있다.
A 모듈에서 내보내고(export
), B 모듈에서 불러와서 참조(import
)하는 경우, A 모듈과 B 모듈은 의존 관계에 있다고 볼 수 있다. 위 모듈들 간의 의존 관계를 정리하면 아래와 같다.
- main -> sub -> constants
여기서 중요한 포인트는 내보내고 참조하는 모듈들간의 "의존 관계"이다.
앞서 이야기 했던 대부분의 번들러는 모듈들 간의 의존 관계를 분석하고 정리하여 번들을 생성한다. 이러한 과정을 번들링이라고 하는데, 번들러의 주요 역할에 따른 과정을 간단히 살펴보자면 아래와 같다.
- 모듈 탐색(Resolution)
- 코드 변환(Transformation)
- 최적화(Optimization)
모듈 탐색 과정을 거쳐 어느 모듈이 어느 다른 모듈을 참조하는지 그래프로 의존 관계를 구성하고, 이 그래프를 순회하며 코드를 구성에 맞게 변환한다. 그리고 마지막으로 최적화 과정을 거치는데 트리 셰이킹(Tree shaking), 압축(Minify), 난독화(Mangling) 등의 과정을 거친다.
코드 변환의 경우 런타임 환경에 맞게 코드를 변환해야 하는 경우(예: 타입스크립트를 자바스크립트로 변환 등) 이 과정에서 처리를 진행한다.
오늘날의 모던 브라우저들은 대부분 ECMAScript Modules를 지원하지만, 아직 완벽하지는 않다. 그리고 널리 사용하는 타입스크립트는 자바스크립트 엔진에서 바로 실행시킬 수 없다. 그렇기 때문에 ESM을 브라우저에서 실행 가능하도록 변환하고, 자바스크립트로 변환하는 등의 과정은 불가피한 것이 현실이다.
앞서 살펴본 애플리케이션을 esbuild로 번들링하면 아래와 같은 형태로 변환될 것이다.
이러한 과정을 거쳐 비로소 자바스크립트 런타임에서 실행 가능한 형태의 코드(번들)로 거듭나게 된다.
런타임 교체를 위한 모듈 체계
번들 코드를 보면 단순히 모듈들이 하나의 파일로 통합되어 있는 상태이다. HMR은 번들 코드가 실행되어 있는 상태에서 특정 모듈 코드를 동적으로 교체해야 하는데, 위와 같은 번들 코드에서는 구현하기 어렵다.
심지어 esbuild는 HMR를 지원하지 않기에, 해당 기능이 필요한 경우 직접 구현해야 한다.
한 번 간단히 생각해보자. ESM로 구현된 코드는 결국 내보내고(export
), 불러오는(import
) 행위를 통해 서로 참조한다. 모듈을 특정 공간에 등록(export
)해두고, 저장된 모듈을 참조(import
)하는 것으로 이러한 동작을 모방할 수 있다.
모듈은 개별적인 네임스페이스를 갖고 있지만, 전역 객체는 그렇지 않다. 모듈에 대한 참조를 전역 객체에 등록해 두고, 전역 객체에서 이를 참조하는 방식으로 구현이 가능하다.
예를 들면 아래와 같다.
여기에 구현된 global.export
는 이해를 돕기 위해 추가한 "가상"의 전역 모듈 체계 구현체이다.
모듈에서 export
하는 대상을 전역 객체에도 동일하게 등록한다. 이렇게 등록된 대상은 이제 ESM 대신 전역 모듈을 통해 참조할 수 있을 것이다.
ESM의 모듈 구문 대신 전역에서 참조할 수 있도록 대체하면 등록된 모듈을 그대로 참조할 수 있다.
이와 같이 런타임 교체를 위한 커스텀 모듈 체계는 번들러를 통해 처리되며, 번들러마다 구현되어있는 방식이 조금씩 다르다.
이처럼 커스텀 모듈 체계를 위해 위해 swc 플러그인을 구현해두었다. 관심이 있다면 아래 라이브러리를 참고하길 바란다.
https://github.com/leegeunhyeok/swc-plugin-global-module
지금까지 살펴본 모듈과 번들링에 대한 내용은 후술할 내용에서 다시 언급할 예정이니 잘 숙지하고 있도록 하자.
모듈 변경 감지
다시 HMR로 돌아오면, 모듈의 변경이 발생했을 때 이를 인지하고 해당 모듈만 다시 변환하여 런타임에 전달해주어야 한다. 과정을 보면 "모듈의 변경"을 감지하는 것으로부터 시작된다.
모듈 변경의 감지는 곧 파일의 변경을 감지하는 것과 동일하다. (모듈은 소스코드 파일로 존재하기 때문에)
파일 시스템의 변경 사항 감지는 운영체제(OS)에서 제공하는 API를 통해 쉽게 받아볼 수 있다. 그리고 Node의 경우 잘 만들어진 오픈소스 라이브러리가 존재하기에 이를 활용하면 쉽게 구현할 수 있다.
본 글에서는 chokidar 라이브러리를 사용하여 모듈 변경 감지 기능을 구현할 예정이다. (실제 Vite 에서 이를 사용하고 있기도 하다) 구현 자체는 아주 간단하다. 감시할 대상 경로를 지정하고 환경에 맞게 옵션을 추가하고 이벤트 핸들러만 등록하면 된다.
이벤트 종류가 여려가지인데, 어느 이벤트가 발생했을 때 HMR 기능이 처리되어야 할까? 정답은 간단하다. 답은 모듈의 의존 관계가 변경될 수 있는 상황의 이벤트가 트리거 되었을 때이다.
이에 해당하는 이벤트는 add
, change
, unlink
, unlinkDir
이다.
add
: 파일 추가 (모듈이 추가되었을 수 있음)change
: 파일 내용 변경 (모듈 내용이 변경되었을 수 있음)unlink
: 파일 제거 (모듈이 제거되었을 수 있음)unlinkDir
: 디렉토리 제거 (모듈이 포함된 디렉토리가 제거되었을 수 있음)
위 코드를 통해 어느 파일(혹은 디렉토리)이 변경되었는지 전달받을 수 있다.
모듈 변환
변경이 감지되었을 때 고려해야 할 점은 역시 "의존 관계"이다. 새로운 모듈이 의존 관계 내에 포함되거나, 제거되는 경우에는 의존 관계 그래프를 다시 구성해야 한다.
단순히 모듈 내용이 변경된 경우에도 추가적인 고려가 필요하다. 그것은 바로 역 의존성(Reverse dependencies)이다.
constants
모듈 파일에서 변경이 감지되었다고 가정하자.
그렇다면 이 모듈의 변경사항만 반영하면 되는 것일까? 당연히 모듈의 변경 사항은 반영되어야 하고, 이를 참조하고 있는 부모 모듈에서도 이에 대한 변경 사항이 반영되어야 한다.
처음 모듈 의존 관계에 대해 살펴보았을 때, 시작점(Entry Point)에서 출발하여 하위 모듈로 탐색했던 것을 기억하는가? 역 의존성은 말 그대로 의존성 관계를 뒤집은 것과 동일하다.
이번에는 constants
가 변경되었으니 여기서부터 출발해 보자.
의존 관계를 보면 아래와 같다.
- constants -> sub -> main
이 말인즉슨, constants
의 변경 사항이 발생할 경우 sub
, main
모듈도 새로 업데이트되어야 한다는 점이다.
그런데 한 가지 의문점이 생긴다. constants
에서 export
한 값은 sub
모듈에서만 참조하고 있는데, 왜 main
도 업데이트 되어야 할까?
이유는 간단하다. main
모듈에서 sub
모듈을 참조하고 있기 때문이다. 하위 모듈의 변경 사항으로 인해 어떠한 부수 효과(Side effect)가 생길지 알 수 없기 때문에 모듈의 시작점(Entry Point)까지 거슬러 올라가며 새로 업데이트해주어야 한다.
런타임에 실행되어야 하는 코드는 곧 아래와 같이 변환된 constants
+ 변환된 sub
+ 변환된 main
가 합쳐진 코드 조각이다.
변경 사항 전달 및 교체
앞서 변환된 코드 조각은 런타임으로 전달되어야 한다. 대부분의 번들러에서는 이 과정을 웹 소켓을 통해 실현한다.
전달된 코드 조각은 런타임에서 eval 과 같은 함수를 통해 동적으로 실행되며 변경 사항 교체가 성공적으로 이루어진 경우와 그렇지 않은 경우로 나눠진다.
만약 변경 사항에 대한 코드 조각을 동적으로 실행시켰을 때 문제가 발생하는 경우 모듈을 찾을 수 없거나, 코드가 잘못되어있는 등 HMR에 문제가 발생한 것으로 판단할 수 있다.
이런 상황이 발생하는 경우에는 전체 새로고침(Fully reload)을 수행하는 것이 일반적이다. 이 역시 try-catch
구문 내에서 HMR 변경 사항을 동적으로 실행시키고, 예외가 발생한 경우에만 전체 새로고침 하도록 구현 가능하다.
이러한 과정을 거쳐 최종적으로 모듈이 동적으로 교체되고, 실행되어 변경사항이 실시간으로 반영되게 된다.
지금까지 살펴본 HMR 동작을 간단히 그림으로 나타내면 아래와 같다.
더 살펴보기
앞서 살펴본 내용에서는 단순히 변경된 모듈을 감지하고, 역 의존성 모듈을 모두 변환한 코드를 런타임에 실행하는 과정에 대해서만 살펴보았다. 설명에 포함되지 않은 내용 역시 상당히 많이 있는데 간단히 알아보도록 하자.
모듈 교체 생명 주기
요즘날 번들러에 구현된 HMR의 경우 훨씬 고도화되어있다. HMR 변경 사항을 수락(반영)하거나 거절(무시)하는 등 생명 주기를 갖고 있고, 이를 통해 더 세부적인 모듈 교체 작업을 수행한다. 예를 들면 모듈의 변경 사항을 반영하기에 앞서 상태를 재설정해야 하는 등 교체 전, 후로 수행해야 할 작업을 생명주기를 통해 처리한다.
관련된 코드는 직접 번들러 코드를 열어보면 알 수 있겠으나, 간단하게 정리되어 있는 구현 스펙 문서 또한 존재한다. 스펙을 제안한 사람은 아래와 같으며 실제 이 분야에서 뛰어난 역량을 갖고 있는 분들이기 때문에 참고하면 큰 도움이 될 것이다.
- Fred K. Schott (Snowpack)
- Jovi De Croock (Preact)
- Evan You (Vue)
스펙 자료는 아래 저장소에서 확인 가능하다.
https://github.com/FredKSchott/esm-hmr
React
React의 경우 변경 사항을 단순히 전달하는 것으로 HMR을 구현할 수 없다. 왜냐하면 React의 컴포넌트는 단순히 코드가 평가되는 것으로 해결되는 것이 아니라 컴포넌트 내부에서 참조하고 있는 여러 가지 상태와 부모로부터 주입받은 props 등 고려할 대상이 훨씬 많고 복잡하다.
그렇다면 다른 번들러들은 이를 어떻게 해결한 것일까?
React에서 Fast Refresh를 구현한 react-refresh 라이브러리가 오픈소스로 공개되어 있다. 다른 번들러들도 이를 기반으로 플러그인을 구현해 두고 사용하는 방식으로 문제를 해결했다.
React 컴포넌트인 경우 register 를 호출하여 react-refresh
컨텍스트에 해당 컴포넌트를 미리 등록해 두고, 추후 해당 컴포넌트의 변경이 발생했을 때 Re-render 혹은 Re-mount 처리를 내부적으로 수행한다.
참고로 이는 모듈 교체 생명 주기와 연관되어있기도 한데, 모듈 변경을 수신하고 수락(accept)했을 시점에 react-refresh
내에 구현되어 있는 performReactRefresh 를 호출하여 React Reconciler에게 변경 사항에 대한 반영을 요청한다. 덕분에 복잡한 구현 없이, 컴포넌트가 갖고 있던 상태를 유지한 채 Re-render 처리를 수행할 수 있게 된다.
이와 관련하여 기술 검증을 해본 기록이 있는데, 궁금하다면 아래 저장소를 참고하길 바란다.
https://github.com/leegeunhyeok/esbuild-react-refresh-poc
마무리
지금까지 개발하는 데 있어 상당히 중요한 비중을 차지하고 있는 HMR에 대해 간략히 살펴보았다. 직접 번들러를 구현하거나 개발 환경을 구축하는 일은 매우 드물겠으나, 혹시나 진행하게 된다면 개발자 경험을 위해서라도 HMR 기능은 반드시 필요하다고 본다.
필자 본인은 esbuild에서 HMR를 구현하기 위해 여러 가지 시도를 해보았는데, 관련하여 정리했던 내용을 글로 작성해 본다.
글은 이만 마무리하도록 하고, 궁금한 부분은 댓글 혹은 이메일로 전달해 주길 바란다.