1. "새로고침 버튼이 닳겠네"
저는 성격이 급합니다. 코드를 고치고 Cmd + S를 누르자마자 고개를 돌려 브라우저를 봅니다.
당연히 화면이 바뀌어 있어야 하죠. 이게 현대 웹 개발의 축복, HMR(Hot Module Replacement)이니까요.
그런데 언젠가부터 제 프로젝트가 말을 안 듣기 시작했습니다. 배경색을 빨간색으로 바꿨는데, 화면은 여전히 파란색입니다. "어라?" 하고 저장 버튼을 연타합니다. 반응이 없습니다. 결국 한숨을 쉬며 브라우저 새로고침(F5)을 누릅니다. 그제야 바뀝니다.
이 짓을 하루에 100번쯤 하다 보니 현타가 왔습니다. "내가 지금 코딩을 하는 건가, 새로고침 기계를 돌리는 건가?" 개발 생산성이 바닥을 치고, 스트레스 지수는 하늘을 찔렀습니다. 그래서 결심했습니다. 이 HMR 녀석을 고쳐내고야 말겠다고.
2. HMR은 마법이 아니다
저는 HMR이 그냥 마법인 줄 알았습니다. 저장하면 알아서 바뀌는 거. 하지만 뜯어보니 아주 정교한 조건부 계약이더군요.
HMR의 작동 원리는 "건물 리모델링"과 비슷합니다.
- 전체 새로고침 (Live Reload): 건물을 폭파하고 기초부터 다시 짓습니다. (느림, 메모리/상태(State) 다 날아감)
- HMR (Hot Module Replacement): 301호 안방의 벽지(Module)만 살짝 뜯어내고 새 벽지를 붙입니다. (빠름, 301호에 살던 사람(State)은 그대로 유지됨)
문제는 301호 벽지를 뜯으려고 하는데, 301호가 302호랑 강력본드로 붙어있다면(강한 의존성) 어떻게 될까요? 벽지를 뜯다가 건물 전체가 흔들립니다. 리모델링 업자(Bundler, Webpack/Vite)는 "에라이, 너무 위험해서 못 고치겠다. 그냥 건물 다시 지어!" 하고 포기해 버립니다. 이게 바로 HMR이 깨지고 전체 새로고침(Full Reload)이 일어나는 이유입니다.
그럼 도대체 뭐가 벽지를 못 뜯게 만드는 걸까요?
3. 내 HMR을 망친 범인들 (흔한 실수)
프로젝트를 뒤져보니 범인들이 하나둘씩 나왔습니다. 주로 React 개발자들이 흔히 저지르는 실수들이었습니다.
범인 1 - 대소문자 무시 (Case Sensitivity)
제 컴퓨터(Mac)는 파일명 대소문자를 구분하지 않습니다. (Case Insensitive File System)
Header.tsx와 header.tsx를 같은 파일로 취급하죠.
하지만 리눅스나 웹팩 같은 번들러는 깐깐합니다.
// ❌ 실제 파일은 Header.tsx인데 소문자로 임포트함
import Header from './header';
이러면 번들러가 헷갈려합니다.
"어, header가 바뀌었네? 근데 내가 아는 모듈 트리는 Header인데? 에이 몰라, 연결 끊어."
파일명을 임포트할 때 대소문자를 정확히 맞추세요.
범인 2 - 익명 함수 (Anonymous Exports)
제가 귀찮아서 컴포넌트에 이름을 안 붙인 게 문제였습니다.
// ❌ 이름 없는 컴포넌트
export default () => <div>Hello</div>;
React Fast Refresh(HMR의 React 버전)는 컴포넌트의 이름을 보고 "아, 얘가 쟤구나" 하고 바꿔치기합니다.
이름이 없으면? "신원 불명! 교체 불가!" 판정을 받습니다. 특히 React.memo나 forwardRef로 감쌀 때 이름을 잃어버리기 쉽습니다.
// ✅ 이름을 꼭 지어주세요
const MyComponent = () => <div>Hello</div>;
export default MyComponent;
범인 3 - 순환 참조 (Circular Dependency)
이게 제일 찾기 힘든 녀석이었습니다. A가 B를 부르고, B가 다시 A를 부르는 죽음의 무도.
User.ts는Post.ts타입을 씁니다.Post.ts는 작성자 정보를 위해User.ts를 씁니다.
제가 User.ts를 수정하면 -> Post.ts도 업데이트해야 함 -> 어? 그럼 다시 User.ts 업데이트...?
번들러는 이 무한 루프를 보다가 뇌정지가 옵니다. 그리고 "에잇, 모르겠다!" 하고 HMR 연결을 끊어버리죠.
해결책: 공통으로 쓰는 타입이나 로직을 제3의 파일(types.ts 등)로 빼서 고리를 끊어야 합니다.
4. 네트워크 문제 - 프록시와 웹소켓
코드 문제가 아니라 도구 설정 문제일 때도 있습니다. HMR은 브라우저와 개발 서버 사이에 웹소켓(WebSocket) 연결을 맺고 통신합니다. 이 선이 끊기면 말짱 꽝입니다.
Nginx 역방향 프록시 (Reverse Proxy) 이슈
혹시 개발 환경에서도 Nginx나 Docker를 쓰고 계신가요? Nginx는 기본적으로 웹소켓 연결을 끊어버립니다. 명시적으로 허용해줘야 합니다.
# nginx.conf
location /ws {
proxy_pass http://frontend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # 웹소켓 필수 헤더
proxy_set_header Connection "Upgrade"; # 웹소켓 필수 헤더
}
브라우저 개발자 도구의 Network 탭에서 /ws 또는 socket.io 요청이 빨간색(실패)인지 확인해보세요.
웹소켓이 막혀있다면 HMR 신호가 브라우저에 도달하지 못합니다.
HTTPS와 인증서 문제
로컬에서 HTTPS(https://localhost)를 쓰는데 인증서가 유효하지 않으면(Self-signed), 브라우저가 보안상의 이유로 웹소켓 연결(WSS)을 차단할 수 있습니다.
이럴 땐 mkcert 같은 도구로 로컬 CA 인증서를 제대로 발급받아야 합니다.
5. Vite/Webpack 설정 문제
WSL2나 Docker 사용자 주목
윈도우 WSL2나 도커 환경에서는 OS의 파일 시스템 이벤트(File Watch Event)가 번들러에게 전달되지 않을 때가 있습니다. (가상 환경의 한계) 파일을 저장했는데 번들러가 "응? 뭐 바뀜?" 하고 모르는 거죠.
이때는 Polling(폴링) 방식을 켜야 합니다. "1초마다 파일 바뀌었는지 감시해!"라고 시키는 거죠. 좀 무식하고 CPU를 쓰지만, 확실합니다.
// vite.config.ts
export default defineConfig({
server: {
watch: {
usePolling: true, // "야, 눈 떼지 말고 계속 쳐다봐"
interval: 100,
},
},
});
.env 파일 수정
환경 변수(.env)를 바꾸고 "왜 안 바뀌지?" 하고 30분 동안 씨름한 적이 있습니다.
환경 변수는 서버가 시작될 때 딱 한 번 읽힙니다. 그러니 코드를 아무리 저장해도 소용없죠.
.env를 고쳤으면 무조건 서버를 껐다 켜세요. 이건 국룰입니다.
6. HMR의 한계 - 상태 보존 (State Preservation)
HMR이 작동했는데도 화면이 이상할 때가 있습니다. 바로 상태(State) 초기화 문제입니다.
React 컴포넌트 파일이 교체되면, 그 안의 useState 값은 유지되려고 노력합니다. 하지만 useEffect는 다시 실행될 수 있습니다.
useEffect(() => {
const timer = setInterval(() => console.log('Tick'), 1000);
return () => clearInterval(timer); // 클린업 함수
}, []);
HMR이 일어나면, 기존 컴포넌트가 파괴되면서 cleanup 함수가 실행되고, 새 컴포넌트가 마운트되면서 useEffect가 다시 실행됩니다.
만약 여러분이 전역 변수나 window 객체에 무언가를 저장했다면? 그건 HMR이 관리해주지 않기 때문에 꼬일 수 있습니다.
HMR을 믿지 말고, 사이드 이펙트(Side Effect)를 잘 정리(Cleanup)하는 코드를 짜는 것이 중요합니다.
6.5. 심화: HMR vs Fast Refresh vs Live Reload
이 용어들이 헷갈리시나요? 족보를 정리해 드립니다.
-
Live Reload (구석기 시대)
- 파일 저장 -> 브라우저 전체 새로고침 (F5).
- Redux/State 다 날아감. 모달 창 띄워놨으면 다시 띄워야 함.
-
HMR (Webpack Hot Module Replacement)
- 전체 새로고침 없이 변경된 모듈만 교체.
- 하지만
useEffect가 재실행되거나, 클래스 컴포넌트의 상태가 유지되지 않는 등 완벽하지 않았음. - 개발자가 수동으로
module.hot.accept같은 코드를 짜야 할 때가 많았음.
-
React Fast Refresh (현대 문명)
- React 팀이 작정하고 만든 "React 전용 HMR".
- 함수형 컴포넌트와 Hooks를 완벽하게 지원.
- 상태(State)를 보존하는 능력이 기가 막힘. 에러가 나서 화면이 멈춰도, 코드 고치면 상태 유지한 채로 복구됨.
- Next.js와 Vite가 기본으로 사용하는 것이 바로 이 기술입니다.
그래서 우리가 "HMR이 고장 났다"고 할 때, 실제로는 "Fast Refresh가 상태 보존에 실패해서 Full Reload로 떨어졌다"는 뜻일 확률이 높습니다.
6.8. 스타트업 개발팀을 위한 HMR 디버깅 체크리스트
팀원이 "제 컴퓨터에서만 HMR이 안 돼요 ㅠㅠ"라고 할 때, 이 리스트를 던져주세요.
- 파일명 대소문자:
import Header from './header'처럼 대소문자 틀린 곳 없는지? (맥/윈도우 사용자 주의) - 익명 함수:
export default () => {}대신export default function Name() {}썼는지? - 환경 변수:
.env바꾸고 서버 재시작 안 했는지? - 순환 참조:
A.ts->B.ts->A.ts구조가 있는지? (madge같은 도구로 체크 가능) - 웹소켓 차단: 회사 VPN이나 프록시가 웹소켓(WS/WSS)을 막고 있는지?
- Safe Write: IDE(WebStorm 등) 설정 중에 "Safe Write" (임시 파일 생성 후 덮어쓰기) 기능이 켜져 있는지? (가끔 HMR 트리거를 막음)
Webpack HMR Runtime의 동작 원리 (How Runtime Works) 자세히 살펴보기
"도대체 브라우저는 파일이 바뀐 걸 어떻게 아는 걸까요?" 마법이 아닙니다. HMR Runtime이라는 작은 자바스크립트 코드가 브라우저에 몰래 심어져 있기 때문입니다.
- 연결 (Connection): 브라우저가 열리면 HMR Runtime이 서버(
localhost:3000)와 웹소켓(WebSocket)을 연결합니다. - 수신 (Receive): 개발자가 파일을 저장하면, 서버가 "야,
Header.js해시값 바뀌었어!"라고 웹소켓 메시지(Manifest)를 보냅니다. - 요청 (Download): Runtime은 새로운
Header.js조각(Chunk)을 JSONP나 fetch로 다운로드합니다. - 버블링 (Bubbling): Runtime은
Header.js를 교체하려고 시도합니다. 실패하면? 부모 컴포넌트로 에러를 전파(Bubble Up)합니다.App.js까지 올라갔는데도 실패하면? - 새로고침 (Fallback): "에라 모르겠다.
window.location.reload()실행!" (이게 우리가 보는 풀 리로드입니다)
그래서 HMR이 깨진다는 건, 4번 버블링 과정에서 부모가 자식을 수용하지 못해서(Decline) 발생하는 현상입니다.
6.7. Vite는 뭐가 다른가? (ESM HMR)
Webpack은 파일 하나가 바뀌면 전체 번들(bundle.js)을 다시 묶어서(Re-bundling) 브라우저에 줬습니다. 프로젝트가 커지면 HMR 반응이 느려졌죠(3초... 5초...).
Vite는 브라우저의 기본 기능인 ES Modules (ESM)을 이용합니다.
파일이 바뀌면 번들링을 다시 하는 게 아니라, "바뀐 파일(Header.js) 딱 하나만" 브라우저가 다시 요청하게 만듭니다.
그래서 프로젝트가 아무리 커져도 HMR 속도가 O(1), 즉 언제나 빠릅니다.
Webpack이 "거대한 택배 상자를 다시 포장하는 것"이라면, Vite는 "편지 한 통만 퀵으로 쏘는 것"입니다.
6.8. 자주 묻는 질문 (FAQ)
Q: 배포된 운영(Production) 환경에서도 HMR이 되나요?
A: 아니요! HMR은 오직 로컬 개발 서버(Development Server)에서만 돌아갑니다. 배포 빌드(npm run build)를 하면 HMR 관련 코드는 모두 제거되고 순수한 정적 파일만 남습니다.
Q: useEffect 말고 useLayoutEffect를 쓰면 상태가 유지되나요?
A: 아니요. HMR 시점에서 상태(State) 보존은 React Fast Refresh의 역할이지 Hook의 종류와는 상관없습니다. 의존성 배열(Dependency Array)을 비워두면 마운트 시 한 번만 실행되지만, HMR은 "재마운트"를 유발하므로 다시 실행됩니다.
Q: Next.js 쓰는데 HMR이 안 돼요.
A: 대부분 페이지 이름이 대소문자가 다르거나(pages/About.tsx vs pages/about.tsx), next.config.js를 수정하고 재시작 안 해서 그렇습니다.
7. 마무리 - 기계를 탓하기 전에
처음엔 "Vite 이거 버그 많네", "Webpack 너무 무거워" 하고 도구 탓을 했습니다. 하지만 알고 보니 90%는 제 코드의 문제(순환 참조, 대소문자 실수, 익명 함수)였습니다.
HMR 문제를 해결하면서 개발 습관도 고쳤습니다.
- 콘솔 로그 확인하기: HMR이 터지면 브라우저 콘솔에
[HMR] The following modules couldn't be hot updated...같은 경고가 뜹니다. 무시하지 말고 읽으세요. - 컴포넌트 작게 만들기: 파일 하나에 컴포넌트 10개씩 때려 박으면 HMR이 작동하기 힘듭니다. 파일 하나당 컴포넌트 하나(One Component per File) 원칙을 지키세요.
HMR이 고쳐지자 개발 속도가 2배는 빨라진 것 같습니다.
저장하자마자 화면이 팟! 하고 바뀌는 그 쾌감. 개발자만이 아는 기쁨이죠.
여러분의 HMR은 안녕하십니까? 새로고침 키에 먼지가 쌓일 때까지, 쾌적한 개발 환경을 만드시길 바랍니다.