다음 포스팅에서 사용한 게시판 api에 대한 자세한 설명 글 입니다.
해당 github에는 주석으로 설명을 하지 않았지만 이번 포스팅에서 따로 설명을 추가하겠습니다.
https://github.com/Ysungho/node
GitHub - Ysungho/node: 노드를 사용해서 만든 블로그 입니다. Trip_share의 데모 버전 입니다.
노드를 사용해서 만든 블로그 입니다. Trip_share의 데모 버전 입니다. . Contribute to Ysungho/node development by creating an account on GitHub.
github.com
다음 github 주소에서 nodebird-api와 nodecat을 참조하면 됩니다.
https://brilliant-star.tistory.com/173
[Node.js]sns, 채팅 서비스 제작: Trip_Share 데모 버전
해당 프로젝트는 Trip_Share를 제작하기 이전에 미리 제작한 데모 버전 입니다. 사용한 코드는 제일 하단에 링크를 첨부했습니다. 이 밖에 사용한 기술에 대한 설명은 다음 링크를 참고해주시기 바
brilliant-star.tistory.com
API 는 Application Programming Interface의 두문자어로 다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점을 의미합니다.
웹 API는 다른 웹 서비스의 기능을 사용하거나 자원을 가져올 수 있는 창구입니다. 흔히 API를 ‘열었다’ 또는 ‘만들었다’고 표현하는데 이는 다른 프로그램에서 현재기능을 사용할 수 있게 허용했음을 뜻합니다.
다른 사람에게 정보를 제공하고 싶은 부분만 API를 열어놓고, 제공하고 싶지않은 부분은 API를 만들지 않는 것입니다.
또한 API를 열어놓았다 하더라도 모든 사람들이 정보를 가져갈 수 있는 것이 아니라 인증된 사람만 일정 횟수 내에서 가져가게 제한을 둘 수 도 있습니다.
서버에 API를 올려서 URL을 통해 접근할 수 있게 만드는 것을 웹 API 서버라고 합니다.
크롤링이란 웹 사이트가 자체적으로 제공하는 API가 없거나 API 이용에 제한이 있을 때 사용하는 방법입니다.
표면적으로 보이는 웹 사이트의 정보를 일정 주기로 수집해 자체적으로 가공하는 기술입니다.
하지만 웹 사이트가 직접 제공하는 API가 아니므로 원하는 정보를 얻지 못할 가능성이 있습니다.
또한 웹 사이트에서 제공하길 원치 않는 정보를 수집한다면 법적인 문제가 발생할 수 있습니다.
npm i
//JWT 모듈 설치
npm i jsonwebtoken
//사용량 제한
npm i express-rate-limit
package.json
{
"name": "nodebird-api",
"version": "0.0.1",
"description": "NodeBird API 서버",
"main": "app.js",
"scripts": {
"start": "nodemon app",
"test": "echo \\"Error: no test specified\\" && exit 1"
},
"author": "sungho",
"license": "ISC",
"dependencies": {
"bcrypt": "^4.0.1",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-rate-limit": "^6.7.1",
"express-session": "^1.17.1",
"jsonwebtoken": "^9.0.1",
"morgan": "^1.10.0",
"mysql2": "^2.1.0",
"nunjucks": "^3.2.1",
"passport": "^0.4.1",
"passport-kakao": "1.0.0",
"passport-local": "^1.0.0",
"sequelize": "^5.21.7",
"uuid": "^7.0.3"
},
"devDependencies": {
"nodemon": "^2.0.3"
}
}
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');
dotenv.config();
const v1 = require('./routes/v1');
const v2 = require('./routes/v2');
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'), '번 포트에서 대기중');
});
models/hashtag.js
const Sequelize = require('sequelize');
module.exports = class Hashtag extends Sequelize.Model {
static init(sequelize) {
return super.init({
title: {
type: Sequelize.STRING(15),
allowNull: false,
unique: true,
},
}, {
sequelize,
timestamps: true,
underscored: false,
modelName: 'Hashtag',
tableName: 'hashtags',
paranoid: false,
charset: 'utf8mb4',
collate: 'utf8mb4_general_ci',
});
}
static associate(db) {
db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
}
};
models/index.js
const Sequelize = require('sequelize');
const env = process.env.NODE_ENV || 'development';
const config = require('../config/config')[env];
const User = require('./user');
const Post = require('./post');
const Hashtag = require('./hashtag');
const Domain = require('./domain');
const db = {};
const sequelize = new Sequelize(
config.database, config.username, config.password, config,
);
db.sequelize = sequelize;
db.User = User;
db.Post = Post;
db.Hashtag = Hashtag;
db.Domain = Domain;
User.init(sequelize);
Post.init(sequelize);
Hashtag.init(sequelize);
Domain.init(sequelize);
User.associate(db);
Post.associate(db);
Hashtag.associate(db);
Domain.associate(db);
module.exports = db;
models/post.js
const Sequelize = require('sequelize');
module.exports = class Post extends Sequelize.Model {
static init(sequelize) {
return super.init({
content: {
type: Sequelize.STRING(140),
allowNull: false,
},
img: {
type: Sequelize.STRING(200),
allowNull: true,
},
}, {
sequelize,
timestamps: true,
underscored: false,
modelName: 'Post',
tableName: 'posts',
paranoid: false,
charset: 'utf8mb4',
collate: 'utf8mb4_general_ci',
});
}
static associate(db) {
db.Post.belongsTo(db.User);
db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' });
}
};
models/user.js
const Sequelize = require('sequelize');
module.exports = class User extends Sequelize.Model {
static init(sequelize) {
return super.init({
email: {
type: Sequelize.STRING(40),
allowNull: true,
unique: true,
},
nick: {
type: Sequelize.STRING(15),
allowNull: false,
},
password: {
type: Sequelize.STRING(100),
allowNull: true,
},
provider: {
type: Sequelize.STRING(10),
allowNull: false,
defaultValue: 'local',
},
snsId: {
type: Sequelize.STRING(30),
allowNull: true,
},
}, {
sequelize,
timestamps: true,
underscored: false,
modelName: 'User',
tableName: 'users',
paranoid: true,
charset: 'utf8',
collate: 'utf8_general_ci',
});
}
static associate(db) {
db.User.hasMany(db.Post);
db.User.belongsToMany(db.User, {
foreignKey: 'followingId',
as: 'Followers',
through: 'Follow',
});
db.User.belongsToMany(db.User, {
foreignKey: 'followerId',
as: 'Followings',
through: 'Follow',
});
db.User.hasMany(db.Domain);
}
};
models/domain.js
const Sequelize = require('sequelize');
module.exports = class Domain extends Sequelize.Model {
static init(sequelize) {
return super.init({
host: {
type: Sequelize.STRING(80),
allowNull: false,
},
type: {
type: Sequelize.ENUM('free', 'premium'),
allowNull: false,
},
clientSecret: {
type: Sequelize.STRING(36),
allowNull: false,
},
}, {
sequelize,
timestamps: true,
paranoid: true,
modelName: 'Domain',
tableName: 'domains',
});
}
static associate(db) {
db.Domain.belongsTo(db.User);
}
};
도메인 모델은 인터넷주소(host)와 도메인종류(type), 클라이언트 비밀키(clientSecret)키가 들어갑니다.
type 컬럼을 보면 ENUM이라는 속성을 가지고 있습니다. 넣을 수 있는 값을 제한하는 데이터 형식입니다.
무료(free)나 프리미엄(premium)중에서 하나의 종류만 선택할 수 있게 했고, 이를 어겼을 때 에러가 발생합니다.
클라이언트 비밀 키는 다른 개발자들으 API를 사용할 때 필요한 비밀 키 입니다.
해당 키가 유출되면 다른 사람을 사칭해서 요청을 보낼 수 있으므로, 유출되지 않도록 주의해야 합니다.
한가지 안전장치로서, 요청을 보낸 도메인까지 일치해야 요청을 보낼 수 있게 제한을 둘 것입니다.
clientSecret 컬럼은 UUID라는 타입을 가집니다. UUID는 충돌 가능성이 매우 적은 랜덤한 문자열 입니다.
routes/auth.js
const express = require('express');
const passport = require('passport');
const bcrypt = require('bcrypt');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
const { User } = require('../models');
const router = express.Router();
router.post('/join', isNotLoggedIn, async (req, res, next) => {
const { email, nick, password } = req.body;
try {
const exUser = await User.find({ where: { email } });
if (exUser) {
return res.redirect('/join?error=exist');
}
const hash = await bcrypt.hash(password, 12);
await User.create({
email,
nick,
password: hash,
});
return res.redirect('/');
} catch (error) {
console.error(error);
return next(error);
}
});
router.post('/login', isNotLoggedIn, (req, res, next) => {
passport.authenticate('local', (authError, user, info) => {
if (authError) {
console.error(authError);
return next(authError);
}
if (!user) {
return res.redirect(`/?loginError=${info.message}`);
}
return req.login(user, (loginError) => {
if (loginError) {
console.error(loginError);
return next(loginError);
}
return res.redirect('/');
});
})(req, res, next); // 미들웨어 내의 미들웨어에는 (req, res, next)를 붙입니다.
});
router.get('/logout', isLoggedIn, (req, res) => {
req.logout();
req.session.destroy();
res.redirect('/');
});
router.get('/kakao', passport.authenticate('kakao'));
router.get('/kakao/callback', passport.authenticate('kakao', {
failureRedirect: '/',
}), (req, res) => {
res.redirect('/');
});
module.exports = router;
routes/index.js
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { User, Domain } = require('../models');
const router = express.Router();
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', 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;
도메인을 등록하는 이유는 등록한 도메인에서 API를 사용할 수 있게 하기 위해서 입니다.
웹 브라우저에서 요청을 보낼 때 응답을 하는 곳과 도메인 이 다르면 CORS(cross-origin resource shareing) 에러가 발생할 수 있습니다.
브라우저가 현재 웹 사이트에서 함부로 다른 서버에 접근 하는 것을 막는 조치입니다.
CORS 문제를 해결하려면 API 서버 쪽에서 미리 허용할 도메인을 등록해야 합니다.
서버에서 서버로 요청을 보내는 경우 CORS 문제가 발생하지 않습니다. CORS는 브라우저에서 발생하는 에러이기 때문입니다.
JWT 인증
💡 JWT 는 JSON web token 의 약어로 json 형식의 데이터를 저장하는 토큰입니다. 다음과 같은 부분으로 구성되었습니다
헤더(HEADER): 토큰 종류와 해시 알고리즘 정보가 들어 있습니다. 페이로드(PAYLOAD): 토큰의 내용물이 인코딩된 부분입니다. 시그니처(SIGNATURE): 일련의 문장려이며, 시그니처를 통해 토큰이 변조되었는지 여부를 확인할 수 있습니다.
*JWT에는 민감한 내용을 넣으면 안됩니다. 내용을 볼 수 있기 때문입니다.
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 = RateLimit({
windowMs: 60 * 1000, // 1분
max: 10,
delayMs: 0,
handler(req, res) {
res.status(this.statusCode).json({
code: this.statusCode, // 기본값 429
message: '1분에 한 번만 요청할 수 있습니다.',
});
},
});
exports.deprecated = (req, res) => {
res.status(410).json({
code: 410,
message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
});
};
요청 헤더에 저장된 토큰(req.headers.authorization)을 사용합니다.
사용자가 쿠키처럼 헤더에 토큰을 넣어 보낼 것입니다. jwt.verify 메서드로 토큰을 검증할 수 있습니다.
메서드의 첫 번째 인수로는 토큰을 두번째 인수로는 토큰의 비밀키를 넣습니다.
토큰의 비밀키가 일치하지 않는다면 인증을 받을 수 없습니다. 그런 경우에는 에러가 발생하여 catch 문으로 이동하게 됩니다.
또한 올바른 토큰이더라도 유효 기간이 지난 경우라면 역시 catch 문으로 이동합니다. 유효 기간 만료시 419 상태 코드를 응답하는데, 이때 코드는 400번대 중에서 마음대로 정해도 됩니다.
인증에 성공한 경우 토큰의 내용이 반환되어 req.decoded에 저장됩니다.
routes/v1
const express = require('express');
const jwt = require('jsonwebtoken');
const { verifyToken, 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: '토큰이 발급되었습니다',
token,
});
} catch (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: '서버 에러',
});
}
});
router.get('/test', verifyToken, (req, res) => {
res.json(req.decoded);
});
router.get('/posts/my', 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, 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;
API 서버의 코드를 바꾸면 API를 사용중인 다른 사람에게 영향을 미칩니다.
특히 기존에 있던 라우터가 수정되는 순간 API를 사용하는 프로그램들이 오작동 할 수 있습니다.
따라서 기존 사용자에게 영향을 미칠 정도로 수정해야 한다면 버전을 올린 라우터를 새로 추가하고 이전에 API를 쓰는 사람들에게는 새로운 API가 나왔음을 알리는 것이 좋습니다.
라우터의 응답을 살펴보면 모두 일정한 형식을 가지고 있습니다. JSON 형태의 code, message 속성이 존재하고, 토큰이 있는 경우 token 속성도 존재합니다.
이렇게 일정한 형식을 갖춰야 응답받는 쪽에서 처리하기가 좋습니다.
code는 HTTP 상태 코드를 사용해도 되고, 임의로 숫자를 부여해도 됩니다. 일관성만 있다면 문제 없습니다.
사용자들이 code를 봐도 어떤 문제인지 알 수 있게 하면 됩니다. 따라서 code를 이해하지 못할 경우를 대비하여 message도 같이 보냅니다.
routes/v2
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(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;
http나 https 같은 프로토콜을 떼어 낼 때는 url.parse 메서드를 사용합니다. 일치하는 것이 있다면 CORS를 허용해서 다음 미들웨어로 보내고, 일치하는 것이 없다면 CORS 없이 next를 호출합니다.
cors 미들웨어 옵션 인수를 주었는데, origin 속성에 허용할 도메인만 따로 적으면 됩니다.
처음 모든 도메인을 허용하는 대신 기입한 도메인만 허용합니다. 여러 개의 도메인을 허용하고 싶다면 배열을 사용하면 됩니다.
사용량 제한
인증된 사용자라고 해도 과도하게 API를 사용하면 API 서버에 무리가 갑니다.
따라서 일정 기간 내에 API를 사용할 수 있는 횟수를 제한하여 서버의 트래픽을 줄이는 것이 좋습니다.
💡 API 응답목록
200: json 데이터 401: 유효하지 않은 토큰 419: 토큰이 만료되었습니다. 420: 새로운 버전이 나왔습니다. 새로운 버전을 확인하세요 429: 1분에 한번만 요청할 수 있습니다. 500~: 기타 서버 에러
핵심 정리
- API는 다른 애플리케이션의 기능을 사용할 수 있게 해주는 창구입니다
- 모바일 서버를 구성할 때 서버를 REST API 방식으로 구현하면 됩니다.
- API 사용자가 API를 쉽게 사용할 수 있도록 사용방법, 요청 형식, 응답 내용에 관한 문서를 준비합시다
- jwt 토큰의 내용은 공개되며 변조될 수 있습니다. 단 시그니처를 확인하면 변조되었는지 체크할 수 있습니다.
- 토큰을 사용하여 api의 오남용을 막습니다. 요청 헤더에 토큰이 있는지를 항상 확인하는 것이 좋습니다.
- app.use 외에도 router.use를 활용하여 라우터 간에 공통되는 로직을 처리할 수 있습니다.
- cors 나 passport.authenticate 처럼 미들웨어 내에서 미들웨어를 실행할 수 있습니다. 미들웨어를 선택적으로 적용하거나 커스터마이징할 때 이 기법을 사용합니다.
- 브라우저와 서버의 도메인이 다르면 요청이 거절된다는 특성(cors)를 이해합시다. 서버와 서버간의 요청에서는 cors 문제가 발생하지 않습니다.
'개발후기 > node.js' 카테고리의 다른 글
[Node.js]채팅 서비스 제작: Trip_Share 데모 버전 (0) | 2023.09.07 |
---|---|
[Node.js]node sns 테스트코드 (0) | 2023.09.06 |
[Node.js]sns 서비스 제작: Trip_Share 데모 버전 (0) | 2023.09.06 |
[Node.js]Trip Share (0) | 2023.09.03 |
[node.js]지역 사회 마켓-경매 시스템 (0) | 2023.05.30 |