React 웹 프론트엔드 로컬 개발 서버의 동작 방식
일반적으로 번들러가 로컬 개발 서버를 같이 제공한다. webpack
은 webpack-dev-server
가 있고, vite
는 plugin-react
가 있다. 추상화가 잘 되어있기 때문에 dev
나 serve
커맨드를 실행하면 일어나는 일에 대해 개발자는 크게 신경쓸 필요가 없다.
하지만 회사에서 내가 유지보수하는 Micro Frontend 프레임워크 podojs에서는 번들러 호환 개발 서버를 그대로 쓰는게 상당히 고통이 컸다. 많이 마개조를 한 상태인데도 그렇다. 정확히 어떤 고통인지는 나중에 더 다룰 기회가 있을 것이다. 이 문제를 어떻게 더 잘 해결해 볼 수 있을까 싶어 로컬 개발 서버에 대해 공부를 해봤다.
이번 포스팅에서는 live-reload를 지원하는 매우 기초적인 로컬 개발 서버를 만들어보고 로컬 개발 서버의 기본적인 동작 방식과 이를 이루는 구성 요소에 대해 알아본다.
조감도
각 구현체들의 실제 동작은 예제 코드를 참조하면 된다.
- IDE(vscode 등)에서 코드를 수정한다.
- 특정 디렉토리 범위를 감시하는 watcher가 bundler의 동작을 트리거한다
- bundler가 성공적으로 빌드를 성공시키면 web-socket에서 웹 브라우저에 떠 있는 react-app에 메시지를 보낸다
- web socket 메시지를 받은 react-app은 브라우저를 새로고침한다.
- 새로고침한 이후 dev-server에서 번들 결과물을 요청해 앱을 다시 그린다.
구성요소
이 섹션에서는 위 조감도의 네모들을 하나씩 설명한다. 오픈소스 번들러(거의 주로 webpack
)를 보면서 내가 지금껏 알고 있는 것들도 한번씩 정리를 해본다.
1. Watcher
특정 파일 디렉토리 이하의 모든 파일의 수정, 추가, 삭제를 감시하여 번들러가 번들링하도록 한다.
기본적으로는 클라이언트의 번들 진입점(webpack.entry)에 위상 의존하는 파일 트리를 전부 watch하면 되지만, 모노레포 위에서 애플리케이션 번들링이 이루어지는 경우 해당 애플리케이션이 의존하는 모든 내부 패키지 등으로도 watch 범위를 늘릴 수 있다.
webpack의 하위 라이브러리라고 할 수 있는 watchpack 은 번들링에 관여하는 디렉토리를 감지하여 임의로 fs.watch
를 붙인다. watch하는 파일의 규모에 따라서 디렉토리에 watcher를 붙일지 모든 파일에 일일히 watcher를 붙일지 정하는 알고리즘이 존재한다.
webpack이 번들링 전에 파일에 watcher를 붙이는 동작이 webpack-dev-middleware 나 webpack-dev-server 단에 위임되어 있는 것이 아닌가? 대충 생각하고 넘겼던 때가 있었는데, 막상 코드를 까보니 wepback이 watchpack을 직접 사용한다. 기본 플러그인 동작에서 이를 수행한다.
webpack config 파일에서는 watchOptions라는 속성을 통해 이 webpack.watch를 제어한다. 이중 ignored 속성이 가장 유용한데, watch하지 않을 디렉토리나 파일의 glob pattern을 넘길 수 있다. watcher가 너무 많을 경우 다시 번들링되는데서 발생하는 성능 이슈나 watch가 제대로 되지 않는 이슈가 있을 수 있다.
parcel의 경우 watcher를 독립적인 라이브러리로 제공한다. @parcel/watcher. 예제 코드에서는 이것을 사용했다.
2. Bundler
번들링은 빌드, 컴파일이라고 거칠게 지칭되기도 한다. webpack은 내부적으로 번들러 인스턴스를 Compiler라고 지칭한다. 그러나 저 둘은 번들링과 성격이 다른 과정이라서 나는 번들링이라는 표현을 선호한다.
직관적으로 생각하면 개발 서버를 구동할때, 코드를 수정하면 번들링이 거듭되면서 새로운 번들링 결과물이 계속해서 누적될 것이다. 그것은 상당히 비효율적이다.
일반적인 로컬 개발 서버에서는 memfs 와 같은 인메모리 파일 시스템을 이용하여 진짜 node.fs 등의 파일시스템에서 읽기/쓰기를 하지 않고 인메모리에 파일을 만든다. webpack에서는 outputFileSystem 옵션으로 이러한 동작 설정이 가능하다.
watcher와 연동되어, watcher에서 유효한 파일 변경이 감지되었을 경우 번들러 인스턴스 호출을 통해 새로운 코드 형상에 일치하는 빌드 결과물을 다시 만든다. webpack-dev-middleware는 node 서버에서 이러한 동작을 통합하는 것을 목적으로 한다.
혹은 예제 코드에서처럼, 번들러 인스턴스의 생성과 호출을 조금더 programmatic하게 제어할 수 있다면 아래와 같이 watcher와 직접 통합이 가능하다.
watcher.subscribe(async (eventLogs) => {
await bundler.run();
});
3. Server
번들 결과물을 브라우저에 서빙하는 서버를 이른다. 기본적으로 브라우저에서 사용하는 로컬 호스트의 같은 호스트, 포트를 사용한다. 그렇게 해야 브라우저 위에서 상대경로로 요청했을 때 제대로 로컬 개발 서버의 자원을 찾아갈 수 있다.
<!-- localhost:3000/main.js로 자원 요청 -->
<script src="main.js" />
webpack-dev-server를 사용한다면 구체적인 path 설정도 지원한다.
html-webpack-plugin을 사용하면, 진입점 번들을 자동으로 서빙하는 index.html
에 위와 같은 script
태그를 붙여 전달하기 때문에 자연스럽게 노출된 서버의 엔드포인트를 잘 활용하게 된다. 다른 번들러들도 비슷한 동작을 지원한다.
예제 코드에서는 memfs를 사용해 번들 결과물에 접근하고, /
엔드포인트 아래에서 모든 자원을 바로 요청할 수 있게 처리했다.
4. Web-Socket
로컬 개발 환경에서는 코드 변경, 다시 번들링된 결과물을 브라우저에 로드하는 등 서버와 브라우저가 동기화 되어 동작해야 한다. 그래서 서버의 몇 가지 상태를 브라우저가 실시간으로 알고 있을 필요가 있다. 최초 로컬 호스트에 접속시 브라우저는 로컬 개발 서버와 웹 소켓 커넥션을 맺는다.
예제 코드에서는 코드 변경 이후 다시 번들링이 완료된 시점에 서버에서 브라우저로 소켓 메시지를 보내고, 해당 메시지를 받아 브라우저에서 새로고침을 한다.
아래 코드는 로컬 개발 서버의 가장 주요하고 잘 요약된 동작이기도 하다.
// 파일 변경 감지
watcher.subscribe(async (eventLogs) => {
// 파일 변경 감지되었다고 브라우저에 소켓 메시지
socket.send({
type: 'changeDetected',
name,
files: eventLogs,
});
// 번들러 인스턴스의 재호출
await bundler.run();
// 번들링 완료 후 완료되었다고 브라우저에 소켓 메시지
socket.send({
type: 'compileSuccess',
name,
});
});
webpack-dev-server에도 웹 소켓이 존재한다. 웹 소켓과 관련된 옵션도 있고, 다음과 같이 웹 소켓을 통해 브라우저와 서버가 상태를 싱크한다.
5. Client-Runtime
live-reload를 지원하려면 번들링 완료 시점에 브라우저가 새로고침 되어야 한다. 그러려면 위에서 본 것과 같이 서버의 번들링 완료 시점에 브라우저가 소켓 메시지를 받으면, 새로고침을 해야 한다.
이러한 동작을 하는 코드가 로컬 호스트 접속시에 평가된 상태여야만 한다. 로컬 개발 환경에서 브라우저는 필요한 동작을 초기화하고 설정해놓는 클라이언트 런타임 코드를 필요로 한다.
예시 코드 처럼 특정 웹 소켓 메시지 도달시 특정 동작을 하는 코드가 대표적인 예시일 수 있다. 여기서는 최초 서빙되는 HTML에 해당 스크립트 코드를 실어 보냈다.
webpack-dev-server보다 추상화 단계가 한 단계 낮은 webpack-dev-middleware를 통해 로컬 개발서버를 셋 업할 때, webpack config의 entry 속성값으로 클라이언트 번들을 추가해서 브라우저에 평가시켜야 한다.
코드 수정 후 변경된 번들을 브라우저에 계속 평가시키면서 동시에 상태값까지 유지할 수 있는 fast-refresh 는 동작을 위해 별도의 런타임 코드를 필요로 한다. fast-refresh를 지원하는 vite의 react-plugin 동작을 보면 변경이 이루어진 모듈이 브라우저에서 평가될 때, 새로 번들링된 모듈 코드의 전후로 일정한 런타임 코드가 추가된 것을 확인할 수 있다.
맺는 말
부득이하게 이번 포스팅에서 조금씩 언급을 했지만, 로컬 개발 서버의 구현 수준(Live Reload, Hot Module Replacement, fast-refresh)에 대해서는 다음 포스팅에서 다룰 예정이다.
References
- webpack-dev-server
- webpack-dev-middleware
- @vitejs/plugin-react
- # Mental model for the new dev flow 🧠 - Pedro Cattori
(끝)