Node.js 교과서 : 10. API 서버 만들기(JWT, CORS)

전지연·2025년 11월 20일

GDGoC_2025

목록 보기
11/16
post-thumbnail

프로젝트 구조 갖추기

  • API(Application Programming Interface)
    : 다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점

  • API 기능 장점
    웹 사이트는 제공하고 싶은 정보만 API를 열어두고, 제공하고 싶지 않은 부분은 막아둘 수 있음
    사용자는 신뢰성 있고 보증된 정보를 쉽게 수집할 수 있음


실습

NodeBird 서비스의 post, hashtag, user를 json 형식으로 제공

특정 도메인에서만 제공된 api를 받을 수 있도록 설정
=> key도 다 직접 생성

package.json 생성

  • 콘솔에 npm init -y 사용하면 편하게 package.json만 생성할 수 있음
    -> 생성 후 내용 수정

middlewares, models, passport, controller, routes 폴더

  • middlewares, models, passport : NodeBird 에서 사용한 그대로 사용
  • controller, routes : auth.js만 사용
  • .env도 그대로 사용

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 authRouter = require('./routes/auth');
const indexRouter = require('./routes');
const v1Router = require('./routes/v1');
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('/v1', v1Router);

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'), '번 포트에서 대기중');
});
  • PORT : 8001에선 노드버드 실행되는 중 -> 8002 사용

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);
  }
};
  • init
    • type 컬럼 : 무료/프리미엄 고객 설정
    • UUID : unique한 랜덤 문자열, key를 하나만 발급해줌
  • associate
    • paranoid : true
      => soft delete 설정

models/user.js

associate에 user과 domain모델을 일대다 관계 연결해줌
(user 1명이 여러 domain 소유 가능)

views 폴더

  • error.html
  • login.html : 도메인 등록하는 화면

routes/index.js

유저의 도메인 정보 등록

const express = require('express');
const { isLoggedIn } = require('./middlewares');
const { renderLogin, createDomain } = require('../controllers');
const router = express.Router();

router.get('/', renderLogin);
router.post('/domain', isLoggedIn, createDomain);

module.exports = router;

controllers/index.js

const { User, Domain } = require('../models');
const { v4 :uuvidv4 } = require('uuid');

exports.renderLogin = async (req, res, next) => {
    try {
        const user = await User.findOne({ where: { id: req.user?.id || null }, include: { model: Domain } });
        res.render('login', {
            user,
            domains: user?.Domains,
        })
    } catch (error) {
        console.error(error);
        next(error);
    }
};

exports.createDomain = async (req, res, next) => {
    try {
        const { host, type } = req.body;
        await Domain.create({
            host,
            type,
            clientSecret: uuid.v4(),
            UserId: req.user.id,
            clientSecret: uuid.v4(),
        });
        res.redirect('/');
    } catch (error) {
        console.error(error);
        next(error);
    }
};
  • user 가 undefined면 null로 바꿔야함

JWT 토큰 발급하기

JWT

: JSON Web Token

  • JWT 토큰 구성 :

    payload는 토큰의 내용물이 인코딩된 부분
    => 내용을 볼 수 있음
    => JWT에 비밀번호 등 민감한 내용 포함되지 말아야 함

  • JWT 토큰 장점 :
    변조 불가능

  • JWT 토큰 단점 :
    용량 큼(내용물이 포함되어 있기 때문)

실습

설치

npm i jsonwebtoken

.env에 JWT KEY 추가

middlewares/index.js

const jwt = require('jsonwebtoken');

exports.isLoggedIn = (req, res, next) => { // 로그인 했는지 판단
  if (req.isAuthenticated()) {   //passport 통해서 로그인 했는지
        next();
    } else {
        res.status(403).send('로그인 필요');
  }
};

exports.isNotLoggedIn = (req, res, next) => { // 로그인 안 했는지 판단
  if (!req.isAuthenticated()) {//passport 통해서 로그인 안했는지
        next();
    } else {
      const message = encodeURIComponent('로그인한 상태입니다.');
      res.redirect(`/?error=${message}`);
  }
};

exports.verifyToken = (req, res, next) => {
  try {
    res.locals.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: '유효하지 않은 토큰입니다.',
    });
  }
}
  • 에러경우 : 토큰 만료, 잘못된 토큰
    • 유효기간 초과는 419에러
    • 잘못된 토큰은 401에러
      (코드 숫자는 400번대 숫자 중 마음대로 선택, 프론트에 알려주기만 하면 됨)

routes/v1.js

미들웨어 정상 작동 테스트
(v1 = version 1)

const express = require('express');

const { verifyToken } = require('./middlewares');
const { createToken, tokenTest } = require('../controllers/v1');

const router = express.Router();

router.post('/token', createToken);   // 토큰 발급 라우터

router.get('/test', verifyToken, tokenTest);   // 토큰 테스트 라우터9

module.exports = router;

app.js에 등록해놓은 v1Router 사용

  • v1Router 사용
    => 연결되는 주소 /v1/~~

controllers/v1.js

const jwt = require('jsonwebtoken');
const { Domain, User } = require('../models');

// 토큰 발급
// POST /v1/token
exports.createToken = async (req, res, next) => {
    const { clientSecret } = req.body;
    try {
        const domain = await Domain.findOne({
            where: { clientSecret },
            include: {
                model: User,
                attributes: ['id', 'nick'],
            },
        });
        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',
            issuer: 'nodebird',
        });
        return res.json({
            code: 200,
            message: '토큰이 발급되었습니다.',
            token,
        });
    } catch (error) {
        console.error(error);
        return res.status(500).json({
            code: 500,
            message: '서버 에러',
        });
    }
};

// 토큰 테스트
// GET /v1/test
exports.testToken = (req, res) => {
    res.json(res.locals.decoded);
}
  • 토큰 발급(createToken)
    : jwt.sign을 사용해서 domain의 내용물로 토큰 생성
  • 토큰 유효기간 : 1분으로 설정

다른 서비스에서 호출하기

API를 사용하는 서비스 생성
=> lecture-api와 같은 위치에 폴더 생성

package.json 생성

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

error.html, .env 생성

  • error.html은 NodeBird API에서 사용한 것 그대로 사용
  • .env

    이때 CLIENT_SECRET은 NodeBird API에서 발급한 것을 사용

routes/index.js

const express = require('express');
const { test } = require('../controllers');
const router = express.Router();

router.get('/test', test);

module.exports = router;

controllers/index.js

const axios = require('axios');

exports.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?.code === 200) { // 토큰 발급 성공
        req.session.jwt = tokenResult.data.token; // 세션에 토큰 저장
      } else { // 토큰 발급 실패
        return res.status(tokenResult.data?.status).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);
  }
};

SNS API 서버 만들기

nodebird-api의 입장으로 돌아와서 나머지 완성

  • v1에서 완성할 나머지 기능
    • 나의 게시글 보기
    • 해시태그 검색 결과

실습

routes/v1.js

...
const { createToken, tokenTest, getMyPosts, getPostsByHashtag } = require('../controllers/v1');
...

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;

controllers/v1.js

const { Domain, User, Hashtag, Post } = require('../models');

...

// 토큰 테스트
// GET /v1/test
exports.testToken = (req, res) => { ... }

// 나의 게시글 보기
exports.getMyPosts = (req, res) => {
    Post.findAll({ where: { userId: req.decoded.id } })
      .then((posts) => {
        res.json({
            code: 200,
            payload: posts,
        });
      })
      .catch((error) => {
        console.error(error);
        return res.status(500).json({
            code: 500,
            message: '서버 에러',
        })
      })
}

// 해시태그 검색 결과
exports.getPostsByHashtag = async (req, res) => {
    try {
      const hashtag = await Hashtag.findOne({ where: { title: req.params.title } });   // req.params.title로 해시태그 검색

        if (!hashtag) {  // 검색결과 없음 or 쓰인적 없는 해시태그
            return res.status(404).json({
                code: 404,
                message: '검색 결과가 없습니다',
            });
        }
        const posts = await hashtag.getPosts();  // 해시태그로 게시글들 검색
        if(posts.length === 0) {
            return res.status(404).json({
                code: 404,
                message: '검색 결과가 없습니다',
            });
        }
        return res.json({
            code: 200,
            payload: posts,
        });
    } catch (error) {
        console.error(error);
        return res.status(500).json({
            code: 500,
            message: '서버 에러',
        });
    }
}
  • middlewares/index.js에서 verifyToken을 통해 검증된 토큰이 res.locals.decoded로 들어감

nodecat 에서 GET /post/myGET /posts/hashtag/:title 라우터 테스트

  • nodecat/routes/index.js
  • nodecat/controllers/index.js
    • request : getMyPosts와 getPostsByHashtag에서 공통적으로 쓰이는 코드, 토큰 발급/갱신 담당

API 사용량 제한하기

일정 기간 내에 api 사용할 수 있는 횟수 제한
-> 서버 트래픽 감소

  • DDOS 등의 악의적인 공격 방어
  • 무료/프리미엄 유저 과금체계
    ex) 무료 유저 : 1시간에 10번, 유료 유저 : 1시간에 100번

라이브러리 설치

npm i express-rate-limit

middlewares/index.js

const jwt = require('jsonwebtoken');

exports.isLoggedIn = (req, res, next) => { ... };

exports.isNotLoggedIn = (req, res, next) => { ... };

exports.verifyToken = (req, res, next) => { ... }

exports.apiLimiter = rateLimit({
  windowMs: 60 * 1000, // 1분
  max: 1, // 1번 허용
  handler(req, res) {
    return res.status(429).json({
      code: this.statusCode,
      message: '1분에 한 번만 요청할 수 있습니다.',
    });
  }
});

exports.deprecated = (req, res) => {   // v2로 업그레이드 안내 미들웨어
  res.status(410).json({
    code: 410,
    message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
  });
}

API 응답 목록

새로운 V2라우터를 만든다.

controllers/v2.js

controllers/v1.js에서 1분이었던 토큰 유효기간을 30분으로 수정

routes/v2.js

const express = require('express');

const { verifyToken, apiLimiter } = require('./middlewares');
const { createToken, tokenTest, getMyPosts } = require('../controllers/v1');

const router = express.Router();

// apiLimiter 추가
router.post('/token', apiLimiter,createToken);  
router.get('/test', apiLimiter, verifyToken, tokenTest);

router.get('/posts/my', apiLimiter, verifyToken, getMyPosts);
router.get('/posts/hashtag/:hashtag', apiLimiter, verifyToken, getPostsByHashtag);

module.exports = router;

routes/v1.js

모든 라우터들의 앞에 deprecated 라우터 추가 -> v1으로 접근한 모든 요청에 deprecated 응답

const express = require('express');

const { verifyToken } = require('./middlewares');
const { createToken, tokenTest } = require('../controllers/v1');

const router = express.Router();

router.use(deprecated);     // 미들웨어

router.post('/token', createToken); 
router.get('/test', verifyToken, tokenTest);

module.exports = router;

app.js 수정

새로 만든 v2라우터를 서버와 연결

...
const authRouter = require('./routes/auth');
const indexRouter = require('./routes');
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');    // new!
...

BUT 이 방법이 ddos공격 방어에 딱히 효과가 있지는 않다.

일반/프리미엄 회원 리밋 다르게 하기

  • middlewares/index.js
    • apiLimiter에서 미들웨어 확정 처리
    • 토큰에서 유저 정보 불러와 max에서 계산
  • routes/v2.js
    • verifyToken보다 apiLimiter가 더 뒤에 있도록 코드 수정

CORS 에러 해결하기

이전까자는 nodeBird - nodeCat 서버끼리 api호출했음

nodeCat의 프론트에서 nodeBird를 호출하면?
=> CORS 에러 발생

요청을 보내는 클라이언트(Port 4000)와 요청을 받는 서버(Port 8002)의 도메인이 다름
=> CORS 에러 발생

에러 해결하려면 nordBird 서버에 cors 모듈을 설치하고 미들웨어 확장 패턴을 사용해 준다.

모듈 설치

npm i cors

routes/v2.js 수정

middlewares/index.js 수정

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const { Domain, User } = require('../models');

...

exports.corsWhenDomainMatches = async (req, res, next) => {
  const domain = await Domain.findOne({
    where: { host: new URL(req.get('Origin')).host }
  });
  if (domain) {
    cors({ origin: req.get('Origin'), credentials: true })(req, res, next);
  } else {
    next();
  }
}

0개의 댓글