[Express] CORS & Swagger 세팅
1. SOP와 CORS 개념
웹 브라우저는 기본적으로 동일 출처 정책(Same-Origin Policy, SOP)이라는 보안 규칙을 따른다. SOP는 서로 다른 출처(Origin)로의 리소스 요청을 차단한다.
출처(Origin)는 세 가지 요소로 구성된다.
| 요소 | 예시 |
|---|---|
| 프로토콜 | https://, http:// |
| 호스트 | neordinary.co.kr, api.umc.com |
| 포트 | :3000, :5500 |
이 세 가지 중 하나라도 다르면 다른 출처로 판단한다. 예를 들어 https://neordinary.co.kr에서 https://api.umc.com을 호출하면, 호스트가 다르기 때문에 브라우저는 요청을 차단한다.
SOP만 있으면 프론트엔드와 백엔드가 서로 다른 도메인에 배포되는 일반적인 구조에서 API 통신이 불가능해진다. CORS(Cross-Origin Resource Sharing)는 서버가 응답 헤더를 통해 "이 출처는 허용한다"고 선언함으로써 SOP의 제한을 안전하게 완화하는 메커니즘이다.
중요한 점은 CORS 체크를 수행하는 주체가 브라우저라는 것이다. 서버는 단지 허용 정보를 헤더에 담아 응답할 뿐이고, 그 헤더를 읽어 요청을 허용하거나 차단하는 것은 브라우저가 담당한다. 따라서 Postman 같은 도구로는 CORS 에러가 발생하지 않는다.
2. Preflight 요청과 CORS 헤더
브라우저는 실제 요청을 보내기 전에 Preflight 요청을 먼저 전송한다. OPTIONS 메서드를 사용해 동일한 경로로 요청을 보내고, 서버가 이 출처를 허용하는지 확인한다.
Preflight를 거치는 이유는 POST, PUT, DELETE 같은 상태 변경 요청이 잘못된 출처에서 실행되면 보안상 위험하기 때문이다. 브라우저가 미리 "허락받는" 절차를 거쳐 안전성을 검증한다.
Preflight 응답에는 다음 헤더들이 포함된다.
| 헤더 | 역할 |
|---|---|
Access-Control-Allow-Origin |
허용할 출처. *(전체) 또는 특정 URL 지정 |
Access-Control-Allow-Methods |
허용할 HTTP 메서드 목록 (GET, POST, PUT, DELETE 등) |
Access-Control-Allow-Headers |
요청에 포함할 수 있는 헤더 (Content-Type, Authorization 등) |
Access-Control-Allow-Credentials |
쿠키 등 자격 증명 정보 포함 허용 여부 (true / false) |
Access-Control-Max-Age |
Preflight 응답을 브라우저가 캐시할 시간(초). 이 시간 동안은 OPTIONS 요청 생략 |
Access-Control-Max-Age가 중요한 이유는 성능 때문이다. POST 요청마다 OPTIONS → POST 순으로 2번의 통신이 발생하는데, Max-Age를 설정하면 브라우저가 결과를 캐시해 OPTIONS를 생략한다. 3600으로 설정하면 1시간 동안 Preflight 없이 바로 실제 요청을 전송한다.
3. CORS 에러 케이스 3가지
Case 1. Origin 미허용
Access to fetch at 'http://localhost:3000/api/v1/users/signup' from origin
'http://127.0.0.1:5500' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
서버가 Access-Control-Allow-Origin 헤더를 응답에 포함하지 않거나, 요청 출처가 허용 목록에 없을 때 발생한다. 해결 방법은 프론트엔드 주소를 origin에 명시하는 것이다.
import cors from "cors";
app.use(
cors({
origin: ["http://127.0.0.1:5500", "http://localhost:3000"],
})
);
Case 2. Headers 미허용
Access to fetch at '...' from origin '...' has been blocked by CORS policy:
Request header field x-auth-token is not allowed by
Access-Control-Allow-Headers in preflight response.
프론트엔드가 x-auth-token 같은 커스텀 헤더를 요청에 포함했는데, 서버의 allowedHeaders에 해당 헤더가 없을 때 발생한다.
app.use(
cors({
origin: ["http://127.0.0.1:5500"],
allowedHeaders: ["Content-Type", "Authorization", "x-auth-token"],
})
);
Case 3. Credentials + 와일드카드 충돌
...blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header
in the response must not be the wildcard '*' when the request's credentials
mode is 'include'.
프론트엔드에서 credentials: 'include'로 쿠키를 포함해 요청할 때, 서버의 Access-Control-Allow-Origin이 *로 설정되어 있으면 브라우저가 차단한다. 보안상 쿠키 같은 민감한 정보가 오갈 때는 전체 허용(*)을 절대 허용하지 않는다.
4. credentials:true와 쿠키
쿠키 기반 인증을 사용할 때는 프론트엔드와 백엔드 양쪽 모두 설정이 필요하다.
- 프론트엔드:
fetch(url, { credentials: 'include' })옵션을 켠다 - 백엔드:
Access-Control-Allow-Credentials: true를 응답하고,origin을*가 아닌 정확한 주소로 지정한다
이 두 조건이 동시에 충족되어야 브라우저가 쿠키를 포함한 요청을 허용한다. 하나라도 빠지면 Case 3 에러가 발생한다.
index.js — credentials 포함 cors 설정
app.use(
cors({
origin: ["http://127.0.0.1:5500", "http://localhost:3000"],
credentials: true,
})
);
origin: "*"과credentials: true는 함께 사용할 수 없다. credentials가 true이면 반드시 명시적인 출처를 지정해야 한다.
5. CORS 실습
CORS를 직접 체험하려면 서버와 다른 출처(Origin)에서 요청을 보내야 한다. 서버가 http://localhost:3000에서 실행 중이라면, 프론트엔드를 http://127.0.0.1:5500처럼 다른 포트에서 실행해야 브라우저가 CORS 정책을 적용한다.
VSCode의 Live Server 확장 프로그램을 설치하면 test.html을 우클릭 → "Open with Live Server"로 http://127.0.0.1:5500에서 열 수 있다.
test.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>내 API 테스트하기</title>
</head>
<body>
<h1>회원가입 테스트</h1>
<button id="signupButton">가입 요청 (성공)</button>
<button id="signupButtonFail">가입 요청 (실패 - 이메일 중복)</button>
<script>
const API_URL = 'http://localhost:3000';
document.getElementById('signupButton').onclick = async () => {
const userData = {
email: `test_${Date.now()}@test.com`,
name: "UMC",
gender: "여성",
birth: "2000-01-01",
address: "서울시",
detailAddress: "UMC구 챌린저동 화이팅아파트",
phoneNumber: "010-1234-5678",
preferences: [1],
};
await callAPI('/api/v1/users/signup', userData);
};
async function callAPI(path, data) {
try {
const response = await fetch(API_URL + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const responseData = await response.json();
if (!response.ok) throw responseData;
console.log('성공:', responseData);
} catch (error) {
console.error('실패:', error);
}
}
</script>
</body>
</html>
서버에 cors() 미들웨어를 등록하지 않은 상태에서 버튼을 누르면 Case 1 에러가 발생한다. 이후 cors()를 추가하고 출처를 허용하면 정상적으로 API 응답이 콘솔에 출력되는 것을 확인할 수 있다.
index.js — cors 미들웨어 등록
import cors from "cors";
import express from "express";
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
6. Swagger/OpenAPI 개요와 설정
Swagger는 RESTful API를 설계하고 문서화하는 데 사용하는 오픈 소스 도구다. 프론트엔드, 백엔드, PM이 함께 일할 때 "어떤 API가 있고 어떻게 쓰면 되는지"를 시각적으로 공유할 수 있다.
UMC 워크북에서는 두 가지 라이브러리를 조합해 사용한다.
swagger-autogen: 라우터 코드와 JSDoc 주석을 분석해 OpenAPI 스펙을 자동 생성swagger-ui-express: Swagger UI를 Express 앱에 마운트해 브라우저에서 확인 가능하게 함
npm add swagger-autogen swagger-ui-express
index.js — Swagger 설정
import swaggerAutogen from "swagger-autogen";
import swaggerUiExpress from "swagger-ui-express";
app.use(
"/docs",
swaggerUiExpress.serve,
swaggerUiExpress.setup({}, {
swaggerOptions: {
url: "/openapi.json",
},
})
);
app.get("/openapi.json", async (req, res, next) => {
const options = {
openapi: "3.0.0",
disableLogs: true,
writeOutputFile: false,
};
const outputFile = "/dev/null";
const routes = ["./src/index.js"];
const doc = {
info: {
title: "UMC 9th",
description: "UMC 9th Node.js 테스트 프로젝트입니다.",
},
host: "localhost:3000",
};
const result = await swaggerAutogen(options)(outputFile, routes, doc);
res.json(result ? result.data : null);
});
서버를 실행하고 localhost:3000/docs에 접속하면 Swagger UI가 렌더링된다. UI는 내부적으로 /openapi.json을 호출해 라우트 정보를 가져온다.
특정 라우트를 Swagger UI에서 숨기고 싶으면 핸들러 안에 아래 주석을 추가한다.
app.get("/openapi.json", async (req, res, next) => {
// #swagger.ignore = true
...
});
7. #swagger 주석 작성법
swagger-autogen은 컨트롤러 함수 내부의 블록 주석(/* */)에서 #swagger.* 형태의 지시어를 파싱해 문서를 생성한다. 자동으로 채워지지 않는 상세 정보(요청 body, 응답 스키마, 설명 등)를 이 방식으로 직접 정의한다.
회원가입 API 예시
user.controller.js
export const handleUserSignUp = async (req, res, next) => {
/*
#swagger.summary = '회원 가입 API';
#swagger.requestBody = {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
email: { type: "string" },
name: { type: "string" },
gender: { type: "string" },
birth: { type: "string", format: "date" },
address: { type: "string" },
detailAddress: { type: "string" },
phoneNumber: { type: "string" },
preferences: { type: "array", items: { type: "number" } }
}
}
}
}
};
#swagger.responses[200] = {
description: "회원 가입 성공 응답",
content: {
"application/json": {
schema: {
type: "object",
properties: {
resultType: { type: "string", example: "SUCCESS" },
error: { type: "object", nullable: true, example: null },
success: {
type: "object",
properties: {
email: { type: "string" },
name: { type: "string" },
preferCategory: { type: "array", items: { type: "string" } }
}
}
}
}
}
}
};
#swagger.responses[400] = {
description: "회원 가입 실패 응답 (이메일 중복 등)",
content: {
"application/json": {
schema: {
type: "object",
properties: {
resultType: { type: "string", example: "FAIL" },
error: {
type: "object",
properties: {
errorCode: { type: "string", example: "U001" },
reason: { type: "string" },
data: { type: "object" }
}
},
success: { type: "object", nullable: true, example: null }
}
}
}
}
};
*/
res.success(...);
};
스키마를 작성하는 순서는 다음과 같다.
- 1단계:
#swagger.summary로 API 제목을 달고, 표준 응답 껍데기(resultType,error,success)를 정의한다 - 2단계:
success객체 안의data,pagination등 실제 응답 구조를 채운다 - 3단계:
data가 배열이면items로 각 항목의 모양을 정의한다 - 4단계: 중첩 객체(JOIN 데이터)와 페이지네이션까지 완성한다
목록 조회 API 예시 (responses만)
review.controller.js
export const handleListStoreReviews = async (req, res, next) => {
/*
#swagger.summary = '상점 리뷰 목록 조회 API';
#swagger.responses[200] = {
description: "상점 리뷰 목록 조회 성공 응답",
content: {
"application/json": {
schema: {
type: "object",
properties: {
resultType: { type: "string", example: "SUCCESS" },
error: { type: "object", nullable: true, example: null },
success: {
type: "object",
properties: {
data: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "number" },
store: { type: "object", properties: { id: { type: "number" }, name: { type: "string" } } },
user: { type: "object", properties: { id: { type: "number" }, email: { type: "string" }, name: { type: "string" } } },
content: { type: "string" }
}
}
},
pagination: {
type: "object",
properties: { cursor: { type: "number", nullable: true } }
}
}
}
}
}
}
}
};
*/
res.success(...);
};
8. Component $ref로 스키마 재사용
API가 많아지면 동일한 스키마(예: 표준 에러 응답, 사용자 객체)를 여러 컨트롤러에 반복해서 붙여넣어야 한다. 나중에 스키마 구조가 바뀌면 모든 파일을 수정해야 하는 문제가 생긴다.
Swagger의 Component 기능을 사용하면 공통 스키마를 한 곳에서 정의하고, 주석에서 $ref로 참조해 재사용할 수 있다.
index.js — doc 객체에 components 추가
const doc = {
info: {
title: "UMC 9th",
description: "UMC 9th Node.js 테스트 프로젝트입니다.",
},
host: "localhost:3000",
components: {
schemas: {
UserSignUpBody: {
type: "object",
properties: {
email: { type: "string" },
name: { type: "string" },
gender: { type: "string" },
birth: { type: "string", format: "date" },
address: { type: "string" },
detailAddress: { type: "string" },
phoneNumber: { type: "string" },
preferences: { type: "array", items: { type: "number" } },
},
},
ErrorResponse: {
type: "object",
properties: {
resultType: { type: "string", example: "FAIL" },
error: {
type: "object",
properties: {
errorCode: { type: "string" },
reason: { type: "string" },
data: { type: "object" },
},
},
success: { type: "object", nullable: true, example: null },
},
},
},
},
};
user.controller.js — $ref로 참조
export const handleUserSignUp = async (req, res, next) => {
/*
#swagger.summary = '회원 가입 API';
#swagger.requestBody = {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/UserSignUpBody" }
}
}
};
#swagger.responses[400] = {
description: "회원 가입 실패 응답",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" }
}
}
};
*/
res.success(...);
};
$ref를 사용하면 ErrorResponse 스키마를 10개의 API에서 참조하더라도, doc 객체의 components.schemas.ErrorResponse 한 곳만 수정하면 모든 API 문서에 반영된다. 스키마 중복이 제거되고 유지보수가 쉬워진다.