멀티 레포 개발 환경 개선: 프록시 기반 통합 개발 서버 구성

여러 프로젝트가 별도의 레포에 존재해 서비스를 개발하려면 각각의 개발 서버를 구동해야 했다. 개별 개발 서버를 구동하는 건 별 문제가 되지 않지만 각 서비스 내에서 공통 서비스로 이동해야 하는 부분 때문에 개발 과정에 불편한 점이 존재했다. 예를 들어, /care에서 /common로 이동하는 링크가 존재한다면 care 프로젝트에는 /common으로의 라우팅이 정의되지 않았기 때문에 메인 페이지로 튕겨버리게 된다.

 

CI, QA 등 배포 환경에서는 도메인과 포트번호(HTTPS)가 동일하기 때문에 /care → /common로 이동하는 기능을 테스트해야 한다면 결국 CI에 배포해야 한다. 하지만 배포 후에 문제가 해결되지 않기라도 하면 수정 → 빌드 → 배포 → 확인 수정 → ...의 과정을 반복적으로 수행해야 하는 불편함이 뒤따르게 된다. 따라서 이러한 불편함을 개선하고자 개발 환경에서 테스트할 수 있도록 프록시를 이용해 모든 서비스를 하나의 포트에 띄우는 통합 개발 서버를 구성하게 되었다. /care는 5000 개발 서버, /common은 5300 개발 서버로 프록시되도록 http-proxy-middleware를 이용해 프록시 미들웨어를 추가해주었다.

 

아래는 통합 개발 서버 구성 과정에서 맞딱뜨린 문제들과 그 해결 과정에 대한 내용이다.

 

 

 

1. 정적 파일 전달 실패

`index.html`이나 컴포넌트 코드에서 요청하는 `/css/styles.css`, `/img/icon.svg` 같은 정적 파일도 해당하는 서비스(5000 or 5300)에서 가져와야 하지만 `http://localhost:3000/css/styles.css`와 같이 /care, /common이 포함되지 않은 루트 경로로 요청을 보내기 때문에 프록시 서버에서 파일을 제대로 전달해주지 못하는 문제가 발생했다.

정적 파일을 어느 서버에서 가져올지 결정할 수 있도록 응답 쿠키 `currentApp`를 설정하는 Express 미들웨어를 추가해줬다. 정적 파일 요청 URL에는 /care, /common이 포함되지 않지만 정적 파일을 요청하기 전에 `index.html`을 가져오기 위한 `http://localhost:3000/care` 또는 `http://localhost:3000/common` 요청이 발생하기 때문에 `currentApp` 쿠키를 문제없이 설정할 수 있을 것이다.

단순하게 `currentApp` 쿠키를 설정해도 문제가 없지만, /care, /common 탭이 둘 이상 켜져 있다면 혼선이 발생해 `currentApp` 쿠키가 제대로 설정되지 않을 수 있다.(동일 오리진이므로 쿠키값이 덮어씌워질 수 있다) 따라서 1차적으로는 요청 헤더의 `referer` 값을 체크해 경로에 /care, /common이 존재하면 해당하는 개발 서버로 프록시를 하고, `referer` 헤더가 존재하지 않는다면 `currentApp` 쿠키 값을 체크하는 방식으로 보완했다. 

 

 

 

2. HMR을 위한 웹소켓 연결 실패

Webpack 개발 서버에서는 HMR(Hot Module Replacement)을 위해 브라우저에서 `/sockjs-node`로 웹소켓 연결 요청을 보내도록 한다. 현재까지 구성된 프록시 서버에서 브라우저가 `http://localhost:3000/sockjs-node`를 요청하더라도 101 응답이 떨어지지 않고 계속 pending 상태에 머물고 있거나 101 응답이 떨어지더라도 코드 변경 사항이 화면에 반영되지 않는 문제가 발생했다. 

 

 

A. HTTP 101 응답이 오지 않고 pending 상태에 머무르는 문제

브라우저 네트워크 탭에도, 프록시 서버 로그에도 어떠한 오류가 뜨지 않았기 때문에 원인을 분석하는 데에 어려움이 있었다. 브라우저 요청, 프록시 응답 어디에 문제가 있는지 파악하기 위해 Wireshark를 통해 오고가는 패킷을 분석해봤다.

브라우저에서 localhost:3000/sockjs-node으로 요청을 보냈지만 pending...

위 스크린샷은 Wireshark "Adapter for loopback traffic capture" 화면에서 브라우저가 `localhost:3000/sockjs-node`으로 요청을 보냈지만 pending 상태일 때 발생하는 패킷 흐름이다. 위에 보이는 소스 포트 60703, 60705는 각각 브라우저 - 프록시 서버, 프록시 서버 - 개발 서버와 통신하기 위해 사용하는 클라이언트 단의 임시 포트(Ephemeral Port)이다.

1. 브라우저(60703)에서 프록시 서버(3000)로 `localhost:3000/sockjs-node` 요청을 보낸다.
2. 프록시 서버(3000)에서 HMR 서버(5300)과 연결하기 위한 포트를 생성(60705)해 브라우저가 보낸 요청을 그대로 프록시한다.
3. HMR 서버(5300)는 웹소켓 핸드쉐이크에 대한 응답을 프록시 서버와 연결된 포트(60705)로 전송한다.
4. 프록시 서버(3000)는 101 응답을 받아 HTTP 프로토콜을 Websocket 프로토콜로 업그레이드한다.
5. ??? 

원인은 101 응답이 일반적인 HTTP 응답과는 다른 프로토콜 전환 응답이기 때문에 이 응답을 적절하게 처리해줘야 하지만 그렇지 않기 때문이었다. 브라우저가 `localhost:3000/sockjs-node`으로 웹소켓 핸드쉐이크를 요청하면 프록시 서버는 5000/5300 포트의 HMR 서버에 그 요청을 보내주고, HMR 서버는 프록시 서버와 연결된 포트로 응답을 돌려준다. 이에 따라 프록시 서버는 5000/5300 개발 서버와 웹소켓 연결을 생성하는 것까지는 성공한다. 하지만 현재 설정이 제대로 되어 있지 않기 때문에, 프로토콜 upgrade 이벤트가 발생해도 프록시 서버가 브라우저에게 웹소켓 업그레이드 응답을 보내는 후속 조치를 하지 않는다. 따라서 프록시 서버는 5000/5300 개발 서버와 웹소켓 연결을 생성하고 끝내버리기 때문에 브라우저가 보낸 `http://localhost:3000/sockjs-node` 요청은 계속 pending 상태에 머물게 되는 것이다.

const COMMON_PROXY_OPTIONS = {
  changeOrigin: true,
  // ✅ 웹소켓을 처리할 수 있도록 옵션 추가
  ws: true,
  logLevel: 'silent',
}

`createProxyMiddleware()`에서는 `ws: true` 옵션을 통해 웹소켓 upgrade 이벤트를 처리할 수 있도록 지원하고 있다. /care, /common, ... 프록시 미들웨어에 해당 옵션을 추가해주고, Node Server 객체의 `"upgrade"` 이벤트 핸들러에서 프록시 미들웨어에 대해 upgrade를 처리해줬다.

server.on('upgrade', (req, socket, head) => {
  if (url.startsWith('/sockjs-node') || currentApp === 'care') {
    careProxy.upgrade(req, socket, head);
  }
  // ✴️ 다른 프록시에 대해서도 upgrade 메서드 호출 ...
});

 

한편, 프록시 서버가 5000, 5300에 모두 요청을 보낸 것은 아래 `http://localhost:3000/`와 같이 루트 경로에 접속할 때 기본적으로 5300 포트로 프록시해주는 코드 때문이었다.

app.use(
  '/',
  createProxyMiddleware({
    target: `http://localhost:${getTargetPort(SERVICE_NAME.common)}`,
    ...COMMON_PROXY_OPTIONS,
  }),
);

`http://localhost:3000/sockjs-node`는 /care, /common로 시작하지 않기 때문에 /care나 /common의 프록시 미들웨어가 아니라 루트 경로의 프록시 미들웨어에서 처리된다. 루트 경로는 5300 포트로 프록시하도록 fallback 개념으로 두었는데 이것이 문제를 일으켰던 것이다. 해당 코드는 삭제하는 걸로 수정했다.

 

 

B. Invalid frame header: 원인

웹소켓 핸드쉐이크 요청으로 101 응답이 떨어졌지만 이번에는 콘솔 창에 Invalid frame header 오류가 뜨면서 웹소켓 연결에 실패했다는 메시지가 보였다. 해당 문제가 발생한 Wireshark 캡처 결과는 아래 이미지와 같다. 

프록시 서버가 5300, 5000 서버 모두에 /sockjs-node 요청을 보내 101 응답을 두 번 받았다

① 프록시 서버가 어째서인지 5300, 5000 모두에 웹소켓 핸드쉐이크 요청을 보낸다.
② 프록시 서버는 먼저 101 응답을 보내준 서버(5300)와 웹소켓 연결을 수립하고, 해당 101 응답을 브라우저에 보내준다. 
③ 브라우저는 프록시 서버와 웹소켓 연결을 수립한다.
④ 프록시 서버는 다음 101 응답을 보내준 서버(5000)와 웹소켓 연결을 수립하고, 해당 101 응답도 브라우저에 보내준다. 
⑤ 브라우저와 프록시 서버 사이에선 이미 웹소켓 연결이 수립되었기 때문에 HTTP 101 응답이 웹소켓 프레임 메시지로 잘못 해석된다.

까지는 문제 없이 브라우저와 프록시 서버 사이에 웹소켓 연결이 정상적으로 수립되지만 ④부터 꼬이기 시작한다. ③에서 브라우저와 프록시 서버가 네트워크 통신을 위해 사용한 소켓은 Websocket 프로토콜로 업그레이드된다. ④에서 프록시 서버는 5000 서버로부터 받은 101 응답도 브라우저에 보내주지만 브라우저와 프록시 서버는 이미 웹소켓으로 연결되어 있다. 브라우저는 하나의 소켓으로 프록시 서버와 웹소켓 연결을 수립했기 때문에 별도의 소켓이 웹소켓 프로토콜로 전환되는 게 아니라 기존 소켓에 웹소켓 프레임으로서 전송된다. 따라서 브라우저는 후속 HTTP 101 응답을 HTTP 응답 메시지가 아니라 웹소켓 프레임으로 잘못 해석하게 된다.   

브라우저, 프록시 서버, 개발 서버의 웹소켓 연결 상태
프록시 서버가 웹소켓 프레임으로 전송하는 101 응답 #1

위 스크린샷은 웹소켓 프레임으로 잘못 해석된 HTTP 응답 메시지를 나타낸다. 이 TCP 세그먼트 데이터를 살펴보면 HTTP 응답 메시지의 상태 라인(Status Line)의 일부인 `HT`가 웹소켓 헤더로 해석된 것을 확인할 수 있다. `HT`는 16진수 `48 54`로 표현되는데, 2진수로는 `01001000 01010100`이 되고, 브라우저는 각각을 웹소켓 프레임 헤더의 FIN, RSV, OpCode, Payload Length 값으로 잘못 해석하고 있는 것이다. 

 

더보기

위 스크린샷은 앞서 살펴본 첫번째 프레임에서 하나의 TCP 세그먼트를 나타내는데 이 세그먼트의 페이로드는 129바이트로 노란색 박스의 데이터에 해당한다. 브라우저는 이 페이로드를 웹소켓 프레임의 헤더로 잘못 해석해 Payload Length가 `1010100`(84)이 되는데, 84바이트는 `TP/1.1 101 Switching Protocols ... sec-websock`까지의 텍스트이다. 그렇다면 이 TCP 세그먼트의 뒷부분은 그대로 버려지는 걸까?

https://www.geeksforgeeks.org/computer-networks/open-systems-interconnection-model-osi

TCP 세그먼트에서 응용 계층의 데이터 뒤에 추가적인 데이터가 붙는 것은 전혀 이상한 것이 아니다. TCP는 전송 계층(Transport Layer) 프로토콜이고, HTTP와 Websocket은 응용 계층(Application Layer) 프로토콜이다. 서버 측에서 데이터를 전송할 때, 전송 계층에서는 그 이전 계층의 페이로드가 어떻든 상관하지 않고 정해진 길이만큼 잘라서 그 앞에 전송 계층의 헤더를 붙여 다음 계층으로 넘긴다. 반대로 클라이언트 측에서는 전송 계층에서 헤더를 뗀 TCP 페이로드(129바이트)를 다음 응용 계층으로 넘기고, 응용 계층에서는 여러 세그먼트로 분할되어 도착한 데이터를 프레임 헤더를 이용해 하나의 프레임으로 재구축한다.

예를 들어, Websocket 프로토콜에서 이 두번째 프레임의 헤더를 해석했을 때 페이로드까지 온전하게 포함되었다면 해당 TCP 패킷 페이로드에는 두 개의 웹소켓 프레임이 담긴 것이고 브라우저에서도 이걸 두 개의 웹소켓 프레임으로 해석한다. 즉, 위 상황에서는 두번째 프레임이 두 개의 TCP 세그먼트 페이로드에 나눠서 도착한 것이다.

두번째로 도착한 세그먼트는 139바이트로, TCP 헤더 64바이트와 페이로드 75바이트이다. 두번째 프레임의 헤더와 일부 페이로드는 앞선 세그먼트에 담겨왔으므로 현재 세그먼트는 두번째 프레임의 나머지 페이로드로 해석된다.  

위 스크린샷에서 웹소켓 프레임이 앞 프레임의 데이터 일부가 합쳐진 것처럼 보이는데 실제로는 `TP/1.1 101 Switching Protocols ... sec-websock`가 첫번째 프레임의 페이로드, `et-accept: ... "liveReload"}`가 두번째 프레임의 헤더와 페이로드이다. Wireshark에서는 두 개의 TCP 세그먼트를 하나의 웹소켓 프레임으로 재구성해서 보여줄 뿐이고, 네트워크 상에서 데이터가 중복으로 전송된 것은 아니다. 스크린샷의 왼쪽 영역을 보면 2 Reassembled TCP Segments (118 bytes) #1058(43), #1060(75)라고 웹소켓 프레임이 #1058, #1060 두 개의 세그먼트에서 재조립된 것이라고 알려주고 있다.

프록시 서버가 웹소켓 프레임으로 전송하는 101 응답 #2

두번째 프레임을 살펴보면 5000 서버가 보냈던 HTTP 101 응답의 일부와 5000 서버가 보낸 웹소켓 데이터가 모두 붙어있는 것을 확인할 수 있다. 프록시 서버가 5000 서버의 101 응답과 그 이후에 받은 웹소켓 프레임을 브라우저와 웹소켓 통신으로 전송하고 있는 것이다. 프록시 서버는 5300, 5000 서버와 각각 웹소켓으로 연결되었지만 브라우저와는 단 하나의 웹소켓으로 연결되어 있기 때문에 어찌보면 당연한 것이라고 할 수 있다. 두번째 프레임의 헤더를 보면 OpCode가 `0101`로, 유효하지 않는 OpCode로 브라우저에 전송하고 있는데 이것이 브라우저에서 출력되었던 Invalid frame header 오류의 원인이라 생각할 수 있다.

 

 

C. Invalid frame header: 코드 상의 오류

그렇다면 왜 HTTP 101 응답이 웹소켓 프레임으로 잘못 해석된 걸까? 결론부터 말하면 프록시 미들웨어의 옵션을 잘못 설정해 프록시 미들웨어가 각각의 path(/care, /common, ...)가 아니라 전부 루트 경로에 대해 등록되어 `ws: true` 옵션에 의해 upgrade 이벤트가 처리되고, `server.on('upgrade', () => { })`에서도 중복으로 upgrade 이벤트가 처리되었기 때문이다.

1. `pathFilter`은 http-proxy-middleware v3에서 추가된 속성
2. 1에 의해 모든 프록시 미들웨어가 루트 경로(`"/"`)에 등록되었다
3. 루트 경로에 등록된 미들웨어의 `ws: true` 옵션과 Node 서버 upgrade 이벤트 핸들러에 의해 프로토콜 업그레이드가 중복으로 처리된다.
const careProxy = createProxyMiddleware({
  target: 'http://localhost:5000',
  // ❌ http-proxy-middleware v2.0.9에서 유효하지 않은 옵션
  pathFilter: '/care/**',
  ...COMMON_PROXY_OPTIONS,
});

프록시 미들웨어의 설정은 위와 같았는데, `pathFilter` 속성은 v3에서 추가되었지만 현재 사용하고 있는 http-proxy-middleware 버전은 v2.0.9였다. 따라서 `pathFilter`가 적용되지 않고, 기본값인 `this.config.context = "/"`으로 설정되어 모든 프록시 미들웨어가 루트 경로에 대해 적용되고 있었다. 설정이 처음부터 잘못되었지만 그럼에도 HMR을 제외한 기능이 정상적으로 작동했던 이유는 정적 파일을 처리해주는 미들웨어가 /care, /common에 해당하는 `index.html`과 번들 JavaScript를 정상적으로 프록시해주고 있었기 때문이다.

확인해보니 동일 경로(`"/"`)에 등록된 미들웨어가 둘 이상일 경우, `app.use()`로 가장 먼저 적용된 미들웨어에 대해서만 프로토콜이 업그레이드되었다. 내가 작성한 코드에서 가장 먼저 적용된 프록시는 common 프록시 미들웨어였다. /care에 접속했다고 했을 때, `ws: true` 옵션에 의해 가장 먼저 등록된 common 프록시 미들웨어에서 http-proxy-middleware 내부적으로 upgrade 이벤트가 처리되고, `server.on('upgrade', () => { })` 코드 때문에 care 프록시 미들웨어까지 중복으로 upgrade 처리가 된 것이다. 

 

 

D. Invalid frame header: 해결

`ws: true` 옵션과 Node `server` upgrade 이벤트 핸들러 중 하나만 적용하면 문제를 해결할 수 있을 것이다. 각 서비스에서 HMR을 위한 웹소켓 연결 요청 URL은 `localhost:3000/sockjs-node`이므로 /care, /common, ... 어디에도 걸리지 않기 때문에 루트 경로에서만 처리할 수 있다. 따라서 프록시 미들웨어 설정을 모두 `ws: false`로 변경하고, `server.on('upgrade', () => { })`에서 각각의 프록시 미들웨어의 `upgrade` 메서드를 호출하는 로직만 남겼다. 하지만 이번에는 브라우저에서 보낸 `localhost:3000/sockjs-node` 요청이 전혀 프록시되지 않았다.  

if (this.proxyOptions.ws === true) {
  this.catchUpgradeRequest(server);
}

// ...

private catchUpgradeRequest = (server: https.Server) => {
  if (!this.wsInternalSubscribed) {
    server.on('upgrade', this.handleUpgrade);
    // prevent duplicate upgrade handling in case external upgrade is also configured
    this.wsInternalSubscribed = true;
  }
};

http-proxy-middleware 소스 코드에서 확인해보니 웹소켓 upgrade 이벤트는 http-proxy라는 별도의 라이브러리를 이용해 처리하고 있다. 하지만 `ws: false`이면 upgrade 이벤트 핸들러를 등록하는 `this.catchUpgradeRequest()`가 실행되지 않도록 되어 있어 `careProxy.upgrade()`, `commonProxy.upgrade()` , ...를 호출해도 아무런 의미가 없게 된다.

private handleUpgrade = async (req: Request, socket, head) => {
  if (this.shouldProxy(this.config.context, req)) {
    const activeProxyOptions = await this.prepareProxyRequest(req);
    
    this.proxy.ws(req, socket, head, activeProxyOptions);
  }
};

`ws: true`로 다시 옵션을 변경하면 `this.catchUpgradeRequest()`가 실행되어 server 객체에 upgrade 이벤트 핸들러를 등록할 수 있지만, 앞서 프록시 미들웨어를 등록할 경로(`this.config.context`)를 /care, /common, ...으로 수정했기 때문에 이번에는 `this.handleUpgrade()`에서 걸리게 된다. `this.shouldProxy()`에서 요청 URL이 `this.config.context`의 하위 경로인지 체크하는데, 현재 설정에서 프록시 미들웨어의 `this.config.context`는 /care, /common, ...이다. 하지만 HMR 요청 URL `/sockjs-node`는 /care, /common의 하위 경로가 아니기 때문에 `/sockjs-node`에 대해서는 `this.proxy.ws()`가 실행되지 않는다.

 

웹소켓 요청 URL이 `/care/sockjs-node`, `/common/sockjs-node`, ... 이런 식이었다면 `ws: true` 옵션을 통해 웹소켓 업그레이드를 처리할 수 있었겠지만 지금처럼 context(` /care`, `/common`, ...)와 웹소켓 요청 URL이 겹치지 상황에서는 `ws: true` 옵션을 이용하기는 어려워 보인다. 따라서 직접 http-proxy 패키지를 이용해 `server.on('upgrade', () => { })`에서 웹소켓 업그레이드 이벤트를 처리해주는 방법이 최선일 것이다.

const httpProxy = require('http-proxy');

server.on('upgrade', (req, socket, head) => {
  // ✴️웹소켓 연결을 위한 proxyServer 객체를 별도로 만들어서 upgrade 이벤트를 처리한다
  const proxy = httpProxy.createProxyServer({
    target: `http://localhost:${targetPort}`,
    ws: true,
    changeOrigin: true,
  });

  proxy.ws(req, socket, head);
});

 

 

 

3. 뒤로가기 동작 오류

`/care/list`에서 `/common/b` 페이지로 이동한 후, react-router `navigate(-1)`로 뒤로가기를 하면 `/care/list`가 아니라 로그인 페이지로 튕기는 문제를 발견했다. CRA 프로젝트이기 때문에 페이지 전환을 하면 프록시 서버에 GET 요청 로그가 보이는 게 정상인데 문제 상황에서는 해당 로그가 뜨지 않았다.

`/care/list`로 뒤로가기 했을 때 브라우저 네트워크 탭을 확인해보니 정적 파일을 프록시 서버로 요청을 보내는 게 아니라 캐시에서 가져오고 있었다. `index.html`은 /care인지 /common인지에 따라 다르기 때문에 개발 중에 `index.html`을 직접 수정하지 않는 한 캐시에서 가져오더라도 다른 서비스의 html로 잘못 가져올 일은 없을 것이다.

하지만 `style.css`, `vendors~main.chunk.js`, `main.chunk.js`와 같은 정적 파일도 모두 캐시에서 가져오고 있었다. /care, /common 등 각각의 서비스는 CRA를 사용하고 있었기 때문에 번들링된 JavaScript 파일 이름은 `bundle.js`, `vendors~main.chunk.js`, `main.chunk.js`으로 모두 동일하다. JavaScript 파일들의 이름은 모두 동일하지만 내용이 전부 다르기 때문에 care에서 common의 스크립트를 가져오게 된다면 문제가 된다. `navigate(-1)`로 이동한 후 다운로드된 JavaScript 파일의 MD5 값을 확인해보니 common에서의 MD5 값과 동일했다. 즉, care의 JavaScript 파일을 가져오는 게 아니라 common의 JavaScript 파일을 가져오고 있었다.

care, common 등 모든 서비스가 SPA이기 때문에 라우팅 관련 코드도 JavaScript 파일에 존재하는데 common에는 `/care/list`로 라우팅할 컴포넌트가 정의되어있지 않기 때문에 와일드카드에 걸려 로그인 페이지로 튕기는 것이다. 반대로 `/common/b` → `/care/list` 이동 후 뒤로가기를 하는 경우에도 care 서비스에는 `/common/b` 라우팅이 정의되어있지 않기 때문에 마찬가지로 메인으로 튕기게 될 것이다.

 

`/care/list`에서나 `/common/b`에서든 JavaScript 파일 이름은 모두 동일한데 왜 `navigate(-1)`이나 뒤로가기로 이동했을 때 이런 문제가 발생하고, `navigate("/care/list")`로 이동할 때는 문제가 없는 걸까? 브라우저는 뒤로가기를 하면 BFCache를 사용해 이전 페이지의 화면을 메모리의 힙 영역까지 복원한다고 한다. `/common/b`에서 뒤로가기를 하면 `/care/list`이기 때문에 care의 `bundle.js`, `vendors~main.chunk.js`, `main.chunk.js`를 가져오는 게 맞다. 하지만 BFCache가 작동해 메모리에 저장된 CSS, JavaScript 파일을 가져오게 되는데 하필 care와 common의 정적 파일 이름이 동일해 `/common/b`에 진입했을 때 해당 파일들의 캐시가 덮어씌워진다. 이후 뒤로가기로 `/care/list`로 돌아왔을 때는 common에서 덮어씌웠던 정적 파일들의 캐시를 그대로 사용하는 것이다.

 

문제를 해결하기 위해서는 BFCache가 적용되지 않도록 해야 한다. BFCache는 사용자 경험을 위해 추가된 브라우저 기능이기 때문에 개발 환경에서는 해당 기능을 비활성화해도 문제가 없을 것이다. 아래와 같이 프록시 응답 헤더 `cache-control`을 캐시가 되지 않도록 설정했다. 

createProxyMiddleware({
  /* ... */
  onProxyRes: (proxyRes) => {
    /* BFCache 방지를 위한 헤더 설정 */
    proxyRes.headers['cache-control'] = 'no-store, no-cache, must-revalidate, proxy-revalidate';
    proxyRes.headers['expires'] = '0';
  },
}),

 

 

 

4. 기타

A. 웹소켓 핸드쉐이크

https://hudi.blog/websocket-with-nodejs

웹소켓 연결을 생성하기 위해서 브라우저는 HTTP 프로토콜을 이용해 아래와 같이 Connection, Upgrade, Sec-Websocket-Key 헤더가 포함된 핸드쉐이크 요청을 보낸다. Sec-Websocket-Key 헤더는 웹소켓 핸드쉐이크 시 서버가 올바른 방식으로 응답했는지 검증하기 위한 난수이며, 웹소켓 핸드셰이크의 인증·무결성 체크에 사용된다. 웹소켓 연결 요청을 받는 서버가 웹소켓 프로토콜을 이해하지 못하고 있다면 101 응답이 아닌 200 응답을 보내주거나, Sec-WebSocket-Accept가 없거나 잘못된 값을 보내줄 수 있기 때문에 웹소켓 핸드쉐이크의 무결성 체크에 사용될 수 있다. 

서버는 웹소켓 프로토콜을 지원한다면 Sec-WebSocket-Key와 GUID를 합쳐 SHA-1로 암호화를 한 후에 Base64로 인코딩한 Sec-WebSocket-Accept 헤더를 포함한 101 Switching Protocols 응답을 브라우저에 보내준다.

 

 

Q. 101 Switching Protocols라는 HTTP 응답이 오는데 왜 프록시 서버는 이 응답을 브라우저에 그대로 돌려주지 못하는가?

클라이언트가 보내는 `GET /sockjs-node` 요청은 일반적인 HTTP 요청이지만 서버에서 보내주는 프로토콜 전환 101 응답은 일반적인 HTTP 응답과는 다르다. 101 응답은 단순히 데이터를 전달하는 것이 목적이 아니라, "이제부터 전화(HTTP)를 끊지 않고, 무전기(WebSocket) 모드로 바꿔서 계속 이야기하자"라고 합의하는 핸드쉐이크의 완료 신호라고 할 수 있다.

 

그렇다면 프록시 서버가 받은 101 응답을 그대로 클라이언트에 전달하면 되는 게 아닐까? 웹소켓 핸드쉐이크에서 사용되는 `Connection`, `Upgrade` 헤더는 대표적인 Hop-by-hop 헤더이다. Hop-by-hop 헤더는 최종 목적지까지 전달되는 End-to-end 헤더와는 달리 다음 서버(Origin Server)로 넘기지 않고 삭제되어야 한다. 프록시 서버에 별다른 설정을 하지 않았다면 HMR 서버에 이 헤더가 전송되지 않기 때문에 프록시 서버와 HMR 서버 사이에 제대로된 웹소켓 핸드쉐이크가 이뤄지지 않을 수 있다. 

또한, 프록시 서버와 HMR 서버 사이에 웹소켓 핸드쉐이크가 성공적으로 이뤄졌다 하더라도 이 웹소켓 연결은 프록시 서버와 HMR 서버 사이의 웹소켓이지, 브라우저와 프록시 서버 사이의 웹소켓이 아니다. 

프록시 서버가 받은 101 응답을 그대로 브라우저에 전달한다면?
1. 프록시 서버는 HTTP 응답이라고 판단해 101 응답을 보낸 후 브라우저와의 소켓 연결을 종료한다.
2. 이후에 브라우저가 웹소켓 프레임을 전송한다면 HTTP 메시지만을 기대하는 프록시 서버는 메시지 파싱에 실패한다. 

프록시 서버 - HMR 서버 사이에 웹소켓 연결이 수립되는지에 대한 문제는 제쳐두고, 프록시 서버가 브라우저에 101 응답을 그대로 전달한다고 해보자. 이때 프록시 서버는 101 응답을 보내면서 웹소켓 연결을 위한 준비를 따로 하지 않는다.(그게 가능하다면 프록시 서버에 브라우저와 웹소켓 연결을 위한 설정이 제대로 되어있다는 의미이기 때문이다.) ① 웹소켓 통신을 하기 위해서는 소켓이 계속 연결되어 있어야 하지만, 프록시 서버는 101 응답을 단순한 HTTP 응답 메시지로 처리할 것이기 때문에 이 응답을 전달한 후에는 소켓 연결이 종료된다. ② 101 응답을 받은 브라우저는 프록시 서버가 웹소켓 연결 준비를 완료했다고 착각하고 이후에는 프록시 서버와 웹소켓 통신을 시도할 것이다. 하지만 프록시 서버는 브라우저와 웹소켓 통신을 할 준비가 되어있지 않기 때문에 이 웹소켓 프레임을 HTTP 메시지로 착각하고 메시지 파싱에 실패한다. 따라서 단순히 프록시 서버가 받은 101 응답을 그대로 브라우저에 전달한다고 해서 웹소켓 통신을 중계할 수 있는 것이 아니다.  

 

그렇다면 프록시 서버가 클라이언트에 101 응답을 보내주면서 웹소켓 연결 생성을 한다면 문제 없지 않을까? 바로 그것이 웹소켓 프록시 서버의 역할이다. 웹소켓 프록시 서버는 Upgrade 이벤트를 처리해서 백엔드와 웹소켓 연결을 수립하고, 클라이언트와 백엔드 소켓을 연결(piping)하고, 양방향 웹소켓 메시지를 중계하는 서버이다.