- Published on
Django + React + Vite 분리 배포 — Render와 Vercel로 나눠 올리기
- Authors

- Name
- Hyo814
Django + React + Vite 분리 배포 — Render와 Vercel로 나눠 올리기
PyNews 프로젝트는 하나의 레포에 Django 백엔드와 React 프론트가 같이 있지만, 배포는 두 곳으로 쪼갰습니다.
- Django API: Render (
type: web) - React + Vite SPA: Vercel
이 글에서는 왜 이렇게 나눴는지, 그리고 그로 인해 생긴 CORS, 환경변수, 정적 파일, 빌드 파이프라인 네 가지 이슈를 어떻게 풀었는지 정리합니다.
1. 왜 분리 배포인가
한 Django 프로젝트 안에서 React 빌드 결과물을 collectstatic으로 올리는 전통적인 방식이 가장 단순합니다. 그런데 PyNews는 처음부터 분리를 택했는데, 이유는 세 가지.
- 프론트의 배포 속도: Vercel은
git push후 수십 초 내에 프리뷰/프로덕션이 뜹니다. Render의 Python 빌드는 의존성 설치 시간이 길어 프론트 작업 리듬을 떨어뜨립니다. - 역할 분리가 깔끔함: 백엔드는 API만 제공하고, 프론트는 정적 자산. 각자 가장 잘 맞는 인프라를 씁니다.
- PR 프리뷰: Vercel은 PR마다 프리뷰 URL을 자동 생성합니다. 디자인/카피 검토가 쉬워집니다.
단점은 CORS 설정이 필요하고, 환경변수를 양쪽에서 관리해야 한다는 것. 이 글의 대부분이 그 얘기입니다.
2. 전체 구조
┌──────────────────────────┐ ┌──────────────────────────┐
│ Vercel │ │ Render │
│ (React + Vite SPA) │──HTTP──▶│ (Django REST API) │
│ python-news.vercel.app │ │ pynews-api.onrender.com │
└──────────────────────────┘ └──────────┬───────────────┘
│
┌──────────▼───────────────┐
│ Render Postgres │
│ pynews-db │
└──────────────────────────┘
프론트는 Vercel CDN에서 즉시 서빙되고, API 호출은 같은 origin이 아닌 Render로 나갑니다. 그래서 CORS 설정이 필수입니다.
3. CORS — django-cors-headers + 정규식 허용
Render의 render.yaml에서 환경변수로 허용 오리진을 넘깁니다.
envVars:
- key: CORS_ALLOWED_ORIGINS
value: "https://python-news.vercel.app,https://python-news-hyo814s-projects.vercel.app"
Django settings.py에서는 콤마 분리 문자열을 리스트로 파싱하고, Vercel 프리뷰 URL까지 받기 위해 정규식 설정을 함께 둡니다.
# settings.py
CORS_ALLOWED_ORIGINS = [
origin.strip()
for origin in os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:5173").split(",")
if origin.strip()
]
CORS_ALLOWED_ORIGIN_REGEXES = [
r"^https://python-news.*\.vercel\.app$",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware", # 맨 앞 권장
# ...
]
포인트 몇 가지:
- 정규식으로 PR 프리뷰 URL 커버: Vercel은 PR/브랜치마다
python-news-git-<branch>-<team>.vercel.app같은 URL을 만듭니다. 리스트에 매번 추가할 수 없으니 정규식이 필수입니다. CorsMiddleware는 미들웨어 맨 앞: 에러 응답에도 CORS 헤더가 붙어야 프론트 측에서 에러 메시지를 읽을 수 있습니다.http://localhost:5173기본값 유지: Vite 기본 개발 포트. 로컬에서 개발 서버 띄우면 바로 붙습니다.
4. ALLOWED_HOSTS — Render의 동적 호스트 대응
Render는 배포 시 서비스에 고유한 호스트네임을 부여합니다(pynews-api.onrender.com 같은 형태). 이걸 ALLOWED_HOSTS에 넣어야 Django가 요청을 받아들입니다.
# settings.py
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
RENDER_EXTERNAL_HOSTNAME = os.environ.get("RENDER_EXTERNAL_HOSTNAME")
if RENDER_EXTERNAL_HOSTNAME:
ALLOWED_HOSTS.append(RENDER_EXTERNAL_HOSTNAME)
RENDER_EXTERNAL_HOSTNAME은 Render가 자동으로 주입하는 환경변수입니다. 별도 설정 없이 붙어서 편리합니다.
5. 프론트 — Vite 빌드 설정과 환경변수
vite.config.js는 플러그인만 있는 최소 구성입니다.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
API 주소는 빌드 타임 환경변수로 주입합니다.
// src/api.js
const API_BASE = import.meta.env.VITE_API_BASE
?? 'http://localhost:8000'
export async function fetchPosts() {
const res = await fetch(`${API_BASE}/api/posts/`)
return res.json()
}
Vercel 대시보드에서 환경변수를 등록하면 되고:
VITE_API_BASE=https://pynews-api.onrender.com
VITE_ 접두사가 붙은 변수만 클라이언트 번들에 포함됩니다. 서버 비밀값을 실수로 노출하지 않도록 Vite가 강제하는 규칙입니다.
6. 정적 파일 — WhiteNoise로 Django 쪽도 정리
프론트는 Vercel에서 서빙되지만, Django 어드민의 정적 파일은 여전히 Django 쪽에서 필요합니다. Render처럼 단일 프로세스 환경에서는 WhiteNoise가 정석입니다.
# settings.py
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # SecurityMiddleware 바로 뒤
# ...
]
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
그리고 배포 빌드 스크립트에서 collectstatic을 실행합니다.
# build.sh
#!/usr/bin/env bash
set -o errexit
pip install -r requirements.txt
python manage.py collectstatic --no-input
python manage.py migrate
CompressedManifestStaticFilesStorage는 파일에 해시 suffix를 붙이고 gzip/brotli 버전을 함께 생성합니다. CDN 없이도 캐시 무효화와 압축 전송이 동시에 해결됩니다.
7. 데이터베이스 연결 — dj-database-url로 URL 통일
Render에서 주입하는 DATABASE_URL을 그대로 받을 수 있게 dj-database-url을 씁니다.
# settings.py
import dj_database_url
DATABASES = {
"default": dj_database_url.config(
default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}",
conn_max_age=600,
)
}
- 로컬: 환경변수 없으면 SQLite 기본값 사용.
- Render:
fromDatabase로 주입된 PostgreSQL URL이 자동으로 연결. conn_max_age=600: 연결 재사용으로 지연 감소.
이 조합으로 로컬 개발 → Render 배포 시 설정 변경이 필요 없습니다. DATABASE_URL 환경변수 하나로 분기됩니다.
8. 빌드 파이프라인 비교
두 서비스의 빌드 흐름은 이렇게 다릅니다.
Render (Django API)
git push
↓
Render가 레포 폴링 / 웹훅 수신
↓
buildCommand: ./build.sh
↓
pip install → collectstatic → migrate
↓
startCommand: gunicorn config.wsgi:application
↓
서비스 교체
Vercel (React SPA)
git push (또는 PR)
↓
Vercel이 웹훅 수신
↓
npm install (또는 캐시 사용)
↓
npm run build → dist/ 디렉터리 생성
↓
정적 파일을 CDN에 배포
↓
새 URL 발급 (프리뷰) or 도메인 전환 (프로덕션)
Vercel 쪽이 훨씬 빠른 이유는 Python 의존성 설치가 없고, 최종 결과물이 정적 파일이기 때문입니다. Render는 컨테이너를 새로 빌드해야 합니다.
9. 분리 배포에서 실수하기 쉬운 것들
이번 프로젝트에서 실제로 겪었던 문제들.
(1) CORS 누락된 경로
/api/*만 CORS 허용 목록에 있고, 이미지 업로드 등 /media/* 경로는 빠져 있었음. django-cors-headers는 기본적으로 모든 경로에 적용되므로 CORS_URLS_REGEX를 별도 지정하지 않았는지 확인 필요.
(2) 프론트 env 변수 오탈자
VITE_API_BASE 대신 API_BASE만 써서 빌드 타임에 주입이 안 됐음. import.meta.env는 undefined를 반환하고, 기본값으로 localhost:8000이 들어가서 프로덕션에서 localhost API를 호출하는 사태가 벌어졌습니다. 배포 직후 네트워크 탭으로 API URL을 반드시 확인.
(3) Render free 플랜의 첫 요청 지연
API 서버가 슬립에 들어간 상태에서 첫 요청이 오면 수십 초 지연 후 응답합니다. 프론트에서는 로딩 스피너와 함께 "처음 접속 시 서버 기동 중입니다" 문구를 보여주는 등의 UX 처리를 해둬야 합니다.
(4) ALLOWED_HOSTS 깜빡
DEBUG=False 상태에서 ALLOWED_HOSTS가 안 맞으면 400 Bad Request가 납니다. Render URL이 바뀌는 경우는 드물지만, 커스텀 도메인을 붙일 때 빼먹으면 즉시 장애입니다.
10. 분리 배포 전 체크리스트
□ Django settings.py의 CORS_ALLOWED_ORIGINS + CORS_ALLOWED_ORIGIN_REGEXES
□ ALLOWED_HOSTS에 RENDER_EXTERNAL_HOSTNAME 자동 주입 로직
□ WhiteNoise 미들웨어 위치 (SecurityMiddleware 바로 뒤)
□ collectstatic이 build.sh에 포함됐는지
□ dj-database-url로 DATABASE_URL 읽도록 설정
□ Vite env 변수는 VITE_ 접두사 필수
□ Vercel 대시보드에 VITE_API_BASE 등록
□ 프론트 네트워크 탭에서 실제 API URL이 Render인지 확인
□ Render free 플랜 슬립 이슈에 대한 UX 처리
□ CI나 E2E 테스트에서 CORS가 올바르게 동작하는지 검증
정리
Django + React 분리 배포는 각 인프라의 장점을 살리는 대신 경계면(CORS/env/호스트)을 신경써야 하는 구조입니다. Vercel의 빠른 프리뷰와 Render의 선언적 yaml을 동시에 누릴 수 있었고, dj-database-url과 CORS_ALLOWED_ORIGIN_REGEXES가 설정의 복잡도를 크게 줄여줬습니다. 프로덕션 첫 트래픽 전에는 네트워크 탭으로 API URL과 CORS 헤더를 반드시 한 번 확인하는 것을 권장합니다.
이것으로 PyNews 프로젝트 시리즈 7편을 마무리합니다.
- Render로 Django 배포 (web + cron 분리)
- 로컬 crontab vs Render Cron Job 비교
- feedparser + BeautifulSoup4로 RSS 크롤링 파이프라인
- Django management command 4종 설계기
- UUID 토큰 기반 뉴스레터 구독/해지
- Editorial vs Crawled 데이터 모델 분리
- Django + React + Vite 분리 배포 (← 이 글)
각 글은 독립적으로 읽을 수 있지만, 프로젝트 전체 맥락으로 묶어 보면 **"작은 Django 프로젝트 하나를 프로덕션 인프라로 올리기 위해 고려해야 할 것들"**의 체크리스트가 됩니다.