상세 컨텐츠

본문 제목

[노드 1팀] 10장. 웹 API 서버 만들기

24-25/Node.js 1

by gooroominuna 2025. 1. 10. 10:00

본문

728x90

10.1. API 서버 이해하기

API (Application Programming Interface)

다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점이다. 

 

웹 API 

다른 웹 서비스의 기능이나 자원을 가져올 수 있는 창구로,

다른 프로그램에서 현재 프로그램의 기능을 사용할 수 있게 허용한다.

📌 정보를 제공하고 싶은 부분만 열어 놓거나, 인증된 사람만 일정 횟수 내에서 가져가게 제한을 둘 수 있다. 

 

웹 API 서버 

서버에 API를 올려서 URL을 통해 접근할 수 있게 만든 것이다. 

 

크롤링 (crawling) 

웹 사이트의 정보를 일정 주기로 수집해 자체적으로 가공하는 기술로, 

웹 사이트가 자체적으로 제공하는 API가 없거나 API 이용에 제한이 있을 때 사용한다. 

📌 도메인/robots.txt에서 웹 사이트가 어떤 페이지의 크롤링을 허용하는지 확인할 수 있다. 

📌 주기적으로 크롤링을 당하면 서버에 무리가 가므로 공개해도 되는 정보들은 API를 통해 가져가게 하는 것이 좋다. 

 

10.2 프로젝트 구조 갖추기

앞서 만들었던 NodeBird 서비스의 게시글, 해시태그, 사용자 정보를 JSON 형식으로 제공하는 API를 만든다. 이때, 인증받은 사용자만 할당량 안에서 API를 호출할 수 있도록 허용한다.  

 

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

{
    "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": "^5.0.0",
        "cookie-parser": "^1.4.5",
        "dotenv": "^16.0.1",
        "express": "^4.17.1",
        "express-session": "^1.17.1",
        "morgan": "^1.10.0",
        "mysql2": "^2.1.0",
        "nunjucks": "^3.2.1",
        "passport": "^0.6.0",
        "passport-kakao": "1.0.0",
        "passport-local": "^1.0.0",
        "sequelize": "^6.19.2",
        "uuid": "^8.3.2"
    },
    "devDependencies": {
        "nodemon": "^2.0.16"
    }
}

 

📍고유한 랜덤 문자열을 만들어내는 데 사용하는 uuid 패키지를 추가한다. 

📍 npm i 명령으로 package.json에 적힌 패키지를 설치한다. 

 

2. nodebird 폴더에 작성한 config, models, passport, middlewares 폴더, controllers와 routes 폴더의 auth.js, .env 파일을 복사해서 nodebird-api 폴더에 붙여 넣는다. 

 

3. 에러를 표시하는 error.html과 app.js를 작성한다. 

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

 

4. 도메인을 등록하는 기능이 생겼으므로 도메인 모델을 추가한다. 

nodebird-api/models/domian.js

const Sequelize = require('sequelize');

class Domain extends Sequelize.Model {
    static initiate(sequelize) {
        Domain.init({
            host: { // 인터넷 주소 
                type: Sequelize.STRING(80),
                allowNull: false,
            },
            type: { // 도메인 종류 
                type: Sequelize.ENUM('free', 'premium'), 
                // ENUM 속성 : free나 premium 중 하나만 값으로 입력할 수 있다. 
                allowNull: false,
            },
            clientSecret: { 
      // 클라이언트 비밀 키 : 다른 개발자들이 NodeBird의 API를 사용할 때 필요한 비밀 키
      // 유출되지 않도록 주의, 요청을 보낸 도메인까지 일치해야 요청 보낼 수 있게 제한한다. 
                type: Sequelize.UUID,   // UUID는 충돌 가능성이 매우 적은 랜덤한 문자열 
                allowNull: false,
            },
        }, {
            sequelize,
            timestamps: true,
            paranoid: true,
            modelName: 'Domain',
            tableName: 'domains',
        });
    }

    static associate(db) {
        db.Domain.belongsTo(db.User);   
    // 사용자 한 명이 여러 도메인을 소유할 수 있으므로 Domian 모델은 User 모델과 1:N 관계
    }
};

module.exports = Domain;

 

5. 로그인 화면을 작성한다. 

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('error')) {
                alert(new URL(location.href).searchParams.get('error'));
            }
            };
        </script>
        {% endif %}
    </body>
</html>

 

6. 라우터를 생성한다. 

nodebird-api/routes/index.js

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

const router = express.Router();

// GET / 라우터 : 접속 시 로그인 화면을 보여준다. 
router.get('/', renderLogin);

// 도메인 등록 라우터 (POST /domain) : 폼으로부터 온 데이터를 도메인 모델에 저장한다. 
router.post('/domain', isLoggedIn, createDomain);

module.exports = router;

 

nodebird-api/controllers/index.js

// 패키지의 함수나 변수를 불러올 때 이름을 v4에서 uuidv4로 변경 
const { v4: uuidv4 } = require('uuid'); 

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

exports.renderLogin = async (req, res, next) => {
    try {
        const user = await User.findOne({
        
        // 시퀄라이즈 where에는 undefined가 들어가면 안 되므로 req.user?.id||null을 사용 
        where: { id: req.user?.id || null }, 
        
        include: { model: Domain },
        });
        res.render('login', {
        user,
        domains: user?.Domains,
        });
    } catch (err) {
        console.error(err);
        next(err);
    }
}

exports.createDomain = async (req, res, next) => {
    try {
        await Domain.create({
        UserId: req.user.id,
        host: req.body.host,
        type: req.body.type,
        
        // 도메인 등록 라우터에서는 clientSecret 값을 uuid 패키지 버전 4를 통해 생성
        clientSecret: uuidv4(),	
        
        });
        res.redirect('/');
    } catch (err) {
        console.error(err);
        next(err);
    }
};

 

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

📌 비밀 키가 유출되면 다른 사람이 API를 사용할 수 있으므로 주의한다. 

📍 웹 브라우저에서 요청을 보낼 때 응답을 하는 곳과 도메인이 다르면 CORS 에러가 발생할 수 있다. CORS는 브라우저에서 발생하는 에러이므로, 서버에서 서버로 요청을 보내는 경우 발생하지 않는다. 

 

10.3 JWT 토큰으로 인증하기

다른 클라이언트가 NodeBird 서비스의 데이터를 가져갈 때 별도의 인증 과정이 필요하다. 

JWT 토큰을 사용해 인증하는 방법을 알아본다. 

 

JWT (JSON Web Token)
JSON 형식의 데이터를 저장하는 토큰으로 다음 세 부분으로 구성된다. 

  1. 헤더 (HEADER) : 토큰 종류와 해시 알고리즘 정보
  2. 페이로드 (PAYLOAD) : 토큰의 내용이 인코딩 된 부분
  3. 시그니처 (SIGNATURE) : 일련의 문자열로, 토큰이 변조되었는지 여부 확인 가능

JWT 특징 

  • 비밀 키가 노출되면 JWT 토큰을 위조할 수 있으므로 비밀 키를 숨겨야 한다.
  • JWT 내용은 볼 수 있기 때문에 민감한 내용을 넣으면 안 된다.
  • JWT 토큰은 JWT 비밀 키를 알지 않는 이상 변조가 불가능하다.
    노출되어도 괜찮은 사용자 정보를 넣어두면 데이터베이스 조회 없이도 사용자를 믿고 권한을 줄 수 있다.
  • JWT 토큰은 내용물이 들어 있어 랜덤 토큰을 사용할 때보다 용량이 크고,
    요청 때마다 토큰이 오가서 데이터 양이 증가한다.
  • 랜덤 문자열을 사용해서 매번 사용자 정보를 조회하는 작업의 비용과
    내용물이 들어있는 JWT 토큰을 사용해서 발생하는 데이터 비용을 비교하여 적절히 사용한다. 

웹 서버에 JWT 토큰 인증 과정 구현하기 

  1. JWT 모듈을 설치한다. npm i jsonwebtoken 
  2. 다른 사용자가 API를 사용하려면 JWT 토큰을 발급받고 인증받아야 한다.
    대부분의 라우터에 공통되므로 이 부분을 미들웨어로 만들어둔다. 

    nodebird-api/middlewares/index.js
    exports.isLoggedIn = (req, res, next) => {
        if (req.isAuthenticated()) {
            next();
        } else {
            res.status(403).send('로그인 필요');
        }
    };
    
    exports.isNotLoggedIn = (req, res, next) => {
        if (!req.isAuthenticated()) {
            next();
        } else {
            const message = encodeURIComponent('로그인한 상태입니다.');
            res.redirect(`/?error=${message}`);
        }
    };
    
    exports.verifyToken = (req, res, next) => {
        try {
    // 요청 헤더에 저장된 토큰을 사용하며, 사용자가 헤더에 토큰을 넣어 보낸다. 
    // jwt.verify로 토큰 검증한다 - 첫 번째 인수 토큰, 두 번째 인수 토큰의 비밀 키 
            res.locals.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
    // 인증이 성공한 경우 토큰 내용이 반환되어 res.locals.decoded에 저장되고, 다음 미들웨어에서 사용할 수 있다.
            return next();
        } catch (error) {
            // 유효 기간이 지난 경우 
            if (error.name === 'TokenExpiredError') { // 유효기간 초과
                return res.status(419).json({
                    code: 419,
                    message: '토큰이 만료되었습니다',
                });
            }
            // 토큰의 비밀 키가 일치하지 않는 경우 
            return res.status(401).json({
                code: 401,
                message: '유효하지 않은 토큰입니다',
            });
        }
    };
  3. 라우터를 생성한다. 
    nodebird-api/routes/v1.js
    테스트해 볼 require('express');
    
    const { verifyToken } = require('../middlewares');
    const { createToken, tokenTest } = require('../controllers/v1');
    
    const router = express.Router();
    
    // POST /v1/token : 토큰을 발급하는 라우터 
    router.post('/token', createToken);
    
    // GET /v1/test : 사용자가 토큰을 테스트해볼 수 있는 라우터 
    router.get('/test', verifyToken, tokenTest);
    
    module.exports = router;

    nodebird-api/controllers/v1.js 
    const jwt = require('jsonwebtoken');
    const { Domain, User, Post, Hashtag } = require('../models');
    
    exports.createToken = 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,         // 1. 토큰의 내용 - 사용자의 아이디
                    nick: domain.User.nick,     // 1. 토큰의 내용 - 사용자의 닉네임 
                }, process.env.JWT_SECRET, {    // 2. 토큰의 비밀 키 
                    expiresIn: '1m', // 1분     // 3. 토큰의 설정 - 유효 기간 1분 
                    issuer: 'nodebird',         // 3. 토큰의 설정 - 발급자 nodebird 
                });
                return res.json({
                    code: 200,
                    message: '토큰이 발급되었습니다',
                    token,
                });
            } catch (error) {
                console.error(error);
                return res.status(500).json({
                code: 500,
                message: '서버 에러',
            });
        }
    };
    
    // 검증에 성공하면 토큰의 내용물을 응답으로 보낸다. 
    exports.tokenTest = (req, res) => {
        res.json(res.locals.decoded);
    };
    
    exports.getMyPosts = (req, res) => {
        Post.findAll({ where: { userId: res.locals.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: '서버 에러',
            });
        });
    };
    
    exports.getPostsByHashtag = 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: '서버 에러',
            });
        }
    };

    📌 라우터에 버전 붙이는 이유  
    API 서버의 코드를 바꾸면 API를 사용 중인 프로그램에 영향을 미친다. 특히 기존에 있던 라우터가 수정되면 API를 사용하는 프로그램이 오작동할 수 있으므로 수정해야 한다면, 버전을 올린 라우터를 새로 추가하고 기존 사용자들에게 새로운 API가 나왔음을 알리는 것이 좋다. 

    📌 라우터의 응답은 일정한 형식을 갖춰야 응답받는 쪽에서 처리하기 쉽다. 

  4. 라우터를 서버에 연결한다. 
    nodebird-api/app.js 
    ...
    dotenv.config();
    
    const v1 = require('./routes/v1');
    
    const authRouter = require('./routes/auth');
    ... 
    app.use(passport.session());
    
    app.use('/v1', v1);
    
    app.use('/auth', authRouter);
    ...


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

NodeBird 앱의 API를 사용하는 서비스 만들기 

 

1. nodebird-api와 같은 위치에 nodecat 폴더를 만들고 package.json을 생성한다. 

{
    "name": "nodecat",
    "version": "0.0.1",
    "description": "NodeBird 2차 서비스",
    "main": "app.js",
    "scripts": {
        "start": "nodemon app"
    },
    "author": "Zero Cho",
    "license": "ISC",
    "dependencies": {
        "axios": "^0.27.2",
        "cookie-parser": "^1.4.6",
        "dotenv": "^16.0.1",
        "express": "^4.18.1",
        "express-session": "^1.17.3",
        "morgan": "^1.10.0",
        "nunjucks": "^3.2.3"
    },
    "devDependencies": {
        "nodemon": "^2.0.16"
    }
}


📌 npm i 명령으로 package.json에 적힌 패키지를 설치한다. 

 

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

 

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>

 

3. 사용자 인증 과정을 테스트하는 라우터를 만든다. 

 

nodecat/.env

COOKIE_SECRET=nodecat
CLIENT_SECRET=________-____-____-____-____________

 

nodecat/routes/index.js

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

const router = express.Router();

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

module.exports = router;

 

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

 

nodebird-api(localhost:8002) nodecat(localhost:4000) 실행 후 localhost:4000/test로 접속하면 토큰 내용이 표시된다. 

 

10.5 SNS API 서버 만들기

 

API 제공자의 입장으로 돌아와 나머지 API 라우터를 완성한다. 

 

nodebird-api/routes/v1.js 

const express = require('express');

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

const router = express.Router();

...

// GET /v1/posts/my : 내가 올린 포스트를 가져오는 라우터 
router.get('/posts/my', verifyToken, getMyPosts);

// GET /v1/posts/hashtag/:title : 해시태그 검색 결과를 가져오는 라우터 
router.get('/posts/hashtag/:title', verifyToken, getPostsByHashtag);

module.exports = router;

 

nodebird-api/controllers/v1.js

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

...

exports.tokenTest = (req, res) => {
    res.json(res.locals.decoded);
};

exports.getMyPosts = (req, res) => {
    Post.findAll({ where: { userId: res.locals.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: '서버 에러',
        });
    });
};

exports.getPostsByHashtag = 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: '서버 에러',
        });
    }
};

 

사용하는 측에도 API를 이용하는 코드를 추가한다. 

 

nodecat/routes/index.js 

const express = require('express');
const { searchByHashtag, getMyPosts } = require('../controllers');

const router = express.Router();

router.get('/myposts', getMyPosts);

router.get('/search/:hashtag', searchByHashtag);

module.exports = router;

 

nodecat/.env

COOKIE_SECRET=nodecat
CLIENT_SECRET=________-____-____-____-____________
API_URL=http://localhost:8002/v1
ORIGIN=http://localhost:4000

 

nodecat/controllers/index.js 

const axios = require('axios');

const URL = process.env.API_URL;
// 요청의 헤더 origin 값을 localhost:4000으로 설정 
axios.defaults.headers.origin = process.env.ORIGIN; 

// request 함수 : NodeBird API에 요청을 보내는 함수 
const request = async (req, api) => {
    try {
        // 세션에 토큰이 없으면 clientSecret을 사용해 토큰 발급 요청을 보낸다. 
        if (!req.session.jwt) { 
        const tokenResult = await axios.post(`${URL}/token`, {
            clientSecret: process.env.CLIENT_SECRET,
        });
        req.session.jwt = tokenResult.data.token; // 토큰은 세션에 저장 
        }
        // 발급받은 후에는 토큰을 이용해 API 요청을 보낸다. 
        return await axios.get(`${URL}${api}`, {
        headers: { authorization: req.session.jwt },
        }); 
    } catch (error) {
        // 토큰이 만료되면 419 에러 -> 토큰 지우고 재발급 
        if (error.response?.status === 419) { 
            delete req.session.jwt;
            return request(req, api);
        } // 419 외의 다른 에러면
        throw error;
    }
};

// GET /myposts : API를 사용해 자신이 작성한 포스트를 JSON 형식으로 가져오는 라우터 
exports.getMyPosts = async (req, res, next) => {
    try {
        const result = await request(req, '/posts/my');
        res.json(result.data);
    } catch (error) {
        console.error(error);
        next(error);
    }
};

// GET /search/:hashtag : API를 사용해 해시태그를 검색하는 라우터 
exports.searchByHashtag = 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);
        }
    }
};

 

localhost:4000/myposts에 접속하면 게시글 목록을, 

localhost:4000/search/노드에 접속하면 노드 해시태그가 달린 게시글 목록을 JSON 형식으로 확인할 수 있다. 

 

📌 nodebird-api 콘솔에는 다음 세 개의 요청이 기록된다. 

POST /v1/posts/hashtag/노드 419 0.962 ms - 56
POST /v1/token 200 23.383 ms - 252
POST /v1/posts/hashtag/노드 200 8.288 ms - 395

 

요청을 보낼 때 토큰이 만료되었으므로 419 에러가 발생한다.

request 함수의 catch 문에 의해 다시 request 함수가 실행되고 토큰을 새로 가져온 뒤 요청을 다시 보낸다. 

이 과정은 NodeCat 콘솔에서는 POST /search/노드 하나의 요청으로만 기록된다.  

 

10.6 사용량 제한 구현하기

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

express-rate-limit 패키지는 이러한 기능을 제공한다. 

 

1. express-rate-limit 패키지를 설치한다.  npm i express-rate-limit 

 

2. apiLimiter 미들웨어와 deprecated 미들웨어를 추가한다. 

 

nodebird-api/middlewares/index.js 

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');

...

// apiLimiter 미들웨어 : 라우터에 사용량 제한이 걸린다. 
exports.apiLimiter = rateLimit({
    windowMs: 60 * 1000, // 기준 시간, 1분
    max: 1, 			 // 허용 횟수, 1분에 한 번 호출 가능
    handler(req, res) {	 // 제한 초과 시 콜백 함수 
    	// 사용량 제한을 초과하면 429 상태코드와 허용량 초과했다는 응답 전송
        res.status(this.statusCode).json({
        code: this.statusCode, // 기본값 429
        message: '1분에 한 번만 요청할 수 있습니다.',
        });
    },
});

// deprecated 미들웨어 : 사용하면 안 되는 라우터에 붙인다. 
exports.deprecated = (req, res) => {
    res.status(410).json({
    	// 410 코드와 새로운 버전을 사용하라는 메시지 응답 
        code: 410,
        message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
    });
};

 

📌 API 응답 목록 

응답 코드 메시지 
200 JSON 데이터입니다. 
401 유효하지 않은 토큰입니다.
410  새로운 버전이 나왔습니다. 새로운 버전을 사용하세요. 
419  토큰이 만료되었습니다. 
429  1분에 한 번만 요청할 수 있습니다. 
500~ 기타 서버 에러

 

3. 사용량 제한이 추가되었으므로 새로운 v2 라우터를 만든다. 

 

nodebird-api/routes/v2.js 

/* 라우터에 사용량 제한 미들웨어 추가 */

const express = require('express');

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

const router = express.Router();

// POST /v2/token
router.post('/token', apiLimiter, createToken);

// POST /v2/test
router.get('/test', apiLimiter, verifyToken, tokenTest);

// GET /v2/posts/my
router.get('/posts/my', apiLimiter, verifyToken, getMyPosts);

// GET /v2/posts/hashtag/:title
router.get('/posts/hashtag/:title', apiLimiter, verifyToken, getPostsByHashtag);

module.exports = router;

 

nodebird-api/controllers/v2.js 

...

exports.createToken = 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: '서버 에러',
        });
    }
};

...

 

nodebird-api/routes/v1.js 

// v1 라우터를 사용할 때는 경고 메시지 

const express = require('express');

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

const router = express.Router();

router.use(deprecated);

// POST /v1/token
router.post('/token', createToken);

...

 

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

 

nodebird-api/app.js

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

 

5. 사용자 입장으로 돌아와 새로 생긴 버전을 호출한다. 

 

nodecat/.env 

COOKIE_SECRET=nodecat
CLIENT_SECRET=________-____-____-____-____________
API_URL=http://localhost:8002/v2
ORIGIN=http://localhost:4000

 

nodebird-api/middlewares/index.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 {
        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: '유효하지 않은 토큰입니다',
        });
    }
};

exports.apiLimiter = rateLimit({
    windowMs: 60 * 1000, // 1분
    max: 10,
    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: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
    });
};

 

📌 버전을 v2로 바꾸지 않고 v1을 계속 사용한다면 410 에러, 1분에 한 번보다 더 많이 호출하면 429 에러가 발생한다. 

 

10.7 CORS 이해하기

NodeCat의 프런트에서 nodebird-api 서버 API를 호출하는 경우 

프런트 화면을 렌더링 하는 라우터 

nodecat/routes/index.js

const express = require('express');
const { searchByHashtag, getMyPosts, renderMain } = require('../controllers');

const router = express.Router();

router.get('/myposts', getMyPosts);

router.get('/search/:hashtag', searchByHashtag);

router.get('/', renderMain);

module.exports = router;

 

nodecat/controllers/index.js 

...
exports.renderMain = (req, res) => {
    res.render('main', {key: process.env.CLIENT_SECRET});
};

 

프론트 화면 

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>

 

CORS(Cross-Origin Resource Sharing) 

요청을 보내는 클라이언트와 요청을 받는 서버의 도메인이 일치하지 않아, 요청이 차단되는 문제가 발생한다.  

브라우저에서 서버로 요청을 보낼 때만 발생하고, 서버에서 서버로 요청을 보낼 때는 발생하지 않는다. 

 

OPTIONS 메서드

실제 요청을 보내기 전에 서버가 요청의 도메인, 헤더와 메서드 등을 허용하는지 체크하는 역할을 한다. 

 

CORS 문제 해결하기 

응답 헤더에 Acess-Control-Allow-Origin이라는 헤더를 넣어야 한다.

이 헤더는 클라이언트 도메인의 요청을 허락하겠다는 의미를 지닌 것으로, cors 패키지를 통해 적용할 수 있다. 

 

1. 응답은 API 서버가 보내는 것이므로 NodeBird API에 cors 모듈을 설치한다. npm i cors 

2. cors 패키지를 v2.js에 적용하면, 응답에 Access-Control-Allow-Origin 헤더가 추가된다. 
nodebird-api/routes/v2.js 

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

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

const router = express.Router();
router.use(cors({ // router.use : v2의 모든 라우터에 적용 
    credentials: true, // crudentials 옵션 : 다른 도메인 간에 쿠키 공유 
}));
...

 

3. 비밀 키가 모두에게 노출되는 문제를 막기 위해 처음 비밀 키 발급 시 허용한 도메인을 적게 하였다. 따라서 호스트와 비밀 키가 모두 일치할 때만 CORS를 허용하게 수정한다. 

nodebird-api/routes/v2.js 

const express = require('express');

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

const router = express.Router();

router.use(corsWhenDomainMatches);
...


nodebird-api/middlewares/index.js 

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const {Domain} = 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();
    }
};

Quiz

  1. (웹 API)는 다른 웹 서비스의 기능이나 자원을 가져올 수 있는 창구이고,
    (웹 API 서버)는 서버에 API를 올려서 URL을 통해 접근할 수 있게 만든 것이다. 
  2. (크롤링)은 웹 사이트의 정보를 일정 주기로 수집해 자체적으로 가공하는 기술로,
    웹 사이트가 자체적으로 제공하는 API가 없거나 API 이용에 제한이 있을 때 사용한다. 
  3. 요청을 보내는 클라이언트와 요청을 받는 서버의 도메인이 일치하지 않으면 (CORS ) 문제가 발생할 수 있다.
  4. (클라이언트 비밀 키)는 랜덤한 문자열로, 발급받은 비밀키는 API를 호출할 때 인증 용도로 사용된다.
  5. (JWT) JSON 형식의 데이터를 저장하는 토큰으로, (헤더), (페이로드), (시그니처) 세 부분으로 구성된다. 
  6. JWT에서 (시그니처)를 통해 토큰이 변조되었는지 여부를 확인할 수 있다.  
  7. CORS 문제를 해결하기 위해 응답 헤더에 (Acess-Control-Allow-Origin)이라는 헤더를 넣어야 한다. 이 헤더는 클라이언트 도메인의 요청을 허락하겠다는 의미를 지닌 것으로, (cors 패키지)를 통해 적용할 수 있다. 

Programming Quiz

1. JWT 토큰을 발급받고 인증받는 미들웨어를 작성하는 코드이다. 빈칸에 들어갈 코드를 작성하시오. 


nodebird-api/middlewares/index.js

exports.verifyToken = (req, res, next) => {
    try {
        /* 
        요청 헤더에 저장된 토큰을 사용하며, 사용자가 헤더에 토큰을 넣어 보낸다. 
        jwt.verify로 토큰 검증한다 - 첫 번째 인수 토큰, 두 번째 인수 토큰의 비밀 키 
        인증이 성공한 경우 토큰 내용이 반환되어 res.locals.decoded에 저장되고, 다음 미들웨어에서 사용할 수 있다.
        */
        ______________________________________________________________________________
        
        return next();
    } catch (error) {
    
        // 유효 기간이 지난 경우 
        if (error.name === 'TokenExpiredError') {
            return ________________({
                code: ___,
                message: '토큰이 만료되었습니다',
            });
        }
        
        // 토큰의 비밀 키가 일치하지 않는 경우 
        return ________________({
            code: ___,
            message: '유효하지 않은 토큰입니다',
        });
    }
};

 

2. API를 사용할 수 있는 횟수를 제한하는 apiLimiter 미들웨어, 새로운 버전을 사용하라는 응답을 전송하는 deprecated 미들웨어를 작성하는 코드이다. 빈칸에 들어갈 코드를 작성하시오. 

 

nodebird-api/middlewares/index.js 

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');

...

/* apiLimiter 미들웨어 : 라우터에 사용량 제한이 걸린다. */
exports.apiLimiter = rateLimit({
    ___________________, // 기준 시간, 1분
    ______, 			 // 허용 횟수, 1분에 한 번 호출 가능
    handler(req, res) {	 // 제한 초과 시 콜백 함수 
    	// 사용량 제한을 초과하면 429 상태코드와 허용량 초과했다는 응답 전송
        res.status(this.statusCode).json({
        code: this.statusCode, // 기본값 429
        message: '1분에 한 번만 요청할 수 있습니다.',
        });
    },
});

// deprecated 미들웨어 : 사용하면 안 되는 라우터에 붙인다. 
________________ = (req, res) => {
    ____________________({
        code: 410, // 410 코드와 새로운 버전을 사용하라는 메시지 응답 
        message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
    });
};

 


Answer

1.

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

 

2. 

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');

...

/* apiLimiter 미들웨어 : 라우터에 사용량 제한이 걸린다. */
exports.apiLimiter = rateLimit({
    windowMs: 60 * 1000, // 기준 시간, 1분
    max: 1, 			 // 허용 횟수, 1분에 한 번 호출 가능
    handler(req, res) {	 // 제한 초과 시 콜백 함수 
    	// 사용량 제한을 초과하면 429 상태코드와 허용량 초과했다는 응답 전송
        res.status(this.statusCode).json({
        code: this.statusCode, // 기본값 429
        message: '1분에 한 번만 요청할 수 있습니다.',
        });
    },
});

/* deprecated 미들웨어 : 사용하면 안 되는 라우터에 붙인다. */ 
exports.deprecated = (req, res) => {
    res.status(410).json({
        code: 410,    	// 410 코드와 새로운 버전을 사용하라는 메시지 응답 
        message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
    });
};

 


출처 :  조현영,  Node.js 교과서 개정 3판, 길벗(2022)

Corner Node.js 1
Editor : Snoopy

10.1. API 서버 이해하기

API (Application Programming Interface)

다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점이다. 

 

웹 API 

다른 웹 서비스의 기능이나 자원을 가져올 수 있는 창구로,

다른 프로그램에서 현재 프로그램의 기능을 사용할 수 있게 허용한다.

📌 정보를 제공하고 싶은 부분만 열어 놓거나, 인증된 사람만 일정 횟수 내에서 가져가게 제한을 둘 수 있다. 

 

웹 API 서버 

서버에 API를 올려서 URL을 통해 접근할 수 있게 만든 것이다. 

 

크롤링 (crawling) 

웹 사이트의 정보를 일정 주기로 수집해 자체적으로 가공하는 기술로, 

웹 사이트가 자체적으로 제공하는 API가 없거나 API 이용에 제한이 있을 때 사용한다. 

📌 도메인/robots.txt에서 웹 사이트가 어떤 페이지의 크롤링을 허용하는지 확인할 수 있다. 

📌 주기적으로 크롤링을 당하면 서버에 무리가 가므로 공개해도 되는 정보들은 API를 통해 가져가게 하는 것이 좋다. 

 

10.2 프로젝트 구조 갖추기

앞서 만들었던 NodeBird 서비스의 게시글, 해시태그, 사용자 정보를 JSON 형식으로 제공하는 API를 만든다. 이때, 인증받은 사용자만 할당량 안에서 API를 호출할 수 있도록 허용한다.  

 

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

{
    "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": "^5.0.0",
        "cookie-parser": "^1.4.5",
        "dotenv": "^16.0.1",
        "express": "^4.17.1",
        "express-session": "^1.17.1",
        "morgan": "^1.10.0",
        "mysql2": "^2.1.0",
        "nunjucks": "^3.2.1",
        "passport": "^0.6.0",
        "passport-kakao": "1.0.0",
        "passport-local": "^1.0.0",
        "sequelize": "^6.19.2",
        "uuid": "^8.3.2"
    },
    "devDependencies": {
        "nodemon": "^2.0.16"
    }
}

 

📍고유한 랜덤 문자열을 만들어내는 데 사용하는 uuid 패키지를 추가한다. 

📍 npm i 명령으로 package.json에 적힌 패키지를 설치한다. 

 

2. nodebird 폴더에 작성한 config, models, passport, middlewares 폴더, controllers와 routes 폴더의 auth.js, .env 파일을 복사해서 nodebird-api 폴더에 붙여 넣는다. 

 

3. 에러를 표시하는 error.html과 app.js를 작성한다. 

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

 

4. 도메인을 등록하는 기능이 생겼으므로 도메인 모델을 추가한다. 

nodebird-api/models/domian.js

const Sequelize = require('sequelize');

class Domain extends Sequelize.Model {
    static initiate(sequelize) {
        Domain.init({
            host: { // 인터넷 주소 
                type: Sequelize.STRING(80),
                allowNull: false,
            },
            type: { // 도메인 종류 
                type: Sequelize.ENUM('free', 'premium'), 
                // ENUM 속성 : free나 premium 중 하나만 값으로 입력할 수 있다. 
                allowNull: false,
            },
            clientSecret: { 
      // 클라이언트 비밀 키 : 다른 개발자들이 NodeBird의 API를 사용할 때 필요한 비밀 키
      // 유출되지 않도록 주의, 요청을 보낸 도메인까지 일치해야 요청 보낼 수 있게 제한한다. 
                type: Sequelize.UUID,   // UUID는 충돌 가능성이 매우 적은 랜덤한 문자열 
                allowNull: false,
            },
        }, {
            sequelize,
            timestamps: true,
            paranoid: true,
            modelName: 'Domain',
            tableName: 'domains',
        });
    }

    static associate(db) {
        db.Domain.belongsTo(db.User);   
    // 사용자 한 명이 여러 도메인을 소유할 수 있으므로 Domian 모델은 User 모델과 1:N 관계
    }
};

module.exports = Domain;

 

5. 로그인 화면을 작성한다. 

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('error')) {
                alert(new URL(location.href).searchParams.get('error'));
            }
            };
        </script>
        {% endif %}
    </body>
</html>

 

6. 라우터를 생성한다. 

nodebird-api/routes/index.js

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

const router = express.Router();

// GET / 라우터 : 접속 시 로그인 화면을 보여준다. 
router.get('/', renderLogin);

// 도메인 등록 라우터 (POST /domain) : 폼으로부터 온 데이터를 도메인 모델에 저장한다. 
router.post('/domain', isLoggedIn, createDomain);

module.exports = router;

 

nodebird-api/controllers/index.js

// 패키지의 함수나 변수를 불러올 때 이름을 v4에서 uuidv4로 변경 
const { v4: uuidv4 } = require('uuid'); 

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

exports.renderLogin = async (req, res, next) => {
    try {
        const user = await User.findOne({
        
        // 시퀄라이즈 where에는 undefined가 들어가면 안 되므로 req.user?.id||null을 사용 
        where: { id: req.user?.id || null }, 
        
        include: { model: Domain },
        });
        res.render('login', {
        user,
        domains: user?.Domains,
        });
    } catch (err) {
        console.error(err);
        next(err);
    }
}

exports.createDomain = async (req, res, next) => {
    try {
        await Domain.create({
        UserId: req.user.id,
        host: req.body.host,
        type: req.body.type,
        
        // 도메인 등록 라우터에서는 clientSecret 값을 uuid 패키지 버전 4를 통해 생성
        clientSecret: uuidv4(),	
        
        });
        res.redirect('/');
    } catch (err) {
        console.error(err);
        next(err);
    }
};

 

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

📌 비밀 키가 유출되면 다른 사람이 API를 사용할 수 있으므로 주의한다. 

📍 웹 브라우저에서 요청을 보낼 때 응답을 하는 곳과 도메인이 다르면 CORS 에러가 발생할 수 있다. CORS는 브라우저에서 발생하는 에러이므로, 서버에서 서버로 요청을 보내는 경우 발생하지 않는다. 

 

10.3 JWT 토큰으로 인증하기

다른 클라이언트가 NodeBird 서비스의 데이터를 가져갈 때 별도의 인증 과정이 필요하다. 

JWT 토큰을 사용해 인증하는 방법을 알아본다. 

 

JWT (JSON Web Token)
JSON 형식의 데이터를 저장하는 토큰으로 다음 세 부분으로 구성된다. 

  1. 헤더 (HEADER) : 토큰 종류와 해시 알고리즘 정보
  2. 페이로드 (PAYLOAD) : 토큰의 내용이 인코딩 된 부분
  3. 시그니처 (SIGNATURE) : 일련의 문자열로, 토큰이 변조되었는지 여부 확인 가능

JWT 특징 

  • 비밀 키가 노출되면 JWT 토큰을 위조할 수 있으므로 비밀 키를 숨겨야 한다.
  • JWT 내용은 볼 수 있기 때문에 민감한 내용을 넣으면 안 된다.
  • JWT 토큰은 JWT 비밀 키를 알지 않는 이상 변조가 불가능하다.
    노출되어도 괜찮은 사용자 정보를 넣어두면 데이터베이스 조회 없이도 사용자를 믿고 권한을 줄 수 있다.
  • JWT 토큰은 내용물이 들어 있어 랜덤 토큰을 사용할 때보다 용량이 크고,
    요청 때마다 토큰이 오가서 데이터 양이 증가한다.
  • 랜덤 문자열을 사용해서 매번 사용자 정보를 조회하는 작업의 비용과
    내용물이 들어있는 JWT 토큰을 사용해서 발생하는 데이터 비용을 비교하여 적절히 사용한다. 

웹 서버에 JWT 토큰 인증 과정 구현하기 

  1. JWT 모듈을 설치한다. npm i jsonwebtoken 
  2. 다른 사용자가 API를 사용하려면 JWT 토큰을 발급받고 인증받아야 한다.
    대부분의 라우터에 공통되므로 이 부분을 미들웨어로 만들어둔다. 

    nodebird-api/middlewares/index.js
    exports.isLoggedIn = (req, res, next) => {
        if (req.isAuthenticated()) {
            next();
        } else {
            res.status(403).send('로그인 필요');
        }
    };
    
    exports.isNotLoggedIn = (req, res, next) => {
        if (!req.isAuthenticated()) {
            next();
        } else {
            const message = encodeURIComponent('로그인한 상태입니다.');
            res.redirect(`/?error=${message}`);
        }
    };
    
    exports.verifyToken = (req, res, next) => {
        try {
    // 요청 헤더에 저장된 토큰을 사용하며, 사용자가 헤더에 토큰을 넣어 보낸다. 
    // jwt.verify로 토큰 검증한다 - 첫 번째 인수 토큰, 두 번째 인수 토큰의 비밀 키 
            res.locals.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
    // 인증이 성공한 경우 토큰 내용이 반환되어 res.locals.decoded에 저장되고, 다음 미들웨어에서 사용할 수 있다.
            return next();
        } catch (error) {
            // 유효 기간이 지난 경우 
            if (error.name === 'TokenExpiredError') { // 유효기간 초과
                return res.status(419).json({
                    code: 419,
                    message: '토큰이 만료되었습니다',
                });
            }
            // 토큰의 비밀 키가 일치하지 않는 경우 
            return res.status(401).json({
                code: 401,
                message: '유효하지 않은 토큰입니다',
            });
        }
    };
  3. 라우터를 생성한다. 
    nodebird-api/routes/v1.js
    테스트해 볼 require('express');
    
    const { verifyToken } = require('../middlewares');
    const { createToken, tokenTest } = require('../controllers/v1');
    
    const router = express.Router();
    
    // POST /v1/token : 토큰을 발급하는 라우터 
    router.post('/token', createToken);
    
    // GET /v1/test : 사용자가 토큰을 테스트해볼 수 있는 라우터 
    router.get('/test', verifyToken, tokenTest);
    
    module.exports = router;

    nodebird-api/controllers/v1.js 
    const jwt = require('jsonwebtoken');
    const { Domain, User, Post, Hashtag } = require('../models');
    
    exports.createToken = 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,         // 1. 토큰의 내용 - 사용자의 아이디
                    nick: domain.User.nick,     // 1. 토큰의 내용 - 사용자의 닉네임 
                }, process.env.JWT_SECRET, {    // 2. 토큰의 비밀 키 
                    expiresIn: '1m', // 1분     // 3. 토큰의 설정 - 유효 기간 1분 
                    issuer: 'nodebird',         // 3. 토큰의 설정 - 발급자 nodebird 
                });
                return res.json({
                    code: 200,
                    message: '토큰이 발급되었습니다',
                    token,
                });
            } catch (error) {
                console.error(error);
                return res.status(500).json({
                code: 500,
                message: '서버 에러',
            });
        }
    };
    
    // 검증에 성공하면 토큰의 내용물을 응답으로 보낸다. 
    exports.tokenTest = (req, res) => {
        res.json(res.locals.decoded);
    };
    
    exports.getMyPosts = (req, res) => {
        Post.findAll({ where: { userId: res.locals.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: '서버 에러',
            });
        });
    };
    
    exports.getPostsByHashtag = 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: '서버 에러',
            });
        }
    };

    📌 라우터에 버전 붙이는 이유  
    API 서버의 코드를 바꾸면 API를 사용 중인 프로그램에 영향을 미친다. 특히 기존에 있던 라우터가 수정되면 API를 사용하는 프로그램이 오작동할 수 있으므로 수정해야 한다면, 버전을 올린 라우터를 새로 추가하고 기존 사용자들에게 새로운 API가 나왔음을 알리는 것이 좋다. 

    📌 라우터의 응답은 일정한 형식을 갖춰야 응답받는 쪽에서 처리하기 쉽다. 

  4. 라우터를 서버에 연결한다. 
    nodebird-api/app.js 
    ...
    dotenv.config();
    
    const v1 = require('./routes/v1');
    
    const authRouter = require('./routes/auth');
    ... 
    app.use(passport.session());
    
    app.use('/v1', v1);
    
    app.use('/auth', authRouter);
    ...


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

NodeBird 앱의 API를 사용하는 서비스 만들기 

 

1. nodebird-api와 같은 위치에 nodecat 폴더를 만들고 package.json을 생성한다. 

{
    "name": "nodecat",
    "version": "0.0.1",
    "description": "NodeBird 2차 서비스",
    "main": "app.js",
    "scripts": {
        "start": "nodemon app"
    },
    "author": "Zero Cho",
    "license": "ISC",
    "dependencies": {
        "axios": "^0.27.2",
        "cookie-parser": "^1.4.6",
        "dotenv": "^16.0.1",
        "express": "^4.18.1",
        "express-session": "^1.17.3",
        "morgan": "^1.10.0",
        "nunjucks": "^3.2.3"
    },
    "devDependencies": {
        "nodemon": "^2.0.16"
    }
}


📌 npm i 명령으로 package.json에 적힌 패키지를 설치한다. 

 

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

 

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>

 

3. 사용자 인증 과정을 테스트하는 라우터를 만든다. 

 

nodecat/.env

COOKIE_SECRET=nodecat
CLIENT_SECRET=________-____-____-____-____________

 

nodecat/routes/index.js

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

const router = express.Router();

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

module.exports = router;

 

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

 

nodebird-api(localhost:8002) nodecat(localhost:4000) 실행 후 localhost:4000/test로 접속하면 토큰 내용이 표시된다. 

 

10.5 SNS API 서버 만들기

 

API 제공자의 입장으로 돌아와 나머지 API 라우터를 완성한다. 

 

nodebird-api/routes/v1.js 

const express = require('express');

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

const router = express.Router();

...

// GET /v1/posts/my : 내가 올린 포스트를 가져오는 라우터 
router.get('/posts/my', verifyToken, getMyPosts);

// GET /v1/posts/hashtag/:title : 해시태그 검색 결과를 가져오는 라우터 
router.get('/posts/hashtag/:title', verifyToken, getPostsByHashtag);

module.exports = router;

 

nodebird-api/controllers/v1.js

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

...

exports.tokenTest = (req, res) => {
    res.json(res.locals.decoded);
};

exports.getMyPosts = (req, res) => {
    Post.findAll({ where: { userId: res.locals.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: '서버 에러',
        });
    });
};

exports.getPostsByHashtag = 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: '서버 에러',
        });
    }
};

 

사용하는 측에도 API를 이용하는 코드를 추가한다. 

 

nodecat/routes/index.js 

const express = require('express');
const { searchByHashtag, getMyPosts } = require('../controllers');

const router = express.Router();

router.get('/myposts', getMyPosts);

router.get('/search/:hashtag', searchByHashtag);

module.exports = router;

 

nodecat/.env

COOKIE_SECRET=nodecat
CLIENT_SECRET=________-____-____-____-____________
API_URL=http://localhost:8002/v1
ORIGIN=http://localhost:4000

 

nodecat/controllers/index.js 

const axios = require('axios');

const URL = process.env.API_URL;
// 요청의 헤더 origin 값을 localhost:4000으로 설정 
axios.defaults.headers.origin = process.env.ORIGIN; 

// request 함수 : NodeBird API에 요청을 보내는 함수 
const request = async (req, api) => {
    try {
        // 세션에 토큰이 없으면 clientSecret을 사용해 토큰 발급 요청을 보낸다. 
        if (!req.session.jwt) { 
        const tokenResult = await axios.post(`${URL}/token`, {
            clientSecret: process.env.CLIENT_SECRET,
        });
        req.session.jwt = tokenResult.data.token; // 토큰은 세션에 저장 
        }
        // 발급받은 후에는 토큰을 이용해 API 요청을 보낸다. 
        return await axios.get(`${URL}${api}`, {
        headers: { authorization: req.session.jwt },
        }); 
    } catch (error) {
        // 토큰이 만료되면 419 에러 -> 토큰 지우고 재발급 
        if (error.response?.status === 419) { 
            delete req.session.jwt;
            return request(req, api);
        } // 419 외의 다른 에러면
        throw error;
    }
};

// GET /myposts : API를 사용해 자신이 작성한 포스트를 JSON 형식으로 가져오는 라우터 
exports.getMyPosts = async (req, res, next) => {
    try {
        const result = await request(req, '/posts/my');
        res.json(result.data);
    } catch (error) {
        console.error(error);
        next(error);
    }
};

// GET /search/:hashtag : API를 사용해 해시태그를 검색하는 라우터 
exports.searchByHashtag = 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);
        }
    }
};

 

localhost:4000/myposts에 접속하면 게시글 목록을, 

localhost:4000/search/노드에 접속하면 노드 해시태그가 달린 게시글 목록을 JSON 형식으로 확인할 수 있다. 

 

📌 nodebird-api 콘솔에는 다음 세 개의 요청이 기록된다. 

POST /v1/posts/hashtag/노드 419 0.962 ms - 56
POST /v1/token 200 23.383 ms - 252
POST /v1/posts/hashtag/노드 200 8.288 ms - 395

 

요청을 보낼 때 토큰이 만료되었으므로 419 에러가 발생한다.

request 함수의 catch 문에 의해 다시 request 함수가 실행되고 토큰을 새로 가져온 뒤 요청을 다시 보낸다. 

이 과정은 NodeCat 콘솔에서는 POST /search/노드 하나의 요청으로만 기록된다.  

 

10.6 사용량 제한 구현하기

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

express-rate-limit 패키지는 이러한 기능을 제공한다. 

 

1. express-rate-limit 패키지를 설치한다.  npm i express-rate-limit 

 

2. apiLimiter 미들웨어와 deprecated 미들웨어를 추가한다. 

 

nodebird-api/middlewares/index.js 

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');

...

// apiLimiter 미들웨어 : 라우터에 사용량 제한이 걸린다. 
exports.apiLimiter = rateLimit({
    windowMs: 60 * 1000, // 기준 시간, 1분
    max: 1, 			 // 허용 횟수, 1분에 한 번 호출 가능
    handler(req, res) {	 // 제한 초과 시 콜백 함수 
    	// 사용량 제한을 초과하면 429 상태코드와 허용량 초과했다는 응답 전송
        res.status(this.statusCode).json({
        code: this.statusCode, // 기본값 429
        message: '1분에 한 번만 요청할 수 있습니다.',
        });
    },
});

// deprecated 미들웨어 : 사용하면 안 되는 라우터에 붙인다. 
exports.deprecated = (req, res) => {
    res.status(410).json({
    	// 410 코드와 새로운 버전을 사용하라는 메시지 응답 
        code: 410,
        message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
    });
};

 

📌 API 응답 목록 

응답 코드 메시지 
200 JSON 데이터입니다. 
401 유효하지 않은 토큰입니다.
410  새로운 버전이 나왔습니다. 새로운 버전을 사용하세요. 
419  토큰이 만료되었습니다. 
429  1분에 한 번만 요청할 수 있습니다. 
500~ 기타 서버 에러

 

3. 사용량 제한이 추가되었으므로 새로운 v2 라우터를 만든다. 

 

nodebird-api/routes/v2.js 

/* 라우터에 사용량 제한 미들웨어 추가 */

const express = require('express');

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

const router = express.Router();

// POST /v2/token
router.post('/token', apiLimiter, createToken);

// POST /v2/test
router.get('/test', apiLimiter, verifyToken, tokenTest);

// GET /v2/posts/my
router.get('/posts/my', apiLimiter, verifyToken, getMyPosts);

// GET /v2/posts/hashtag/:title
router.get('/posts/hashtag/:title', apiLimiter, verifyToken, getPostsByHashtag);

module.exports = router;

 

nodebird-api/controllers/v2.js 

...

exports.createToken = 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: '서버 에러',
        });
    }
};

...

 

nodebird-api/routes/v1.js 

// v1 라우터를 사용할 때는 경고 메시지 

const express = require('express');

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

const router = express.Router();

router.use(deprecated);

// POST /v1/token
router.post('/token', createToken);

...

 

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

 

nodebird-api/app.js

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

 

5. 사용자 입장으로 돌아와 새로 생긴 버전을 호출한다. 

 

nodecat/.env 

COOKIE_SECRET=nodecat
CLIENT_SECRET=________-____-____-____-____________
API_URL=http://localhost:8002/v2
ORIGIN=http://localhost:4000

 

nodebird-api/middlewares/index.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 {
        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: '유효하지 않은 토큰입니다',
        });
    }
};

exports.apiLimiter = rateLimit({
    windowMs: 60 * 1000, // 1분
    max: 10,
    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: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
    });
};

 

📌 버전을 v2로 바꾸지 않고 v1을 계속 사용한다면 410 에러, 1분에 한 번보다 더 많이 호출하면 429 에러가 발생한다. 

 

10.7 CORS 이해하기

NodeCat의 프런트에서 nodebird-api 서버 API를 호출하는 경우 

프런트 화면을 렌더링 하는 라우터 

nodecat/routes/index.js

const express = require('express');
const { searchByHashtag, getMyPosts, renderMain } = require('../controllers');

const router = express.Router();

router.get('/myposts', getMyPosts);

router.get('/search/:hashtag', searchByHashtag);

router.get('/', renderMain);

module.exports = router;

 

nodecat/controllers/index.js 

...
exports.renderMain = (req, res) => {
    res.render('main', {key: process.env.CLIENT_SECRET});
};

 

프론트 화면 

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>

 

CORS(Cross-Origin Resource Sharing) 

요청을 보내는 클라이언트와 요청을 받는 서버의 도메인이 일치하지 않아, 요청이 차단되는 문제가 발생한다.  

브라우저에서 서버로 요청을 보낼 때만 발생하고, 서버에서 서버로 요청을 보낼 때는 발생하지 않는다. 

 

OPTIONS 메서드

실제 요청을 보내기 전에 서버가 요청의 도메인, 헤더와 메서드 등을 허용하는지 체크하는 역할을 한다. 

 

CORS 문제 해결하기 

응답 헤더에 Acess-Control-Allow-Origin이라는 헤더를 넣어야 한다.

이 헤더는 클라이언트 도메인의 요청을 허락하겠다는 의미를 지닌 것으로, cors 패키지를 통해 적용할 수 있다. 

 

1. 응답은 API 서버가 보내는 것이므로 NodeBird API에 cors 모듈을 설치한다. npm i cors 

2. cors 패키지를 v2.js에 적용하면, 응답에 Access-Control-Allow-Origin 헤더가 추가된다. 
nodebird-api/routes/v2.js 

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

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

const router = express.Router();
router.use(cors({ // router.use : v2의 모든 라우터에 적용 
    credentials: true, // crudentials 옵션 : 다른 도메인 간에 쿠키 공유 
}));
...

 

3. 비밀 키가 모두에게 노출되는 문제를 막기 위해 처음 비밀 키 발급 시 허용한 도메인을 적게 하였다. 따라서 호스트와 비밀 키가 모두 일치할 때만 CORS를 허용하게 수정한다. 

nodebird-api/routes/v2.js 

const express = require('express');

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

const router = express.Router();

router.use(corsWhenDomainMatches);
...


nodebird-api/middlewares/index.js 

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const {Domain} = 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();
    }
};

Quiz

  1. (웹 API)는 다른 웹 서비스의 기능이나 자원을 가져올 수 있는 창구이고,
    (웹 API 서버)는 서버에 API를 올려서 URL을 통해 접근할 수 있게 만든 것이다. 
  2. (크롤링)은 웹 사이트의 정보를 일정 주기로 수집해 자체적으로 가공하는 기술로,
    웹 사이트가 자체적으로 제공하는 API가 없거나 API 이용에 제한이 있을 때 사용한다. 
  3. 요청을 보내는 클라이언트와 요청을 받는 서버의 도메인이 일치하지 않으면 (CORS ) 문제가 발생할 수 있다.
  4. (클라이언트 비밀 키)는 랜덤한 문자열로, 발급받은 비밀키는 API를 호출할 때 인증 용도로 사용된다.
  5. (JWT) JSON 형식의 데이터를 저장하는 토큰으로, (헤더), (페이로드), (시그니처) 세 부분으로 구성된다. 
  6. JWT에서 (시그니처)를 통해 토큰이 변조되었는지 여부를 확인할 수 있다.  
  7. CORS 문제를 해결하기 위해 응답 헤더에 (Acess-Control-Allow-Origin)이라는 헤더를 넣어야 한다. 이 헤더는 클라이언트 도메인의 요청을 허락하겠다는 의미를 지닌 것으로, (cors 패키지)를 통해 적용할 수 있다. 

Programming Quiz

1. JWT 토큰을 발급받고 인증받는 미들웨어를 작성하는 코드이다. 빈칸에 들어갈 코드를 작성하시오. 


nodebird-api/middlewares/index.js

exports.verifyToken = (req, res, next) => {
    try {
        /* 
        요청 헤더에 저장된 토큰을 사용하며, 사용자가 헤더에 토큰을 넣어 보낸다. 
        jwt.verify로 토큰 검증한다 - 첫 번째 인수 토큰, 두 번째 인수 토큰의 비밀 키 
        인증이 성공한 경우 토큰 내용이 반환되어 res.locals.decoded에 저장되고, 다음 미들웨어에서 사용할 수 있다.
        */
        ______________________________________________________________________________
        
        return next();
    } catch (error) {
    
        // 유효 기간이 지난 경우 
        if (error.name === 'TokenExpiredError') {
            return ________________({
                code: ___,
                message: '토큰이 만료되었습니다',
            });
        }
        
        // 토큰의 비밀 키가 일치하지 않는 경우 
        return ________________({
            code: ___,
            message: '유효하지 않은 토큰입니다',
        });
    }
};

 

2. API를 사용할 수 있는 횟수를 제한하는 apiLimiter 미들웨어, 새로운 버전을 사용하라는 응답을 전송하는 deprecated 미들웨어를 작성하는 코드이다. 빈칸에 들어갈 코드를 작성하시오. 

 

nodebird-api/middlewares/index.js 

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');

...

/* apiLimiter 미들웨어 : 라우터에 사용량 제한이 걸린다. */
exports.apiLimiter = rateLimit({
    ___________________, // 기준 시간, 1분
    ______, 			 // 허용 횟수, 1분에 한 번 호출 가능
    handler(req, res) {	 // 제한 초과 시 콜백 함수 
    	// 사용량 제한을 초과하면 429 상태코드와 허용량 초과했다는 응답 전송
        res.status(this.statusCode).json({
        code: this.statusCode, // 기본값 429
        message: '1분에 한 번만 요청할 수 있습니다.',
        });
    },
});

// deprecated 미들웨어 : 사용하면 안 되는 라우터에 붙인다. 
________________ = (req, res) => {
    ____________________({
        code: 410, // 410 코드와 새로운 버전을 사용하라는 메시지 응답 
        message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
    });
};

 


Answer

1.

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

 

2. 

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');

...

/* apiLimiter 미들웨어 : 라우터에 사용량 제한이 걸린다. */
exports.apiLimiter = rateLimit({
    windowMs: 60 * 1000, // 기준 시간, 1분
    max: 1, 			 // 허용 횟수, 1분에 한 번 호출 가능
    handler(req, res) {	 // 제한 초과 시 콜백 함수 
    	// 사용량 제한을 초과하면 429 상태코드와 허용량 초과했다는 응답 전송
        res.status(this.statusCode).json({
        code: this.statusCode, // 기본값 429
        message: '1분에 한 번만 요청할 수 있습니다.',
        });
    },
});

/* deprecated 미들웨어 : 사용하면 안 되는 라우터에 붙인다. */ 
exports.deprecated = (req, res) => {
    res.status(410).json({
        code: 410,    	// 410 코드와 새로운 버전을 사용하라는 메시지 응답 
        message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
    });
};

 


출처 :  조현영,  Node.js 교과서 개정 3판, 길벗(2022)

Corner Node.js 1
Editor : Snoopy

 
728x90

관련글 더보기