API 서버
API는 Application Programming Interface의 두문자어로, 다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점을 의미한다.
서버에 API를 올려서 URL을 통해 접근할 수 있게 만든 것을 웹 API 서버라고 한다.
프로젝트 생성
이전에 만든 NodeBird와 데이터베이스를 공유하는 서버를 만들자.
{
"name": "nodebird-api",
"version": "1.0.0",
"description": "NodeBird API 서버",
"main": "app.js",
"scripts": {
"start": "nodemon app",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "hvvan",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.0.0",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-rate-limit": "^5.1.3",
"express-session": "^1.17.1",
"jsonwebtoken": "^8.5.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.13",
"uuid": "^8.1.0"
},
"devDependencies": {
"nodemon": "^2.0.3"
}
}
npm i
package.json에 적힌 패키지를 설치하자.
그리고 NodeBird에서 config, models, passport를 복사해서 갖고 오자.
routes에서는 auth.js와 middlewares.js만 그대로 사용한다.
views폴더를 만들고 error.html을 만들자.
<h1>{{ message }}</h1>
<h2>{{ error.status }}</h2>
<prew>{{ error.stack }}</prew>
views/error.html
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 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('/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'), '번 포트에서 대기 중...');
});
app.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.UUID,
allowNull: false,
},
}, {
sequelize,
timestamps: true,
paranoid: true,
modelName: 'Domain',
tableName: 'domains',
});
}
static associate(db){
db.Domain.belongsTo(db.User);
}
};
models/domain.js
도메인 모델에는 인터넷 주소와 도메인 종류, 클라이언트 비밀 키가 들어간다.
ENUM이라는 속성은 넣을 수 있는 값을 제한하는 데이터 형식이고, UUID는 충돌 가능성이 매우 적은 랜덤한 문자열이다.
이제 새로 생성한 도메인 모델을 시퀄라이즈와 연결하자. 사용자 모델과 일대다 관계를 가지는데, 사용자 한 명이 여러 도메인을 소유할 수 있기 때문이다.
'use strict';
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[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;
model/index.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/user.js
이제 뷰를 짜보자. 대신 카카오 로그인은 설정을 또 해야 하기 때문에 제외하고 구현하자.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>API 서버 로그인</title>
<style>
.input-group label { width: 200px; display: inline-block; }
</style>
</head>
<body>
{% if user and user.id %}
<span class="user-name">안녕하세요! {{user.nick}}님</span>
<a href="/auth/logout">
<button>로그아웃</button>
</a>
<fieldset>
<legend>도메인 등록</legend>
<form action="/domain" method="post">
<div>
<label for="type-free">무료</label>
<input type="radio" id="type-free" name="type" value="free">
<label for="type-premium">프리미엄</label>
<input type="radio" id="type-premium" name="type" value="premium">
</div>
<div>
<label for="host">도메인</label>
<input type="text" id="host" name="host" placeholder="ex) zerocho.com">
</div>
<button>저장</button>
</form>
</fieldset>
<table>
<tr>
<th>도메인 주소</th>
<th>타입</th>
<th>클라이언트 비밀키</th>
</tr>
{% for domain in domains %}
<tr>
<td>{{domain.host}}</td>
<td>{{domain.type}}</td>
<td>{{domain.clientSecret}}</td>
</tr>
{% endfor %}
</table>
{% else %}
<form action="/auth/login" id="login-form" method="post">
<h2>NodeBird 계정으로 로그인하세요.</h2>
<div class="input-group">
<label for="email">이메일</label>
<input id="email" type="email" name="email" required autofocus>
</div>
<div class="input-group">
<label for="password">비밀번호</label>
<input id="password" type="password" name="password" required>
</div>
<div>회원가입은 localhost:8001에서 하세요.</div>
<button id="login" type="submit">로그인</button>
</form>
<script>
window.onload = () => {
if (new URL(location.href).searchParams.get('loginError')) {
alert(new URL(location.href).searchParams.get('loginError'));
}
};
</script>
{% endif %}
</body>
</html>
views/login.html
다음은 도메인을 등록하는 화면이다. 로그인하지 않았다면 로그인 창이 먼저 뜨고, 로그인한 사용자에게는 도메인 등록 화면을 보여준다.
const express = require('express');
const {v4: uuidv4} = require('uuid');
const {User, Domain} = require('../models');
const {isLoggedIn} = require('./middlewares');
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', 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;
routes/index.js
GET /는 접속 시 로그인 화면을 보여주며, 도메인 등록 라우터는 폼으로부터 온 데이터를 도메인 모델에 저장한다.
이제 서버를 실행하고 테스트해보자.
사용자 정보는 NodeBird앱과 공유하므로 NodeBird 앱의 아이디로 로그인하면 된다.
도메인을 등록하는 이유는 등록한 도메인에서만 API를 사용할 수 있게 하기 위해서이다.
웹 브라우저에서 요청을 보낼 때, 응답을 하는 곳과 도메인이 다르면 CORS(Cross-Origin Resource Sharing) 에러가 발생할 수 있다. 브라우저가 현재 웹 사이트에서 함부로 다른 서버에 접근하는 것을 막는 조치이다.
localhost:400 도메인을 등록해보자.
발급받은 비밀 키는 localhost:4000 서비스에서 NodeBird API를 호출할 때 인증 용도로 사용한다.
비밀키가 유출되지 않게 조심해야 한다.