CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
📚 교육용 풀스택 SaaS 개발기 시리즈 (24편)
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.local의VITE_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.md의 2차 점검 버그픽스 표에 이 자리가 한 줄로 박혀 있어요. “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가 그대로 fetch의 method 옵션에 넘기고 있었어요. 대문자로 정규화하는 자리가 한 곳도 없었던 거예요.
여기서 한 가지 더 확인이 필요했어요. 왜 같은 코드가 어떤 환경에서는 정상 동작하는가. 다른 React Admin 예제를 검색해 보면 'patch' 소문자 그대로 박는 코드가 분명히 동작하는데, 우리만 깨지는 상황이었거든요. 그 차이가 Vite 프록시 경유 여부에 있다는 걸 한 시간 더 굴려서 알았어요.
# .env.local (당시 모습)
VITE_API_BASE_URL=http://localhost:3000/api/v1/admin
# ↑ 절대 URL 직접 사용 (cross-origin)
우리는 .env.local에 BE 절대 URL을 박아서 Vite 프록시를 우회하고 있었어요. 이러면 브라우저 입장에서 5173 → 3000 cross-origin이라 CORS가 강제되고, 프록시의 정규화 효과를 못 받습니다.
🔬 진짜 범인 — 두 자리가 동시에, 그리고 Vite 6 프록시가 메서드를 정규화 안 한다

여기서 두 가지가 왜 동시에 무너졌는가가 깔끔하게 풀렸어요. 한 자리만 고치면 다른 자리가 침묵으로 다른 증상을 내는 식이라, 두 자리를 같이 잡지 않으면 회로가 안 닫혔습니다.
자리 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 + 대문자 메서드 | PATCH | OK (응답 헤더와 매칭됨) |
| 절대 URL + 소문자 메서드 | patch | ❌ CORS 거부 (지금 우리 상황) |
| 프록시 + 대문자 메서드 | PATCH | OK (cross-origin 자체 없음) |
| 프록시 + 소문자 메서드 | patch | ❌ 업스트림 라우팅 실패 |
절대 URL + 소문자가 가장 빨갛게 무너지는 조합이고, 그 한 자리에 우리가 정확히 서 있었어요.
자리 3: allowedHeaders가 비어 있으면 반사 모드의 미묘한 함정에 걸린다
여기서 끝이 아니었어요. 메서드를 대문자로 정규화해도 *x-requested-with*가 빠진 자리는 따로 남았습니다. cors 패키지는 allowedHeaders를 명시 안 했을 때 두 가지 모드 중 하나로 동작해요.
Access-Control-Request-Headers가 있으면 그 값을 그대로 반사해서Access-Control-Allow-Headers에 박는다- 그 헤더가 없으면 Allow-Headers 자체를 안 내려준다
문제는 반사가 일관적이지 않다는 거예요. 브라우저가 Access-Control-Request-Headers에 박는 값은 fetch에 명시한 헤더만 모아서 알파벳 순으로 정렬해서 보냅니다. 그런데 프록시·미들웨어·CDN이 중간에 헤더를 추가하거나 제거할 수 있어요. 그러면 클라이언트가 보낸 목록과 서버가 받은 목록이 어긋나서 반사가 한 글자씩 빠지는 증상이 납니다.
🔍 단서:
allowedHeaders를 명시하지 않으면 디버깅 자체가 비결정적이 돼요. 같은 코드가 어떤 날은 통과, 어떤 날은 실패하는 이유의 90%가 이 자리. 명시는 보안 정책이 아니라 디버깅 가능성을 위한 자리예요
이 셋을 한 줄로 합치면 이렇게 돼요.
CORS 디버깅은 OPTIONS 응답 헤더 3종(Origin·Methods·Headers)이 실제 요청을 모두 덮는지를 글자 단위로 비교하는 일이다. 자동화된 매칭이 친절하지 않으니, 송신 측 정규화와 수신 측 명시 양쪽으로 결정성을 회복시켜야 한다.
🛠️ 해결 — method.toUpperCase() + allowedHeaders 명시 + .env.local 프록시 경유

세 자리를 순서대로 정리해요. 좌측은 고치기 전 시퀀스, 우측은 고친 후 시퀀스예요. 같은 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를 정비했어요. methods에 OPTIONS를 추가하고, 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 | 하드코딩 → 환경변수 콤마 분리 | 스테이징·프로덕션 도메인을 같이 관리 |
methods | OPTIONS 추가 | 일부 미들웨어 조합에서 명시가 안전 |
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-Headers와 Max-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 없음 → 매 요청 OPTIONS | maxAge: 86400 — 24시간 캐시 |
| 디버깅 | 콘솔 에러만 보고 BE 설정 추측 | Network 탭 OPTIONS 응답 헤더 3종을 글자 단위로 검수 |
| 회귀 방지 | 코드 리뷰에만 의존 | OPTIONS e2e + ESLint(소문자 메서드 금지) + CORS 헤더 점검 스크립트 |
다음 편 #26에서는 같은 Admin Portal 점검 흐름의 마무리, 16/20 시나리오까지 도달한 1차 완료 점검으로 들어가요. CORS·DTO·dataProvider 세 자리를 모두 잡고 나서 나머지 시나리오들이 어떤 결로 침묵하고 있었는지를 다시 펼칩니다.
📚 교육용 풀스택 SaaS 개발기 시리즈 (24편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드