Vite 설정 가이드

2024년 12월 4일

vite의 config 설정에서 middleware가 미치는 영향

server.middlewareMode : true
배포 시에 위와 같이 사용하고 있는 vite의 설정을 변경 해주지 않으면 에러가 발생합니다. 이러한 이유는 vite 이 기본적으로 SPA(single page application) 이기 때문입니다. 이 말이 의미하는 것은 페이지는 index.html 을 기반으로 동작한다는 이야기입니다.
SPA 는 기본적으로 서버와 통신하여 각각의 페이지와 URL의 이동을 하지 않고 Javascript를 통해서 클라이언트 측에서 콘텐츠를 동적으로 변경하는 방법을 사용합니다.
문제가 되는 상황은 여기서 발생합니다. 특정 사용자가 특정 url, 예를 들어 http://175.45.205.245/voting/3badeb16-38d3-4add-8486-47f4ee431024/waiting 라는 url을 통해서 접근을 하고자 할 경우 해당 URL은 index.html 이 등록되어 있는 경로가 아니기 때문에 브라우저는 이 URL에 대한 자원을 서버에 직접 요청을 보내게 됩니다.
하지만 위와 같이 작성되어 있는 물리적인 경로가 등록이 되어 있지 않은 이상 해당 경로에 서버가 파일을 갖고 있지 않기 때문에 해당 위치에서 html 파일을 찾을 수 없기 때문에 에러가 발생하게 되는 것입니다.
이러한 문제를 해결하는 방법은 모든 URL 요청을 메인 index.html 파일로 부터 리다이렉트 되도록 서버를 구성해야 합니다. vite 을 사용할 경우는 vite에서 제공하는 서버를 기본적인 middleware 라우터로 설정하여 루트 경로에 접근하지 않더라도 리다이렉트가 되게끔 만들기 위해서는 middleware로 vite의 서버 라우터를 사용하겠다고 명시적으로 표기 해주어야 합니다.
브라우저 요청: /dashboard

Vite 미들웨어:
1. /dashboard 실제 파일 확인 → 없음
2. index.html 반환

브라우저:
1. index.html 로드
2. JavaScript 실행
3. 라우터가 /dashboard URL 확인
4. 해당 컴포넌트 렌더링
이를 요약하면 위와 같습니다. 우선 서버의 요청으로 부터 html 파일을 찾을 수 없을 경우 서버에 등록되어 있는 index.html 을 반환하고 해당 파일에 등록되어 있는 라우터에서 url로 전송이된 경로를 찾아 반환하여 url로 초대를 받은 사용자가 참여할 수 있게 됩니다.

이러한 관점에서의 vite 의 build 설정

app.use(express.static("public"));
app.use(express.static("uploads"));
app.use(express.static("files"));
express 를 한 번이라도 사용해보셨다면 위의 설정을 해본 경험이 있으실 것입니다. 이러한 설정을 해주어야 하는 이유는 무엇일까요?
서버는 기본적으로 보안상의 이유로 서버의 파일 시스템에 대한 직접적인 접근을 허용하지 않습니다. 만약 이러한 제한이 없다면 악의적인 사용자가 서버의 중요한 시스템 파일이나 민감한 데이터에 접근할 수 있게 되기 때문이죠!
하지만 서버에 배포를 하기 위해서는 파일 시스템에 등록되어 있는 자원들을 사용해야 합니다. 그렇기 때문에 위와 같은 설정을 통해서 특정 디렉토리만 외부에 노출 하겠다고 명시적으로 설정을 해주고 이 디렉토리 이외에는 접근할 수 없도로 제한하는 역할을 수행합니다.
또한 URL을 통한 경로 조작을 방지하기 위해서 정적인 경로로 정규화하는 과정을 갖습니다. 그리고 이러한 과정은 서버가 자원을 찾기 위해서 서버에 등록되어 있는 모든 파일 시스템을 탐색할 일이 없게 만들기 때문에 서버의 효율성을 높일 수 있습니다!
그럼 지금까지 왜 프론트엔드와 관련 없어 보이는 이야기를 했을 까요? 프론트 엔드에서도 build 시 어떤 파일들을 명시적으로 사용해달라고 명시해주어야 하기 때문입니다.

vite build 시 일어나는 작업들

build 하는 과정에서 트리 쉐이킹을 하고, 코드 분할을 하는 등의 작업에 대해서 설명하지 않고 오로지 배포의 관점에서 설명 해보겠습니다.
project/ ├── src/ │ ├── components/ │ ├── assets/ │ └── App.vue ├── public/ ├── package.json └── vite.config.js
위와 같은 경로를 갖고 있던 폴더들이 build를 진행할 경우
dist/ ├── index.html (최적화된 HTML) └── assets/ ├── js/ (번들링된 JavaScript) ├── css/ (최적화된 CSS) └── images/ (최적화된 이미지)
위와 같이 압축하여 배포가 되는 것이 어떤 측면에서 유리하기 때문에 build 시 위와 같이 압축이 되는 것일까요?
개발 환경에서는 코드의 가독성, 유지보수성, 모듈화를 위해 많은 파일들로 분리하여 관리합니다. 예를 들어, 컴포넌트별로 파일을 나누고, 스타일도 개별 파일로 관리하며, 이미지와 같은 에셋들도 용도별로 구분된 폴더에 저장합니다. 이는 개발자가 코드를 효율적으로 관리하고 수정할 수 있게 해주지만, 이러한 구조를 그대로 프로덕션 환경에 배포하면 여러 문제가 발생합니다.
첫 째로, 네트워크 측면에서 효율적이지 못하다는 단점이 있습니다. 브라우저에서 사용되는 모든 파일들은 HTTP 요청을 통해서 불러와야 합니다. 그렇기 때문에 단일 파일을 불러오는 것보다 기존의 개발 환경을 유지하면서 파일을 불러오는 것은 오버헤드를 발생시키기 때문에 효율적이지 못합니다.
둘째로, 파일 크기의 문제입니다. 개발 환경의 코드는 주석, 긴 변수명, 들여쓰기 등을 포함하고 있어 파일 크기가 큽니다. 또한 사용하지 않는 코드도 포함될 수 있습니다. 이는 불필요한 대역폭 사용과 로딩 시간 증가를 초래합니다.
그리고 위와 같이 압축되어 있는 경로의 파일들이 최적화되고 단일 파일들만 서버에 올리면 되기 때문에 배포 프로세스를 단순화 시킬 수 있습니다. 또한 파일이 제한되어 있고 파일 명이 해시로 작성되어 있기 때문에 캐싱을 진행하기에 유리하기 때문에 build시에는 위와 같은 압축 전략을 사용하는 것입니다.

그래서 vite 설정은 어떻게 해야 할까

코드 분할을 위해서 해야 할 설정

manualChunks(id) {
  if (id.includes("node_modules")) {
    if (id.includes("@tanstack")) return "vendor-tanstack";
    if (id.includes("react")) return "vendor-react";
    if (id.includes("@socket")) return "vendor-socket";
    return "vendor";
  }

  if (id.includes("/features/")) {
    const feature = id.split("/features/")[1].split("/")[0];
    return `feature-${feature}`;
  }
}
manualChunks는 Vite의 코드 분할 전략에서 핵심적인 역할을 하는 기능입니다. manualChunks가 수행하는 작업은 빌드 시점에서 프로젝트에 사용하고 있는 모듈들을 어떤 청크에 포함시켜 관리할 것인지를 수행하는 작업입니다.
현재 위의 설정은 주로 node_modules 폴더 내에서도 프로젝트를 진행함에 있어서 주로 사용되는 패키지인 tanstack, react ,socket 모듈들 별도의 청크로 관리하여 빌드 시 성능을 향상 시킵니다.
이렇게 모듈 별로 별도의 청크로 관리하는 것이 성능 상의 이점이 이유가 되는 이유는 무엇일까요? 우선 브라우저의 캐싱 효율성을 향상 시킬 수 있기 때문입니다. 그렇다면 또 이러한 이유는 무엇일까요?
이에 대해서 예시를 통해서 조금 더 자세하게 알아 보겠습니다. 우선 브라우저의 캐싱 전략은 HTTP 캐시 메커니즘을 기반으로 작동합니다. 코드 분할 정책이 이점을 갖게 되는 캐싱 매커니즘이 있는데 바로 캐시 검증 매커니즘을 수행할 때 이점을 갖습니다.
빌드 시의 각 JavaScript 파일은 고유한 URL과 캐시 키를 가지게 되는데, 이때 Vite의 빌드 시스템은 각 청크(chunk)에 대해 파일 내용을 기반으로 한 해시값을 생성합니다. 예를 들어 vendor-react-[hash].js와 같은 형식으로 파일이 생성됩니다.
app-bundle-[hash].js (5MB) - React (2MB) - Redux (1MB) - 비즈니스 로직 (2MB)
만약에 프로젝트에서 사용되는 모듈들을 별도의 청크로 분리하지 않고 사용을 하게 될 경우 위와 같이 한 개의 파일에서 여러 로직들을 모두 사용하게 될 것입니다.
이 경우 만약 비즈니스 로직만 수정이 될 경우 어떻게 될 까요? 실제로 필요하고 사용되는 비즈니스 로직의 파일을 다시 업데이트 하기 위해서 2MB 의 파일만 다운로드하면 되지만 실제로 이루어지는 업데이트는 5MB 파일을 다시 다운로드 받아야 하는 불필요한 과정을 거쳐야 합니다.
vendor-react-[hash1].js (2MB) vendor-redux-[hash2].js (1MB) business-logic-[hash3].js (2MB)
여기서 위와 같이 청크를 분리하여 파일을 만들 었을 경우에는 비즈니스 로직을 수정할 경우 비즈니스 로직에 해당하는 파일만 다운로드 받으면 되기 때문에 요구와 관련되지 않은 불필요한 파일들은 다운로드 받지 않아도 된다는 장점이 있습니다.
또한 여기서 HTTP 캐싱 매커니즘 중 ETag 을 활용하는 전략이 적극적으로 사용될 수 있습니다. ETag는 특정 버전의 리소스를 식별하는 고유한 식별자입니다. Vite가 빌드를 수행할 때, 각 청크(chunk)의 내용을 기반으로 해시값을 생성하고, 이 해시값이 ETag로 사용됩니다. 이는 마치 각 코드 조각에 고유한 지문을 부여하는 것과 같습니다. 이러한 내용을 배경으로 다시 위의 시나리오를 반복해서 생각 해보겠습니다.
app-bundle-[hash].js (5MB) - React (2MB) - Redux (1MB) - 비즈니스 로직 (2MB) ETag : [hash]
만약 위와 같이 파일을 사용할 경우에 Etag 는 파일 생성시 같이 생성된 해시 값이 Etag 값이 됩니다. 그리고 이 후 비즈니스 로직이 변경되면 전체 파일의 내용이 변경되므로, ETag도 완전히 새로운 값으로 변경됩니다. 결과적으로 브라우저는 React 코드가 변경되지 않았음에도 전체 파일을 다시 다운로드해야 합니다.
vendor-react-[hash1].js (2MB) vendor-redux-[hash2].js (1MB) business-logic-[hash3].js (2MB)
하지만 코드를 분리한다면 앞 서 설명한 것과 같이 비즈니스 로직의 해쉬 값만 변경되고 또한 Etag 만 변경이 될 것입니다. 그리고 이 후 다른 브라우저가 If-None-Match 헤더에 저장된 ETag를 포함하여 요청을 보냅니다. 파일이 변경되지 않았다면(ETag가 동일하다면), 서버는 304 Not Modified 응답을 보낸 후 브라우저에서 캐시된 버전을 사용할 수 있기 때문에 코드 분할이 브라우저의 캐싱 전략에 큰 영향을 줄 수 있습니다.

자원들이 위치하는 경로를 구조화 하기

assetFileNames: (assetInfo) => {
  const fileName = assetInfo.name || "unknown";
  const extType = fileName.split(".").pop()?.toLowerCase() || "";
 
  if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) {
    return `assets/images/[name]-[hash][extname]`;
  }

  if (/glb|hdr/i.test(extType)) {
    return `assets/models/[name]-[hash][extname]`;
  }

  if (/woff2?|ttf|eot|otf/i.test(extType)) {
    return `assets/fonts/[name]-[hash][extname]`;
  }

  return `assets/[name]-[hash][extname]`;
},
웹에서 사용되는 자원들은 웹의 성능을 저하시키는 원인이 되기도 하지만 웹의 주된 기능과 사용자의 경험을 향상 시킬 수 있는 주된 요소이기 때문에 효율적으로 관리하는 것이 중요합니다. 그렇다면 이러한 관점에서 경로를 구조화는 것의 장점에 대해서 알아 보겠습니다.
# Nginx 설정 예시
location /assets/images/ {
    expires 10d;
    add_header Cache-Control "public, no-transform";
}

location /assets/fonts/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}
위와 같이 자원들이 구조화가 되어 있을 경우에는 특정 타입의 파일에 따라서 다른 캐싱 전략을 사용할 수 있습니다. 예를 들어 이미지의 경우는 캐싱 전략을 조금 더 짧은 기한을 둘 수 있고 폰트의 경우 페이지 내에서 중요한 자원에 해당하기 때문에 더 긴 기한 동안 캐싱 전략을 사용할 수 있습니다.
또한 경로에 대한 정보가 서버가 찾아야 하는 자원에 대한 정보를 즉시 피드백을 줄 수 있습니다. 서버는 /assets/images/라는 경로를 보고 즉시 이미지 파일을 찾아야 한다는 것을 알 수 있습니다. 그렇기 때문에 서버가 다른 종류의 요청(API 호출 등)을 처리하는 미들웨어를 거치지 않고 바로 정적 파일 서빙 레이어로 요청을 라우팅할 수 있게 합니다
또한 이렇게 분리가 되었을 경우 일 타입별로 분리되어 있어 특정 유형의 리소스에만 문제가 있는지 빠르게 확인할 수 있습니다.

로컬 서버에서 외부 서버를 CORS 문제 없이 사용하기

server: {
  port: 3000,
  proxy: {
    "/api": {
      target: env.SOCKET_URL,
      changeOrigin: true,
      secure: false,
      ws: true,
      configure: (proxy) => {
        proxy.on("proxyRes", (proxyRes) => {
          proxyRes.headers["cache-control"] = "public, max-age=31536000";
        });
      },
    },
  },
  middlewareMode: env.NODE_ENV === "development" ? false : true,
},
CORS프록시 서버의 관계를 이해하기 위해, 먼저 웹 브라우저의 동작 방식부터 살펴보겠습니다.브라우저의 Same-Origin Policy는 보안을 위해 다른 출처(도메인, 포트, 프로토콜)로의 HTTP 요청을 기본적으로 제한합니다. 예를 들어, localhost:3000에서 실행되는 프론트엔드 애플리케이션이 api.example.com으로 직접 요청을 보내려고 하면 CORS 오류가 발생합니다.
이때 프록시 서버가 중요한 역할을 합니다. 프록시 서버는 중개자 역할을 하여 클라이언트와 서버 사이의 통신을 중계합니다. 이에 대해서 더 자세하게 알아 보겠습니다.
브라우저(localhost:3000) → API 서버(api.example.com) 1. 브라우저가 요청을 보내기 전에 Same-Origin Policy 검사 2. 출처가 다르면 브라우저가 preflight 요청(OPTIONS)을 보냄 3. 서버의 CORS 헤더를 확인하고 허용된 경우에만 실제 요청 진행
프록시 서버가 CORS 문제를 해결할 수 있는 방법에 대해서 이야기하기 위해서는 우선 브라우저가 실제로 CORS 를 어떻게 확인하고 있는 지에 대해서 알 필요가 있습니다. 브라우저가 CORS 를 확인하는 과정은 위와 같고 여기서 중요한 것은 preflight 요청을 보낸다는 것입니다.
서버가 실제 데이터를 전송하기 이전, 본격적인 요청 전에 서버 측에서 그 요청의 메서드와 헤더에 대해서 인식하고 있는 지 확인하는 과정을 갖는데 이 과정이 바로 preflight 입니다. 메서드와 헤더를 확인하여 실제로 작업이 수행 가능한지의 여부를 먼저 체크하는데 이 경우에는 CORS 가 허용이 되었는 지 확인을 하는 것입니다.
브라우저(localhost:3000) → 프록시 서버(localhost:3000) → API 서버(api.example.com) 1. 브라우저는 같은 출처의 프록시 서버와 통신 2. 프록시 서버는 브라우저가 아닌 일반 HTTP 클라이언트로서 API 서버와 통신
여기서 프록시 서버가 중간에 포함될 경우 CORS 정책에 포함이 되지 않는데 이는 Same-Origin-Policy 를 만족하기 때문입니다.
동일 출처 정책(SOP) 는 브라우저는 자원의 출처를 비교할 때 세 가지를 기준으로 합니다
  1. 프로토콜 (예: http://, https://)
  2. 호스트명 (예: example.com, api.example.com)
  3. 포트 번호 (예: 80, 443)
이러한 조건에 부합해 동일 출처로 판단이 되었을 경우 자원의 요청에 대해서 제한을 두지 않습니다. 그렇기 때문에 브라우저(localhost:3000) 에서 프록시 서버(localhost:3000)로 요청을 보내도 어떠한 문제도 일어나지 않고 자원을 요청하고 응답 받을 수 있습니다.
여거시 프록시 서버를 통해서 통신하는 것의 장점이 들어납니다. 기본적으로 서버 간 통신을 할 경우에 CORS 정책 검사가 적용이 되지 않습니다. 서버는 요청을 받을지 여부를 자체적인 인증/인가 메커니즘으로 결정하기 때문에 서버는 HTTP 클라이언트로서 다른 서버와 자유롭게 통신할 수 있습니다. 이러한 과정을 vite에서는 어떻게 이루어지는 지 설명하고 마무리 하겠습니다.
브라우저가 http://localhost:3000/api/users로 요청 ↓ Vite 개발 서버가 이 요청을 가로챔 ↓ 프록시 설정을 확인하고 '/api'로 시작하는 요청임을 인식 ↓ 요청을 env.SOCKET_URL + '/api/users'로 변환 ↓ 프록시 서버가 실제 API 서버로 요청을 전달 ↓ changeOrigin: true로 인해 Host 헤더가 대상 서버에 맞게 변경됨 ↓ API 서버는 마치 직접적인 요청처럼 받아들이고 처리 ↓ API 서버가 응답을 프록시 서버로 전송 ↓ 프록시 서버가 응답 헤더를 수정 (cache-control 등) ↓ 최종적으로 브라우저로 응답 전달