Back-end/Node.js

[Node.js] 웹 API 서버 만들기 (1)

poppy 2021. 7. 30. 12:12
반응형

저번에 만들었던 SNS 서비스인 NodeBird 앱을 사용하여 웹 API 서버를 만들어보겠습니다. API 는 Application Programming Interface 로 다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점을 의미합니다. 따라서 웹 API는 다른 웹 서비스의 기능을 사용하거나 자원을 가져올 수 있는 창구입니다. 제공하고 싶은 부분만 API를 열어 둘 수 있고 인증된 사람만 일정 횟수 내에서 가져가게 제한을 둘 수도 있습니다.

 

1. 웹 API 서버 만들기

package.json 파일을 생성한 후 "npm install" 을 콘솔에 입력하여 패키지를 설치합니다.

{
  "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": "Zero Cho",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^4.0.1",
    "cookie-parser": "^1.4.5",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "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.7",
    "uuid": "^7.0.3"
  },
  "devDependencies": {
    "nodemon": "^2.0.3"
  }
}

 

저번에 만들었던 NodeBird 앱의 파일들을 재사용하겠습니다. 필요한 파일들을 압축파일로 첨부해뒀습니다. config.json 파일은 자신의 데이터베이스에 맞게 수정해주세요!!

nodebird-api.zip
0.01MB

 

도메인을 등록하는 기능이 생겼으므로 도메인 모델을 추가하겠습니다. models 폴더 안에 domain.js 파일을 만듭니다. type 컬럼을 보면 ENUM 이라는 속성을 갖고 잇는데 이것은 넣을 수 있는 값을 제한하는 데이터 형식입니다. type 컬럼에는 'free' 나 'premium' 중 하나의 값만 가질 수 있습니다. 클라이언트 비밀키는 NodeBird의 API를 사용할 때 필요한 비밀키로 유츌되지 않도록 주의해야 합니다. UUID는 충돌 가능성이 매우 적은 랜덤한 문자열입니다.

// 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.UUID,
        allowNull: false,
      },
    }, {
      sequelize,
      timestamps: true,
      paranoid: true,
      modelName: 'Domain',
      tableName: 'domains',
    });
  }

  static associate(db) {
    db.Domain.belongsTo(db.User); // User 모델과 1:N 관계
  }
};

 

도메인 모델을 시퀄라이즈, 사용자 모델과 연결하겠습니다. "추가" 라고 주석이 있는 부분을 추가해주면 됩니다.

// 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/user.js
const Sequelize = require('sequelize');

module.exports = class User extends Sequelize.Model {
// .. 생략
  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); // 추가
  }
};

 

로그인 화면을 만들겠습니다. 프론트 부분이라서 파일로 첨부하겠습니다.

login.html
0.00MB

 

로그인 화면의 라우터를 만들겠습니다. routes 폴더에 index.js 파일을 생성합니다. GET / 라우터는 접속시 로그인 화면을 보여줍니다. 사용자가 로그인을 하면 도메인 등록 화면으로 바뀝니다. GET /domain은 도메인 등록 라우터입니다. clientSecret 값은 uuid 패키지를 통해 생성합니다. 

// 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;

 

여기까지 한 후 콘솔에 npm start 를 입력하여 로컬에 접속하면 다음과 같은 화면이 뜹니다. 로그인 화면에서 로그인을 한 후 도메인을 등록하면 클라이언트 비밀키를 발급받을 수 있습니다. 추후에 클라이언트 비밀키를 사용해야하므로 저장해두세요!!

 

2. JWT 토큰으로 인증하기

JWT 토큰이란?

 

JWT는 JSON Web Token 으로 JSON 형식의 데이터를 저장하는 토큰입니다. JWT는 다음과 같이 세부분으로 이루어져 있습니다.

  • 헤더(HEADER) - 토큰 종류와 해시 알고리즘 정보가 들어 있음
  • 페이로드(PAYLOAD) - 토큰의 내용물이 인코딩된 부분
  • 시그니처(SIGNATURE) - 일련의 문자열이며 시그니처를 통해 토큰이 변조되었는지 여부를 확인할 수 있음

 

JWT 모듈을 설치한 후 .env 파일에 토큰의 비밀키를 추가합니다.

npm install jsonwebtoken
COOKIE_SECRET=nodebirdsecret
KAKAO_ID=5d4daf57becfd72fd9c919882552c4a6
JWT_SECRET=jwtSecret // 추가

 

토큰을 인증하는 미들웨어를 만듭니다. middlewares.js 파일을 수정합니다. jwt.verify 는 토큰을 검증하는 메서드입니다. 첫번재 인수는 토큰이고, 두번재 인수는 토큰의 비밀키입니다. 토큰 검증에 성공하면 req.decoded 에 저장되는데 토큰의 내용은 사용자의 아이디, 닉네임, 발급자 등 입니다. 

const jwt = require('jsonwebtoken');

//.. 생략
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: '유효하지 않은 토큰입니다',
    });
  }
};

 

토큰을 발급하는 라우터를 만들겠습니다. routes 폴더 안에 v1.js 파일을 만듭니다. POST /v1/token은 토큰을 발급하는 라우터이고, GET /v1/text는 토큰을 테스트하는 라우터입니다. 

// routes/v1.js
const express = require('express');
const jwt = require('jsonwebtoken');

const { verifyToken } = require('./middlewares');
const { Domain, User } = require('../models');

const router = express.Router();

// 토큰 발급 라우터
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({ // 토큰 발급 - 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);
});

module.exports = router;

 

app.js 에 토큰 발급 라우터를 연결합니다. "추가" 라고 주석친 부분을 추가하면 됩니다. 여기까지 하면 웹 API 서버가 완성됩니다. 

// .. 생략
const v1 = require('./routes/v1'); // 추가
const authRouter = require('./routes/auth');
const indexRouter = require('./routes');
const { sequelize } = require('./models');
const passportConfig = require('./passport');
// .. 생략
app.use('/v1', v1); // 추가
app.use('/auth', authRouter);
app.use('/', indexRouter);
// .. 생략

 

3. 다른 서비스에서 웹 API 호출하기

위에서 만든 웹 API 호출하는 다른 서비스를 만들어보겠습니다. 다른 서비스의 이름은 NodeCat 이라고 하겠습니다. 이 서비스를 만드는데 기본적으로 필요한 파일들은 첨부하겠습니다.

nodecat.zip
0.02MB

 

.env 파일을 만들어 아까 발급받은 클라이언트 비밀키를 저장해둡니다.

COOKIE_SECRET=nodecat
CLIENT_SECRET=8193a477-1160-4610-ad44-af7b39834c27

 

토큰 테스트 라우터를 만들겠습니다. routes 폴더 안에 index.js 파일을 생성합니다.

const express = require('express');
const axios = require('axios');

const router = express.Router();

// 토큰 테스트 라우터
router.get('/test', async (req, res, next) => {
  try {
    if (!req.session.jwt) { // 세션에 토큰이 없으면 토큰 발급 시도
      const tokenResult = await axios.post('http://localhost:3000/v1/token', {
        clientSecret: process.env.CLIENT_SECRET,
      });
      if (tokenResult.data && tokenResult.data.code === 200) { // 토큰 발급 성공
        req.session.jwt = tokenResult.data.token; // 세션에 토큰 저장
      } else { // 토큰 발급 실패
        return res.json(tokenResult.data); // 발급 실패 사유 응답
      }
    }
    // 발급받은 토큰 테스트
    const result = await axios.get('http://localhost:3000/v1/test', {
      headers: { authorization: req.session.jwt },
    });
    return res.json(result.data);
  } catch (error) {
    console.error(error);
    if (error.response.status === 419) { // 토큰 만료 시
      return res.json(error.response.data);
    }
    return next(error);
  }
});

module.exports = router;

 

웹 API 서버와 다른 서비스인 Nodecat 콘솔에 모두 npm start 를 한 후 "http://localhost:4000/test" 에 접속하면 다음과 같은 화면을 볼 수 있습니다. 1분 후에 다시 접속하면 토큰이 만료됐다는 메시지가 뜰 것입니다.

반응형