이미지 로딩 중...
AI Generated
2025. 11. 24. · 4 Views
HMR 심화 완벽 가이드
개발 중 파일을 저장하면 전체 페이지를 새로고침하지 않고도 변경사항이 즉시 반영되는 Hot Module Replacement의 깊은 내부 동작 원리를 배워봅니다. React Fast Refresh와 Vue HMR의 작동 방식부터 직접 커스텀 핸들러를 작성하는 방법까지, 실무에서 바로 활용할 수 있는 심화 지식을 제공합니다.
목차
1. HMR API 이해하기
시작하며
여러분이 React나 Vue로 개발할 때 코드를 저장하면 페이지 새로고침 없이 화면이 바뀌는 것을 본 적 있나요? 심지어 입력했던 폼 데이터나 스크롤 위치까지 그대로 유지되죠.
이 마법 같은 기능이 바로 HMR(Hot Module Replacement)입니다. 하지만 실제로 이 기능이 어떻게 작동하는지, 내부에서 무슨 일이 일어나는지 알고 있나요?
단순히 "자동으로 된다"고만 생각하면 문제가 생겼을 때 해결하기 어렵습니다. 오늘은 HMR의 핵심 API를 직접 들여다보면서, 이 기능이 정확히 어떻게 구현되는지 배워보겠습니다.
이를 이해하면 여러분만의 커스텀 HMR 로직을 만들 수도 있고, 기존 도구들이 왜 그렇게 동작하는지 깊이 이해할 수 있습니다.
개요
간단히 말해서, HMR API는 Vite나 Webpack 같은 빌드 도구가 제공하는 특별한 인터페이스입니다. 이 API를 통해 개발자는 모듈이 업데이트될 때 어떤 작업을 수행할지 직접 정의할 수 있습니다.
실무에서 왜 이게 중요할까요? 예를 들어, 여러분이 복잡한 상태를 가진 차트 라이브러리를 개발 중이라고 해봅시다.
일반적인 새로고침으로는 차트의 상태가 모두 초기화되어 매번 처음부터 데이터를 다시 로드해야 합니다. 하지만 HMR API를 사용하면 차트의 상태를 유지하면서도 코드 변경사항만 반영할 수 있습니다.
기존에는 전체 페이지를 새로고침해야 했다면, 이제는 변경된 모듈만 교체하고 나머지는 그대로 유지할 수 있습니다. 이것이 개발 생산성을 획기적으로 높이는 핵심 요소입니다.
HMR API의 핵심 특징은 세 가지입니다. 첫째, import.meta.hot 객체를 통해 HMR 기능에 접근할 수 있습니다.
둘째, accept() 메서드로 모듈 업데이트를 받아들일지 결정합니다. 셋째, dispose() 메서드로 모듈이 교체되기 전 정리 작업을 수행합니다.
이러한 특징들을 이해하면 HMR을 자유자재로 활용할 수 있습니다.
코드 예제
// store.js - HMR API를 활용한 상태 보존 예제
let appState = { count: 0, user: null };
export function getState() {
return appState;
}
export function setState(newState) {
appState = { ...appState, ...newState };
}
// HMR API: 이 모듈이 업데이트를 받아들일 수 있음을 선언
if (import.meta.hot) {
// 모듈이 교체되기 전에 현재 상태를 저장
import.meta.hot.dispose((data) => {
data.preservedState = appState;
console.log('상태 저장:', data.preservedState);
});
// 새 모듈이 로드된 후 저장된 상태를 복원
import.meta.hot.accept((newModule) => {
if (import.meta.hot.data.preservedState) {
appState = import.meta.hot.data.preservedState;
console.log('상태 복원:', appState);
}
});
}
설명
이것이 하는 일: 위 코드는 애플리케이션의 상태를 HMR 과정에서도 유지하는 방법을 보여줍니다. 파일이 수정되어도 count나 user 같은 중요한 데이터가 사라지지 않도록 보호합니다.
첫 번째로, import.meta.hot 객체를 확인합니다. 이 객체는 개발 모드에서만 존재하며, 프로덕션 빌드에서는 undefined입니다.
따라서 if 문으로 감싸서 안전하게 사용합니다. 이렇게 하면 실제 배포 시에는 HMR 관련 코드가 모두 제거됩니다.
그 다음으로, dispose() 메서드가 실행됩니다. 이 메서드는 모듈이 교체되기 직전에 호출되는데, 여기서 현재 상태를 data 객체에 저장합니다.
data 객체는 특별한 저장소로, 새 모듈과 이전 모듈 사이에서 데이터를 전달하는 다리 역할을 합니다. 마치 이사할 때 물건을 박스에 담는 것처럼, 중요한 상태를 안전하게 보관합니다.
세 번째 단계로, accept() 메서드가 새 모듈을 받아들입니다. 새 모듈이 로드되면 콜백 함수가 실행되고, 여기서 import.meta.hot.data에 저장했던 상태를 꺼내서 복원합니다.
이 과정은 순식간에 일어나서 사용자는 전혀 눈치채지 못합니다. 마지막으로, 전체 과정을 정리하면 이렇습니다.
파일 저장 → dispose 실행(상태 저장) → 새 모듈 로드 → accept 실행(상태 복원) → 화면 업데이트. 이 모든 과정이 자동으로 일어나지만, 우리가 dispose와 accept를 정의함으로써 상태 보존이라는 핵심 기능을 구현할 수 있습니다.
여러분이 이 코드를 사용하면 개발 중에 상태가 초기화되지 않아 훨씬 빠르게 개발할 수 있습니다. 폼 입력 값, 로그인 정보, 페이지 스크롤 위치 등을 유지하면서 코드 수정 효과만 바로 확인할 수 있어 개발 생산성이 크게 향상됩니다.
실전 팁
💡 import.meta.hot은 반드시 if 문으로 체크하세요. 프로덕션에서는 undefined이므로 체크 없이 사용하면 에러가 발생합니다.
💡 dispose에서는 클린업 작업도 함께 수행하세요. 타이머나 이벤트 리스너가 있다면 여기서 제거해야 메모리 누수를 방지할 수 있습니다.
💡 accept() 호출 시 의존성을 명시할 수 있습니다. import.meta.hot.accept('./utils.js', callback)처럼 특정 모듈만 감시하면 불필요한 업데이트를 줄일 수 있습니다.
💡 data 객체에는 직렬화 가능한 데이터만 저장하세요. 함수나 DOM 노드는 저장할 수 없으며, JSON으로 변환 가능한 순수 데이터만 사용해야 합니다.
💡 HMR이 실패했을 때를 대비해 import.meta.hot.invalidate()를 사용하세요. 이 메서드는 HMR이 불가능할 때 전체 페이지를 새로고침하도록 강제합니다.
2. React Fast Refresh 원리
시작하며
여러분이 React 컴포넌트를 수정하고 저장할 때, 컴포넌트의 상태가 그대로 유지되는 것을 경험해보셨나요? useState로 관리하던 카운터 값이 그대로 남아있고, 입력했던 텍스트도 사라지지 않습니다.
이것은 일반적인 HMR과는 다른 특별한 기술입니다. 일반 HMR은 모듈을 교체하지만, React Fast Refresh는 컴포넌트를 교체하면서도 React의 상태를 보존합니다.
이 차이를 이해하지 못하면 왜 가끔 상태가 초기화되는지, 왜 어떤 변경사항은 적용되지 않는지 알 수 없습니다. React Fast Refresh는 단순한 HMR이 아니라, React의 내부 구조를 깊이 이해하고 만들어진 정교한 시스템입니다.
컴포넌트 트리를 분석하고, 어떤 부분을 교체할 수 있고 어떤 부분은 새로고침해야 하는지 자동으로 판단합니다. 오늘은 React Fast Refresh가 내부적으로 어떻게 작동하는지, 어떤 원리로 상태를 보존하는지 깊이 파헤쳐보겠습니다.
개요
간단히 말해서, React Fast Refresh는 React 컴포넌트를 실시간으로 교체하면서도 컴포넌트의 state와 effect를 보존하는 기술입니다. 이는 Babel 플러그인과 React Runtime이 협력하여 만들어내는 결과입니다.
실무에서 왜 이게 중요할까요? 복잡한 폼을 개발할 때를 생각해보세요.
사용자 정보, 주소, 결제 정보 등 여러 단계를 거쳐야 하는 폼에서 스타일을 조금 수정할 때마다 처음부터 다시 입력해야 한다면 얼마나 비효율적일까요? Fast Refresh 덕분에 입력한 데이터를 유지하면서 UI만 수정할 수 있습니다.
기존 HMR은 모듈을 교체하면 모든 상태가 초기화됩니다. 하지만 Fast Refresh는 React 컴포넌트를 특별히 취급합니다.
컴포넌트 함수를 교체하되, React가 관리하는 Fiber 노드는 그대로 유지하여 상태를 보존합니다. Fast Refresh의 핵심은 세 가지입니다.
첫째, 컴포넌트 함수를 식별 가능한 형태로 변환합니다. 둘째, 변경 사항을 분석하여 안전하게 교체할 수 있는지 판단합니다.
셋째, React의 Fiber 트리를 이용해 필요한 부분만 재렌더링합니다. 이 세 가지가 결합되어 마법 같은 개발 경험을 만들어냅니다.
코드 예제
// Counter.jsx - Fast Refresh가 적용되는 React 컴포넌트
import { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
const [message, setMessage] = useState('');
// Fast Refresh는 이 effect를 재실행하지 않습니다
useEffect(() => {
console.log('마운트됨 - 한 번만 실행');
return () => console.log('언마운트됨');
}, []);
// 이 함수 로직을 수정해도 count 상태는 유지됩니다
const handleClick = () => {
setCount(c => c + 1);
setMessage(`클릭 횟수: ${count + 1}`); // 이 메시지를 수정하면 즉시 반영
};
// JSX를 수정해도 상태는 보존됩니다
return (
<div>
<h1>카운터: {count}</h1> {/* 스타일 변경 시 count 값 유지 */}
<button onClick={handleClick}>증가</button>
<p>{message}</p>
</div>
);
}
// Babel이 자동으로 추가하는 Fast Refresh 런타임 코드
// (실제로는 보이지 않지만 내부적으로 생성됨)
if (import.meta.hot) {
import.meta.hot.accept();
// React Refresh Runtime에 컴포넌트 등록
window.$RefreshReg$(Counter, 'Counter');
window.$RefreshSig$(); // 컴포넌트 시그니처 생성
}
설명
이것이 하는 일: React Fast Refresh는 컴포넌트 코드가 변경될 때 React의 내부 상태 트리(Fiber Tree)는 그대로 유지하면서 컴포넌트 함수만 새로운 버전으로 교체합니다. 마치 자동차 엔진이 작동하는 중에 부품만 교체하는 것과 비슷합니다.
첫 번째로, Babel 플러그인이 빌드 시점에 각 컴포넌트를 분석합니다. Counter라는 함수 컴포넌트를 발견하면, 이 컴포넌트에 고유한 시그니처(서명)를 만듭니다.
이 시그니처는 컴포넌트의 훅 사용 패턴을 기록합니다. useState가 두 개 있고, useEffect가 하나 있다는 정보를 저장하죠.
이 정보가 나중에 컴포넌트를 안전하게 교체할 수 있는지 판단하는 기준이 됩니다. 두 번째로, 파일이 수정되면 React Refresh Runtime이 작동합니다.
새로운 Counter 함수가 로드되면, 이전 버전의 시그니처와 비교합니다. 만약 훅의 개수나 순서가 바뀌었다면 안전하지 않다고 판단하여 전체를 리마운트합니다.
하지만 단순히 JSX나 함수 로직만 바뀌었다면 안전하다고 판단하여 교체를 진행합니다. 세 번째로, React의 Fiber 트리를 활용합니다.
React는 내부적으로 모든 컴포넌트를 Fiber라는 객체로 관리하는데, 이 Fiber 노드에 state와 effect가 저장되어 있습니다. Fast Refresh는 이 Fiber 노드를 그대로 유지하고, type 속성만 새로운 Counter 함수로 바꿉니다.
그런 다음 해당 Fiber부터 다시 렌더링을 시작합니다. 이 과정에서 useState는 기존 값을 반환하고, useEffect는 의존성 배열에 변화가 없으면 재실행되지 않습니다.
네 번째로, 경계(boundary) 개념이 적용됩니다. 만약 Counter 컴포넌트가 교체 불가능한 변경(예: export 추가/삭제)을 겪었다면, 그 위의 부모 컴포넌트까지 올라가며 교체 가능한 경계를 찾습니다.
최악의 경우 루트까지 올라가면 전체 앱이 리마운트되지만, 대부분의 경우 가까운 부모에서 경계를 찾아 그 부분만 새로고침합니다. 여러분이 이 원리를 이해하면 Fast Refresh가 왜 작동하지 않는지, 왜 가끔 상태가 초기화되는지 정확히 알 수 있습니다.
또한 컴포넌트를 작성할 때 Fast Refresh가 잘 작동하도록 코드를 구조화할 수 있습니다. 예를 들어, 컴포넌트 내부에서 다른 컴포넌트를 정의하면 Fast Refresh가 제대로 작동하지 않는다는 것을 알고, 컴포넌트를 항상 최상위에 선언할 수 있습니다.
실전 팁
💡 컴포넌트는 항상 파일의 최상위 레벨에 선언하세요. 다른 함수 안에서 컴포넌트를 정의하면 매번 새로운 함수로 인식되어 Fast Refresh가 작동하지 않습니다.
💡 export default를 추가하거나 제거하면 전체 모듈이 리로드됩니다. 이는 모듈 시스템 자체의 변경이므로 Fast Refresh가 처리할 수 없습니다. 가능하면 export 구조는 초기에 확정하세요.
💡 클래스 컴포넌트는 Fast Refresh를 지원하지 않습니다. 상태가 항상 초기화되므로, 개발 편의성을 위해서라도 함수 컴포넌트와 훅을 사용하는 것이 좋습니다.
💡 HOC(Higher-Order Component)나 렌더 프롭 패턴은 Fast Refresh와 궁합이 좋지 않습니다. 가능하면 커스텀 훅으로 로직을 재사용하는 방식을 선택하세요.
💡 개발자 도구 콘솔에서 $RefreshReg$ 함수를 확인할 수 있습니다. 이 함수가 정의되어 있지 않다면 Fast Refresh가 제대로 설정되지 않은 것이므로 Vite나 Webpack 설정을 점검하세요.
3. Vue HMR 동작 방식
시작하며
여러분이 Vue 컴포넌트의 템플릿이나 스타일을 수정할 때, 컴포넌트 상태가 유지되면서 변경사항만 반영되는 경험을 해보셨나요? Vue의 HMR은 React와는 다른 방식으로 작동하지만, 마찬가지로 놀라운 개발 경험을 제공합니다.
Vue의 특별한 점은 .vue 파일이 template, script, style이라는 세 부분으로 명확히 나뉘어 있다는 것입니다. Vue는 이 구조를 활용하여 각 부분을 독립적으로 HMR 처리할 수 있습니다.
템플릿만 바꿨다면 스크립트는 그대로 두고, 스타일만 바꿨다면 나머지는 건드리지 않습니다. 이런 세밀한 제어가 가능한 이유는 Vue의 컴파일러와 런타임이 긴밀하게 협력하기 때문입니다.
@vitejs/plugin-vue 같은 플러그인이 .vue 파일을 분석하고, 각 섹션에 대한 별도의 HMR 핸들러를 생성합니다. 오늘은 Vue HMR이 내부적으로 어떻게 작동하는지, 그리고 이를 활용하여 더 나은 개발 경험을 만드는 방법을 알아보겠습니다.
개요
간단히 말해서, Vue HMR은 .vue 파일의 각 블록(template, script, style)을 개별 모듈로 취급하여 변경된 부분만 선택적으로 업데이트합니다. 이는 Vue Compiler와 Vue SFC(Single File Component) Compiler가 협력하여 구현됩니다.
실무에서 이것이 중요한 이유를 예를 들어보겠습니다. 여러분이 복잡한 데이터 테이블을 만들고 있다고 해봅시다.
사용자가 필터를 설정하고, 정렬 순서를 바꾸고, 특정 행을 선택한 상태에서 CSS 스타일만 조금 수정하고 싶습니다. Vue HMR은 스타일만 교체하고 컴포넌트 인스턴스는 전혀 건드리지 않아 모든 상태가 완벽히 보존됩니다.
기존의 전체 페이지 새로고침 방식에서는 모든 상태가 날아갑니다. 일반 HMR도 모듈 전체를 교체하면서 상태를 잃을 수 있습니다.
하지만 Vue HMR은 변경 범위를 정확히 파악하여 최소한의 영향만 줍니다. Vue HMR의 핵심 특징은 네 가지입니다.
첫째, SFC를 세 개의 독립적인 모듈로 분리 컴파일합니다. 둘째, 각 블록에 대한 별도의 HMR 경계를 설정합니다.
셋째, Vue 3의 반응성 시스템을 활용하여 컴포넌트를 재렌더링합니다. 넷째, 스타일 블록은 완전히 독립적으로 처리하여 JS 실행 없이 CSS만 교체합니다.
이런 세밀한 제어 덕분에 Vue는 매우 빠르고 예측 가능한 HMR을 제공합니다.
코드 예제
<!-- Counter.vue - Vue HMR이 각 블록을 독립적으로 처리 -->
<template>
<!-- 템플릿만 수정하면 render 함수만 교체, 상태 유지 -->
<div class="counter">
<h1>카운터: {{ count }}</h1>
<button @click="increment">증가</button>
<p>{{ message }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
// 이 데이터는 템플릿이나 스타일 수정 시 유지됩니다
const count = ref(0);
const message = computed(() => `현재 값: ${count.value}`);
// 이 함수 로직을 수정하면 script 블록만 HMR 처리
const increment = () => {
count.value++;
console.log('증가:', count.value);
};
// Vue HMR API를 직접 사용할 수도 있습니다
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
console.log('스크립트 블록 업데이트됨');
});
}
</script>
<style scoped>
/* 스타일만 수정하면 CSS만 교체, JS 실행 없음 */
.counter {
padding: 20px;
border: 2px solid #42b883; /* 이 색상을 바꿔도 count는 그대로 */
}
</style>
설명
이것이 하는 일: Vue HMR은 .vue 파일의 각 섹션을 독립적인 모듈로 다루어, 실제로 변경된 부분만 정확히 업데이트합니다. 이는 마치 레고 블록처럼 필요한 부분만 교체하는 방식입니다.
첫 번째로, Vite의 Vue 플러그인이 .vue 파일을 분석합니다. Counter.vue를 만나면 이를 세 개의 가상 모듈로 분해합니다: Counter.vue?vue&type=template, Counter.vue?vue&type=script, Counter.vue?vue&type=style.
이 세 모듈은 독립적으로 import되고 번들링됩니다. 각 모듈은 자신만의 HMR 경계를 가지므로, 한 모듈의 변경이 다른 모듈에 영향을 주지 않습니다.
두 번째로, 파일이 변경되면 Vue Compiler가 정확히 어느 블록이 수정되었는지 감지합니다. 예를 들어 <template> 안의 h1 텍스트를 바꿨다면, template 블록만 다시 컴파일됩니다.
새로운 render 함수가 생성되고, 기존 컴포넌트 인스턴스의 render 함수만 교체됩니다. setup 함수는 다시 실행되지 않으므로 count ref는 그대로 유지됩니다.
세 번째로, 스타일 업데이트는 완전히 별개로 처리됩니다. <style> 블록을 수정하면 JavaScript와 전혀 관계없이 CSS만 교체됩니다.
브라우저의 <style> 태그 내용을 직접 수정하는 방식이므로 컴포넌트 인스턴스에 전혀 영향을 주지 않습니다. 이는 가장 빠른 HMR 방식으로, 밀리초 단위의 즉각적인 반영이 가능합니다.
네 번째로, script 블록의 변경은 조금 더 복잡합니다. setup 함수의 로직을 바꾸면 해당 컴포넌트 인스턴스를 완전히 새로 만들어야 할까요?
Vue는 영리하게도 컴포넌트를 리마운트하지만, 부모로부터 받은 props는 그대로 전달하고, 가능한 경우 DOM 노드도 재사용합니다. 이를 통해 빠른 업데이트와 상태 보존 사이의 균형을 맞춥니다.
다섯 번째로, 전체 흐름을 정리하면 이렇습니다. 파일 저장 → Vue Compiler가 변경된 블록 감지 → 해당 블록만 재컴파일 → HMR 메시지를 브라우저에 전송 → 브라우저가 변경된 모듈만 교체 → Vue Runtime이 필요한 부분만 업데이트.
이 과정이 최적화되어 있어 대부분의 경우 100ms 이내에 완료됩니다. 여러분이 이 원리를 알면 Vue 컴포넌트를 작성할 때 HMR을 고려한 구조를 만들 수 있습니다.
예를 들어, 자주 변경되는 스타일은 scoped style에 두고, 로직은 composable로 분리하여 변경 영향을 최소화할 수 있습니다. 또한 HMR이 느리게 느껴진다면 어느 블록의 변경이 많은 재컴파일을 유발하는지 파악하여 최적화할 수 있습니다.
실전 팁
💡 <style scoped> 블록의 변경이 가장 빠릅니다. 스타일을 자주 조정해야 한다면 인라인 스타일보다 scoped style을 사용하세요.
💡 Composables를 별도 파일로 분리하면 로직 변경 시 해당 composable을 사용하는 모든 컴포넌트가 업데이트됩니다. 개발 중인 컴포넌트에만 영향을 주려면 로직을 컴포넌트 내부에 두세요.
💡 <script setup>을 사용하면 일반 <script>보다 HMR이 더 빠릅니다. setup 함수가 간결해지고 컴파일 결과가 최적화되기 때문입니다.
💡 defineProps나 defineEmits를 수정하면 컴포넌트가 리마운트됩니다. 이는 컴포넌트 인터페이스의 변경이므로 불가피합니다. 개발 초기에 props 구조를 확정하는 것이 좋습니다.
💡 Vite의 Vue 플러그인 옵션에서 hmr: false로 설정하면 HMR을 끌 수 있습니다. 디버깅 시 HMR이 방해가 된다면 일시적으로 비활성화해보세요.
4. 커스텀 HMR 핸들러 작성
시작하며
여러분이 특별한 파일 형식을 다루고 있다고 해봅시다. JSON 설정 파일, YAML 데이터, 혹은 자체 제작한 DSL(Domain Specific Language) 파일 같은 것들이죠.
이런 파일들을 수정할 때도 전체 페이지를 새로고침하지 않고 변경사항만 반영하고 싶지 않나요? Vite나 Webpack의 기본 HMR은 JavaScript와 CSS 모듈에 최적화되어 있습니다.
하지만 여러분만의 커스텀 파일 형식이나 특별한 업데이트 로직이 필요할 때는 어떻게 할까요? 전체 새로고침으로 돌아갈 수밖에 없을까요?
다행히 HMR API는 확장 가능하게 설계되어 있습니다. 여러분은 직접 HMR 핸들러를 작성하여 어떤 종류의 파일이든 어떤 방식으로든 HMR을 적용할 수 있습니다.
이는 강력한 개발 도구를 만들거나, 팀의 특별한 워크플로우를 지원하는 데 매우 유용합니다. 오늘은 커스텀 HMR 핸들러를 직접 작성하는 방법을 배워보겠습니다.
실제 동작하는 예제를 통해 여러분만의 HMR 로직을 구현할 수 있게 될 것입니다.
개요
간단히 말해서, 커스텀 HMR 핸들러는 특정 모듈이 업데이트될 때 실행될 사용자 정의 로직입니다. import.meta.hot.accept()와 dispose() 메서드를 조합하여 파일 변경에 대응하는 맞춤형 동작을 정의할 수 있습니다.
실무에서 언제 이것이 필요할까요? 예를 들어, 여러분이 실시간 차트 대시보드를 개발한다고 해봅시다.
차트 설정을 JSON 파일로 관리하는데, 이 파일을 수정할 때마다 전체 앱이 새로고침되면 차트가 다시 로드되고 애니메이션이 처음부터 재생됩니다. 커스텀 HMR 핸들러를 만들면 JSON 설정만 교체하고 차트는 부드럽게 업데이트할 수 있습니다.
기존에는 특별한 파일 형식에 대한 HMR이 없어서 전체 새로고침에 의존해야 했습니다. 하지만 이제는 직접 핸들러를 작성하여 어떤 리소스든 HMR 지원을 추가할 수 있습니다.
커스텀 HMR 핸들러의 핵심 구성 요소는 네 가지입니다. 첫째, accept() 메서드로 특정 모듈의 업데이트를 구독합니다.
둘째, 콜백 함수에서 새 모듈을 받아 처리 로직을 실행합니다. 셋째, dispose()로 이전 모듈의 정리 작업을 수행합니다.
넷째, invalidate()로 HMR이 불가능한 경우를 처리합니다. 이 네 가지를 적절히 조합하면 매우 정교한 HMR 동작을 구현할 수 있습니다.
코드 예제
// chart-config.js - 차트 설정을 관리하는 모듈
export const chartConfig = {
type: 'line',
data: { labels: [], datasets: [] },
options: { responsive: true }
};
// app.js - 커스텀 HMR 핸들러 구현
import { chartConfig } from './chart-config.js';
import Chart from 'chart.js/auto';
let chartInstance = null;
// 차트 생성 함수
function createChart(config) {
const ctx = document.getElementById('myChart').getContext('2d');
return new Chart(ctx, config);
}
// 초기 차트 생성
chartInstance = createChart(chartConfig);
// 커스텀 HMR 핸들러: chart-config.js 변경 감지
if (import.meta.hot) {
import.meta.hot.accept('./chart-config.js', (newModule) => {
// 새로운 설정을 가져옵니다
const newConfig = newModule.chartConfig;
// 기존 차트를 파괴하지 않고 업데이트
chartInstance.data = newConfig.data;
chartInstance.options = newConfig.options;
// 부드러운 애니메이션과 함께 업데이트
chartInstance.update('active');
console.log('차트 설정이 HMR로 업데이트됨');
});
// 모듈 제거 시 정리 작업
import.meta.hot.dispose(() => {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
console.log('차트 인스턴스 정리됨');
}
});
}
설명
이것이 하는 일: 위 코드는 차트 설정 파일이 변경될 때 전체 앱을 새로고침하지 않고 차트만 부드럽게 업데이트하는 커스텀 HMR 로직입니다. 이를 통해 개발 중에도 차트 상태와 사용자 인터랙션을 유지할 수 있습니다.
첫 번째로, import.meta.hot.accept()의 첫 번째 인자로 감시할 모듈 경로를 지정합니다. './chart-config.js'를 지정하면 이 파일이 변경될 때만 콜백이 실행됩니다.
만약 인자를 생략하고 import.meta.hot.accept()만 호출하면 현재 모듈 자신의 변경을 감시합니다. 의존성을 명시하는 것이 더 정확하고 효율적입니다.
두 번째로, 콜백 함수가 newModule 파라미터를 받습니다. 이것은 새로 로드된 모듈의 exports를 담고 있는 객체입니다.
newModule.chartConfig로 새로운 설정에 접근할 수 있죠. 여기서 중요한 것은 기존 chartInstance를 파괴하지 않고 데이터만 교체한다는 점입니다.
chartInstance.destroy()를 호출하면 차트가 완전히 사라졌다가 다시 나타나겠지만, update() 메서드를 사용하면 부드러운 전환 애니메이션을 유지합니다. 세 번째로, update('active') 호출이 핵심입니다.
Chart.js의 update 메서드는 현재 차트의 데이터와 옵션을 새로운 값으로 교체하고 다시 렌더링합니다. 'active'라는 애니메이션 모드를 전달하여 마치 사용자가 차트와 인터랙션하는 것처럼 자연스러운 전환을 만듭니다.
이것이 단순 새로고침과의 가장 큰 차이점입니다. 네 번째로, dispose() 핸들러가 정리 작업을 담당합니다.
이 모듈이 완전히 교체되거나 제거될 때(예: import 문을 삭제했을 때), dispose가 호출되어 차트 인스턴스를 깨끗이 정리합니다. 이를 통해 메모리 누수를 방지하고, 이벤트 리스너나 타이머 같은 부수 효과들을 제거할 수 있습니다.
다섯 번째로, 에러 처리를 추가하면 더욱 견고해집니다. 예를 들어 newConfig가 유효하지 않은 형식이라면 어떻게 될까요?
try-catch로 감싸서 에러 발생 시 import.meta.hot.invalidate()를 호출하면 됩니다. 이는 HMR을 포기하고 전체 페이지를 새로고침하라는 신호입니다.
안전한 fallback을 제공하는 것이죠. 여러분이 이 패턴을 활용하면 WebSocket 연결 관리, 애니메이션 인스턴스, 외부 라이브러리 초기화 등 다양한 시나리오에 커스텀 HMR을 적용할 수 있습니다.
핵심은 "무엇을 유지하고 무엇을 교체할 것인가"를 명확히 결정하는 것입니다. 상태는 유지하고 로직만 교체하거나, 인스턴스는 유지하고 데이터만 교체하는 식으로 세밀한 제어가 가능합니다.
실전 팁
💡 accept()의 콜백이 에러를 던지면 HMR이 실패하고 전체 새로고침이 발생합니다. 반드시 try-catch로 감싸고, 복구 불가능한 에러는 invalidate()를 호출하세요.
💡 여러 모듈을 동시에 감시하려면 배열을 전달하세요: import.meta.hot.accept(['./a.js', './b.js'], callback). 하나라도 변경되면 콜백이 실행됩니다.
💡 순환 참조를 조심하세요. A 모듈이 B를 accept하고 B가 A를 accept하면 무한 루프가 발생할 수 있습니다. 의존성 방향을 한쪽으로 일관되게 유지하세요.
💡 dispose()는 모듈이 교체될 때만 호출됩니다. 페이지 새로고침이나 탭 종료 시에는 호출되지 않으므로, 브라우저 수명주기 이벤트(beforeunload 등)도 함께 사용해야 합니다.
💡 HMR 핸들러 자체도 HMR이 적용됩니다. 핸들러 로직을 수정하면 새 핸들러가 등록되므로, 개발 중에 핸들러를 실험하고 개선하기 쉽습니다.
5. HMR 경계(boundary) 개념
시작하며
여러분이 작은 컴포넌트 하나를 수정했는데 전체 앱이 새로고침되는 경험을 해본 적 있나요? 혹은 반대로 루트 설정을 바꿨는데 변경사항이 반영되지 않아 수동으로 새로고침해야 했던 적은요?
이런 현상은 HMR 경계(boundary)를 이해하지 못해서 발생합니다. HMR 경계는 HMR이 어디까지 영향을 미칠지 결정하는 중요한 개념입니다.
마치 건물의 방화벽처럼, 변경사항이 어디서 멈춰야 하는지 정의합니다. 모든 모듈이 HMR을 처리할 수 있는 것은 아닙니다.
어떤 모듈은 자신의 업데이트를 스스로 처리할 수 있지만(자체 수용), 어떤 모듈은 부모에게 전파해야 합니다. 이 전파가 계속되다가 accept()를 호출하는 모듈을 만나면 그곳이 경계가 됩니다.
경계 개념을 이해하면 왜 HMR이 때로 예상과 다르게 작동하는지 알 수 있고, 코드를 구조화하여 HMR을 최적화할 수 있습니다.
개요
간단히 말해서, HMR 경계는 모듈 업데이트가 전파되다가 멈추는 지점입니다. accept()를 호출하는 모듈이 경계를 형성하며, 이 경계 내에서만 HMR이 발생하고 경계 밖은 영향을 받지 않습니다.
실무에서 이것이 왜 중요할까요? 여러분이 큰 프로젝트에서 작업한다고 상상해보세요.
components/Button.jsx를 수정했을 때, 이 버튼을 사용하는 모든 페이지가 새로고침되면 개발 속도가 크게 느려집니다. 적절한 HMR 경계를 설정하면 Button을 사용하는 컴포넌트만 업데이트되고, 전체 앱은 그대로 유지됩니다.
기존의 단순한 모듈 시스템에서는 하나를 바꾸면 전체를 다시 로드해야 했습니다. HMR 경계 개념이 도입되면서 변경의 영향 범위를 제어할 수 있게 되었고, 이것이 빠른 개발 피드백 루프를 가능하게 합니다.
HMR 경계의 핵심 원리는 세 가지입니다. 첫째, 모듈이 자신의 업데이트를 accept하면 그곳이 경계입니다(자체 수용).
둘째, 모듈이 의존성의 업데이트를 accept하면 그곳도 경계입니다(의존성 수용). 셋째, 어떤 경계도 없으면 업데이트가 루트까지 전파되어 전체 새로고침이 발생합니다.
이 규칙들을 이해하면 HMR 동작을 완벽히 예측할 수 있습니다.
코드 예제
// utils.js - HMR 경계가 없는 유틸리티 모듈
export function formatDate(date) {
return date.toLocaleDateString('ko-KR');
}
// 이 파일은 accept()를 호출하지 않음 → 경계 없음
// api.js - utils.js를 import하지만 경계를 만들지 않음
import { formatDate } from './utils.js';
export function fetchUserData() {
const lastLogin = formatDate(new Date());
return { user: 'John', lastLogin };
}
// 이 파일도 accept()가 없음 → 업데이트가 계속 전파
// App.jsx - HMR 경계를 형성하는 모듈
import { fetchUserData } from './api.js';
import { useState, useEffect } from 'react';
export default function App() {
const [data, setData] = useState(null);
useEffect(() => {
setData(fetchUserData());
}, []);
return <div>{data?.user} - {data?.lastLogin}</div>;
}
// React Fast Refresh가 자동으로 경계를 생성
// 따라서 utils.js나 api.js를 수정하면 App까지만 전파됨
// main.js - 루트 모듈
import App from './App.jsx';
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(<App />);
// 만약 main.js를 수정하면 경계가 없어 전체 새로고침
// App.jsx는 경계이므로 여기서 멈추고 그 아래만 HMR 적용
설명
이것이 하는 일: HMR 경계는 모듈 간의 업데이트 전파 경로를 제어합니다. 변경된 모듈에서 시작하여 import 체인을 따라 올라가다가 accept()를 만나면 멈추는 메커니즘입니다.
첫 번째로, utils.js를 수정했다고 가정해봅시다. utils.js 자체는 accept()를 호출하지 않으므로 자체 수용 경계가 없습니다.
HMR 시스템은 "누가 이 모듈을 import하는가?"를 확인합니다. api.js가 utils.js를 import하므로 업데이트가 api.js로 전파됩니다.
api.js도 accept()가 없으므로 다시 부모를 찾습니다. 두 번째로, 업데이트가 App.jsx에 도달합니다.
React Fast Refresh는 모든 React 컴포넌트에 자동으로 HMR 경계를 설정합니다. App 컴포넌트는 accept()를 호출하는 것과 같은 효과를 가지므로 여기가 경계가 됩니다.
HMR 시스템은 "App.jsx가 업데이트를 받아들일 수 있구나"라고 판단하고, 여기서 전파를 멈춥니다. 세 번째로, App.jsx만 새로 평가됩니다.
App 함수가 다시 실행되고, fetchUserData()도 새 버전을 호출하며, formatDate()도 새 로직이 적용됩니다. 하지만 main.js는 전혀 영향을 받지 않습니다.
React의 createRoot는 여전히 같은 인스턴스를 유지하고, 전역 상태나 다른 모듈들도 그대로입니다. 오직 App 컴포넌트 트리만 재렌더링됩니다.
네 번째로, 만약 App.jsx도 경계가 아니었다면 어떻게 될까요? 업데이트는 main.js까지 전파됩니다.
main.js는 루트 모듈이고 그 위에 부모가 없으므로, HMR 시스템은 "더 이상 전파할 곳이 없다"고 판단합니다. 이때 전체 페이지 새로고침이 트리거됩니다.
이것이 경계가 없을 때의 fallback 동작입니다. 다섯 번째로, 경계를 전략적으로 배치하는 것이 중요합니다.
너무 위쪽(루트 가까이)에 경계를 두면 작은 변경에도 많은 모듈이 재평가됩니다. 너무 아래쪽(leaf 모듈)에 두면 경계에 도달하기 전에 많은 모듈을 거쳐야 합니다.
이상적으로는 페이지나 주요 컴포넌트 수준에 경계를 두는 것이 좋습니다. 여러분이 경계 개념을 활용하면 코드 구조를 개선할 수 있습니다.
자주 변경되는 모듈(개발 중인 기능)은 명확한 경계 아래에 배치하고, 안정적인 모듈(라이브러리 래퍼, 설정)은 경계 밖에 두세요. 또한 디버깅 시 "왜 전체 새로고침이 일어났지?"라는 질문에 답할 수 있습니다.
import 체인을 따라가며 accept()가 어디 있는지 확인하면 원인을 찾을 수 있습니다.
실전 팁
💡 React Fast Refresh나 Vue HMR 플러그인은 자동으로 컴포넌트에 경계를 만듭니다. 별도의 accept() 호출이 필요 없는 이유입니다.
💡 경계를 확인하려면 브라우저 콘솔에서 "[HMR] updated: ./path/to/module.js"를 찾으세요. 어떤 모듈이 경계 역할을 했는지 알려줍니다.
💡 전역 상태 관리 라이브러리(Zustand, Jotai 등)는 보통 자체 HMR 지원을 제공합니다. 스토어 정의에 경계를 만들어 상태를 보존합니다.
💡 경계가 너무 많으면 성능이 오히려 저하될 수 있습니다. 각 경계마다 HMR 런타임 코드가 추가되기 때문입니다. 적절한 균형을 찾으세요.
💡 main.js나 index.js 같은 엔트리 파일은 절대 경계를 만들지 마세요. 엔트리가 경계가 되면 전체 앱이 매번 리마운트되어 HMR의 의미가 없어집니다.
6. HMR 성능 최적화 팁
시작하며
여러분의 프로젝트가 점점 커지면서 HMR이 느려지는 것을 느낀 적 있나요? 작은 변경을 저장했는데 반영되기까지 5초, 10초씩 걸리면 HMR의 장점이 사라집니다.
HMR 성능은 프로젝트 규모가 커질수록 중요해집니다. 수백 개의 컴포넌트, 수천 개의 모듈을 가진 대규모 애플리케이션에서는 HMR 최적화가 개발 생산성에 직접적인 영향을 미칩니다.
느린 HMR은 개발자의 집중력을 흐트러뜨리고, 빠른 실험과 반복을 방해합니다. 다행히 HMR 성능을 개선할 수 있는 여러 기법들이 있습니다.
모듈 그래프 최적화, 의존성 관리, 빌드 도구 설정, 코드 구조 개선 등 다양한 레벨에서 최적화가 가능합니다. 오늘은 실전에서 바로 적용할 수 있는 HMR 성능 최적화 방법들을 배워보겠습니다.
이 팁들을 적용하면 대규모 프로젝트에서도 빠른 HMR을 경험할 수 있습니다.
개요
간단히 말해서, HMR 성능 최적화는 모듈 업데이트 시 처리해야 할 작업량을 줄이고, 불필요한 재평가를 피하며, HMR 경계를 전략적으로 배치하는 것입니다. 실무에서 이것이 얼마나 중요한지 예를 들어보겠습니다.
여러분이 100개 이상의 컴포넌트를 가진 대시보드를 개발한다고 해봅시다. 각 컴포넌트가 여러 유틸리티와 훅을 import하고, 이들이 서로 복잡하게 얽혀있습니다.
하나의 공통 유틸리티를 수정하면 수십 개의 모듈이 재평가되어야 하고, HMR이 수 초 걸립니다. 최적화를 통해 이를 밀리초 단위로 줄일 수 있습니다.
기존에는 "HMR이 느리면 어쩔 수 없다"고 생각했을 수도 있습니다. 하지만 적절한 최적화를 적용하면 대부분의 경우 1초 이내, 많은 경우 100ms 이내로 HMR을 완료할 수 있습니다.
HMR 성능 최적화의 핵심 원칙은 다섯 가지입니다. 첫째, 의존성 체인을 짧게 유지합니다.
둘째, 자주 변경되는 모듈은 격리합니다. 셋째, HMR 경계를 적절히 배치합니다.
넷째, 무거운 작업은 지연 로딩합니다. 다섯째, 빌드 도구 설정을 최적화합니다.
이 원칙들을 따르면 프로젝트 규모와 관계없이 빠른 HMR을 유지할 수 있습니다.
코드 예제
// 최적화 전: 모든 유틸을 한 파일에 모음 (나쁜 예)
// utils.js - 이 파일을 수정하면 전체 앱이 영향받음
export function formatDate(date) { /* ... */ }
export function formatCurrency(num) { /* ... */ }
export function validateEmail(email) { /* ... */ }
export function debounce(fn, delay) { /* ... */ }
// 50개 이상의 유틸 함수들...
// 최적화 후: 관련 기능별로 분리 (좋은 예)
// utils/date.js - 날짜 관련만 수정하면 날짜 사용 모듈만 영향
export function formatDate(date) {
return date.toLocaleDateString('ko-KR');
}
// utils/currency.js - 통화 관련만 독립적으로 HMR
export function formatCurrency(num) {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW'
}).format(num);
}
// components/ProductCard.jsx - 필요한 것만 import
import { formatCurrency } from '@/utils/currency';
// formatDate를 import하지 않으므로 date.js 변경에 영향 안 받음
export default function ProductCard({ product }) {
return (
<div>
<h2>{product.name}</h2>
<p>{formatCurrency(product.price)}</p>
</div>
);
}
// vite.config.js - 빌드 도구 최적화
export default {
server: {
hmr: {
overlay: false // 에러 오버레이 비활성화로 HMR 속도 향상
}
},
optimizeDeps: {
include: ['react', 'react-dom'], // 자주 사용하는 의존성 사전 번들링
},
build: {
rollupOptions: {
output: {
manualChunks: { // 코드 스플리팅으로 HMR 범위 축소
vendor: ['react', 'react-dom'],
utils: ['lodash', 'date-fns']
}
}
}
}
};
설명
이것이 하는 일: HMR 성능 최적화는 모듈 변경 시 영향받는 범위를 최소화하여 업데이트 시간을 줄입니다. 위 예제는 큰 모듈을 작은 모듈로 분리하여 HMR 효율을 높이는 방법을 보여줍니다.
첫 번째로, 모듈 분리의 효과를 이해해봅시다. utils.js에 모든 유틸리티를 모아두면, formatDate 하나만 수정해도 이 파일을 import하는 모든 모듈이 재평가됩니다.
100개 컴포넌트가 utils.js를 import한다면 100개 모두가 HMR 대상이 됩니다. 하지만 utils/date.js로 분리하면 날짜 관련 유틸을 사용하는 컴포넌트만 영향을 받습니다.
이는 HMR 범위를 10분의 1, 심지어 100분의 1로 줄일 수 있습니다. 두 번째로, import 문의 선택성이 중요합니다.
ProductCard는 formatCurrency만 필요하므로 currency.js만 import합니다. date.js를 import하지 않기 때문에 date.js가 변경되어도 ProductCard는 완전히 무관합니다.
반대로 만약 import * as utils from './utils'처럼 전체를 import했다면 모든 유틸 변경에 영향을 받습니다. 필요한 것만 import하는 습관이 HMR 성능에 직접적인 영향을 미칩니다.
세 번째로, Vite 설정의 최적화 옵션들을 살펴봅시다. optimizeDeps.include는 자주 사용하는 라이브러리를 사전에 번들링하여 개발 서버 시작 속도와 HMR 속도를 모두 향상시킵니다.
React나 React-DOM 같은 라이브러리는 변경되지 않으므로 미리 번들링해두면 매번 처리할 필요가 없습니다. 이는 특히 ESM으로 제공되지 않는 CJS 패키지에 효과적입니다.
네 번째로, manualChunks 설정이 HMR 경계를 최적화합니다. 코드를 vendor(라이브러리)와 utils(유틸리티)로 분리하면, 비즈니스 로직을 수정할 때 라이브러리 코드는 전혀 영향받지 않습니다.
청크가 작을수록 HMR이 빠르고, 명확한 경계가 있을수록 불필요한 재평가를 피할 수 있습니다. 다섯 번째로, hmr.overlay: false 설정도 주목할 만합니다.
에러 오버레이는 편리하지만, 오버레이를 렌더링하는 과정 자체가 약간의 시간을 소모합니다. 개발 중에 많은 HMR이 발생한다면 오버레이를 끄고 콘솔에서 에러를 확인하는 것이 더 빠를 수 있습니다.
물론 이는 선호도에 따라 조정할 수 있습니다. 여러분이 이런 최적화를 적용하면 대규모 프로젝트에서도 쾌적한 개발 경험을 유지할 수 있습니다.
특히 팀 프로젝트에서 공통 모듈 구조를 최적화하면 팀 전체의 생산성이 향상됩니다. HMR이 느려지기 시작하면 이는 코드 구조를 개선할 신호로 받아들이세요.
모듈 분리, 의존성 정리, 경계 설정 등을 통해 코드베이스의 건강도를 높일 수 있습니다.
실전 팁
💡 큰 배럴 파일(index.js에서 모든 걸 re-export)은 HMR의 적입니다. 배럴 파일은 모든 의존성을 연결하므로 하나만 바뀌어도 전체가 재평가됩니다. 직접 import하는 것이 더 빠릅니다.
💡 Vite의 개발 서버를 재시작하면 캐시가 초기화되어 HMR이 빨라질 수 있습니다. 가끔 의존성 그래프가 꼬이면 재시작이 해결책입니다.
💡 브라우저 개발자 도구의 Network 탭에서 HMR 요청을 모니터링하세요. 하나의 변경에 수십 개의 모듈이 로드된다면 최적화가 필요한 신호입니다.
💡 Dynamic import()를 활용하여 무거운 모듈을 지연 로딩하세요. 개발 중에 자주 수정하지 않는 모듈은 필요할 때만 로드하면 초기 HMR 속도가 빨라집니다.
💡 환경 변수를 자주 바꾸지 마세요. 환경 변수 변경은 전체 앱 재시작을 유발합니다. 개발 중에는 환경 변수를 고정하고 필요하면 코드 내에서 하드코딩하여 테스트하세요.
댓글 (0)
함께 보면 좋은 카드 뉴스
WebSocket과 Server-Sent Events 실시간 통신 완벽 가이드
웹 애플리케이션에서 실시간 데이터 통신을 구현하는 핵심 기술인 WebSocket과 Server-Sent Events를 다룹니다. 채팅, 알림, 실시간 업데이트 등 현대 웹 서비스의 필수 기능을 구현하는 방법을 배워봅니다.
API 테스트 전략과 자동화 완벽 가이드
API 개발에서 필수적인 테스트 전략을 단계별로 알아봅니다. 단위 테스트부터 부하 테스트까지, 실무에서 바로 적용할 수 있는 자동화 기법을 익혀보세요.
효과적인 API 문서 작성법 완벽 가이드
API 문서는 개발자와 개발자 사이의 가장 중요한 소통 수단입니다. 이 가이드에서는 좋은 API 문서가 갖춰야 할 조건부터 Getting Started, 엔드포인트 설명, 에러 코드 문서화, 인증 가이드, 변경 이력 관리까지 체계적으로 배워봅니다.
API 캐싱과 성능 최적화 완벽 가이드
웹 서비스의 응답 속도를 획기적으로 개선하는 캐싱 전략과 성능 최적화 기법을 다룹니다. HTTP 캐싱부터 Redis, 데이터베이스 최적화, CDN까지 실무에서 바로 적용할 수 있는 핵심 기술을 초급자 눈높이에서 설명합니다.
OAuth 2.0과 소셜 로그인 완벽 가이드
OAuth 2.0의 핵심 개념부터 구글, 카카오 소셜 로그인 구현까지 초급 개발자를 위해 쉽게 설명합니다. 인증과 인가의 차이점, 다양한 Flow의 특징, 그리고 보안 고려사항까지 실무에 바로 적용할 수 있는 내용을 다룹니다.