사용량 제한 구현하기
일시적으로 인증된 사용자(토큰을 발급받은 사용자)만 API를 사용할 수 있게 필터를 두긴 했지만, 아직 충분하지는 않습니다. 인증된 사용자라고 해도 과도하게 API를 사용하면 API 서버에 무리가 갑니다. 따라서 일정 기간 내에 API를 사용할 수 있는 횟수를 제한하여 서버의 트래픽을 줄이는 것이 좋습니다. 유로 서비스라면 과금 체계별로 횟수에 차이를 둘 수도 있습니다. 예를 들면 무료료 이용하는 사람은 1시간에 열 번을 허용하고, 유료로 이용하는 사람은 1시간에 100번을 허용하는 식입니다.
이러한 기능 또한 npm에 패키지로 만들어져 있습니다. 이 기능을 제공하는 express-rate-limit 패키지를 소개합니다. nodebird-api 서버에 다음 패키지를 설치합니다.
$ npm i express-rate-limit
verifyToken 미들웨어 아래에 apiLimit 미들웨어와 deprecated 미들웨어를 추가합니다.
nodebird-api/routes/middlewares.js
const jwt = require("jsonwebtoken");
const RateLimit = require("express-rate-limit");
exports.isLoggedIn = (req, res, next) => {
if (req.isAuthenticated()) {
next();
} else {
res.status(403).send("로그인 필요");
}
};
exports.isNotLoggedIn = (req, res, next) => {
if (!req.isAuthenticated()) {
next();
} else {
res.redirect("/");
}
};
exports.verifyToken = (req, res, next) => {
try {
req.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
return next();
} catch (error) {
if (error.name === "TokenExpiredError") {
// 유효 기간 초과
return res.status(419).json({
code: 419,
message: "토큰이 만료되었습니다",
});
}
return res.status(401).json({
code: 401,
message: "유효하지 않은 토큰입니다",
});
}
};
exports.apiLimiter = new RateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: 1,
handler(req, res) {
res.status(this.statusCode).json({
code: this.statusCode, // 기본값 428
message: "1분에 한 번만 요청할 수 있습니다.",
});
},
});
exports.deprecated = (req, res) => {
res.status(410).json({
code: 410,
message: "새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.",
});
};
이제 apilimiter 미들웨어를 라우터에 넣으면 라우터에 사용량 제한이 걸립니다. 이 미들웨어의 옵션으로는 windowMs(기준 시간), max(허용 횟수), handler(제한 초과 시 콜백 함수) 등이 있습니다. 현재 설정은 1분에 한 번 호출 가능한 상태가 되어 있습니다. 사용량 제한을 초과할 때는 429 상태 코드와 함께 사용량을 초과했다는 응답을 전송합니다.
deprecated 미들웨어는 사용하면 안 되는 라우터에 붙여줄 것입니다. 410 코드와 함께 새로운 버전을 사용하라는 메시지를 응답합니다.
아래와 같이 클라이언트로 보내는 응답 코드를 정리해두면 좋습니다. 클라이언트가 프로그래밍을 할 때 많은 도움이 됩니다.
API 응답 목록
| 응답 코드 | 메시지 |
| 200 | JSON 데이터입니다. |
| 401 | 유효하지 않은 토큰입니다. |
| 410 | 새로운 버전이 나왔습니다. 새로운 버전을 사용하세요. |
| 419 | 토큰이 만료되었습니다. |
| 429 | 1분에 한 번만 요청할 수 있습니다. |
| 500~ | 기타 서버 에러 |
사용량 제한이 추가되었으므로 기존 API 버전과 호환되지 않습니다. 새로운 v2 라우터를 만들어 봅시다.
nodebird-api/routes/v2.js
const express = require("express");
const jwt = require("jsonwebtoken");
const { verifyToken, apiLimiter } = require("./middlewares");
const { Domain, User, Post, Hashtag } = require("../models");
const router = express.Router();
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;
토큰 유효 기간을 30분으로 늘렸고, 라우터에 사용량 제한 미들웨어를 추가했습니다.
기존 v1 라우터를 사용할 때는 경고 메시지를 띄워줍니다.
nodebird-api/routes/v1.js
const express = require("express");
const jwt = require("jsonwebtoken");
const { veifyToken, deprecated } = require("./middlewares");
const { Domain, User, Post, Hashtag } = require("../models");
const router = express.Router();
router.use(deprecated);
router.post("/token", 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: "1m", // 1분
issuer: "nodebird",
}
);
return res.json({
code: 200,
message: "토큰이 발급되었습니다",
});
} catch (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: "서버 에러",
});
}
});
router.get("/test", veifyToken, (req, res) => {
res.json(req.decoded);
});
router.get("/posts/my", veifyToken, (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", veifyToken, 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;
라우터 앞에 deprecated 미들웨어를 추가하여 v1으로 접근한 모든 요청에 deprecated 응답을 보내도록 합니다.
실제 서비스 운영 시에는 v2가 나왔다고 바로 v1을 닫아버리거나 410 에러를 응답하기보다는 적정한 기간을 두고 옮겨지는 것이 좋습니다. 사용자가 변경된 부분을 자신의 코드에 반영할 시간이 필요하기 때문입니다. 노드의 LTS 방식도 참고할 만한 방식입니다.
앞으로 이런 식으로 v3, v4 라우터를 추가하면서 v1, v2와 같은 라우터는 순차적으로 제거하면 됩니다.
새로 만든 라우터를 서버와 연결합니다.
nodebird-api/app.js
const express = require("express");
const path = require("path");
const cookieParser = require("cookie-parser");
const passport = require("passport");
const morgan = require("morgan");
const session = require("express-session");
const nunjucks = require("nunjucks");
const dotenv = require("dotenv");
const v1 = require("./routes/v1");
const v2 = require("./routes/v2");
dotenv.config();
const authRouter = require("./routes/auth");
const indexRouter = require("./routes");
const { sequelize } = require("./models");
const passportConfig = require("./passport");
const app = express();
passportConfig();
app.set("port", process.env.PORT || 8002);
app.set("view engine", "html");
nunjucks.configure("views", {
express: app,
watch: true,
});
sequelize
.sync({ force: false })
.then(() => {
console.log("데이터베이스 연결 성공");
})
.catch((err) => {
console.error(err);
});
app.use(morgan("dev"));
app.use(express.static(path.join(__dirname, "public")));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(
session({
resave: false,
saveUninitialized: false,
secret: process.env.COOKIE_SECRET,
cookie: {
httpOnly: true,
secure: false,
},
})
);
app.use(passport.initialize());
app.use(passport.session());
app.use("/v1", v1);
app.use("/v2", v2);
app.use("/auth", authRouter);
app.use("/", indexRouter);
app.use((req, res, next) => {
const error = new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
error.status = 404;
next(error);
});
app.use((err, req, res, next) => {
res.locals.message = err.message;
res.locals.error = process.env.NODE_ENV !== "production" ? err : {};
res.status(err.status || 500);
res.render("error");
});
app.listen(app.get("port"), () => {
console.log(app.get("port"), "번 포트에서 대기중");
});
사용자 입장(NodeCat)으로 돌아와서 새로 생긴 버전을 호출해봅시다. 버전만 v1에서 v2로 바꾸면 됩니다.
nodecat/routes/index.js
const express = require("express");
const { v4: uuidv4 } = require("uuid");
const { User, Domain } = require("../models");
const { isLoggedIn } = require("./middlewares");
const router = express.Router();
const URL = "http://localhost:8002/v2";
router.get("/", async (req, res, next) => {
try {
const user = await User.findOne({
where: { id: (req.user && req.user.id) || null },
include: { model: Domain },
});
res.render("login", {
user,
domains: user && user.Domains,
});
} catch (err) {
console.error(err);
next(err);
}
});
router.post("/domain", isLoggedIn, async (req, res, next) => {
try {
await Domain.create({
UserId: req.user.id,
host: req.body.host,
type: req.body.type,
clientSecret: uuidv4(),
});
res.redirect("/");
} catch (err) {
console.error(err);
next(err);
}
});
module.exports = router;
만약 v2로 바꾸지 않고 v1을 계속 사용한다면 410 에러가 발생합니다.
deprecate된 API 사용 화면
{ "code": 410, "message": "새로운 버전이 나왔습니다. 새로운 버전을 사용하세요." }
1분에 한 번보다 더 많이 API를 호출하면 420 에러가 발생합니다.
사용량 초과 화면
{ "code": 429, "message": "1분에 한 번만 요청할 수 있습니다." }
이 예제는 사용량을 초과하는 것을 보여주고자 1분에 한 번으로 사용량을 제한했습니다. 실제 서비스에서는 서비스 정책에 맞게 제한량을 조절하세요. 아래 코드에서는 1분에 열 번으로 수정했습니다.
const jwt = require("jsonwebtoken");
const RateLimit = require("express-rate-limit");
exports.isLoggedIn = (req, res, next) => {
if (req.isAuthenticated()) {
next();
} else {
res.status(403).send("로그인 필요");
}
};
exports.isNotLoggedIn = (req, res, next) => {
if (!req.isAuthenticated()) {
next();
} else {
res.redirect("/");
}
};
exports.verifyToken = (req, res, next) => {
try {
req.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
return next();
} catch (error) {
if (error.name === "TokenExpiredError") {
// 유효 기간 초과
return res.status(419).json({
code: 419,
message: "토큰이 만료되었습니다",
});
}
return res.status(401).json({
code: 401,
message: "유효하지 않은 토큰입니다",
});
}
};
exports.apiLimiter = new RateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: 10,
handler(req, res) {
res.status(this.statusCode).json({
code: this.statusCode, // 기본값 428
message: "1분에 한 번만 요청할 수 있습니다.",
});
},
});
exports.deprecated = (req, res) => {
res.status(410).json({
code: 410,
message: "새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.",
});
};
현재는 nodebird-api 서버가 재시작되면 사용량이 초기화되므로 실제 서비스에서 사용량을 저장할 데이터베이스를 따로 마련하는 것이 좋습니다. 보통 레디스가 많이 사용됩니다. 단, express-rate-limit을 데이터베이스와 연결하는 것을 지원하지 않으므로 npm에서 새로운 패키지를 찾아보거나 직접 구현해야 합니다.
'프로그래밍 언어 > NODE JS' 카테고리의 다른 글
| 노드 서비스 테스트하기 - 테스트 준비하기 (0) | 2025.12.13 |
|---|---|
| CORS 이해하기 (0) | 2025.12.11 |
| 다른 서비스에서 호출하기 (0) | 2025.11.28 |
| JWT 토큰으로 인증하기 (0) | 2025.11.25 |
| JWT 토큰으로 인증하기 (1) (0) | 2025.11.22 |