상세 컨텐츠

본문 제목

[Node.js] 10장 웹 API 서버 만들기

21-22/21-22 Node.js

by Kimpeep 2021. 12. 27. 13:00

본문

728x90
모든 코드는 저자의 github에서 가져왔습니다.
https://github.com/ZeroCho/nodejs-book/tree/master/ch10

10.1 API 서버 이해하기

  • API(Application Programming Interface): 다른 서비스의 기능을 사용하거나 자원을 가져올 수 있는 인터페이스
  • 웹 API 서버: 다른 웹 서비스의 기능을 사용하거나 자원을 가져올 수 있는 창구

API를 제공하는 것이 크롤링 당하는 것보다 트래픽도 부하될 가능성이 적고 원하는 정보만 사용자에게 제공할 수 있어서 좋다.


10.2 프로젝트 구조 갖추기

  • 다른 서버에 NodeBird 서비스의 게시글, 해시태그, 사용자 정보를 JSON형식으로 제공하는 것이 목표(단, 인증된 사용자에게 정해진 할당량 안에서 API 호출 허용)

 

nodebird-api 폴더를 만들고 package.json 파일을 생성한다.

// nodebird-api/package.json
{
    "name": "nodebird-api",
    "version": "0.0.1",
    "description": "NodeBird API 서버",
    "main": "app.js",
    "scripts": {
        "start": "nodemon app"
    },
    "author": "Zero Cho",
    "license": "ISC",
    "dependencies": {
        "bcrypt": "^5.0.0",
        "cookie-parser": "^1.4.5",
        "dotenv": "^8.2.0",
        "express": "^4.17.1",
        "express-session": "^1.17.1",
        "morgan": "^1.10.0",
        "mysql2": "^2.1.0",
        "nunjucks": "^3.2.1",
        "passport": "^0.4.1",
        "passport-local": "^1.0.0",
        "sequelize": "^6.2.3",
        "uuid": "^8.2.0"
    },
    "devDependencies": {
        "nodemon": "^2.0.3"
    }
}
  • uuid 패키지: 고유한 랜덤 문자열을 만들어내는데 사용

 

package.json에 적힌 패키지를 설치한다.

$ npm i

9장의 NodeBird의 config, models, passport 폴더 전체와 routes폴더의 auth.js와 middlewares.js 만 복사해서 nodebird-api 폴더에 붙여 넣는다. 그리고 .env 파일도 복사해온다.

 

  • error.html: 에러를 표시할 파일
<!-- nodebird-api/views/error.html-->
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>
// 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');

dotenv.config();
const v1 = require('./routes/v1');
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('/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'), '번 포트에서 대기중');
});
  • 포트 번호 8002 지정

 

app.js로 도메인(인터넷 주소)를 등록하는 기능이 생겼으므로 도메인 모델을 추가한다.

// nodebird-api/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);
  }
};
  • type 컬럼의 ENUM: 넣은 수 있는 값을 제한하는 데이터형식
  • clientSecret: 다른 개발자들이 Nodebird의 API를 사용할 때 필요한 비밀키
  • 새로 생성한 모델을 시퀄라이즈와 연결
// nodebird-api/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;
// nodebird-api/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);
  }
};
  • 사용자 한 명이 여러 도메인을 소유할 수 있기 때문에 사용자 모델과 일대다 관계를 가짐
  • login.html: 로그인하는 화면
<!-- nodebird-api/views/login.html-->
<!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>
  • 유저가 맞다면 도메인 등록하는 보임
  • 유저가 아니라면 로그인 창이 뜸

 

// nodebird-api/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();

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;
  • GET / 라우터는 접속시 로그인 화면을 보여줌
  • POST / 라우터는 폼으로부터 온 데이터를 도메인 모델에 저장

 

서버를 실행하고 http://localhost:8002 로 접속한다.

http://localhost:8002 접속 화면 [출처: Node.js 교과서 개정2판]

로그인 후에는 아래와 같이 도메인 등록화면이 뜬다.

도메인 등록 화면 [출처: Node.js 교과서 개정2판]

  • 등록한 도메인에서만 API를 사용할 수 있게 하도록 도메인을 등록
  • 무료와 프리미엄은 사용량 제한을 구현하기 위해 구분

도메인 등록 후 화면 [출처: Node.js 교과서 개정2판]

  • 클라이언트 비밀키: 랜덤한 문자열
  • 발급받은 비밀키는 localhost:4000 서비스에서 NodeBird API를 호출할 때 인증용도로 사용

10.3 JWT 토큰으로 인증하기

API는 남에게 데이터를 제공하는 만큼 별도의 인증과정이 필요하고 이 책은 JWT 토큰으로 인증하는 방법을 보인다.

JWT(JSON Web Token): JSON 형식의 데이터를 저장하는 토큰이다.

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

JWT 토큰 예시 [출처: Node.js 교과서 개정2판]

 

웹 서버에 JWT 토큰 인증 과정을 구현하려면 먼저 JWT 모듈을 설치한다.

$ npm i jsonwebtoken

 

다른 사용자가 API를 쓰려면 JWT 토큰을 발급받고 인증받아야하는데 이 과정은 여러 라우터에서 반복적으로 행해지므로 미들웨어로 만든다.

// .env
COOKIE_SECRET=nodebirdsecret
KAKAO_ID=5d4daf57becfd72fd9c919882552c4a6
JWT_SECRET=jwtSecret
// nodebird-api/routes/middlewares.js
const jwt = require('jsonwebtoken');

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: '유효하지 않은 토큰입니다',
    });
  }
};
  • req.headers.authorization: 요청 헤더에 저장된 토큰으로 사용자는 이런식으로 헤더에 토큰을 넣어 보냄
  • jwt.verify 메서드: 토큰을 검증

인증에 성공하면 토큰의 내용이 return되어 req.decoded에 저장된다.

 

req.decoded를 통해 다음 미들웨어에서 토큰의 내용물을 사용할 수 있다.

// nodebird-api/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({
      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;
  • POST 라우터는 토큰을 발급함
  • GET 라우터는 사용자가 토큰을 테스트함
  • 라우터의 응답은 일정한 형식을 갖춤

GET 라우터는 사용자가 발급받은 토큰을 테스트하는 라우터이다.

토큰을 검증하는 미들웨어를 거친 후, 검증이 성공했다면 토큰의 내용물을 응답으로 보낸다.

 

POST 라우터에서는 전달받은 클라이언트 비밀키로 도메인이 등록됐는지 확인하고 토큰을 발급해서 응답한다.

토큰은 아래 코드와 같이 jwt.sign 메소드로 발급받을 수 있다.

  • 첫 번째 인수는 토큰의 내용
  • 두 번째 인수는 토큰의 비밀키
  • 세 번째 인수는 토큰의 설정

 

지금 만든 라우터를 서버에 연결한다.

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

dotenv.config();
const v1 = require('./routes/v1');
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('/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'), '번 포트에서 대기중');
});

10.4 다른 서비스에서 호출하기

이때까지 API를 제공하는 서버를 만들었고 제공되는 API를 사용하는 서비스도 만들어볼 것이다.

지금 만들 2차 서비스 이름은 NodeCat 이다.

 

nodebird-api 폴더와 같은 위치에 nodecat이라는 새로운 폴더를 만든다.

// nodecat/package.json
{
  "name": "nodecat",
  "version": "0.0.1",
  "description": "노드버드 2차 서비스",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app"
  },
  "author": "Zero Cho",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.21.1",
    "cookie-parser": "^1.4.5",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "express-session": "^1.17.1",
    "morgan": "^1.10.0",
    "nunjucks": "^3.2.1"
  },
  "devDependencies": {
    "nodemon": "^2.0.3"
  }
}
$ npm i

 

서버 파일과 에러를 표시할 파일을 생성한다.

// nodecat/app.js
const express = require('express');
const morgan = require('morgan');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');

dotenv.config();
const indexRouter = require('./routes');

const app = express();
app.set('port', process.env.PORT || 4000);
app.set('view engine', 'html');
nunjucks.configure('views', {
  express: app,
  watch: true,
});

app.use(morgan('dev'));
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('/', 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/views/error.html-->
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>

 

API를 사용하려면 사용자 인증을 받아야하므로 사용자 인증이 잘 되는지 테스트하는 라우터를 만든다.

그리고 아까 발급받은 clientSecret을 .env 파일에 넣어서 만든다.

사람마다 다를 것이니 자기가 발급받은 clientSecret 키를 넣어야한다.

// .env
COOKIE_SECRET=nodecat
CLIENT_SECRET=7d67444e-fd01-4f9b-8680-f72464d02a57
// nodecat/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:8002/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:8002/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;
  • GET /test 라우터: NodeCat 서비스가 토큰 인증 과정을 테스트하는 라우터

 

이렇게 만든 서비스 nodecat과 nodebird-api를 동시에 실행시켜 토큰을 받아오는 것을 실제로 테스트할 수 있다.


10.5 SNS API 서버 만들기

다시 nodebird-api로 돌아와 나머지 API 라우터를 완성할 것이다.

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

const { verifyToken } = require('./middlewares');
const { Domain, User, Post, Hashtag } = 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({
      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;
  • 내가 올린 포스트를 가져오는 GET /post/my 라우터 추가
  • 해시태그 검색결과를 가져오는 GET /posts/hashtag/:title 라우터를 추가

10.6 사용량 제한 구현하기

인증된 사용자라 해도 과도하게 API를 사용하면 서버에 무리가 가기 때문에 일정 기간 내에 API를 사용할 수 있는 횟수를 제한하여 트래픽을 줄이는 것이 좋다.

 

이 기능은 npm에 express-rate-limit 패키지로 만들어져있다.

$ npm i express-rate-limit

 

middlewares.js에서 apiLimiter 미들웨어와 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: 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: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
  });
};
  • apiLimiter 미들웨어를 라우터에 넣으면 라우터에 사용량 제한이 걸린다.
  • deprecated 미들웨어는 사용하면 안되는 라우터에 붙여줄 것이다.

 

사용량 제한이 추가되었으므로 기존 API 버전과 호환되지 않으므로 버전 2 라우터를 만들어보자.

// 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 라우터를 사용할 때는 경고 메시지를 띄워줘야하니 v1.js도 수정한다.

// nodebird-api/routes/v1.js
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;

라우터 앞에 deprecated 미들웨어를 추가하여 v1으로 접근한 모든 요청에 deprecated 응답을 보내도록 한다.

 

 

새로 만든 라우터를 app.js로 서버와 연결한다.

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

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'), '번 포트에서 대기중');
});

 

사용자 입장의 NodeCat에서 새로 생긴 버전을 호출한다.

// nodecat/index.js
const express = require('express');
const axios = require('axios');

const router = express.Router();
const URL = 'http://localhost:8002/v2';

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);
    }
  }
});

module.exports = router;

10.7 CORS 이해하기

이때까지 Nodecat이 호출하는 것은 서버에서 서버로 API를 호출한 것이다.

만약 Nodecat을 프런트에서 nodebird-api의 서버 API를 호출하면 어떻게 될까?

 

 

먼저 이 문제를 발생시키기위해 index.js에 프런트 화면을 렌더링하는 라우터를 추가한다.

main.html의 프런트 화면도 추가한다.

// nodecat/routes/index.js
const express = require('express');
const axios = require('axios');

const router = express.Router();
const URL = 'http://localhost:8002/v2';

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.render('main', { key: process.env.CLIENT_SECRET });
});

module.exports = router;
<!-- nodecat/views/main.html-->
<!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>

변경 후, http://localhost:4000에 접속하면 Access-Control-Allow-Origin이라는 헤더가 없다는 내용의 에러가 발생한다.

이렇게 서버에서 서버로 요청을 보낼 때와 달리 브라우저에서 서버로 요청을 보낼 때는 문제가 생긴다.

이런 문제를 CORS(Cross-Origin Resource Sharing) 문제라고 부른다.

 

CORS 문제를 해결하기 위해서는 응답 헤더에 Access-Control-Allow-Origin 헤더를 넣어야 한다.

res.set 메서드로 직접 넣을 수도 있지만 npm에는 cors라는 패키지가 처리해준다.

NodeBird API 서버에서 설치한다.

$ npm i cors

 

cors 패키지를 설치한 후 v2.js에 적용한다.

// nodebird-api/routes/v2.js
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;
  • router.use로 v2의 모든 라우터에 cors 적용
  • credentials: true 옵션: 다른 도메인 간에 쿠키가 공유 활성화

다시 http://localhost:4000에 접속해보면 토큰이 발급된 것을 볼 수 있다.


Quiz

1. 다른 서비스의 기능을 사용하거나 자원을 가져올 수 있는 인터페이스는? (  API  )
2. 사용자 인증을 돕는 JSON 형식의 데이터를 저장하는 토큰은 (  JWT토큰  )이고 토큰 종류와 해시 알고리즘 정보가 들어있는 부분은 (  헤더(HEADER)  )이다.
4. CORS 문제는 서버 API에서 (  프런트  )로 호출하여 생긴 문제이다.
5. 대부분의 라우터에 공통적으로 작동하는 기능이 있다면 (  미들웨어  )로 만드는 것이 좋다.
6. (  uuid  )패키지는 36자리 고유한 랜덤 문자열을 생성하고 클라이언트 비밀키 값을 만들 때 사용될 수 있다.
7. 요청을 보내는 곳과 응답하는 곳의 (  도메인  )이 다르면 에러가 발생할 수 있기 때문에 등록해준다.

728x90

관련글 더보기