CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드

DTO를 class로 바꾼 다음 날 새벽, 같은 PATCH 흐름에서 이번엔 CORS가 빨갛게 물들었어요. 'Method patch is not allowed'. 메서드 이름이 소문자였습니다. 한쪽은 NestJS의 `allowedHeaders` 한 줄이 비어 있어서, 다른 한쪽은 React Admin의 dataProvider가 `method`를 소문자로 그대로 넘기고 있어서 — 그리고 그걸 Vite 6 프록시가 정규화 없이 그대로 흘려보내서. 두 자리를 동시에 잡고, `.env.local`을 절대 URL에서 `/api` 프록시 경유로 바꾸고, OPTIONS 응답 헤더를 검수 체크리스트로 박은 자정 디버깅 6시간을 다시 짚었습니다.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • GET·POST·PUT은 잘 됐는데 PATCH만 빨갛게. 운영자 상태 변경, 정책 설정 저장, 학원 정보 수정 — 셋 다 PATCH였고 셋 다 똑같이 Method patch is not allowed by Access-Control-Allow-Methods in preflight response 한 줄을 토했어요. 같은 컨트롤러의 GET/POST/PUT은 OPTIONS·실요청 한 쌍이 깔끔하게 200으로 떨어졌고요
  • curl은 200, 브라우저는 빨강. curl로 직접 PATCH를 쏘면 200 OK인데, 같은 페이로드를 React Admin이 보내면 프리플라이트도 가기 전에 콘솔이 빨갛게 물드는 패턴. 이 비대칭 자체가 CORS 문제의 100% 신호예요. CORS는 브라우저만이 강제하는 메커니즘이라, 서버 입장에서는 잘못이 없어 보이거든요
  • 자리 1 — allowedHeaders 누락. app.enableCors({ origin, methods, credentials })까지만 박아 두고 allowedHeaders를 비워 둔 상태. 이러면 cors 패키지가 Access-Control-Allow-Headers요청 헤더 그대로 반사하는 모드로 동작하는데, 우리 dataProvider는 Authorization·X-Requested-With·Content-Type 셋을 같이 보내고 있어서 일부 환경에서 미묘하게 어긋났어요
  • 자리 2 — Vite 6 프록시가 소문자 patch를 그대로 흘렸다. 에러 메시지의 메서드 이름이 소문자였다는 게 결정적이었어요. React Admin의 useUpdate가 내부적으로 method: 'patch'(소문자)로 dataProvider에 넘기고, 우리가 그대로 fetch에 박았는데, Vite 6.x 프록시가 메서드를 정규화하지 않고 그대로 업스트림으로 흘렸습니다. 그래서 NestJS의 OPTIONS 응답에는 PATCH가 박혀 있는데 실제 요청 메서드patch로 가서 매칭이 안 났어요
  • method.toUpperCase() 한 줄과 .env.local. 해결은 두 자리. ① rest-data-provider의 httpClient 호출 직전에 const httpMethod = method.toUpperCase()를 박아 대문자로 정규화, ② .env.localVITE_API_BASE_URL절대 URL에서 /api/v1/admin(프록시 경유)로 바꿔서 개발 환경에서 cross-origin 자체를 없앰
  • OPTIONS 응답 헤더 3종 체크리스트. Access-Control-Allow-Origin·Allow-Methods·Allow-Headers 셋의 값이 실제 요청의 origin·method·headers를 모두 덮는지만 보면 30분 안에 원인이 잡혀요. 이 체크리스트가 없으면 cors 패키지의 동적 동작에 휘둘려 길을 잃기 쉽습니다
  • 앵글 한 줄. CORS 디버깅의 95%는 메시지를 정확히 읽는 일이에요. patch 소문자 한 글자가 모든 걸 말해 줬는데, 처음 한 시간을 대문자라고 가정한 채로 넘긴 게 길을 늦췄어요. 콘솔 한 줄을 글자 단위로 읽는 습관이 디버깅의 절반

🪪 재구성 안내. 이 편은 이전 편에서 DTO interface→class 전환을 끝낸 직후, 같은 PATCH 라인에서 이번엔 CORS가 빨갛게 물든 자정의 6시간을 세션 아카이브 2026-01-14_0100.md“CORS PATCH 디버깅 (Critical)” 섹션과 2차 점검 버그픽스 표를 교차해 재구성한 글이에요. apps/api/src/main.ts, apps/admin-portal/src/providers/rest-data-provider.ts 등 직접 인용한 코드는 당시 동작을 글로 표현하기 위한 재구성본이고, 변수명·로직 흐름은 커밋 정황과 정합하지만 라인 단위까지 동일하다고 보증하지는 않습니다.

🔥 증상 — GET·POST·PUT은 다 되는데 PATCH만 빨갛게 빠졌다

전 편에서 DTO interface→class 전환을 새벽 두 시 즈음 끝내고, 정책 설정 PATCH가 진짜 사람 같은 메시지로 검증 에러를 돌려주는 걸 확인하고 잠깐 숨을 돌렸어요. 그러고 FE 쪽에서 같은 PATCH를 한 번 호출해서 양쪽이 살아 있는지만 보고 자려고 했습니다. 15분이면 끝날 줄 알았는데 그 자리에서 6시간이 더 깎였어요.

pnpm --filter admin-portal dev로 React Admin을 띄우고, http://localhost:5173/admins/7/show에서 운영자의 상태를 INACTIVE → ACTIVE로 토글했어요. 콘솔이 곧장 빨개졌습니다.

Access to fetch at 'http://localhost:3000/api/v1/admin/admins/7/status'
from origin 'http://localhost:5173' has been blocked by CORS policy:
Method patch is not allowed by Access-Control-Allow-Methods in preflight response.

Method patch is not allowed. 익숙한 메시지였지만, 조합이 묘했어요. 같은 컨트롤러의 다른 엔드포인트는 잘 되고 있었거든요.

GET    /api/v1/admin/admins        → 200  (목록 조회 정상)
POST   /api/v1/admin/admins        → 201  (생성 정상)
PUT    /api/v1/admin/admins/7      → 200  (전체 수정 정상)
PATCH  /api/v1/admin/admins/7/status → ❌ CORS preflight 실패

세 개는 통과인데 PATCH만 막힌다는 게 부분적인 CORS 문제가 분명했어요. CORS 설정이 통째로 빠졌으면 GET조차 안 됐을 텐데, GET이 되니까요.

📌 핵심: 부분적인 CORS 실패는 그 자체가 가장 큰 단서예요. 메서드별로/엔드포인트별로 어떤 게 되고 어떤 게 안 되는지를 표로 그려 두면 의심해야 할 자리의 범위가 단번에 좁혀집니다. 조용히 다 막힐 때보다 부분적으로 통과할 때가 디버깅하기 더 쉬워요

증상을 한 번 더 바꿔 봤어요. 같은 PATCH 페이로드를 curl로 직접 쐈습니다.

curl -X PATCH http://localhost:3000/api/v1/admin/admins/7/status \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' \
  -d '{ "status": "ACTIVE" }' -i

# HTTP/1.1 200 OK
# Content-Type: application/json
# { "success": true, "data": { ... } }

200. 서버 로직에는 문제가 없다는 뜻이에요. 같은 페이로드를 브라우저 콘솔에서 fetch로 직접 쐈을 때도 마찬가지였습니다.

// Chrome 콘솔에서 직접
await fetch('http://localhost:3000/api/v1/admin/admins/7/status', {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${localStorage.getItem('token')}`,
  },
  body: JSON.stringify({ status: 'ACTIVE' }),
});
// → 200 OK

콘솔 fetch는 200, React Admin이 보낸 PATCH는 CORS 에러. 같은 origin·같은 메서드·같은 헤더라고 내가 믿었던 두 호출이 정반대 결과를 내고 있었어요. 이 비대칭이 본론의 입구였습니다.

🔍 단서: curl과 콘솔 fetch는 200, 라이브러리 호출만 빨강인 패턴은 보통 클라이언트 라이브러리가 내가 모르는 헤더/메서드/포맷을 쓰고 있다는 뜻이에요. 라이브러리를 의심하기 전에 Network 탭에서 실제 요청을 글자 단위로 비교하는 게 첫 자리

세션 아카이브 2026-01-14_0100.md2차 점검 버그픽스 표에 이 자리가 한 줄로 박혀 있어요. “CORS PATCH → allowedHeaders 추가”. 그 아래에 “CORS PATCH 디버깅 (Critical)” 섹션이 따로 잡혀 있고, *“최종 원인: Vite 6.x 프록시가 소문자 HTTP 메서드(patch)를 인식하지 못함”*까지 한 줄로 적혀 있는데, 그 한 줄에 도달하기까지의 흐름이 이 글의 본론이에요.

🔍 탐색 — Network 탭의 OPTIONS 응답을 글자 단위로 읽다

처음에는 BE 설정만 의심했어요. CORS 에러는 서버가 응답 헤더를 잘못 내려줘서 나는 거니까, FE 쪽을 볼 일이 없을 거라고 생각했거든요. apps/api/src/main.ts를 열었습니다.

// ❌ apps/api/src/main.ts (당시 모습 재구성)
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api/v1');

  app.enableCors({
    origin: 'http://localhost:5173',
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    credentials: true,
    // allowedHeaders: 비어 있음
  });

  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.listen(3000);
}

methods 배열에 PATCH가 박혀 있었어요. 분명히 박았는데. 한 시간쯤 왜 PATCH가 인식이 안 되지만 들여다봤는데 그 자리만 봐서는 길이 없었습니다. Network 탭으로 넘어갔어요.

PATCH 호출 직전에 OPTIONS 한 줄이 먼저 박혀 있었습니다. 두 줄을 동시에 펼쳐 봤어요.

Request:
OPTIONS /api/v1/admin/admins/7/status HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: patch         # ← 소문자
Access-Control-Request-Headers: authorization,content-type,x-requested-with

Response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE      # ← PATCH 대문자
Access-Control-Allow-Headers: authorization,content-type     # ← x-requested-with 없음
Access-Control-Allow-Credentials: true

두 자리가 동시에 어긋나 있었어요.

첫째, *요청의 Access-Control-Request-Method: patch*가 소문자로 박혀 있는데, *응답의 Access-Control-Allow-Methods*는 PATCH 대문자였어요. CORS 명세상 Access-Control-Request-Method 값은 대소문자 구분하는 비교 대상이라, patch ∈ {GET, POST, PUT, PATCH, DELETE} 매칭이 실패하고 있었습니다.

둘째, *요청의 Access-Control-Request-Headers*에 x-requested-with가 들어가 있는데, *응답의 Access-Control-Allow-Headers*에는 그 자리가 비어 있었어요. cors 패키지가 allowedHeaders를 안 받았을 때 요청 헤더를 그대로 반사해 주는 기본 동작이 있는데, 어떤 이유로 x-requested-with가 빠진 상태였습니다.

⚠️ 주의: Network 탭의 OPTIONS 응답 헤더를 글자 단위로 읽는 것이 CORS 디버깅의 50%예요. 콘솔 에러 메시지는 짧게 요약된 결론만 알려 주고, 어긋났는지는 OPTIONS 응답 헤더에서만 보입니다. 이걸 안 보고 BE 코드만 들여다보면 같은 자리만 빙빙 돌아요

여기서 왜 메서드가 소문자로 가는가가 핵심 질문으로 바뀌었어요. React Admin이 보내든 우리 dataProvider가 보내든 결국 내 코드patch(소문자)를 fetch에 박고 있다는 뜻이니까. dataProvider 파일을 열었습니다.

// ❌ apps/admin-portal/src/providers/rest-data-provider.ts (당시 모습 재구성)
export const restDataProvider = (apiUrl: string): DataProvider => ({
  // ...
  update: async (resource, params) => {
    const url = `${apiUrl}/${resource}/${params.id}`;
    return fetcher(url, 'patch', params.data);   // ← 소문자 'patch'
  },

  updateMany: async (resource, params) => {
    const url = `${apiUrl}/${resource}/${params.ids[0]}`;
    return fetcher(url, 'patch', params.data);
  },
  // ...
});

async function fetcher(url: string, method: string, body?: unknown) {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest',
  };
  const token = localStorage.getItem('token');
  if (token) headers['Authorization'] = `Bearer ${token}`;

  const response = await fetch(url, {
    method,                                       // ← 그대로 'patch'
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });
  // ...
}

update/updateMany에서 *문자열 리터럴 'patch'*를 그대로 박아 두고, fetcher그대로 fetchmethod 옵션에 넘기고 있었어요. 대문자로 정규화하는 자리가 한 곳도 없었던 거예요.

여기서 한 가지 더 확인이 필요했어요. 왜 같은 코드가 어떤 환경에서는 정상 동작하는가. 다른 React Admin 예제를 검색해 보면 'patch' 소문자 그대로 박는 코드가 분명히 동작하는데, 우리만 깨지는 상황이었거든요. 그 차이가 Vite 프록시 경유 여부에 있다는 걸 한 시간 더 굴려서 알았어요.

# .env.local (당시 모습)
VITE_API_BASE_URL=http://localhost:3000/api/v1/admin
# ↑ 절대 URL 직접 사용 (cross-origin)

우리는 .env.localBE 절대 URL을 박아서 Vite 프록시를 우회하고 있었어요. 이러면 브라우저 입장에서 5173 → 3000 cross-origin이라 CORS가 강제되고, 프록시의 정규화 효과를 못 받습니다.

🔬 진짜 범인 — 두 자리가 동시에, 그리고 Vite 6 프록시가 메서드를 정규화 안 한다

새벽 4시 OPTIONS 응답 헤더 한 줄에서 소문자 patch가 보이던 순간
새벽 4시 OPTIONS 응답 헤더 한 줄에서 소문자 patch가 보이던 순간

여기서 두 가지가 왜 동시에 무너졌는가가 깔끔하게 풀렸어요. 한 자리만 고치면 다른 자리가 침묵으로 다른 증상을 내는 식이라, 두 자리를 같이 잡지 않으면 회로가 안 닫혔습니다.

자리 1: cors 패키지의 Access-Control-Allow-Methods는 메서드를 그대로 박는다

NestJS의 app.enableCors()는 내부적으로 Express용 cors 패키지를 그대로 쓰고 있어요. 이 패키지의 동작을 코드로 한 줄씩 따라가 봤습니다.

// node_modules/cors/lib/index.js (간소화)
function configureMethods(options) {
  let methods = options.methods || 'GET,HEAD,PUT,PATCH,POST,DELETE';
  if (Array.isArray(methods)) methods = methods.join(',');
  return { 'Access-Control-Allow-Methods': methods };  // ← 그대로 합쳐서 응답 헤더에 박음
}

그래서 응답 헤더의 Access-Control-Allow-Methods 값은 우리가 박은 그대로 'GET,POST,PUT,PATCH,DELETE'로 나가요. 그런데 브라우저가 OPTIONS를 보낼 때 Access-Control-Request-Method에 박아 보내는 값은 fetch 호출에 박힌 method 문자열을 그대로입니다. 즉 우리가 method: 'patch'로 박으면 브라우저는 그대로 ‘patch’를 보내요.

[Browser]  Request-Method: patch
[Server]   Allow-Methods:  GET,POST,PUT,PATCH,DELETE
[Browser]  매칭: 'patch' ∈ {GET,POST,PUT,PATCH,DELETE} ?
              → 대소문자 구분 비교에서 false
              → preflight 거부, 콘솔에 에러

📌 핵심: CORS 명세는 메서드 매칭에서 대소문자를 구분하는 곳과 안 하는 곳이 섞여 있어요. 안전한 길은 클라이언트 측에서 항상 대문자로 정규화하는 것입니다. 서버 쪽 응답 헤더는 라이브러리가 박는 대로 두고, 송신 측에서 표준화하는 게 RFC 7231 메서드 토큰 정의(case-sensitive)와도 맞아떨어집니다

자리 2: Vite 6 프록시는 req.method를 그대로 업스트림에 흘린다

같은 fetch가 프록시 경유면 어떻게 될까. Vite의 dev server는 http-proxy(node-http-proxy) 위에서 동작해요. 핵심 함수가 proxyReq.method = req.method그대로 박는 부분이라, 클라이언트가 소문자로 보내면 업스트림도 소문자로 받습니다.

// 단순화한 흐름
client → vite-dev (5173) → vite-proxy → upstream NestJS (3000)
                              method: 'patch' (그대로)

NestJS의 라우터는 @Patch() 데코레이터로 등록된 핸들러를 내부에서 대문자 ‘PATCH’로 매칭해요. 그래서 프록시 경유로 소문자 ‘patch’가 도착하면 라우터가 못 찾아서 404가 떨어지거나, 일부 미들웨어에서 405 Method Not Allowed가 나오기도 합니다. 그 어느 쪽도 우리가 보고 있던 CORS 에러는 아니었지만, 동일 원인에 뿌리를 둔 두 자식 증상이었어요.

여기서 왜 같은 React Admin 예제가 다른 곳에서는 잘 동작하는지도 풀렸습니다.

환경method 표기결과
절대 URL + 대문자 메서드PATCHOK (응답 헤더와 매칭됨)
절대 URL + 소문자 메서드patch❌ CORS 거부 (지금 우리 상황)
프록시 + 대문자 메서드PATCHOK (cross-origin 자체 없음)
프록시 + 소문자 메서드patch❌ 업스트림 라우팅 실패

절대 URL + 소문자가 가장 빨갛게 무너지는 조합이고, 그 한 자리에 우리가 정확히 서 있었어요.

자리 3: allowedHeaders가 비어 있으면 반사 모드의 미묘한 함정에 걸린다

여기서 끝이 아니었어요. 메서드를 대문자로 정규화해도 *x-requested-with*가 빠진 자리는 따로 남았습니다. cors 패키지는 allowedHeaders를 명시 안 했을 때 두 가지 모드 중 하나로 동작해요.

  1. Access-Control-Request-Headers가 있으면 그 값을 그대로 반사해서 Access-Control-Allow-Headers에 박는다
  2. 그 헤더가 없으면 Allow-Headers 자체를 안 내려준다

문제는 반사가 일관적이지 않다는 거예요. 브라우저가 Access-Control-Request-Headers에 박는 값은 fetch에 명시한 헤더만 모아서 알파벳 순으로 정렬해서 보냅니다. 그런데 프록시·미들웨어·CDN이 중간에 헤더를 추가하거나 제거할 수 있어요. 그러면 클라이언트가 보낸 목록서버가 받은 목록이 어긋나서 반사가 한 글자씩 빠지는 증상이 납니다.

🔍 단서: allowedHeaders를 명시하지 않으면 디버깅 자체가 비결정적이 돼요. 같은 코드가 어떤 날은 통과, 어떤 날은 실패하는 이유의 90%가 이 자리. 명시는 보안 정책이 아니라 디버깅 가능성을 위한 자리예요

이 셋을 한 줄로 합치면 이렇게 돼요.

CORS 디버깅은 OPTIONS 응답 헤더 3종(Origin·Methods·Headers)이 실제 요청을 모두 덮는지를 글자 단위로 비교하는 일이다. 자동화된 매칭이 친절하지 않으니, 송신 측 정규화와 수신 측 명시 양쪽으로 결정성을 회복시켜야 한다.

🛠️ 해결 — method.toUpperCase() + allowedHeaders 명시 + .env.local 프록시 경유

CORS PATCH preflight 실패 시퀀스와 method.toUpperCase 정규화 + Vite 프록시 경유로 회피한 성공 시퀀스를 좌우로 비교한 다이어그램

세 자리를 순서대로 정리해요. 좌측은 고치기 전 시퀀스, 우측은 고친 후 시퀀스예요. 같은 PATCH 한 줄이 송신 측 정규화·수신 측 명시·프록시 경유 세 자리를 통과하면서 어떻게 결정성을 회복하는지 따라가 봅니다.

자리 1: rest-data-provider 한 줄 — method.toUpperCase()

fetcher 함수에 한 줄을 박았어요. 두 군데가 아니라 진입점 한 곳에서 정규화하면, 호출자가 어떤 케이스로 보내든 안전합니다.

// ✅ apps/admin-portal/src/providers/rest-data-provider.ts (재구성)
async function fetcher(url: string, method: string, body?: unknown) {
  const httpMethod = method.toUpperCase();        // ← 진입점에서 정규화

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest',
  };
  const token = localStorage.getItem('token');
  if (token) headers['Authorization'] = `Bearer ${token}`;

  const response = await fetch(url, {
    method: httpMethod,                            // ← 'PATCH'로 박힘
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!response.ok) {
    const errorBody = await response.json().catch(() => ({}));
    throw new HttpError(errorBody.message ?? response.statusText, response.status, errorBody);
  }
  return response.json();
}

호출자 쪽 리터럴은 그대로 두기로 했어요. update: ... fetcher(url, 'patch', ...)로 박아도 정규화가 진입점에서 일어나니까. 호출자 N곳을 모두 대문자로 바꿔 다니는 길은 누락 가능성이 있고, 진입점 한 자리가 안전했습니다.

💡 인사이트: 입력 정규화는 진입점에 모으는 것이 원칙이에요. 호출자 측에서 정규화하면 N개 자리를 동기화해야 하지만, 진입점 한 자리는 한 자리만 보면 됩니다. 호출자에 반복되는 변환 로직이 보이면 진입점으로 끌어내릴 자리가 있는지 의심하세요

자리 2: NestJS app.enableCors()allowedHeaders 명시

main.ts를 정비했어요. methodsOPTIONS를 추가하고, allowedHeaders에 우리가 실제로 보내는 헤더를 명시하고, origin도 환경변수로 끌어냈습니다.

// ✅ apps/api/src/main.ts (재구성)
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api/v1');

  const corsOrigins = (process.env.CORS_ORIGIN ?? 'http://localhost:5173')
    .split(',')
    .map((s) => s.trim());

  app.enableCors({
    origin: corsOrigins,
    methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
    allowedHeaders: [
      'Content-Type',
      'Authorization',
      'X-Requested-With',
      'Accept',
    ],
    exposedHeaders: ['X-Total-Count'],   // React Admin 페이지네이션
    credentials: true,
    maxAge: 86400,                        // 프리플라이트 캐시 24시간
  });

  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.listen(3000);
}

다섯 자리가 바뀌었어요.

항목변경이유
origin하드코딩 → 환경변수 콤마 분리스테이징·프로덕션 도메인을 같이 관리
methodsOPTIONS 추가일부 미들웨어 조합에서 명시가 안전
allowedHeaders누락 → 4개 명시반사 모드 비결정성 제거, 디버깅 가능성 회복
exposedHeaders추가React Admin이 X-Total-Count를 읽어야 페이지네이션 동작
maxAge추가 (86400초)매 요청마다 OPTIONS가 안 가도록 캐시

maxAge프리플라이트 응답을 24시간 캐시하라는 신호라, 같은 origin·method·headers 조합에 대해 OPTIONS 라운드트립이 사라져요. 개발 중에는 수정 후 캐시를 비우는 자리가 따로 필요하지만, 프로덕션에서는 비용 절감이 큽니다.

⚠️ 주의: origin: '*'(와일드카드)와 credentials: true함께 쓰면 브라우저가 거부해요. 브라우저는 credentials를 허용하는 origin은 반드시 명시되어야 한다고 강제합니다. 와일드카드를 쓰고 싶다면 credentials를 끄거나, origin 함수로 동적 매칭을 박아야 해요

자리 3: .env.local — 절대 URL을 프록시 경유로 바꾸기

마지막 자리는 개발 환경에서 cross-origin 자체를 없애는 길. .env.local을 한 줄 바꿨어요.

# ❌ Before
VITE_API_BASE_URL=http://localhost:3000/api/v1/admin

# ✅ After
VITE_API_BASE_URL=/api/v1/admin

그리고 vite.config.ts에 프록시 룰을 박았습니다.

// apps/admin-portal/vite.config.ts (재구성)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        // /api/v1/admin/... 그대로 업스트림으로 프록시
      },
    },
  },
});

이러면 브라우저 입장에서 모든 요청이 http://localhost:5173/api/v1/admin/...같은 origin에 가요. CORS 자체가 작동하지 않으니 OPTIONS 프리플라이트도 안 가고, 디버깅 표면적이 한 단계 줄어듭니다.

📌 핵심: 개발 환경에서 CORS를 우회하는 가장 깔끔한 길은 프록시 경유예요. CORS 설정을 박는 게 무의미한 게 아니라, 프로덕션에서 진짜로 cross-origin이 일어날 때는 프록시가 없으니 BE 설정이 결국 살아 있어야 합니다. 개발은 프록시로 단순화, 프로덕션은 명시적 CORS — 두 자리를 분리해서 운영하세요

✅ 검증 — Network 탭 OPTIONS 응답이 깔끔해지고 PATCH가 200으로 떨어진다

세 자리를 모두 바꾸고 pnpm dev를 두 번 다 재시작했어요(BE는 app.enableCors 변경 반영, FE는 .env.local 변경 반영). React Admin에서 다시 PATCH를 호출했습니다.

Request:
PATCH /api/v1/admin/admins/7/status HTTP/1.1
Host: localhost:5173                  # ← 같은 origin (프록시 경유)
Content-Type: application/json
Authorization: Bearer <token>
X-Requested-With: XMLHttpRequest

Response:
HTTP/1.1 200 OK
Content-Type: application/json
{ "success": true, "data": { ... } }

프리플라이트가 사라졌어요. 같은 origin이라 OPTIONS 자체가 안 가고, PATCH 한 줄이 메서드 대문자로 정규화된 채로 깔끔하게 200을 받았습니다.

cross-origin 동작도 따로 검증해야 했어요. 프로덕션에서는 프록시가 없으니까. 콘솔에서 절대 URL로 직접 호출했습니다.

// Chrome 콘솔에서 — 절대 URL로 cross-origin 시뮬레이션
await fetch('http://localhost:3000/api/v1/admin/admins/7/status', {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${localStorage.getItem('token')}`,
    'X-Requested-With': 'XMLHttpRequest',
  },
  body: JSON.stringify({ status: 'INACTIVE' }),
});
// → 200 OK

OPTIONS 응답 헤더도 확인했어요.

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization,X-Requested-With,Accept
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Total-Count
Access-Control-Max-Age: 86400

세 줄(Origin·Methods·Headers) 모두 실제 요청의 모든 항목을 덮고 있고, 추가로 Expose-HeadersMax-Age까지 명시되어 있어요. 더는 반사 모드의 비결정성에 휘둘리지 않습니다.

회귀를 막는 e2e를 한 줄 박았어요.

// apps/api/test/cors.e2e-spec.ts (재구성)
describe('CORS preflight', () => {
  it('responds 204 with all required headers for PATCH', async () => {
    const res = await request(app.getHttpServer())
      .options('/api/v1/admin/admins/7/status')
      .set('Origin', 'http://localhost:5173')
      .set('Access-Control-Request-Method', 'PATCH')
      .set('Access-Control-Request-Headers', 'authorization,content-type,x-requested-with');

    expect(res.status).toBe(204);
    expect(res.headers['access-control-allow-origin']).toBe('http://localhost:5173');
    expect(res.headers['access-control-allow-methods']).toContain('PATCH');
    expect(res.headers['access-control-allow-headers']).toContain('X-Requested-With');
    expect(res.headers['access-control-allow-credentials']).toBe('true');
  });

  it('rejects unknown origin', async () => {
    const res = await request(app.getHttpServer())
      .options('/api/v1/admin/admins/7/status')
      .set('Origin', 'http://evil.example.com')
      .set('Access-Control-Request-Method', 'PATCH');

    // cors 패키지는 미허용 origin에 Allow-Origin 헤더 자체를 안 내림
    expect(res.headers['access-control-allow-origin']).toBeUndefined();
  });
});

OPTIONS 응답 헤더 3종을 e2e로 잠가 두면 누군가 app.enableCors()부주의하게 수정해도 빌드가 빨갛게 깨져요. 회귀의 자리를 기계가 지킨다는 뜻입니다.

🛡️ 예방 — 송신 측 정규화 + 수신 측 명시 + 프록시 컨벤션

같은 자리를 두 번 안 밟으려고 세 자리를 컨벤션으로 박았어요.

컨벤션: docs/fe-conventions.md

## HTTP 클라이언트 작성 규칙

### 메서드 대소문자
- fetch에 넘기는 method는 **반드시 대문자 정규화**
- dataProvider의 진입점 함수에서 `method.toUpperCase()` 한 번만 호출
- 호출자(useUpdate 등)에서 소문자로 넘겨도 진입점이 흡수

### 헤더 명시
- 외부 라이브러리가 자동으로 박는 헤더(Authorization, X-Requested-With 등)는
  반드시 BE의 `allowedHeaders`에도 명시되어 있어야 함
- FE에서 헤더를 추가하면 *반드시* BE main.ts allowedHeaders도 같이 업데이트 (PR 체크리스트)

### 개발 환경 cross-origin
- `.env.development` / `.env.local`의 API URL은 **상대 경로** (`/api/...`)
- 절대 URL은 *프로덕션에서만* 사용
- vite.config.ts proxy로 dev cross-origin 회피

ESLint 규칙: 소문자 HTTP 메서드 리터럴 금지

dataProvider 디렉토리에 한정해서 소문자 HTTP 메서드 문자열 리터럴을 막는 규칙을 박았어요. no-restricted-syntax로 충분합니다.

// .eslintrc.json (재구성)
{
  "overrides": [
    {
      "files": ["apps/admin-portal/src/providers/**/*.ts"],
      "rules": {
        "no-restricted-syntax": [
          "error",
          {
            "selector": "Literal[value=/^(get|post|put|patch|delete|head|options)$/]",
            "message": "HTTP 메서드 리터럴은 대문자로 작성하세요 (예: 'PATCH')."
          }
        ]
      }
    }
  ]
}

이 규칙은 진입점 정규화가 있어도 한 번 더 잡아 주는 안전망이에요. 호출자 측 리터럴까지 대문자로 통일하면 Network 탭에서 메서드를 빠르게 식별하기도 좋습니다.

점검 스크립트: BE allowedHeaders ↔ FE 실제 헤더 일치 확인

가장 흔한 실수는 FE에서 헤더를 새로 추가했는데 BE allowedHeaders에 누락하는 경우예요. CI에 한 줄 체크를 박았습니다.

// scripts/check-cors-headers.ts (재구성)
import { execSync } from 'child_process';

const FE_HEADERS = grepFeHeaders('apps/admin-portal/src/providers');
const BE_ALLOWED = grepBeAllowedHeaders('apps/api/src/main.ts');

const missing = FE_HEADERS.filter(
  (h) => !BE_ALLOWED.some((a) => a.toLowerCase() === h.toLowerCase()),
);
if (missing.length > 0) {
  console.error('BE allowedHeaders에 다음 헤더가 누락:', missing);
  process.exit(1);
}

완전한 정적 분석은 아니지만 대부분의 실수는 잡혀요. CI에 한 줄 박아 두면 PR 단계에서 빨갛게 잡히니, 새벽에 콘솔 빨갛게 만나는 일이 줄어듭니다.

🔍 단서: 예방 규칙은 1인 다역에서 더 비싸게 작용해요. PR 리뷰어가 있으면 사람의 눈이 한 번 거르지만, 1인 다역은 그 자리가 비어 있어서, ESLint·CI 스크립트·e2e 같은 자동화된 게이트에 더 많이 의존해야 합니다. 컨벤션 문서는 기계의 게이트가 못 잡는 자리를 채우는 보조 역할로 두세요

📋 정리 — 핵심 요약

자리❌ 안티패턴✅ 권장 패턴
HTTP 메서드fetch(url, { method: 'patch' }) 소문자 그대로fetch(url, { method: method.toUpperCase() }) — 진입점 한 자리에서 정규화
app.enableCors()methods만 명시, allowedHeaders 누락4종 헤더 명시 + Origin·Methods·Headers·Credentials 모두 명시
origin'http://localhost:5173' 하드코딩process.env.CORS_ORIGIN.split(',') — 다중 도메인 환경변수
와일드카드 + 자격증명origin: '*' + credentials: true (스펙 위반)명시적 origin 또는 origin: (req, cb) => ... 함수
개발 환경 cross-origin.env.local에 절대 URL → 매번 OPTIONS상대 경로 + vite.config.ts proxy → 같은 origin
프리플라이트 캐시maxAge 없음 → 매 요청 OPTIONSmaxAge: 86400 — 24시간 캐시
디버깅콘솔 에러만 보고 BE 설정 추측Network 탭 OPTIONS 응답 헤더 3종을 글자 단위로 검수
회귀 방지코드 리뷰에만 의존OPTIONS e2e + ESLint(소문자 메서드 금지) + CORS 헤더 점검 스크립트

다음 편 #26에서는 같은 Admin Portal 점검 흐름의 마무리, 16/20 시나리오까지 도달한 1차 완료 점검으로 들어가요. CORS·DTO·dataProvider 세 자리를 모두 잡고 나서 나머지 시나리오들이 어떤 결로 침묵하고 있었는지를 다시 펼칩니다.

📚 교육용 풀스택 SaaS 개발기 시리즈 (24편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드