Vite 프로젝트에서 리액트 컴포넌트는 어떻게 HMR될까? (소스코드 뜯어보기)

우현민·2023년 4월 14일
49

Vite

목록 보기
1/1
post-thumbnail

Vite

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

엄밀히는 리액트 프로젝트 보다는 CSR SPA 렌더링을 하는 정적 리액트 웹사이트 프로젝트라고 해야 정확한 표현이고, vite 보다는 create-vite 패키지를 이용해 생성한 template-react-ts 보일러플레이트 라고 해야 정확한 표현입니다만, 편의상 이렇게 표현하겠습니다.

설정이 워낙 많고 케이스가 다양하기 때문에, 모든 설정은 yarn create vite 를 하고 React, Typescript 를 선택하여 생성한 것을 기준으로 하겠습니다. vite 버전은 2023년 4월 12일 기준 vite git 메인 브랜치 헤드인 cdd9c2320650f34c46e02f3777239e595cf6543d 를 기준으로 하겠습니다.



이번 글에서는 vite 에서 App.tsx 에 있는 App 이라는 리액트 컴포넌트를 수정했을 때 HMR이 무슨 과정을 거치며, 그 과정에서 vite가 무슨 일을 해 주는지를 브라우저 개발자 도구와 vite 소스코드를 함께 뒤적거리며 알아보겠습니다.



HMR 과정 확인하기

먼저 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 로 발송된 걸 보니 needFullReloadfalse 였나 봅니다. 이 부분에 대한 미스테리는 지금 다뤄 버리면 내용이 산으로 가기에, 다음 섹션에서 알아보겠습니다. 참고로 미리 스포를 하자면, 리액트 컴포넌트가 아닌 대부분의 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 니까, fetchUpdatequeueUpdate 만 살펴보면 되겠네요! (참고: update.type여기서 확인할 수 있듯이 js-updatecss-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 에 해답이 있습니다. 그리고, 지금까지에 비해 꽤나 쫓아가기 어렵습니다. qualifiedCallbackmod 에서 가져온 거고, modhotModulesMap 에서 가져온 값입니다. hotModulesMap 은 빈 Map 으로 초기화되며, 여기에 set 을 수행하는 곳은 acceptDeps 한 곳 뿐입니다. 그리고 다시, 이 acceptDepshot.accepthot.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') {
        // ...
    },

hotimport.meta.hot 을 통해 외부에 노출됩니다. 공식문서 를 보면 (원문 / 번역), 플러그인은 import.meta.hot.accept를 수행하여 "HMR된다고 등록" 함과 동시에 모듈의 HMR을 자체적으로 수행할 수 있습니다.

모듈 자신에 대한 HMR을 확인하기 위해서는 import.meta.hot.accept를 사용하고 업데이트된 모듈을 받는 콜백을 전달합니다: (중략) 이렇게 Hot updates를 "허용한" 모듈은 HMR 범위로 간주됩니다.

어? "HMR 범위로 간주" 된다고? 아까의 미스테리 - 왜 리액트 컴포넌트는 update 타입이고 일반 상수파일은 full-reload 가 날라가는가 - 가 해결됩니다. vite 가 리액트 컴포넌트를 HMR 대상이라고 판단했기 때문에, 리액트 컴포넌트는 HMR이 되니까 아까 앞에서 봤던 isFullReloadfalse여서 update 타입이 날아가고, 다른 것들은 HMR이 안 되니까 full-reload가 날아갑니다. 이 부분에 대한 판단 로직은 propagateUpdate 함수에서 찾아볼 수 있습니다. (리액트 컴포넌트에 대해 node.isSelfAcceptingtrue 입니다.)

너무 길어졌네요. 정리하자면 이런 상황입니다.

  • 외부의 플러그인이 리액트 컴포넌트에 대해 import.meta.hot.accept 를 실행해서 HMR 콜백을 등록했다
  • 해당 콜백이 리액트 컴포넌트의 HMR을 책임지고 진행하고 있다.
  • 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 가 어떤 역할을 담당하는지 알아봤습니다.

정리하면,

  • React Typescript 템플릿에서 우리는 vite 에 plugin 으로 @vitejs/plugin-react 를 등록해뒀습니다.
  • vite 개발서버를 열고 접속하면 웹소켓이 연결되며, 개발서버는 파일 변경 시 이 소켓을 통해 이벤트를 전달합니다.
  • 리액트 컴포넌트를 수정하고 저장할 경우, @vitejs/plugin-react 에 의해 해당 컴포넌트는 HMR 대상이라고 판단되어 소켓을 통해 update 타입이 전송됩니다. 브라우저는 update 메세지를 받아서 해당 파일을 다시 요청하고, 넘겨받은 모듈을 react-refresh 에게 넘기면 react-refresh 가 컴포넌트 HMR을 진행합니다.
  • 리액트 컴포넌트가 아닌 것을 (정확히는 HMR대상이라고 마킹되지 않은 것을) 수정하고 저장할 경우 , 소켓을 통해 full-refresh 타입이 전송됩니다. 브라우저는 full-refresh 메세지를 받아서 페이지를 리로드합니다.
profile
프론트엔드 개발자입니다

5개의 댓글

comment-user-thumbnail
2023년 4월 21일

좋은 글 감사합니다! 도움 많이 받고 갑니다 👊👊

1개의 답글
comment-user-thumbnail
2023년 12월 14일

멋진글 잘보고갑니당~!!

답글 달기
comment-user-thumbnail
2024년 4월 12일

잘 읽고갑니당. 소스코드 뜯어보며 vite의 동작원리를 찾아가다니 멋지네요

답글 달기
comment-user-thumbnail
2024년 5월 4일

좋은글 감사합니다 !

답글 달기