
내 코드가 화면에 안 바뀐다: HMR 고장 수리기
개발 중에 코드를 수정했는데 브라우저가 반응이 없나요? 새로고침을 백만 번 하다가 지쳐서 찾아낸 HMR(Hot Module Replacement)의 원리와 고장 원인, 그리고 해결 방법을 '노가다 개발자'의 시선으로 정리했습니다.

개발 중에 코드를 수정했는데 브라우저가 반응이 없나요? 새로고침을 백만 번 하다가 지쳐서 찾아낸 HMR(Hot Module Replacement)의 원리와 고장 원인, 그리고 해결 방법을 '노가다 개발자'의 시선으로 정리했습니다.
습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

전역 상태 관리를 위해 Redux 대신 Context API를 선택했습니다. 하지만 `UserContext`에 모든 정보를 담자마자 앱 전체가 리렌더링되기 시작했습니다. Context 분리(Splitting) 전략.

저는 성격이 급합니다. 코드를 고치고 Cmd + S를 누르자마자 고개를 돌려 브라우저를 봅니다.
당연히 화면이 바뀌어 있어야 하죠. 이게 현대 웹 개발의 축복, HMR(Hot Module Replacement)이니까요.
그런데 언젠가부터 제 프로젝트가 말을 안 듣기 시작했습니다. 배경색을 빨간색으로 바꿨는데, 화면은 여전히 파란색입니다. "어라?" 하고 저장 버튼을 연타합니다. 반응이 없습니다. 결국 한숨을 쉬며 브라우저 새로고침(F5)을 누릅니다. 그제야 바뀝니다.
이 짓을 하루에 100번쯤 하다 보니 현타가 왔습니다. "내가 지금 코딩을 하는 건가, 새로고침 기계를 돌리는 건가?" 개발 생산성이 바닥을 치고, 스트레스 지수는 하늘을 찔렀습니다. 그래서 결심했습니다. 이 HMR 녀석을 고쳐내고야 말겠다고.
저는 HMR이 그냥 마법인 줄 알았습니다. 저장하면 알아서 바뀌는 거. 하지만 뜯어보니 아주 정교한 조건부 계약이더군요.
HMR의 작동 원리는 "건물 리모델링"과 비슷합니다.
문제는 301호 벽지를 뜯으려고 하는데, 301호가 302호랑 강력본드로 붙어있다면(강한 의존성) 어떻게 될까요? 벽지를 뜯다가 건물 전체가 흔들립니다. 리모델링 업자(Bundler, Webpack/Vite)는 "에라이, 너무 위험해서 못 고치겠다. 그냥 건물 다시 지어!" 하고 포기해 버립니다. 이게 바로 HMR이 깨지고 전체 새로고침(Full Reload)이 일어나는 이유입니다.
그럼 도대체 뭐가 벽지를 못 뜯게 만드는 걸까요?
프로젝트를 뒤져보니 범인들이 하나둘씩 나왔습니다. 주로 React 개발자들이 흔히 저지르는 실수들이었습니다.
제 컴퓨터(Mac)는 파일명 대소문자를 구분하지 않습니다. (Case Insensitive File System)
Header.tsx와 header.tsx를 같은 파일로 취급하죠.
하지만 리눅스나 웹팩 같은 번들러는 깐깐합니다.
// ❌ 실제 파일은 Header.tsx인데 소문자로 임포트함
import Header from './header';
이러면 번들러가 헷갈려합니다.
"어, header가 바뀌었네? 근데 내가 아는 모듈 트리는 Header인데? 에이 몰라, 연결 끊어."
파일명을 임포트할 때 대소문자를 정확히 맞추세요.
제가 귀찮아서 컴포넌트에 이름을 안 붙인 게 문제였습니다.
// ❌ 이름 없는 컴포넌트
export default () => <div>Hello</div>;
React Fast Refresh(HMR의 React 버전)는 컴포넌트의 이름을 보고 "아, 얘가 쟤구나" 하고 바꿔치기합니다.
이름이 없으면? "신원 불명! 교체 불가!" 판정을 받습니다. 특히 React.memo나 forwardRef로 감쌀 때 이름을 잃어버리기 쉽습니다.
// ✅ 이름을 꼭 지어주세요
const MyComponent = () => <div>Hello</div>;
export default MyComponent;
이게 제일 찾기 힘든 녀석이었습니다. A가 B를 부르고, B가 다시 A를 부르는 죽음의 무도.
User.ts는 Post.ts 타입을 씁니다.Post.ts는 작성자 정보를 위해 User.ts를 씁니다.제가 User.ts를 수정하면 -> Post.ts도 업데이트해야 함 -> 어? 그럼 다시 User.ts 업데이트...?
번들러는 이 무한 루프를 보다가 뇌정지가 옵니다. 그리고 "에잇, 모르겠다!" 하고 HMR 연결을 끊어버리죠.
해결책: 공통으로 쓰는 타입이나 로직을 제3의 파일(types.ts 등)로 빼서 고리를 끊어야 합니다.
코드 문제가 아니라 도구 설정 문제일 때도 있습니다. HMR은 브라우저와 개발 서버 사이에 웹소켓(WebSocket) 연결을 맺고 통신합니다. 이 선이 끊기면 말짱 꽝입니다.
혹시 개발 환경에서도 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://localhost)를 쓰는데 인증서가 유효하지 않으면(Self-signed), 브라우저가 보안상의 이유로 웹소켓 연결(WSS)을 차단할 수 있습니다.
이럴 땐 mkcert 같은 도구로 로컬 CA 인증서를 제대로 발급받아야 합니다.
윈도우 WSL2나 도커 환경에서는 OS의 파일 시스템 이벤트(File Watch Event)가 번들러에게 전달되지 않을 때가 있습니다. (가상 환경의 한계) 파일을 저장했는데 번들러가 "응? 뭐 바뀜?" 하고 모르는 거죠.
이때는 Polling(폴링) 방식을 켜야 합니다. "1초마다 파일 바뀌었는지 감시해!"라고 시키는 거죠. 좀 무식하고 CPU를 쓰지만, 확실합니다.
// vite.config.ts
export default defineConfig({
server: {
watch: {
usePolling: true, // "야, 눈 떼지 말고 계속 쳐다봐"
interval: 100,
},
},
});
환경 변수(.env)를 바꾸고 "왜 안 바뀌지?" 하고 30분 동안 씨름한 적이 있습니다.
환경 변수는 서버가 시작될 때 딱 한 번 읽힙니다. 그러니 코드를 아무리 저장해도 소용없죠.
.env를 고쳤으면 무조건 서버를 껐다 켜세요. 이건 국룰입니다.
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)하는 코드를 짜는 것이 중요합니다.
이 용어들이 헷갈리시나요? 족보를 정리해 드립니다.
useEffect가 재실행되거나, 클래스 컴포넌트의 상태가 유지되지 않는 등 완벽하지 않았음.module.hot.accept 같은 코드를 짜야 할 때가 많았음.그래서 우리가 "HMR이 고장 났다"고 할 때, 실제로는 "Fast Refresh가 상태 보존에 실패해서 Full Reload로 떨어졌다"는 뜻일 확률이 높습니다.
팀원이 "제 컴퓨터에서만 HMR이 안 돼요 ㅠㅠ"라고 할 때, 이 리스트를 던져주세요.
import Header from './header' 처럼 대소문자 틀린 곳 없는지? (맥/윈도우 사용자 주의)export default () => {} 대신 export default function Name() {} 썼는지?.env 바꾸고 서버 재시작 안 했는지?A.ts -> B.ts -> A.ts 구조가 있는지? (madge 같은 도구로 체크 가능)"도대체 브라우저는 파일이 바뀐 걸 어떻게 아는 걸까요?" 마법이 아닙니다. HMR Runtime이라는 작은 자바스크립트 코드가 브라우저에 몰래 심어져 있기 때문입니다.
localhost:3000)와 웹소켓(WebSocket)을 연결합니다.Header.js 해시값 바뀌었어!"라고 웹소켓 메시지(Manifest)를 보냅니다.Header.js 조각(Chunk)을 JSONP나 fetch로 다운로드합니다.Header.js를 교체하려고 시도합니다. 실패하면? 부모 컴포넌트로 에러를 전파(Bubble Up)합니다. App.js까지 올라갔는데도 실패하면?window.location.reload() 실행!" (이게 우리가 보는 풀 리로드입니다)그래서 HMR이 깨진다는 건, 4번 버블링 과정에서 부모가 자식을 수용하지 못해서(Decline) 발생하는 현상입니다.
Webpack은 파일 하나가 바뀌면 전체 번들(bundle.js)을 다시 묶어서(Re-bundling) 브라우저에 줬습니다. 프로젝트가 커지면 HMR 반응이 느려졌죠(3초... 5초...).
Vite는 브라우저의 기본 기능인 ES Modules (ESM)을 이용합니다.
파일이 바뀌면 번들링을 다시 하는 게 아니라, "바뀐 파일(Header.js) 딱 하나만" 브라우저가 다시 요청하게 만듭니다.
그래서 프로젝트가 아무리 커져도 HMR 속도가 O(1), 즉 언제나 빠릅니다.
Webpack이 "거대한 택배 상자를 다시 포장하는 것"이라면, Vite는 "편지 한 통만 퀵으로 쏘는 것"입니다.
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를 수정하고 재시작 안 해서 그렇습니다.
처음엔 "Vite 이거 버그 많네", "Webpack 너무 무거워" 하고 도구 탓을 했습니다. 하지만 알고 보니 90%는 제 코드의 문제(순환 참조, 대소문자 실수, 익명 함수)였습니다.
HMR 문제를 해결하면서 개발 습관도 고쳤습니다.
[HMR] The following modules couldn't be hot updated... 같은 경고가 뜹니다. 무시하지 말고 읽으세요.HMR이 고쳐지자 개발 속도가 2배는 빨라진 것 같습니다.
저장하자마자 화면이 팟! 하고 바뀌는 그 쾌감. 개발자만이 아는 기쁨이죠.
여러분의 HMR은 안녕하십니까? 새로고침 키에 먼지가 쌓일 때까지, 쾌적한 개발 환경을 만드시길 바랍니다.