junyeokk
Blog
Vite·2025. 11. 16

Vite Dev Server Proxy

프론트엔드 개발 서버에서 API를 호출하면 거의 확실하게 CORS 에러를 만난다. 프론트가 localhost:5173에서 돌고, 백엔드가 localhost:3000이나 원격 서버에 있으면 브라우저가 출처(origin)가 다르다고 요청을 차단한다.

text
Access to fetch at 'http://localhost:3000/users' from origin 'http://localhost:5173'
has been blocked by CORS policy

이걸 해결하는 방법은 크게 세 가지다.

  1. 백엔드에서 CORS 헤더 추가Access-Control-Allow-Origin 설정. 가장 정석이지만, 개발용으로만 열어두면 프로덕션에서 실수로 남길 수 있고, 외부 API라면 헤더를 건드릴 수도 없다.
  2. 브라우저 확장 프로그램으로 CORS 우회 — 개발자 본인 브라우저에서만 동작하고, 팀원마다 설정해야 한다.
  3. 개발 서버 프록시 — 프론트엔드 개발 서버가 중간에서 요청을 대신 전달한다. 브라우저는 같은 출처로 요청하니 CORS 문제 자체가 없다.

Vite의 dev server proxy는 세 번째 방식이다. 핵심 아이디어는 간단하다: 브라우저 → Vite 서버 → 백엔드 API. 브라우저 입장에서는 모든 요청이 localhost:5173으로 가니까 CORS 위반이 아니고, Vite 서버가 서버 사이드에서 실제 백엔드로 요청을 전달한다. 서버 간 통신에는 CORS 제약이 없다.

왜 프록시가 CORS를 우회할 수 있는가

CORS(Cross-Origin Resource Sharing)는 브라우저 보안 정책이다. 서버가 아니라 브라우저가 강제하는 규칙이다.

text
[브라우저] --fetch--> [localhost:3000]  ← 출처가 다름, 브라우저가 차단
[브라우저] --fetch--> [localhost:5173/api/users]  ← 같은 출처, OK
                          ↓ (Vite 서버가 대신 전달)
                      [localhost:3000/users]  ← 서버 간 통신, CORS 무관

프록시를 사용하면 브라우저는 자신과 같은 출처(localhost:5173)에 요청을 보낸다고 인식한다. Vite 서버는 그 요청을 받아서 실제 백엔드에 전달하고, 응답을 다시 브라우저에 돌려준다. 서버 간 HTTP 요청에는 Same-Origin Policy가 적용되지 않기 때문에 CORS 에러가 발생하지 않는다.

기본 설정

vite.config.tsserver.proxy에 설정한다.

typescript
import { defineConfig } from "vite";

export default defineConfig({
  server: {
    proxy: {
      "/api": "http://localhost:3000",
    },
  },
});

가장 간단한 형태다. /api로 시작하는 모든 요청을 http://localhost:3000으로 전달한다.

text
GET /api/users  →  http://localhost:3000/api/users
POST /api/auth  →  http://localhost:3000/api/auth

여기서 주의할 점은, 경로 전체가 그대로 전달된다는 것이다. /api/usershttp://localhost:3000/api/users가 된다. 만약 백엔드에서 /api 접두사 없이 /users로 라우팅하고 있다면, 경로를 재작성해야 한다.

상세 옵션 설정

문자열 대신 객체를 전달하면 세부 옵션을 조절할 수 있다.

typescript
export default defineConfig({
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
        secure: false,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

각 옵션을 하나씩 살펴보자.

target

프록시 요청이 전달될 대상 서버 URL이다. 프로토콜(http:// 또는 https://)을 포함해야 한다.

typescript
target: "http://localhost:3000"       // 로컬 백엔드
target: "https://api.example.com"     // 원격 API 서버

changeOrigin

typescript
changeOrigin: true

이 옵션은 프록시 요청의 Host 헤더를 target의 호스트명으로 변경한다. 기본값은 false다.

왜 필요한가? HTTP 요청에는 Host 헤더가 포함된다. changeOriginfalse면 원래 요청의 Host(localhost:5173)가 그대로 전달된다. 대부분의 서버는 Host 헤더를 기반으로 라우팅하거나 보안 검증을 하기 때문에, localhost:5173이라는 호스트에서 온 요청을 거부할 수 있다.

text
changeOrigin: false → Host: localhost:5173  (원래 값 유지)
changeOrigin: true  → Host: localhost:3000  (target의 호스트로 변경)

외부 API 서버에 프록시할 때는 거의 항상 true로 설정해야 한다. 로컬 백엔드라도 true로 하는 게 안전하다.

secure

typescript
secure: false

target이 HTTPS일 때, SSL 인증서 검증을 건너뛸지 여부다. 기본값은 true(검증 수행).

개발 환경에서 자체 서명 인증서를 사용하는 서버에 프록시할 때 false로 설정한다. 프로덕션 환경에서는 당연히 true여야 하지만, 개발 서버 프록시는 어차피 프로덕션에서 사용되지 않으니 false로 두는 경우가 많다.

rewrite

typescript
rewrite: (path) => path.replace(/^\/api/, "")

요청 경로를 변환하는 함수다. 원래 경로를 인자로 받아서 변환된 경로를 반환한다.

위 설정에서 일어나는 일:

text
/api/users    →  /users
/api/auth     →  /auth
/api/v2/data  →  /v2/data

/api는 프론트엔드에서 "이 요청은 프록시로 보내라"는 식별자 역할을 할 뿐, 실제 백엔드 경로에는 없는 접두사일 수 있다. rewrite로 이 접두사를 제거하면 백엔드가 기대하는 경로로 정확히 전달된다.

정규식 ^\/api에서 ^는 경로의 시작 부분만 매칭한다. /api가 경로 중간에 나타나는 경우(예: /user/api/settings)는 변환하지 않는다.

실제 사용 패턴

환경 변수로 target 동적 설정

개발 환경마다 백엔드 주소가 다를 수 있다. Vite의 loadEnv와 조합하면 .env 파일에서 target을 읽어올 수 있다.

typescript
import { defineConfig, loadEnv } from "vite";

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), "");
  const apiTarget = env.API_TARGET || "https://api.projectbuildup.dev";

  return {
    server: {
      proxy: {
        "/api": {
          target: apiTarget,
          changeOrigin: true,
          secure: false,
          rewrite: (path) => path.replace(/^\/api/, ""),
        },
      },
    },
  };
});

.env.development 파일:

text
API_TARGET=http://localhost:3000

.env.staging 파일:

text
API_TARGET=https://staging-api.example.com

여기서 loadEnv의 세 번째 인자 ""는 접두사 필터다. 기본적으로 Vite는 VITE_ 접두사가 붙은 환경 변수만 클라이언트 코드에 노출하지만, loadEnv에서 빈 문자열을 전달하면 모든 환경 변수를 읽을 수 있다. API_TARGET은 서버 설정에서만 쓰이니 VITE_ 접두사 없이 사용하는 게 자연스럽다 — 클라이언트 번들에 노출될 필요가 없으니까.

defineConfig에 함수를 전달하면 mode 인자를 받을 수 있다. vite dev는 기본적으로 development 모드, vite buildproduction 모드다. loadEnv는 이 mode에 맞는 .env.[mode] 파일을 로드한다.

정규식 키로 유연한 매칭

프록시 키가 ^로 시작하면 정규식으로 해석된다.

typescript
proxy: {
  "^/api/v[12]/.*": {
    target: "http://localhost:3000",
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api\/v[12]/, ""),
  },
}

이 설정은 /api/v1/users, /api/v2/products 같은 경로는 프록시하지만, /api/v3/은 매칭하지 않는다. 여러 API 버전을 선택적으로 프록시할 때 유용하다.

여러 경로 프록시

typescript
proxy: {
  "/api": {
    target: "http://localhost:3000",
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api/, ""),
  },
  "/auth": {
    target: "http://localhost:4000",
    changeOrigin: true,
  },
  "/uploads": {
    target: "http://localhost:5000",
    changeOrigin: true,
  },
}

마이크로서비스 구조에서 각 서비스가 다른 포트에서 돌고 있을 때, 경로 접두사별로 다른 서버에 프록시할 수 있다. 프론트엔드 코드에서는 /api/users, /auth/login, /uploads/image.png처럼 호출하면 된다.

WebSocket 프록시

typescript
proxy: {
  "/socket.io": {
    target: "ws://localhost:3001",
    ws: true,
  },
}

ws: true 옵션을 추가하면 WebSocket 연결도 프록시할 수 있다. 실시간 기능이 있는 앱에서 WebSocket 서버가 별도 포트에서 돌고 있을 때 사용한다.

내부 동작 원리

Vite의 dev server proxy는 내부적으로 http-proxy 라이브러리를 사용한다. Vite의 개발 서버는 connect 기반 미들웨어 서버인데, 프록시 설정이 있으면 해당 경로로 들어오는 요청을 가로채는 미들웨어를 등록한다.

요청 처리 흐름:

text
1. 브라우저가 /api/users 요청
2. Vite dev server가 요청을 수신
3. 등록된 프록시 규칙과 경로 매칭
4. 매칭되면 rewrite 함수로 경로 변환
5. changeOrigin이면 Host 헤더 교체
6. http-proxy가 target 서버로 요청 전달
7. target 서버의 응답을 브라우저에 전달

매칭되지 않는 요청은 그대로 Vite의 정적 파일 서빙이나 HMR 처리로 넘어간다.

configure 옵션

프록시 인스턴스에 직접 접근해서 이벤트 리스너를 붙일 수 있다.

typescript
proxy: {
  "/api": {
    target: "http://localhost:3000",
    changeOrigin: true,
    configure: (proxy, options) => {
      proxy.on("error", (err, req, res) => {
        console.log("프록시 에러:", err.message);
      });
      proxy.on("proxyReq", (proxyReq, req, res) => {
        console.log("프록시 요청:", req.method, req.url);
      });
      proxy.on("proxyRes", (proxyRes, req, res) => {
        console.log("프록시 응답:", proxyRes.statusCode);
      });
    },
  },
}

디버깅할 때 유용하다. 프록시가 제대로 동작하는지, 어떤 요청이 어디로 가는지 로그로 확인할 수 있다. error 이벤트를 잡아두면 백엔드 서버가 꺼져있을 때 Vite 서버 자체가 크래시하는 걸 방지할 수도 있다.

주의 사항

프록시는 개발 환경 전용이다

server.proxyvite dev에서만 동작한다. vite build로 빌드된 결과물에는 프록시가 포함되지 않는다. 프로덕션에서는 Nginx, CloudFront, 또는 같은 도메인에서 API를 서빙하는 등 별도의 방법으로 CORS를 해결해야 한다.

이건 실수하기 쉬운 부분이다. 개발할 때 프록시로 잘 동작하다가 배포하면 CORS 에러가 터진다. 프로덕션 환경에서의 API 경로 처리를 미리 계획해두는 게 좋다.

base 옵션과의 조합

Vite의 base 옵션이 /app/ 같은 비루트 경로로 설정되어 있으면, 프록시 키에도 이 base를 포함해야 한다.

typescript
export default defineConfig({
  base: "/app/",
  server: {
    proxy: {
      "/app/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/app\/api/, ""),
      },
    },
  },
});

프록시 vs CORS 헤더

둘 다 개발 환경에서 CORS 문제를 해결하지만, 접근 방식이 다르다.

비교 항목Dev Server ProxyCORS 헤더
설정 위치프론트엔드(vite.config)백엔드 서버
동작 방식요청 우회브라우저 정책 완화
프로덕션 적용불가가능
preflight 요청없음OPTIONS 요청 발생 가능
외부 API사용 가능서버 수정 필요

프록시의 장점은 preflight 요청이 없다는 것이다. CORS를 사용하면 Content-Type: application/json 같은 non-simple 요청에 대해 브라우저가 먼저 OPTIONS 요청을 보내는데, 프록시에서는 같은 출처이므로 이 과정이 생략된다. 개발 중 네트워크 탭이 깔끔해지는 소소한 이점이 있다.

CRA(Create React App)에서 Vite로

CRA에서는 package.json"proxy": "http://localhost:3000" 한 줄로 프록시를 설정했다. 단순하지만 경로별 분기, rewrite, WebSocket 프록시 같은 세부 제어가 불가능했다.

Vite에서는 경로별로 다른 target을 지정하고, rewrite로 경로를 변환하고, 정규식으로 유연하게 매칭할 수 있다. 설정은 좀 더 장황하지만 그만큼 제어력이 높다.

json
// CRA - package.json
{ "proxy": "http://localhost:3000" }
typescript
// Vite - vite.config.ts
server: {
  proxy: {
    "/api": {
      target: "http://localhost:3000",
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/api/, ""),
    },
  },
}

CRA에서 마이그레이션할 때 http-proxy-middlewaresetupProxy.js를 만들었던 경험이 있다면, Vite의 proxy도 같은 http-proxy 기반이라 개념은 거의 동일하다.

정리

  • 프록시는 브라우저→Vite→백엔드 구조로 Same-Origin을 유지해 CORS를 원천 차단하고, preflight OPTIONS 요청도 발생하지 않는다
  • changeOrigin으로 Host 헤더를 맞추고, rewrite로 프론트 전용 접두사를 제거하면 대부분의 시나리오를 커버할 수 있다
  • 개발 전용이므로 프로덕션 배포 시 Nginx 리버스 프록시나 같은 도메인 서빙 등 별도 CORS 전략을 반드시 준비해야 한다

관련 문서