최근 새로 만들어지는 리액트 프로젝트들은 create-react-app 대신 vite 를 많이 이용하는 추세인 것 같습니다. npm init vite 나 yarn create vite 를 통해 리액트 프로젝트를 생성하고 개발하면, 상상 이상으로 빠른 속도에 놀라게 됩니다.

엄밀히는 리액트 프로젝트 보다는 CSR SPA 렌더링을 하는 정적 리액트 웹사이트 프로젝트라고 해야 정확한 표현이고, vite 보다는
create-vite패키지를 이용해 생성한template-react-ts보일러플레이트 라고 해야 정확한 표현입니다만, 편의상 이렇게 표현하겠습니다.
설정이 워낙 많고 케이스가 다양하기 때문에, 모든 설정은
yarn create vite를 하고React,Typescript를 선택하여 생성한 것을 기준으로 하겠습니다. vite 버전은 2023년 4월 12일 기준vitegit 메인 브랜치 헤드인 cdd9c2320650f34c46e02f3777239e595cf6543d 를 기준으로 하겠습니다.
이번 글에서는 vite 에서 App.tsx 에 있는 App 이라는 리액트 컴포넌트를 수정했을 때 HMR이 무슨 과정을 거치며, 그 과정에서 vite가 무슨 일을 해 주는지를 브라우저 개발자 도구와 vite 소스코드를 함께 뒤적거리며 알아보겠습니다.
먼저 devserver 와 브라우저 사이에 웹소켓이 연결된단 걸 확인하고, 파일이 수정되었을 때 devserver 가 브라우저에게 메시지를 쏘는 걸 확인하고, 그 다음에는 브라우저가 모듈을 불러와서 react-refresh 에게 넘기는 것까지 확인해 보겠습니다.
💡 vite 개발서버를 열고 접속하면 브라우저와 개발서버 사이에 웹소켓이 하나 연결된다
yarn dev 를 통해 vite devserver 를 실행하면, devserver는 웹소켓을 받을 준비를 해 둡니다. (소스코드)
// 서버
const ws = createWebSocketServer(httpServer, config, httpsOptions)
또한 우리가 브라우저에서 http://localhost:5173 에 접속하면, 개발자 도구를 보면 아래와 같이 첫 번째 js 파일로 @vite/client 를 불러오는 것을 확인할 수 있는데요,

이 @vite/client 가 자바스크립트를 수행하여 개발서버와 웹소켓을 연결합니다. (소스코드)
// 브라우저
let socket: WebSocket;
...
socket = setupWebSocket(socketProtocol, socketHost, fallback)
...
function setupWebSocket(/* ... */) {
const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
...
그리고 다시 개발자 도구를 통해 웹소켓이 연결되고 {"type": "connected"} 라는 메세지가 전송되는 것을 확인할 수 있습니다. 이 메세지는 당연히 서버에서 보내는 거겠죠? (소스코드)

좋아요, 이제 vite 개발서버를 열고 접속하면 브라우저와 개발서버 사이에 웹소켓이 하나 연결된다는 걸 확인했습니다.
💡 파일을 수정하면 chokidar 이 파일 수정을 감지하여 vite 가 아까 연결해둔 웹소켓을 통해 update 타입의 메세지를 쏜다
/src/App.tsx 파일을 간단하게 수정하여 콘솔로그를 하나 추가하고 저장해 보았더니, 아까 설정해둔 웹소켓을 타고 { type: 'update', ... } 이벤트가 들어오는 걸 확인할 수 있었습니다. 이 부분 로직을 좀더 자세히 알아볼까요?
| 파일 수정 | 개발자 도구 |
|---|---|
![]() | ![]() |
개발서버 소스코드를 보면, 파일 수정을 감지하는 로직이 있습니다. vite는 파일 변경을 감지할 때 chokidar 이라는 라이브러리를 이용합니다. (소스코드)

그리고 파일이 수정되었을 때, onHMRUpdate 를 수행하도록 등록합니다.
// 서버
watcher.on('change', async (file) => {
file = normalizePath(file)
// invalidate module graph cache on file change
moduleGraph.onFileChange(file)
await onHMRUpdate(file, false)
})
코드를 보면 onHMRUpdate 는 당연하게도 파일 수정뿐 아니라 파일 추가나 파일 제거 상황에도 수행되도록 등록됩니다. 우리는 리액트 컴포넌트를 수정하는 케이스만 확인할 거니까, 자세히 알아보지는 않겠습니다.
아무튼 onHMRUpdate 를 타고 들어가면 handleHMRUpdate 가 나오고, 여기저기 있는 config 대응 로직이나 디버깅 로직들 등을 무시하고 지나가면 updateModules 에 도착하게 됩니다. 이 함수를 요약하면 아래와 같습니다.
// 서버
export function updateModules(
file: string,
modules: ModuleNode[],
timestamp: number,
{ config, ws, moduleGraph }: ViteDevServer,
afterInvalidation?: boolean,
): void {
const updates: Update[] = []
let needFullReload = false
for (const mod of modules) {
const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)
if (hasDeadEnd) {
needFullReload = true
continue
}
updates.push(현재 모듈을 가공한 객체)
}
if (needFullReload) {
ws.send({ type: 'full-reload' })
return
}
ws.send({ type: 'update', updates })
}
오, 아까 App.tsx 를 수정했을 때 type이 update 로 발송된 걸 보니 needFullReload 는 false 였나 봅니다. 이 부분에 대한 미스테리는 지금 다뤄 버리면 내용이 산으로 가기에, 다음 섹션에서 알아보겠습니다. 참고로 미리 스포를 하자면, 리액트 컴포넌트가 아닌 대부분의 js 수정사항들 (가령 constant 수정 등) 은 full-reload 를 유발합니다.
좋아요, 이제 파일을 수정하면 chokidar 이 파일 수정을 감지하여 vite 가 아까 연결해둔 웹소켓을 통해 update 타입의 메세지를 쏜다는 걸 확인했습니다.
💡
@vite/client가 웹소켓을 받아서 모듈을 다시 불러온다
💡 불러온 모듈은 "외부의 누군가" 덕분에 HMR된다
아까 /src/App.tsx 를 수정했죠? 이때 브라우저 개발자 도구 js 섹션을 보면, /src/App.tsx 만 다시 불러오는 걸 확인할 수 있습니다. 아래 사진을 보면, waterfall 이 오른쪽 끝 구석에 초록색으로 살짝 떠있는 걸로 보아 /src/App.tsx 만 다시 불러온 것입니다.

웹소켓 메세지를 받아서 해당 파일을 다시 요청하는 건 브라우저의 역할이라고 추측할 수 있겠죠? 다시 @vite/client 를 보겠습니다.
@vite/client 는 message 를 받으면 handleMessage 를 호출하도록 등록합니다. handleMessage 는 message의 type 필드를 기준으로 switch문을 돌리는데요, 그중 우리가 살펴보고 있는 update 타입에 대한 로직은 아래와 같습니다. 마찬가지로 조금 길다 보니 요약해서 작성하겠습니다. (원본 소스코드)
// 브라우저
await Promise.all(
payload.updates.map(async (update): Promise<void> => {
if (update.type === 'js-update') {
return queueUpdate(fetchUpdate(update))
}
// 이 밑에는 50줄 가량의 css update 대응 로직
})
)
다행히도 우리의 업데이트는 js-update 니까, fetchUpdate 와 queueUpdate 만 살펴보면 되겠네요! (참고: update.type 은 여기서 확인할 수 있듯이 js-update 와 css-update 두가지 타입만 있습니다.)
queueUpdate 는 update 가 여러 개일 때 비동기를 처리하기 위한 장치로, 넘겨받은 콜백을 순서대로 수행합니다. 우리가 살펴보는 케이스는 update 가 한개이므로, 위의 코드는 사실상 아래 코드나 다름없습니다.
const callback = fetchUpdate(payload.updates[0]) // update 가 한개라서 Promise.all 을 날려도 똑같다
callback(); // update 가 한개라서 queueUpdate 도 사실상 콜백을 수행하는 역할만 한다
그럼 fetchUpdate 를 확인해볼까요? 이전까지는 명확하게 타고 들어가고 타고 들어가서 확인할 수 있는 쉬운 코드였는데, 갑자기 난이도가 확 올라갔습니다. 그래서 이번에는 요약을 하고 우리 케이스에 맞지 않는 분기문을 제거해서, 우리의 케이스에 맞게 읽기 쉬운 버전으로 바꿔서 데려왔습니다. 원본 소스코드도 같이 확인해보시는 걸 추천드립니다.
// 우리의 케이스 (/src/App.tsx 수정) 에서는
// isSelfUpdate 는 true 이므로 제거
// qualifiedCallbacks 는 길이가 1인 배열이므로 quealifiedCallback 으로 변경.
// qualifiedCallbacks.deps 도 길이가 1인 배열임
async function fetchUpdate({
path,
acceptedPath,
timestamp,
explicitImportRequired,
}: Update) {
const mod = hotModulesMap.get(path)
let fetchedModule: ModuleNamespace | undefined
const qualifiedCallback = mod.callbacks.filter(({ deps }) => deps.includes(acceptedPath))[0]
fetchedModule = await import(`${acceptedPath}?t=${timestamp}`)
return () => {
qualifiedCallback.fn(fetchedModule)
}
}
빼고 나니 생각보다 별게 없습니다. 그런데.. 너무 없습니다. 중간에 await import() 를 하는 걸 보니 동적 임포트를 통해 변경된 모듈을 다시 받아온다는 건 이해했습니다. 하지만 모듈을 다시 불러온다고 해서 HMR이 일어날 리는 없습니다. HMR은 언제 되는 걸까요? 우리의 개발모드 리액트 웹사이트는 어떻게 업데이트되는 걸까요?
저기 굉장히 수상하게 생긴 qualifiedCallback 에 해답이 있습니다. 그리고, 지금까지에 비해 꽤나 쫓아가기 어렵습니다. qualifiedCallback 은 mod 에서 가져온 거고, mod 는 hotModulesMap 에서 가져온 값입니다. hotModulesMap 은 빈 Map 으로 초기화되며, 여기에 set 을 수행하는 곳은 acceptDeps 한 곳 뿐입니다. 그리고 다시, 이 acceptDeps 는 hot.accept 와 hot.acceptExports 두 곳에서만 수행됩니다. 이중 우리의 HMR을 담당해준 건 accept 입니다.
function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) {
// ...
hotModulesMap.set(ownerPath, mod)
}
const hot: ViteHotContext = { // 👈 얘가 범인
// ...
accept(deps?: any, callback?: any) {
if (typeof deps === 'function' || !deps) {
acceptDeps([ownerPath], ([mod]) => deps?.(mod))
} else if (typeof deps === 'string') {
// ...
},
hot 는 import.meta.hot 을 통해 외부에 노출됩니다. 공식문서 를 보면 (원문 / 번역), 플러그인은 import.meta.hot.accept를 수행하여 "HMR된다고 등록" 함과 동시에 모듈의 HMR을 자체적으로 수행할 수 있습니다.
모듈 자신에 대한 HMR을 확인하기 위해서는
import.meta.hot.accept를 사용하고 업데이트된 모듈을 받는 콜백을 전달합니다: (중략) 이렇게 Hot updates를 "허용한" 모듈은 HMR 범위로 간주됩니다.
어? "HMR 범위로 간주" 된다고? 아까의 미스테리 - 왜 리액트 컴포넌트는 update 타입이고 일반 상수파일은 full-reload 가 날라가는가 - 가 해결됩니다. vite 가 리액트 컴포넌트를 HMR 대상이라고 판단했기 때문에, 리액트 컴포넌트는 HMR이 되니까 아까 앞에서 봤던 isFullReload가 false여서 update 타입이 날아가고, 다른 것들은 HMR이 안 되니까 full-reload가 날아갑니다. 이 부분에 대한 판단 로직은 propagateUpdate 함수에서 찾아볼 수 있습니다. (리액트 컴포넌트에 대해 node.isSelfAccepting 이 true 입니다.)
너무 길어졌네요. 정리하자면 이런 상황입니다.
import.meta.hot.accept 를 실행해서 HMR 콜백을 등록했다full-reload 가 아닌 update 타입으로 전송된다좋아요. 이제 @vite/client 가 웹소켓을 받아서 모듈을 다시 불러온다는 것과, 불러온 모듈은 "외부의 누군가" 덕분에 HMR된다는 것까지 왔습니다. 마지막으로 그 "외부의 누군가"를 찾으러 가 볼까요?
@vitejs/plugin-react💡
@vitejs/plugin-react가 리액트 컴포넌트가 수정되면react-refresh에게 넘기도록 등록한다
HMR은 아무튼 브라우저에 있는 소스코드가 진행할 거예요. 아까 본 개발자 도구에 누가 봐도 "내가 범인이다!" 라고 소리치는 친구가 있습니다.

react-refresh 는 리액트에서 공식적으로 지원하는 HMR 도구입니다. 아마 누군가가 플러그인을 통해 react-refresh 를 수행하도록 지정했겠군요. 플러그인은 모두 컨피그 파일에 들어있으니, 제 프로젝트의 vite.config.ts 파일을 보겠습니다.

플러그인이 단 하나밖에 없군요? @vitejs/plugin-react. 이 친구 소스코드에서 드디어 범인을 찾았습니다.
const footer = `
if (import.meta.hot) {
window.$RefreshReg$ = prevRefreshReg;
window.$RefreshSig$ = prevRefreshSig;
RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => {
RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports);
import.meta.hot.accept((nextExports) => {
if (!nextExports) return;
const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports);
if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
});
});
}`
중간에 보면, import.meta.hot.accept 를 수행하면서 모듈을 받아서 react-refresh 에게 넘기는 코드가 있는 것을 확인할 수 있습니다.
좋아요. 이제 우리는 react-refresh 에게 모듈을 넘기는 친구까지 찾아냈습니다.
이렇게 react vite 프로젝트에서 컴포넌트를 수정했을 때 발생하는 HMR 과정에서 vite 가 어떤 역할을 담당하는지 알아봤습니다.
정리하면,
@vitejs/plugin-react 를 등록해뒀습니다.@vitejs/plugin-react 에 의해 해당 컴포넌트는 HMR 대상이라고 판단되어 소켓을 통해 update 타입이 전송됩니다. 브라우저는 update 메세지를 받아서 해당 파일을 다시 요청하고, 넘겨받은 모듈을 react-refresh 에게 넘기면 react-refresh 가 컴포넌트 HMR을 진행합니다.full-refresh 타입이 전송됩니다. 브라우저는 full-refresh 메세지를 받아서 페이지를 리로드합니다.
좋은 글 감사합니다! 도움 많이 받고 갑니다 👊👊