다크 모드
Appendix A. 사내 실무 환경설정 및 Docker/Nginx 배포 가이드
"로컬에서만 잘 돌아가는 코드는 가치가 반감된다. 개발, 검증, 운영 환경을 안전하게 분기하고, Docker 멀티 스테이지 빌드와 Nginx로 React 19.3 SPA를 프로덕션에 배포하는 엔터프라이즈 실무 표준을 정립한다."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
.env 파일과 VITE_ 접두사 | 환경별 설정값을 빌드 시점에 정적 번들로 주입하는 규칙 | A.1 |
ImportMetaEnv 타입 확장 (vite-env.d.ts) | import.meta.env.VITE_...에 자동완성과 리터럴 유니온 타입 부여 | A.1 [설정 A-3] |
Docker 멀티 스테이지 빌드 (AS builder) | 무거운 Node.js 빌드 환경과 초경량 Nginx 실행 이미지를 분리 | A.2 🆕 박스 |
Nginx try_files, no-cache, immutable | SPA 라우팅 404 방지 및 신규 배포 직후 청크 불일치 에러 원천 차단 | A.3 [코드 A-3] |
host.docker.internal & /api/ 리버스 프록시 | 동일 출처(Same-Origin) 통신으로 CORS Preflight 문제를 제거하는 설정 | A.3, A.4 |
A.1 환경변수 분기 전략 (.env)과 리버스 프록시
사내 시스템은 보통 로컬(Local), 개발/검증(Dev/Stage), 운영(Prod) 환경으로 나뉩니다.
소스 코드 안에 http://localhost:8080을 하드코딩해서는 안 됩니다. Vite는 접두사 **VITE_**로 시작하는 환경변수를 빌드 시점에 코드 내부로 주입해 줍니다.
[설정 A-1] 로컬 개발 환경 (frontend/.env.development)
ini
# 로컬 개발 환경: Vite 개발 서버의 리버스 프록시(/api -> localhost:8080)를 타도록 빈 문자열 지정
VITE_API_BASE_URL=
VITE_ENV_NAME=local[설정 A-2] 사내 운영 환경 (frontend/.env.production)
ini
# 운영 배포 환경: Nginx 리버스 프록시(/api)를 동일 도메인으로 태우거나 사내 API 게이트웨이 지정
VITE_API_BASE_URL=
VITE_ENV_NAME=productionCAUTION
VITE_ 변수는 비밀(Secret)이 아닙니다!VITE_로 시작하는 값은 npm run build를 실행할 때 JavaScript 번들(dist/assets/*.js) 안에 평문 문자열 그대로 박혀 사용자 브라우저로 내려갑니다. 개발자 도구 Sources 탭을 열면 누구나 볼 수 있으므로, DB 비밀번호, AWS 시크릿 키, 관리자 마스터 토큰은 절대 VITE_ 변수에 넣지 마십시오. 그런 비밀값은 반드시 백엔드(Spring Boot application.yml, 서버 환경변수, Vault)에만 두어야 합니다. 반대로 VITE_ 접두사가 없는 변수는 프론트엔드 코드에서 undefined로 차단됩니다.
[설정 A-3] 환경변수 TypeScript 타입 선언 (frontend/src/vite-env.d.ts)
import.meta.env.VITE_ENV_NAME을 쓸 때 오타를 컴파일 타임에 잡고 자동완성을 받으려면 src/vite-env.d.ts에 인터페이스를 선언합니다:
typescript
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_ENV_NAME: 'local' | 'dev' | 'production';
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}.env 파일의 모든 값은 런타임에 **문자열(string)**로 들어온다는 점에 주의하십시오. 예를 들어 VITE_ENABLE_MOCK=true라고 적어도 코드에서는 'true' 문자열이므로 import.meta.env.VITE_ENABLE_MOCK === 'true'로 비교해야 합니다.
TIP
왜 운영 환경에서도 동일 출처(Same-Origin) /api 리버스 프록시를 권장할까요?VITE_API_BASE_URL=https://board-api.company.com처럼 프론트엔드 도메인과 다른 주소(Cross-Origin)로 직접 호출하면, 매 API 요청마다 브라우저가 CORS Preflight(OPTIONS) 사전 요청을 보내 네트워크 지연이 두 배로 들고, HttpOnly 인증 쿠키(SameSite, Credentials) 설정이 매우 까다로워집니다. 아래 nginx.conf처럼 Nginx가 /api/ 요청을 백엔드로 프록시(proxy_pass)하면 프론트엔드와 백엔드가 같은 도메인이 되어 CORS 문제가 원천 소멸합니다.
A.2 프로덕션 배포를 위한 .dockerignore와 멀티 스테이지 Dockerfile
React 19.3 + TypeScript SPA는 빌드를 마치면 순수한 정적 파일(dist/ 폴더 안의 HTML, CSS, JS)만 남습니다.
운영 서버에 무거운 Node.js 런타임이나 1GB짜리 node_modules를 띄울 필요 없이, **1단계 빌드 스테이지(Node 22 LTS)**에서 정적 번들만 구워낸 뒤 **2단계 서빙 스테이지(초경량 Nginx Alpine)**로 결과물만 복사하는 멀티 스테이지 빌드가 엔터프라이즈 표준입니다.
먼저 호스트 PC(Windows/macOS)의 node_modules가 Docker 빌드 컨텍스트로 딸려 들어가 바이너리 충돌이나 속도 저하를 일으키지 않도록 frontend/.dockerignore 파일을 작성합니다:
[코드 A-1] frontend/.dockerignore
txt
node_modules
dist
.git
.vite
coverage
*.log[코드 A-2] 멀티 스테이지 빌드 (frontend/Dockerfile)
dockerfile
# =====================================================================
# 1단계: 빌드 스테이지 (Node.js 22 LTS Alpine 환경)
# =====================================================================
FROM node:22-alpine AS builder
WORKDIR /app
# 레이어 캐시 극대화를 위해 package.json과 lock 파일만 먼저 복사 후 클린 설치
COPY package*.json ./
RUN npm ci
# 전체 소스 코드 복사 및 TypeScript 타입 검사(tsc -b) + Vite 프로덕션 번들 빌드
COPY . .
RUN npm run build
# =====================================================================
# 2단계: 서빙 스테이지 (초경량 Nginx Alpine 환경 - 약 25MB)
# =====================================================================
FROM nginx:alpine-slim
# 1단계(builder)에서 생성된 정적 dist 폴더만 Nginx 웹 루트로 복사
COPY --from=builder /app/dist /usr/share/nginx/html
# SPA History 라우팅, 청크 캐시 제어, /api 리버스 프록시를 위한 Nginx 설정 주입
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]🆕 처음 보는 문법: Dockerfile 핵심 명령어 해부
| 명령 | 의미 |
|---|---|
FROM node:22-alpine AS builder | Node.js 22가 설치된 경량 리눅스 이미지에서 시작하며, 이 빌드 단계에 builder라는 별칭을 붙입니다. |
COPY package*.json ./ → RUN npm ci | 소스 코드가 바뀌어도 package.json이 그대로라면 Docker가 이전 npm ci 레이어 캐시를 그대로 재사용해 빌드 시간을 수 초로 단축합니다. |
COPY --from=builder /app/dist ... | 앞 단계(builder)의 파일 시스템에서 오직 /app/dist 결과물만 쏙 빼옵니다. 최종 이미지에는 Node.js도 node_modules도 남지 않아 보안 취약점 공격 표면과 이미지 용량이 극적으로 줄어듭니다. |
A.3 SPA 라우팅 404 방지와 배포 직후 청크 에러를 막는 nginx.conf
React Router의 BrowserRouter를 운영 Nginx에 배포할 때 반드시 챙겨야 할 3가지 핵심 설정이 있습니다:
- SPA Fallback (
try_files $uri $uri/ /index.html): 사용자가/write나/login주소에서 브라우저 새로고침(F5)을 눌렀을 때, Nginx가 물리 파일을 찾지 못해404 Not Found를 띄우는 대신/index.html을 내려주어 React Router가 화면을 그리게 합니다. index.html캐시 절대 금지 (no-cache) vs/assets/*1년 영구 캐시 (immutable):- 만약 브라우저가 옛날
index.html을 캐싱하고 있으면, 신규 배포 직후 파일명 해시가 바뀐 지연 로딩 청크(LoginPage-a1b2c3.js)를 옛 이름으로 요청하다가Failed to fetch dynamically imported module에러로 화면이 뻗어버립니다! - 따라서
index.html은 절대 캐싱하지 않고 매번 서버에서 최신본을 확인하게 하며, Vite가 내용 기반 해시(Hash)를 붙여 생성한/assets/*파일만 1년간 영구 캐싱(immutable)합니다.
- 만약 브라우저가 옛날
/api/백엔드 리버스 프록시:/api/로 들어오는 요청은 Spring Boot 백엔드 컨테이너(또는 호스트의 8080 포트)로 전달합니다.
[코드 A-3] 엔터프라이즈 표준 설정 (frontend/nginx.conf)
nginx
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip 텍스트 압축 전송 활성화 (초기 JS/CSS 번들 네트워크 전송량 70% 절감)
gzip on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# 1. index.html은 항상 최신 배포 버전을 바라보도록 브라우저 캐시 금지 (청크 불일치 에러 원천 차단!)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# 2. Vite가 파일명에 콘텐츠 해시(Hash)를 붙인 정적 자산(/assets/*)은 1년간 강력 영구 캐싱
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# 3. SPA 라우팅 폴백: 요청한 경로에 물리 파일이 없으면 /index.html을 반환하여 React Router에 위임
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# 4. 백엔드 Spring Boot API 리버스 프록시 (동일 출처 통신으로 CORS 원천 박멸)
# - 로컬 Docker Desktop 실습 시: http://host.docker.internal:8080/api/
# - docker-compose / K8s 운영 시: http://backend-service:8080/api/
location /api/ {
proxy_pass http://host.docker.internal:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}A.4 사내 컨테이너 빌드·기동 및 검증 명령어
사전 조건: 백엔드(Spring Boot)가 호스트 PC의 8080 포트에서 실행 중이어야 합니다(backend/에서 ./mvnw spring-boot:run 또는 .\mvnw.cmd spring-boot:run).
bash
# 1. 프론트엔드 Docker 이미지 빌드 (내부에서 tsc 타입 검사 + Vite 프로덕션 번들링 수행)
cd frontend
docker build -t company-react19-board:latest .
# 2-a. 컨테이너 기동 — Docker Desktop (Windows / macOS)
docker run -d -p 80:80 --name react-board-fe company-react19-board:latest
# 2-b. 컨테이너 기동 — Linux Docker Engine (host.docker.internal 호스트 게이트웨이 수동 매핑 필수!)
docker run -d -p 80:80 --add-host=host.docker.internal:host-gateway --name react-board-fe company-react19-board:latest
# 3. 기동 상태 및 Nginx 로그 확인
docker logs react-board-fe- 브라우저에서
http://localhost/에 접속해 게시글 목록이 정상적으로 조회되는지 확인합니다. http://localhost/write주소에서 **새로고침(F5)**을 눌렀을 때 Nginx 404 에러 페이지가 뜨지 않고 11장의<ProtectedRoute>가 동작해/login?returnUrl=%2Fwrite로 안내하면 SPA Fallback과 리버스 프록시가 모두 완벽하게 작동하는 것입니다!