상세 컨텐츠

본문 제목

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

23-24/Node.js 1

by Hetbahn 2023. 12. 29. 10:00

본문

728x90


API는 Application Programming Interface의 두문자어로, 다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점을 의미한다.
웹 API는 다른 웹 서비스의 기능을 사용하거나 자원을 가져올 수 있는 창구이다.
서버에 API를 올려서 URL을 통해 접근할 수 있게 만든 것을 웹 API 서버라고 한다.
노드를 모바일 서버로 사용하려면 이번 장과 같이 서버를 REST API 구조로 구성하면 된다.
 
다른 서비스에 NodeBird 서비스의 게시글, 해시태그, 사용자 정보를 JSON 형식으로 제공하며 인증을 받은 사용자에게만 일정한 할당량 안에서 API를 호출할 수 있도록 허용할 것이다.
 
package.json 파일 생성

 npm init

 
uuid 패키지 : 고유한 랜덤 문자열을 만들어내는 데 사용
 

"dependencies": {
    // 생략
    "uuid": "^8.2.0"
  },

 
 
package.json에 적힌 패키지 설치

npm i

 
 
 
도메인 모델 생성

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

 
ENUM 속성 : 넣을 수 있는 값을 제한하는 데이터 형식
UUID : 충돌 가능성이 매우 적은 랜덤한 문자열 
 
도메인 모델을 시퀼라이즈와 연결 

const Domain = require('./domain');
db.Domain = Domain;
Domain.init(sequelize);
Domain.associate(db);

 
사용자 모델과 일대다 관계 가짐

db.User.hasMany(db.Domain);

 
 
폼으로부터 온 데이터를 도메인 모델에 저장하는 라우터

const { v4: uuidv4 } = require('uuid');
// 패키지의 변수를 가져올 때 이름을 v4에서 uuidv4로 바꿈 

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(),
      // 1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed
    });
    res.redirect('/');
  } catch (err) {
    console.error(err);
    next(err);
  }
});

 
clientSecret의 값을 uuid 패키지를 통해 생성한다.
uuid 중에서도 4 버전을 사용한다.
 

도메인 등록 화면

도메인을 등록하는 이유는 등록한 도메인에서만 API를 사용할 수 있게 하기 위해서이다.
발급받은 비밀 키는 localhost:4000 서비스에서 NodeBird API를 호출할 때 인증 용도로 사용한다.
 

JWT 토큰으로 인증하기

JWT는 JSON Web Token의 약어로, JSON 형식의 데이터를 저장하는 토큰이다.

 헤더(HEADER): 토큰 종류와 해시 알고리즘 정보가 들어 있다.
 페이로드(PAYLOAD): 토큰의 내용물이 인코딩된 부분이다.
 시그니처(SIGNATURE): 일련의 문자열이며, 시그니처를 통해 토큰이 변조되었는지 여부를 확인할 수 있다.
 
시그니처는 JWT 비밀 키로 만들어진다.
JWT 토큰은 JWT 비밀 키를 알지 않는 이상 변조가 불가능하다.
비밀번호를 제외하고 사용자의 이메일이나 사용자의 권한 같은 것들을 넣어두면 데이터베이스 조회 없이도 그 사용자를 믿고 권한을 줄 수 있다.
 
JWT 모듈 설치

npm i jsonwebtoken

 
 
.env파일에 추가

JWT_SECRET=jwtSecret

 
JWT 모듈 불러오기

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

 
요청 헤더에 저장된 토큰(req.headers.authorization)을 사용한다.
jwt.verify 메서드로 토큰을 검증할 수 있다.
- 메서드의 첫 번째 인수로는 토큰을, 두 번째 인수로는 토큰의 비밀 키를 넣는다.
- 토큰의 비밀 키가 일치하지 않는다면 인증을 받을 수 없습니다. 그런 경우에는 에러가 발생하여 catch문으로 이동한다.
- 올바른 토큰이더라도 유효 기간이 지난 경우라면 역시 catch문으로 이동한다.
- 인증에 성공한 경우에는 토큰의 내용이 반환되어 req.decoded에 저장된다.
 

const token = jwt.sign({
  id: domain.user.id,
  nick: domain.user.nick,
}, process.env.JWT_SECRET, {
  expiresIn: '1m', // 유효 기간
  issuer: 'nodebird', // 발급자
});

 
토큰은 jwt.sign 메서드로 발급받을 수 있다.
- 첫 번째 인수는 토큰의 내용이다.
- 두 번째 인수는 토큰의 비밀 키이다.
- 세 번째 인수는 토큰의 설정이다.
 
토큰을 검증하는 미들웨어를 거친 후, 검증이 성공했다면 토큰의 내용물을 응답으로 보낸다.

router.get('/test', verifyToken, (req, res) => {
  res.json(req.decoded);
});

 
 
 
 
라우터를 서버에 연결

const v1 = require('./routes/v1');
app.use('/v1', v1);

 
Note ≡ JWT 토큰으로 로그인하려면

router.post('/login', isNotLoggedIn, (req, res, next) => {
  passport.authenticate('local', { session: false }, (authError, user, info) => {
    if (authError) {

 
authenticate 메서드의 두 번째 인수로 옵션을 주면 세션을 사용하지 않을 수 있다.
세션에 데이터를 저장하지 않기 때문에 serializeUser와 deserializeUser는 사용하지 않는다.
이후 모든 라우터에 verifyToken 미들웨어를 넣어 클라이언트에서 보낸 쿠키를 검사한 후 토큰이 유효하면 라우터로 넘어가고, 그렇지 않으면 401 에러를 응답하면 된다.
사용자의 권한 확인을 위해 데이터베이스를 사용하지 않기 때문에 데이터베이스의 부담을 줄일 수 있다.
 

다른 서비스에서 호출하기

nodecat 폴더 생성
.env 파일에 발급받은 clientSecret을 넣는다.

COOKIE_SECRET=nodecat
CLIENT_SECRET=7d67444e-fd01-4f9b-8680-f72464d02a57
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);
  }
});

 
요청이 왔을 때 세션에 발급받은 토큰이 저장되어 있지 않다면, POST http://localhost:8002/v1/token 라우터로부터 토큰을 발급받는다.
발급에 성공했다면 발급받은 토큰으로 다시 GET http://localhost:8002/v1/test에 접근하여 토큰이 유효한지 테스트한다.
 JWT 토큰을 요청 본문 대신 authorization 헤더에 넣는다.
 

SNS API 서버 만들기

사용자에게 제공해도 되는 정보를 API로 만들면 된다.

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
      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, // 해시태그 검색 결과
    });
  }

 

사용량 제한 구현하기

일정 기간 내에 API를 사용할 수 있는 횟수를 제한하여 서버의 트래픽을 줄이는 것이 좋다.

npm i express-rate-limit

 

const RateLimit = require('express-rate-limit');

exports.apiLimiter = new RateLimit({
  windowMs: 60 * 1000, // 1분
  max: 1, // 요청 횟수
  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 미들웨어를 라우터에 넣으면 라우터에 사용량 제한이 걸린다.

router.get('/test', verifyToken, apiLimiter, (req, res) => {
  res.json(req.decoded);
});

 
nodebird-api 서버가 재시작되면 사용량이 초기화되므로 실제 서비스에서 사용량을 저장할 데이터베이스를 따로 마련하는 것이 좋다.
 

CORS 이해하기

 NodeCat의 프런트에서 nodebird-api의 서버 API를 호출하면 Access-Control-Allow-Origin이라는 헤더가 없다는 내용의 에러가 발생한다.
브라우저와 서버의 도메인이 일치하지 않으면, 기본적으로 요청이 차단된다.
 이 문제를 CORS(Cross-Origin Resource Sharing) 문제라고 부른다.
CORS 문제를 해결하기 위해서는 응답 헤더에 Access-Control-Allow-Origin 헤더를 넣어야 한다.
 
응답은 API 서버가 보내는 것이 때문에 NodeBird API에 cors 모듈을 설치하면 된다.

npm i cors

 
이후 모든 라우터에 cors 패키지를 적용하면 응답에 Access-Control-Allow-Origin 헤더가 추가되어 나간다. 

const cors = require('cors');

router.use(cors({
  credentials: true,
}));

 
credentials: true 옵션은  Access-Control-Allow-Credentials 헤더를 true로 만든다.
 
요청을 보내는 주체가 클라이언트라서 비밀 키(process.env.CLIENT_SECRET)가 모두에게 노출되기 때문에 호스트와 비밀 키가 모두 일치할 때만 CORS를 허용하게 수정한다.

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'), //허용할 도메인만 따로 적음 (http://localhost:4000)
      credentials: true,
    })(req, res, next);
  } else {
    next();
  }
});

 
프로토콜을 떼어낼 때는 url.parse 메서드를 사용한다.
일치하는 것이 있다면 cors를 허용해서 다음 미들웨어로 보내고, 일치하는 것이 없다면 cors 없이 next를 호출한다.
여러 개의 도메인을 허용하고 싶다면 배열을 사용한다.
 
 
문제
1. (  API )는 다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점을 의미한다.
2. ( ENUM )이라는 속성은 넣을 수 있는 값을 제한하는 데이터 형식이다.
3.  ( UUID )는 충돌 가능성이 매우 적은 랜덤한 문자열이다.
4. (  JWT )는 JSON 형식의 데이터를 저장하는 토큰이다.
5. (  jwt.verify ) 메서드로 토큰을 검증할 수 있다.
6. 토큰은 ( jwt.sign ) 메서드로 발급받을 수 있다.
7. (  sign ) 메서드의 첫 번째 인수는 토큰의 내용이고, 두 번째 인수는 토큰의 비밀 키이다.
 
코드 문제
1. 토큰의 유효 기간을 10분으로, 발급자를 corner로 적으시오.

const token = jwt.sign({
  id: domain.user.id,
  nick: domain.user.nick,
}, process.env.JWT_SECRET, {
  // 코드 작성
});

const token = jwt.sign({
  id: domain.user.id,
  nick: domain.user.nick,
}, process.env.JWT_SECRET, {
  expiresIn: '10m',
  issuer: 'corner',
});
 
2. domain이 있을 경우, 도메인 모델로 클라이언트의 도메인(req.get('origin'))과 호스트가 일치하는 것이 있는지 검사한 후 다른 도메인 간에 쿠키가 공유되도록 하는 코드를 작성하시오. 

router.use(async (req, res, next) => {
  const domain = await Domain.findOne({
    where: { host: url.parse(req.get('origin')).host },
  });
  if (domain) {
    cors({
      // 코드 작성
    })(req, res, next);
  } else {
    next();
  }
});

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

출처: 조현영  『Node.js 교과서』 개정판 3판, 10장

Node.js #1

Editor : 7b은서

728x90

관련글 더보기