CORS 이해하기
NodeCat이 nodebird-api를 호출하는 것은 서버에서 서버로 API를 호출하는 것입니다. 만약 Nodecat의 프런트에서 nodebird-api의 서버 API를 호출하면 어떻게 될까요?
routes/index.js에 프론트 화면을 렌더링하는 라우터를 추가합니다.
const express = require("express");
const axios = require("axios");
const router = express.Router();
const URL = "http://localhost:8002/v1";
axios.defaults.headers.origin = "http://localhost:4000"; // origin 헤더 추가 // ❶
const request = async (req, api) => {
try {
if (!req.session.jwt) {
// 세션에 토큰이 없으면
const tokenResult = await axios.post(`${URL}/token`, {
clientSecret: process.env.CLIENT_SECRET,
});
req.session.jwt = tokenResult.data.token; // 세션에 토큰 저장
}
return await axios.get(`${URL}${api}`, {
headers: { authorization: req.session.jwt },
}); // API 요청
} catch (error) {
if (error.response.status === 419) {
// 토큰 만료시 토큰 재발급 받기
delete req.session.jwt;
return request(req, api);
} // 419 외의 다른 에러면
return error.response;
}
};
router.get("/mypost", async (req, res, next) => {
// ❷
try {
const result = await request(req, "/posts/my");
res.json(result.data);
} catch (error) {
console.error(error);
next(error);
}
});
router.get("/search/:hashtag", async (req, res, next) => {
// ❸
try {
const result = await request(
req,
`/posts/hashtag/${encodeURIComponent(req.params.hashtag)}`
);
res.json(result.data);
} catch (error) {
if (error.code) {
console.error(error);
next(error);
}
}
});
router.get("/", (req, res) => {
res.router("main", { key: process.env.CLIENT_SECRET });
});
module.exports = router;
프런트 화면도 추가합니다.
<!DOCTYPE html>
<html>
<head>
<title>프론트 API 요청</title>
</head>
<body>
<div id="result"></div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
axios
.post("http://localhost:8002/v2/token", {
clientSecret: "{{key}}",
})
.then((res) => {
document.querySelector("#result").textContent = JSON.stringify(
res.data
);
})
.catch((err) => {
console.error(err);
});
</script>
</body>
</html>
clientSecret의 {{key}} 부분이 넌적스에 의해 실제 키로 치환돼서 렌더링됩니다. 단, 실제 서비스에서는 서버에서 사용하는 비밀 키와 프런트에서 사용하는 비밀 키를 따로 두는 게 좋습니다. 보통 서버에서 사용하는 비밀 키가 더 강력하기 때문입니다. 프런트에서 사용하는 비밀 키는 모든 사람에게 노출되는 단점도 따릅니다. 데이터베이스에서 clientSecret 외에 frontSecret 같은 컬럼을 추가해서 따로 관리하는 것을 권장합니다.
http://localhost:4000에 접속하면 에러가 발생하며 제대로 동작하지 않습니다. 브라우저 콘솔 창을 보면 에러를 확인할 수 있습니다.
Access-Control-Allow-Origin이라는 헤더가 없다는 내용의 에러입니다. 이처럼 브라우저와 서버의 도메인이 일치하지 않으면, 기본적으로 요청이 차단됩니다. 이 현상은 브라우저에서 서버로 요청을 보낼 때만 발생하고, 서버에서 서버로 요청을 보낼 때는 발생하지 않습니다. 현재 요청을 보내는 클라이언트(localhost:4000)와 요청을 받는 서버(localhost:8002)의 도메인이 다릅니다. 이 문제를 CORS(Cross-Origin Resource Sharing) 문제라고 부릅니다.
📌 CORS 동작 흐름
브라우저는 POST 요청을 보내기 전에 OPTIONS(preflight) 요청을 먼저 보냄.
- OPTIONS 요청:
서버가 이 도메인의 요청을 허용하는지 사전 확인하는 역할. - 서버가 적절한 CORS 헤더를 보내지 않으면 브라우저가 본 요청을 차단.
Network 탭을 보면 Method가 POST 대신 OPTIONS로 표시됩니다. OPTIONS 메서드는 실제 요청을 보내기 전에 서버가 이 도메인을 허용하는지 체크하는 역할을 합니다.
📌 Network 탭에서 확인되는 내용
- POST 요청을 보내도 실제로는 먼저 OPTIONS 요청이 표시됨.
- 요청 메서드: OPTIONS
- 경로: /v2/token
NodeBird API 서버 콘솔에도 OPTIONS 요청이 기록됩니다.
NodeBird API 콘솔
OPTIONS /v2/token 200 0.302 ms – 4
CORS 문제를 해결하기 위해서는 응답 헤더에 Access-Control-Allow-Origin 헤더를 넣어야 합니다. 이 헤더는 클라이언트 도메인의 요청을 허락하겠다는 뜻을 가지고 있습니다. res.set 헤더로도 직접 넣어도 되지만, npm에는 편하게 설치할 수 있는 패키지가 있습니다. 바로 cors입니다.
응답 헤더를 조작하려면 NodeCat이 아니라 Nodebird API 서버에서 바꿔야 합니다. 응답은 API 서버가 보내는 것이 때문입니다. NodeBird API에 cors 모듈을 설치하면 됩니다.
NodeBird API 콘솔
$ npm i cors
cors 패키지를 설치한 후 v2.js에 적용합니다.
const express = require("express");
const jwt = require("jsonwebtoken");
const cors = require("cors");
const { verifyToken, apiLimiter } = require("./middlewares");
const { Domain, User, Post, Hashtag } = require("../models");
const router = express.Router();
router.use(
cors({
credentials: true,
})
);
router.post("/token", apiLimiter, async (req, res) => {
const { clientSecret } = req.body;
try {
const domain = await Domain.findOne({
where: { clientSecret },
include: {
model: User,
attribute: ["nick", "id"],
},
});
if (!domain) {
return res.status(401).json({
code: 401,
message: "등록되지 않은 도메인입니다. 먼저 도메인을 등록하세요",
});
}
const token = jwt.sign(
{
id: domain.User.id,
nick: domain.User.nick,
},
process.env.JWT_SECRET,
{
expiresIn: "30m", // 30분
issuer: "nodebird",
}
);
return res.json({
code: 200,
message: "토큰이 발급되었습니다",
token,
});
} catch (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: "서버 에러",
});
}
});
router.get("/test", verifyToken, apiLimiter, (req, res) => {
res.json(req.decoded);
});
router.get("/posts/my", apiLimiter, verifyToken, (req, res) => {
Post.findAll({ where: { userId: req.decoded.id } })
.then((posts) => {
console.log(posts);
res.json({
code: 200,
payload: posts,
});
})
.catch((error) => {
console.error(error);
return res.status(500).json({
code: 500,
message: "서버 에러",
});
});
});
router.get(
"/posts/hashtag/:title",
verifyToken,
apiLimiter,
async (req, res) => {
try {
const hashtag = await Hashtag.findOne({
where: { title: req.params.title },
});
if (!hashtag) {
return res.status(404).json({
code: 404,
message: "검색 결과가 없습니다",
});
}
const posts = await hashtag.getPosts();
return res.json({
code: 200,
payload: posts,
});
} catch (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: "서버 에러",
});
}
}
);
module.exports = router;
router.use로 v2의 모든 라우터에 적용했습니다. 이제 응답에 Access-Control-Allow-Origin 헤더가 추가되어 나갑니다. credentials: true라는 옵션도 주었는데, 이 옵션을 활성화해야 다른 도메인 간에 쿠키가 공유됩니다. 서버 간의 도메인이 다른 경우에는 이 옵션을 활성화하지 않으면 로그인되지 않을 수 있습니다. 참고로 axios에서도 도메인이 다르면, 쿠키를 공유해야 하는 경우
withCredentials: true 옵션을 줘서 요청을 보내야 합니다.
다시 http://localhost:4000에 접속해보면 토큰이 발급된 것을 볼 수 있습니다. 이 토큰을 사용해서 다른 API 요청을 보내면 됩니다. 토큰이 발급되지 않고 429 에러가 발생한다면, 이전 절에서 적용한 사용량 제한 때문에 그런 것이므로 제한이 풀릴 때 다시 시도하면 됩니다.
응답 헤더를 보면 Access-Control-Allow-Origin이 *로 되어 있습니다. *는 모든 클라이언트의요청을 허용한다는 뜻입니다. credentials: true 옵션은 Access-Control-Allow-Credentials 헤더를 true로 만듭니다.
하지만 이것 때문에 새로운 문제가 생겼습니다. 요청을 보내는 주체가 클라이언트라서 비밀 키(process.env.CLIENT_SECRET)가 모두에게 노출됩니다. 방금 CORS 요청도 허용했으므로 이 비밀 키를 가지고 다른 도메인들이 API 서버에 요청을 보낼 수 있습니다.
이 문제를 막기 위해 처음에 비밀 키 발급 시 허용한 도메인을 적게 했습니다. 호스트와 비밀 키가 모두 일치할 때만 CORS를 허용하게 수정하면 됩니다.
const express = require("express");
const jwt = require("jsonwebtoken");
const cors = require("cors");
const url = require("url");
const { verifyToken, apiLimiter } = require("./middlewares");
const { Domain, User, Post, Hashtag } = require("../models");
const router = express.Router();
router.use(
cors({
credentials: true,
})
);
router.use(async (req, res, next) => {
const domain = await Domain.findOne({
where: { host: url.parse(req.get("origin")).host },
});
if (domain) {
cors({
origin: req.get("origin"),
credentials: true,
})(req, res, next);
} else {
next();
}
});
router.post("/token", apiLimiter, async (req, res) => {
const { clientSecret } = req.body;
try {
const domain = await Domain.findOne({
where: { clientSecret },
include: {
model: User,
attribute: ["nick", "id"],
},
});
if (!domain) {
return res.status(401).json({
code: 401,
message: "등록되지 않은 도메인입니다. 먼저 도메인을 등록하세요",
});
}
const token = jwt.sign(
{
id: domain.User.id,
nick: domain.User.nick,
},
process.env.JWT_SECRET,
{
expiresIn: "30m", // 30분
issuer: "nodebird",
}
);
return res.json({
code: 200,
message: "토큰이 발급되었습니다",
token,
});
} catch (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: "서버 에러",
});
}
});
router.get("/test", verifyToken, apiLimiter, (req, res) => {
res.json(req.decoded);
});
router.get("/posts/my", apiLimiter, verifyToken, (req, res) => {
Post.findAll({ where: { userId: req.decoded.id } })
.then((posts) => {
console.log(posts);
res.json({
code: 200,
payload: posts,
});
})
.catch((error) => {
console.error(error);
return res.status(500).json({
code: 500,
message: "서버 에러",
});
});
});
router.get(
"/posts/hashtag/:title",
verifyToken,
apiLimiter,
async (req, res) => {
try {
const hashtag = await Hashtag.findOne({
where: { title: req.params.title },
});
if (!hashtag) {
return res.status(404).json({
code: 404,
message: "검색 결과가 없습니다",
});
}
const posts = await hashtag.getPosts();
return res.json({
code: 200,
payload: posts,
});
} catch (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: "서버 에러",
});
}
}
);
module.exports = router;
먼저 도메인 모델로 클라이언트의 도메인(req,get('origin'))과 호스트가 일치하는 것이 있는지 검사합니다. http나 https 같은 프로토콜을 떼어낼 때는 url.parse 메서드를 사용합니다. 일치하 는 것이 있다면 CORS를 허용해서 다음 미들웨어로 보내고, 일치하는 것이 없다면 CORS 없이 next를 호출합니다.
cors 미들웨어에 옵션 인수를 주었는데요. origin 속성에 허용할 도메인만 따로 적으면 됩니다. * 처럼 모든 도메인을 허용하는 대신 기입한 도메인만 허용합니다. 여러 개의 도메인을 허용하고 싶 다면 배열을 사용하면 됩니다.
또 하나 특이한 점이 있습니다. passport.authenticate 미들웨어처럼 cors 미들웨어에도 (req, res, next) 인수를 직접 줘서 호출했습니다. 이는 미들웨어의 작동 방식을 커스터마이징하고 싶을 때 사용하는 방법이라고 설명했습니다. 다음 두 코드가 같은 역할을 한다는 것을 기억해두면 다양하게 활용할 수 있습니다.
router.use(cors());
router.use((req, res, next) => {
cors()(req, res, next);
});
다시 http://localhost:4000에 접속하면 성공적으로 토큰을 가져옵니다. 응답의 헤더를 확인해 보면 Access-Control-Allow-Origin이 * 대신 http://localhost:4000으로 적용되어 있습니다.
이렇게 특정한 도메인만 허용하므로 허용되지 않은 다른 도메인에서 요청을 보내는 것을 차단할 수 있습니다.
현재 클라이언트와 서버에서 같은 비밀 키를 써서 문제가 될 수 있습니다. 따라서 그림 10-19와 같이 다양한 환경의 비밀 키를 발급하는 카가오처럼 환경별로 키를 구분해서 발급하는 것이 바람 직합니다. 카카오의 경우 REST API 키가 서버용 비밀 키고, 자바스크립트 키가 클라이언트용 비 밀 키입니다. 이렇게 여러 키를 발급하는 것을 직접 구현해보길 바랍니다.
'프로그래밍 언어 > NODE JS' 카테고리의 다른 글
| 유닛 테스트 (0) | 2025.12.16 |
|---|---|
| 노드 서비스 테스트하기 - 테스트 준비하기 (0) | 2025.12.13 |
| 사용량 제한 구현하기 (0) | 2025.12.04 |
| 다른 서비스에서 호출하기 (0) | 2025.11.28 |
| JWT 토큰으로 인증하기 (0) | 2025.11.25 |