refactor: 프론트·API 를 같은 도메인(/api)으로 통합

브라우저가 프론트와 API 를 서로 다른 도메인으로 호출하던 구조를 같은 도메인의 /api 경로로 통합한다.
운영은 Nginx Proxy Manager 의 Proxy Host(/ → frontend:8080)와 Custom location(/api → backend:3000)이,
개발은 vite dev 프록시가 같은 규칙으로 백엔드에 넘긴다.

- 같은 출처 호출이라 CORS 설정이 필요 없고 CSP connect-src 'self' 가 그대로 동작한다
  (기존 예시대로 별도 도메인에 배포하면 CSP 에 막혀 API 호출이 실패했다)
- 프론트 번들의 baseURL 이 /api 상대경로라 도메인이 바뀌어도 다시 빌드할 필요가 없다
- NPM 설정 절차와 주의점(Cache Assets 끄기, proxy 네트워크 생성)을 README "운영 배포"에 정리했다
- 프록시 설정이 빠지면 frontend nginx 가 /api 를 404 로 막는다 — index.html(200)로 조용히 실패하지 않게

함께 고친 CORS 문제:
- CORS_ORIGIN 에 콤마로 여러 오리진을 적으면 문자열 전체를 하나로 비교해 모두 막히던 버그
- 운영에서도 localhost:5173·localhost:3000 이 credentials 포함으로 항상 허용되던 문제
- 형식이 잘못된 오리진(경로 포함 등)은 기동 시점에 실패하도록 검증 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 00:04:03 +09:00
parent 2f94b6db12
commit 6dcbb45eb9
26 changed files with 456 additions and 90 deletions
+6 -4
View File
@@ -1,15 +1,17 @@
# ==========================================
# 프론트엔드 환경변수 (development)
#
# ⚠️ VITE_API_BASE_URL 은 루트 .env.development 의 BACKEND_API_URL 이
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
# ⚠️ 보통은 이 파일이 필요 없다.
# API 는 같은 도메인의 /api 로 호출하고(useApi.ts 기본값), vite dev 프록시가 백엔드로 넘긴다.
# 컨테이너 빌드 때는 루트 .env.development 의 BACKEND_API_URL 이 build arg 로 주입된다.
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
#
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
# 백엔드를 프록시 없이 직접 호출해야 하는 특수한 경우에만 복사해 쓴다.
# cp .env.development.example .env.development
# → 절대 URL 로 바꾸면 다른 오리진 호출이 되므로 루트 CORS_ORIGIN 에 이 프론트 주소를 추가해야 한다.
#
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
# ==========================================
# VITE_API_BASE_URL=http://localhost:3000/api
# VITE_API_BASE_URL=/api
+6 -5
View File
@@ -1,15 +1,16 @@
# ==========================================
# 프론트엔드 환경변수 (production)
#
# ⚠️ VITE_API_BASE_URL 은 루트 .env.production 의 BACKEND_API_URL 이
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
# ⚠️ 보통은 이 파일이 필요 없다.
# API 는 같은 도메인의 /api 로 호출하고(useApi.ts 기본값), 리버스 프록시(Nginx Proxy Manager 의
# Custom location)가 백엔드로 넘긴다. 컨테이너 빌드 때는 루트 .env.production 의 BACKEND_API_URL 이
# build arg 로 주입된다. (SSOT: 루트 .env — .claude/rules/env-structure.md)
#
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
# compose 를 거치지 않고 호스트에서 직접 빌드할 때만 복사해 쓴다.
# cp .env.production.example .env.production
#
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
# ==========================================
# VITE_API_BASE_URL=http://localhost:3000/api
# VITE_API_BASE_URL=/api
+3 -1
View File
@@ -16,7 +16,9 @@ RUN npm ci
COPY --chown=node:node . .
ARG BUILD_MODE=production
ARG VITE_API_BASE_URL
# 브라우저가 호출할 API 경로 — 기본은 같은 도메인의 /api (리버스 프록시가 백엔드로 넘긴다).
# compose 가 루트 BACKEND_API_URL 을 넘긴다. API 를 별도 도메인에 둘 때만 절대 URL 이 들어온다.
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build-only -- --mode ${BUILD_MODE}
+2 -1
View File
@@ -2,7 +2,8 @@
// Vite 환경변수 타입 정의 — VITE_ prefix 만 클라이언트 번들에 포함됨
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
/** API baseURL — 비어 있으면 같은 도메인의 /api (useApi.ts). 별도 도메인에 API 를 둘 때만 절대 URL */
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
+14 -1
View File
@@ -16,7 +16,8 @@ server {
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
# - default-src 'self' : 기본적으로 같은 출처만 허용
# - style-src 'unsafe-inline' : Vue 의 동적 스타일 바인딩(:style)이 인라인 스타일을 생성하므로 필요
# - connect-src 'self' : API 가 다른 도메인이면 그 origin 을 반드시 추가할 것
# - connect-src 'self' : API 는 같은 도메인의 /api 로 호출하므로 'self' 로 충분하다.
# API 를 별도 도메인에 둘 때만 그 origin 을 추가한다
# (예: connect-src 'self' https://api.example.com)
# - img-src data: blob: : 인라인 아이콘·캔버스/파일 미리보기 대응
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
@@ -33,6 +34,18 @@ server {
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
# ----------------------------------------
# /api 는 백엔드 전용 경로 — 여기까지 왔다면 리버스 프록시 설정이 빠진 것이다
#
# 운영에서는 Nginx Proxy Manager 의 Custom location(/api)이 이 경로를 backend 로 보내므로
# 이 컨테이너에는 도달하지 않는다. 그 설정이 빠지면 아래 SPA 폴백이 index.html 을 200 으로 돌려줘
# API 호출이 **조용히** HTML 을 받아 실패한다 — 404 로 막아 문제가 바로 드러나게 한다.
# (/api 로 시작하는 경로는 프론트엔드 라우트로 쓰지 않는다 — .claude/rules/api-design.md)
# ----------------------------------------
location ^~ /api {
return 404;
}
# ----------------------------------------
# SPA 라우팅
# ----------------------------------------
+16 -1
View File
@@ -4,7 +4,22 @@ import {
ERROR_CODE_MESSAGES,
FALLBACK_ERROR_CODE,
} from '@/shared/constants/error-codes.generated'
import { getErrorMessage } from './useApi'
import { getErrorMessage, resolveApiBaseUrl } from './useApi'
describe('API 기본 경로', () => {
it('설정이 없으면 같은 도메인의 /api 로 호출한다 (리버스 프록시가 백엔드로 넘긴다)', () => {
expect(resolveApiBaseUrl(undefined)).toBe('/api')
// compose 는 정의되지 않은 변수를 빈 문자열로 넘긴다
expect(resolveApiBaseUrl('')).toBe('/api')
expect(resolveApiBaseUrl(' ')).toBe('/api')
})
it('API 를 별도 도메인에 둔 경우에만 절대 URL 을 그대로 쓴다', () => {
expect(resolveApiBaseUrl('https://api.myservice.io/api')).toBe(
'https://api.myservice.io/api',
)
})
})
describe('에러 코드 사전', () => {
it('등록된 코드는 해당 한국어 메시지를 반환한다', () => {
+18 -1
View File
@@ -35,6 +35,23 @@ const API_TIMEOUT_MS = 10000
/** 동일 코드의 알림이 짧은 시간에 중복 노출되는 것을 막는 간격 */
const DUPLICATE_ALERT_WINDOW_MS = 3000
/**
* API 기본 경로 — 프론트와 같은 도메인의 /api.
* 운영은 리버스 프록시(Nginx Proxy Manager 의 Custom location)가, 개발은 vite dev 프록시가
* 이 경로를 백엔드로 넘긴다. 같은 출처라 CORS 가 필요 없고, 도메인이 바뀌어도 다시 빌드하지 않는다.
*/
export const DEFAULT_API_BASE_URL = '/api'
/**
* 빌드 시 주입된 baseURL 을 확정한다.
* 비어 있으면(compose 가 빈 문자열을 넘기는 경우 포함) 같은 도메인의 /api 를 쓴다.
* API 를 별도 도메인에 둘 때만 절대 URL 이 들어온다 — 그때는 백엔드 CORS_ORIGIN 과 nginx CSP 도 함께 바꾼다.
*/
export function resolveApiBaseUrl(configured: string | undefined): string {
const trimmed = configured?.trim()
return trimmed ? trimmed : DEFAULT_API_BASE_URL
}
function isErrorCode(code: string): code is ErrorCode {
return code in ERROR_CODE_MESSAGES
}
@@ -59,7 +76,7 @@ const showErrorUI = (code: ErrorCode, message: string) => {
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
baseURL: resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL),
timeout: API_TIMEOUT_MS,
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
withCredentials: true,
+6 -3
View File
@@ -59,12 +59,15 @@ export default defineConfig(({ mode }) => {
// 컨테이너로 띄울 때는 compose 의 `--port`(컨테이너 내부 고정 포트)가 이 값을 덮어쓴다.
port: frontendPort,
// 포트가 이미 사용 중이면 조용히 다음 포트로 옮기지 않고 실패한다.
// 루트 .env 의 CORS_ORIGIN 과 compose 포트 매핑이 이 포트에 묶여 있어,
// 말없이 5174 로 옮겨가면 API 요청이 원인 모르게 막힌다 (fail-fast).
// 대개 다른 vite(컨테이너 vite 포함 — compose 가 같은 FRONTEND_PORT 를 매핑한다)가 이미 떠 있다는 뜻이라,
// 말없이 5174 로 옮겨가면 브라우저는 여전히 5173 의 엉뚱한 서버를 보게 된다 (fail-fast).
strictPort: true,
// 도커 바인드 마운트에서는 inotify 가 호스트 쪽 변경을 잡지 못해 폴링이 필요하다
watch: usePolling ? { usePolling: true } : undefined,
// 개발 서버 API 프록시.
// 개발 서버 API 프록시 — 운영 리버스 프록시(Nginx Proxy Manager 의 Custom location /api)와 같은 역할.
// 브라우저는 개발·운영 모두 같은 출처의 /api 로만 호출하므로 CORS 가 끼어들지 않는다.
// 접두어 매칭이라 /api-docs(Swagger)도 백엔드로 간다 — 운영의 location /api 와 같은 규칙이다.
//
// useApi 의 baseURL 은 '/api'(상대경로)라 프록시가 없으면 vite 가 SPA 폴백으로
// index.html 을 200 으로 돌려준다 — 요청이 조용히 HTML 을 받아 실패한다.
//