본문 바로가기

ComputerScience/NodeJs

node - 10.2 웹 API 서버 만들기 (api 호출, 사용량 제한)

728x90

https://thebook.io/080229/

 

더북(TheBook): Node.js 교과서 개정 2판

 

thebook.io

* 위 내용을 정리하였음

1. 다른 서비스에서 호출하기

- api를 제공하는 서버를 만들어보았다. 

- 이번에는 이 api를 사용하는 서비스를 만들어보자.

- 이전에 만든 rest api 서버는 nodebird-api 서버이다.

- 이 api를 사용해서 2차 서비스를 제공하는 nodecat이라는 서비스를 만들어보자.

// package.json

{
  "name": "newservice",
  "version": "0.0.1",
  "description": "new service using api",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app"
  },
  "author": "jsdysw",
  "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"
  }
}

- 이 2차 서비스는 1차 서비스의 api를 통해 데이터(json)를 가져오는 것이 주 목적이다.

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

- api를 사용하기 위해 사용자 인증을 해야 한다. 이를 테스트 하는 라우터를 만들어보자.

COOKIE_SECRET = nodecat
CLIENT_SECRET =418fad26-ebfd-4d74-9f7a-271071febd02
// 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;

- 토큰이 세션에 없으면 토큰을 발급을 요청한다. 

- 토큰 발급이 성공하면 세션에 토큰을 저장하고 다시 test라우터로 토큰을 검증한다. 이때 보통 토큰을 요청 본문 대신 authorization 헤더에 넣는다. 

- api 서버를 8002번에 띄워놓고 클라이언트의 서버를 4000번에 띄워서 발급받은 토큰을 검증해보자.

- 브라우저에서 localhost:4000/test를 호출한다.

- 1분이 지나면 토큰이 만료되기 때문에 토큰을 갱신하는 작업이 필요하다.

2. sns api 서버 만들기

- api 제공자 (8002번)의 나머지 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 /posts/my, 내 게시물 반환

- GET /posts/hashtag/:title , 해시태그 검색 결과 반환

 

- 자 그럼 이 api를 사용자가 사용해보자.

nodecat/routes/index.js

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

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

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;

- request 함수는 NodeBird API에 요청을 보낸다. 

- 토큰이 없으면 토큰을 발급해서 api에 요청으로 함께 보내고 만약 토큰이 만료되면 세션의 구 토큰을 지우고 새롭게 재귀적으로 request를 호출한다.

- GET /mypost라우터는 api를 사용해 json으로 받아오라는 라우터이다.

- 클라이언트 비밀키가 유출되면 다른사람이 나의 게시글을 가져갈 수 있는 것이다.

- 1분뒤 요청이 만료되어도 다시 요청하면 토큰을 재발급 한다.

3. 사용량 제한하기

- 토큰을 발급받았다고 무한정 api를 호출하게 해서 서버에 무리가 되면 안된다.

- 한시간에 열변만 호출을 허용한다던지 유료, 무료 에 따라 호출 횟수를 다르게 할 수 있다.

- express-rate-limit 패키지로 이를 구현해보자.

- api 서버에서 이 패키지를 설치한다.

npm i express-rate-limit
// 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분에 10 번만 요청할 수 있습니다.',
    });
  },
});

exports.deprecated = (req, res) => {
  res.status(410).json({
    code: 410,
    message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
  });
};

- apiLimiter 미들웨어를 라우터 안에 넣으면 이제 라우터에 사용량 제한이 걸린다.

- windowMs(기준시간), max(허용횟수), handler(제한 초과 시 콜백)

- deprecated 미들웨어는 사용하면 안되는 라우터에 넣어줄 것이다. 

응담 코드 메시지
200 json 데이터입니다
401 유효하지 않은 토큰
410 새로운 버전이 나옴
419 토큰 만료
429 1분에 10번만 요청 가능
500~ 기타 서버에러

- 사용량 제한이 추가 되었으므로 새로운 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;
// nodebird-app/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);

......

- 이제 예전 버전인 v1 라우터에 대해서는 경고를 띄워 주자.

- 이렇게 순차적으로 사용자가 v2로 넘어가면 v1을 지워주는게 좋다.

 

- 이제 v2를 서버에 연결한다.

// nodebird-app/app.js

const v2 = require('./routes/v2');

...

app.use('/v2', v2);

...

 

- 이제 사용자 입장에서 v2를 호출해보자.

- 너무 많이 요청하면 이렇게 나온다.

- v1으로 호출하면 새로운 버전 안내가 나온다.

 

- 지금은 nodebird-api가 재시작되면 사용량이 초기화 된다. 따라서 실제 서비스 에서는 사용량을 저장할 데이터 베이스를 마련하는 것이 좋고 레디스가 보통 많이 사용된다.

4. CORS 이해하기

- 이전까지는 Nodecat의 서버가 nodebird-api를 호출했다. (서버에서 서버로 API 호출)

// nodecat/routes/index.js

router.get('/', (req, res) => {
  res.render('main', { key: process.env.CLIENT_SECRET });
});

- 이번에는 nodecat의 front에서 nodebird-api의 서버 API를 호출해서 렌더링 해보자.

<!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>

- 넌적스가 key를 치환하여 렌더링한다.

- 막상 서버를 실행하면 다음과 같이 에러가 나온다.

- 이 에러는 위처럼 front(localhost:4000)에서 요청을 받은 서버(localhost:8002)의 도메인이 다르기 때문에 발생한 것이다.

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

- 참고로 서버에서 서버로 요청을 보낼때는 발생하지 않는다.

- 네트워크 탭을 보면 가장먼저 OPTIONS Method로 서버가 이 도메인을 허용하는지 체크하는 역할을 한다.

- 이 문제를 해결하기 위해서는 응답 헤더에 Access-Control-Allow-Origin 헤더를 넣어야 한다. (클라이언트 도메인의 요청을 허락하겠다는 뜻)

npm i cors

- 응답을 해주는 API 서버에서 응답을 조작하기 위해 cors 패키지를 설치한다.

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

....

- v2 모든 라우터에 적용한다.

- 호스트와 비밀키가 일치하는 경우에만 CORS 요청을 허용해준다.

- url.parse로 http, https 같은 프로토콜을 떼어낸다. 이 주소로 DB에 등록이 되어있는 도메인인지 확인한다.

- 이렇게 하면 응답에 Access-Control-Allow-Origin 헤더가 추가되어 나간다.

- credentials: true 를 활성화하면 다른 도메인간 쿠키가 공유된다. 만약 서버간의 도메인이 다른 경우 이 옵션이 false면 로그인이 되지 않을 수 있다. 

- 또한 axios에서도 도메인이 다른 경우 쿠키를 공유할때 withCredentials:true 옵션을 주어서 요청을 보내야 한다.

router.use(cors());

router.use((req,res,next) => {
  cors()(req,res,next);
});

- 참고로 이런식으로 미들웨어를 확장하는 사용법은 잊지말자.

- 보면 응답 헤더에 Access-Control-Allow-Origin에 요청자의 주소와 Credentials가 true로 설정되어있다.

- 등록되지 않은 도메인의 경우는 요청을 차단한다.

 

- 지금은 클라이언트에서 사용하는 비밀키와 서버에서 사용하는 비밀키가 같다.

- 보통은 각각 환경별로 여러개의 키를 발급하여 구분해서 사용한다.

- 이렇게 CORS 문제를 해결했지만 다른 방법도 있다.

- 클라이언트의 브라우저 도메인과 동일한 프록시 서버를 api 서버 사이에 두는 것이다. 브라우저의 요청은 프록시서버로 가고 프록시 서버가 api서버에게 요청하는 것이다. 서로 다른 도메인의 서버끼리의 통신이라 CORS가 문제되지 않는다.

728x90
반응형