웹 API 서버 만들기

이전에 만들었던 sns(Git [ch9/sns5]) 서비스의 API(sns-api)를 만들어보겠다. (데이터베이스를 sns 서비스와 공유하겠다.) 또한 snsplus라는 sns-api를 이용하는 서비스도 간단하게 만들어보겠다.

책 Node.js 교과서(개정 2판) 책의 10장의 내용을 참고했다.

전체 클라이언트 서버 관계

  • sns5: 나의 앱(localhost:8001)
  • sns-api: sns5의 API 앱(localhost:8002)
  • snsplus: sns-api를 이용하여 데이터를 가져오는 제 3자 앱(localhost:4000)

Github 주소1(sns-api, snsplus): https://github.com/delay-100/study-node/tree/main/ch10
Github 주소2(sns5): https://github.com/delay-100/study-node/tree/main/ch9/sns5


API란?

API(Application Programming Interface)

  • 다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점/창구
  • 웹 API서버: 서버에 API를 올려서 URL을 통해 접근할 수 있게 만든 것
  • 다른 서비스에서 내 웹 사이트의 정보를 가져갈 수 있음(물론 공개해도 되는 정보들만 API로 만듦)
  • 정보를 모든 사람이 아니라, 인증된 사용자에게만 제공

+크롤링(crawling): 웹 사이트가 자체적으로 제공하는 API가 없거나 API 이용에 제한이 있을 때 사용하는 방법


1. 웹 API 서버 만들기(sns-api 앱)

데이터베이스를 sns5 앱과 공유하고 있기 때문에, .env, config, models, passport, routes/auth.js, routes/middlewares.js를 그대로 붙혀넣어 사용!
+해당 코드는 Github에서 갖다 쓰면 된다.

sns-api 실행화면

1. npm 패키지 설치하기(package.json)

Git [sns-api/package.json] - npm의 패키지 설치

{
  "name": "sns-api",
  "version": "1.0.0",
  "description": "sns API 서버",
  "main": "app.js",
  "scripts": {
    "start": "nodemon"
  },
  "author": "delay100",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^5.0.1",
    "cookie-parser": "^1.4.6",
    "cors": "^2.8.5",
    "dotenv": "^16.0.0",
    "express": "^4.17.2",
    "express-rate-limit": "^5.1.1",
    "express-session": "^1.17.2",
    "jsonwebtoken": "^8.5.1",
    "morgan": "^1.10.0",
    "mysql2": "^2.3.3",
    "nunjucks": "^3.2.3",
    "passport": "^0.5.2",
    "passport-kakao": "^1.0.1",
    "passport-local": "^1.0.0",
    "sequelize": "^6.16.1",
    "uuid": "^8.3.2"
  },
  "devDependencies": {
    "nodemon": "^2.0.15"
  }
}

몇몇 패키지를 소개하겠다. 나머지는 이전 포스팅들에서 많이 다뤘기 떄문에 블로그에서 검색하면 나올 것이다!

  • uuid: 고유한 랜덤 문자열을 만들 때 사용
  • jsonwebtoken: Json Web Token, Json 포맷을 이용하여 사용자에 대한 속성을 저장
  • express-rate-limit: api 사용량 제한, 보통 레디스가 많이 사용됨, 단, express-rate-limit은 db와 연결하는 것을 지원하지 않으므로 npm에서 새로운 패키지를 찾거나 직접 구현해야 함
  • cors: 요청을 보내는 클라이언트와 요청을 받는 서버의 도메인이 다른 경우 발생하는 에러를 해결하기 위한 패키지

2. 기본 설정(sns-api/app.js, sns-api/views/error.html)

Git [sns-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');
const v1 = require('./routes/v1');
const v2 = require('./routes/v2');

dotenv.config(); // .env 파일을 쓸 수 있게 함
const authRouter = require('./routes/auth');
const indexRouter = require('./routes');
const { sequelize } = require('./models');
const passportConfig = require('./passport');

const app = express();
passportConfig();
app.set('port', process.env.PORT || 8002);
app.set('view engine', 'html');
nunjucks.configure('views', {
    express: app,
    watch: true,
});
sequelize.sync({ force: false})
    .then(() => {
        console.log('데이터베이스 연결 성공');
    })
    .catch((err) => {
        console.error(err);
    });

app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false}));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET,
    cookie: {
        httpOnly: true,
        secure: false,
    },
}));

app.use(passport.initialize());
app.use(passport.session());

app.use('/v1', v1);
app.use('/v2', v2);
app.use('/auth', authRouter);
app.use('/', indexRouter);

app.use((req, res, next) => {
    const error = new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
    error.status = 404;
    next(error);
});

app.use((err, req, res, next) => {
    res.locals.message = err.message;
    res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
    res.status(err.status || 500);
    res.render('error');
});

app.listen(app.get('port'), () => {
    console.log(app.get('port'), '번 포트에서 대기 중');
});

Git [sns-api/views/error.html]

<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>

3. 도메인(domain) 모델 추가하기

Git [sns-api/models/domain.js]

const Sequelize = require('sequelize');

module.exports = class Domain extends Sequelize.Model {
    static init(sequelize) {
        return super.init({
            host: { // 인터넷 주소(host)
                type: Sequelize.STRING(80),
                allowNull: false,
            },
            type: { // 도메인 종류(type)
                type: Sequelize.ENUM('free', 'premium'), // ENUM: 넣을 수 있는 값을 제한하는 데이터 형식, [무료: free, 프리미엄: proemium] 중 하나의 종류만 선택 가능-> 어기면 에러
                allowNull: false,
            },
            clientSecret: { // 클라이언트 비밀 키(clientSecret) - 다른 개발자들이 sns의 API를 사용할 때 필요한 비밀 키
                type: Sequelize.UUID, // UUID: 충돌 가능성이 매우 적은 랜덤한 문자열
                allowNull: false,
            },
        }, {
            sequelize,
            timestamps: true,
            paranoid: true,
            modelName: 'Domain',
            tableName: 'domains',
        });
    }
    static associate(db) {
        db.Domain.belongsTo(db.User); // User(1): Domain(N)으로, 일대다 관계
    }
}

+Git [sns-api/models/index.js - 생성한 domain 모델 추가하기

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

+Git [sns-api/models/user.js] - domain 모델과 1:N 관계이기 때문에 추가해주어야 함

...
db.User.hasMany(db.Domain);

4. API서버의 메인화면(html) + 도메인을 등록하는 화면 포함

Git [sns-api/views/login.html] - 간단하게 동작만 들어있는 html 작성

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>API 서버 로그인</title>
        <style>
            .input-group label { width: 200px; display: inline-block; }
        </style>
    </head>
    <body>
        {% if user and user.id %}
        <span class="user-name">안녕하세요! {{ user.nick }}님</span>
        <a href="/auth/logout">
            <button>로그아웃</button>
        </a>
        <fieldset>
            <legend>도메인 등록</legend>
            <form action="/domain" method="post">
                <div>
                    <label for="type-free">무료</label>
                    <input type="radio" id="type-free" name="type" value="free">
                    <label for="type-premium">프리미엄</label>
                    <input type="radio" id="type-premium" name="type" value="premium">
                </div>
                <div>
                    <label for="host">도메인</label>
                    <input type="text" id="host" name="host" placeholder="ex) zerocho.com">
                </div>
                <button>저장</button>
            </form>
        </fieldset>
        <table>
            <tr>
                <th>도메인 주소</th>
                <th>타입</th>
                <th>클라이언트 비밀키</th>
            </tr>
            {% for domain in domains %} 
            <tr>
                <td>{{domain.host}}</td>
                <td>{{domain.type}}</td>
                <td>{{domain.clientSecret}}</td>
            </tr>
            {% endfor %}
        </table>
        {% else %} 
            <form action="/auth/login" id="login-form" method="post">
                <h2>NodeBird 계정으로 로그인하세요.</h2>
                <div class="input-group">
                    <label for="email">이메일</label>
                    <input id="email" type="email" name="email" required autofocus>
                </div>
                <div class="input-group">
                    <label for="password">비밀번호</label>
                    <input id="password" type="password" name="password" required>
                </div>
                <div>회원가입은 localhost:8001에서 하세요.</div>
                <button id="login" type="submit">로그인</button>
            </form>
            <script>
                window.onload = () => {
                    if( new URL(location.href).searchParams.get('loginError')){
                        alert(new URL(location.href).setParams.get('loginError'));
                    }
                };
            </script>
            {% endif %}
    </body>
</html>

5. 기본 라우터 세팅(GET /, 도메인 등록)

Git [sns-api/routes/index.js]

const express = require('express');
const { v4: uuidv4 } = require('uuid'); // uuidv4를 가져오면서 v4로 이름을 바꾸며 가져옴
const {User, Domain } = require('../models');
const { isLoggedIn } = require('./middlewares');

const router = express.Router();

// GET / 요청 - 접속 시 로그인 화면을 보여줌
router.get('/', async (req, res, next) => {
    try {
        const user = await User.findOne({ 
            where: { id: req.user && req.user.id || null},
            include: { model: Domain }, // 해당 Domain을 가진 User가 있는지 검색  
        });
        res.render('login', {
            user,
            domains: user && user.Domains, // user 객체가 있는지 체크해야 서버에서 오류가 나지 않기때문에 user으로 확인을 먼저 해줌
        });
    } catch (err) {
        console.error(err);
        next(err);
    }
});

// POST /domain 요청 - 도메인 등록 라우터
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(), // clientSecret 값을 uuid 패키지(버전 4-36자리 문자열 형식으로 생김)를 통해 생성, 세 번째 마디의 첫 번째 숫자가 4
        });
        res.redirect('/');
    } catch(err){
        console.error(err);
        next(err);
    }
});

module.exports = router;

2. JWT 토큰으로 인증하기

JWT(JSON Web Token)

  • JSON 형식의 데이터를 저장하는 토큰
  • JWT 비밀 키를 알지 않는 이상 변조가 불가능 함
  • 공식 홈페이지: https://jwt.io/

    구성

    • 헤더(header): 토큰 종류와 해시 알고리즘 정보
    • 페이로드(payload): 토큰의 내용물이 인코딩된 부분
    • 시그니처(signature): 일련의 문자열, 시그니처를 통해 토큰이 변조되었는지 여부를 확인/JWT 비밀키로 만들어짐

jsonwebtoken 설치

npm i jsonwebtoken

Git [sns-api/.env] 中 jwt secret key 설정

...
JWT_SECRET=여기

1. 토큰 검증 미들웨어 생성

Git [sns-api/routes/middlewares.js] 中 토큰 검증 미들웨어 생성

const jwt = require('jsonwebtoken'); // Json Web Token, Json 포맷을 이용하여 사용자에 대한 속성을 저장
...

// 토큰 검증 미들웨어
exports.verifyToken = (req, res, next) => {
    try {
        req.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET); // 요청 헤더에 저장된 토큰(req.headers.authorization) 사용, jwt.verify: 토큰 검증
                                                                                     // 첫 번째 인수: 토큰(토큰의 내용: 사용자 아이디, 닉네임, 발급자, 유효 기간), 두 번째 인수: 토큰의 비밀 키
                                                                                     // 토큰의 비밀 키가 일치하지 않으면/유효기간이 지난 경우 catch문으로 이동
        return next(); // 다음 미들웨어에서 req.decoded를 통해 토큰의 내용물을 사용할 수 있음
    } catch (error) {
        if (error.name == 'TokenExpiredError') { // 유효 기간 초과
            return res.status(419).json({ // 코드는 400번대 중 마음대로 정해도 됨
                code: 419,
                message: '토큰이 만료되었습니다',
            });
        }
        return res.status(401).json({ // 토큰의 비밀 키가 일치하지 않는 경우
            code: 401,
            message: '유효하지 않은 토큰입니다',
        });
    }
};
...

2. 구버전(v1) 만들기

Git [sns-api/routes/v1.js] 中 post('/token'): 도메인 등록 확인 라우터, get('/test'): 사용자가 발급 받은 토큰 테스트 라우터

// 라우터의 이름: v1(버전 1)

const express = require('express');
const jwt = require('jsonwebtoken'); // JWT(JSON Web Token) 토큰 인증

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

const router = express.Router();

// router.use(deprecated); // 라우터 앞에 deprecated 미들웨어를 추가하여 v1으로 접근한 모든 요청에 deprecated 응답을 보냄

// 토큰을 발급하는 라우터
router.post('/token', async (req, res) => {
    const { clientSecret } = req.body; // snsplus/routes/index.js에서 clientSecret: process.env.CLIENT_SECRET
    try {
        const domain = await Domain.findOne({ // 전달받은 클라이언트 비밀 키로 도메인이 등록된 것인지를 확인
            where: { clientSecret },
            include: {
                model: User,
                attribute: ['nick', 'id'],
            },
        });
        if(!domain) {
            return res.status(401).json({
                code: 401,  // HTTP 상태 코드를 사용해도 되고, 임의로 숫자를 부여해도 됨
                message: '등록되지 않은 도메인입니다. 먼저 도메인을 등록하세요.', // 사용자가 어떤 문제인지 알 수 있게 message도 보냄
            });
        }
        const token = jwt.sign({  // jwt.sign의 첫 번째 인수: 토큰의 내용
            id: domain.User.id, // 사용자의 id
            nick: domain.User.nick, // 사용자의 nickname
        }, process.env.JWT_SECRET, { // jwt.sign의 두 번째 인수: 토큰의 비밀 키, 세 번째 인수: 토큰의 설정
            expiresIn: '1m', // 토큰의 유효 기간을 1분으로 설정, 60*1000처럼 밀리초 단위로 적어도 됨
            issuer: 'sns', // 발급자
        });
        return res.json({
            code: 200,
            message: '토큰이 발급되었습니다.', 
            token,
        });
    } catch (error) {
        console.error(error);
        return res.status(500).json({
            code: 500,
            message: '서버 에러',
        });
    }
});

// 사용자가 발급받은 토큰을 테스트해볼 수 있는 라우터
router.get('/test', verifyToken, (req, res) => { // verifyToken: routes/middlewares.js 에서 토큰 검증
    res.json(req.decoded); // 검증 성공 시 토큰의 내용물을 응답으로 보냄, json 형태: code, message 속성 존재(+token이 있으면 token 속성도 존재)
});

// 내가 올린 포스트를 가져오는 라우터
router.get('/posts/my', verifyToken, (req, res) => {
    Post.findAll({where: { userId: req.decoded.id }})
        .then((posts) => {
            console.log(posts);
            res.json({
                code: 200,
                message: 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;

3. API 서버 사용하기(snsplus 앱 생성)

위에서 만든 API를 사용하는(테스트 용) snsplus 앱을 간단하게 만들어보자!

1. npm 패키지 설치하기(package.json)

Git [snsplus/package.json]

{
  "name": "snsplus",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "nodemon app"
  },
  "author": "delay100",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.26.0",
    "cookie-parser": "^1.4.6",
    "dotenv": "^16.0.0",
    "express": "^4.17.2",
    "express-session": "^1.17.2",
    "morgan": "^1.10.0",
    "nunjucks": "^3.2.3"
  },
  "devDependencies": {
    "nodemon": "^2.0.15"
  }
}

2. 기본 설정(snsplus/app.js, snsplus/views/error.html)

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

Git [snsplus/views/error.html]

<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>

Git [snsplus/.env] - sns-api 실행화면에서 발급 받았던 클라이언트 비밀키를 적음

CLIENT_SECRET=c817989f-6d93-49ff-bff9-6948ad9d452b

Git [snsplus/routes/index.js]

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

const router = express.Router();
const URL = 'http://localhost:8002/v1';
// const URL = 'http://localhost:8002/v2';
axios.defaults.headers.origin = 'http://localhost:4000'; // 요청의 헤더 origin값을 localhost:4000으로 설정 - 어디서 요청하는지 파악하기 위해 사용, 주소가 바뀌면 이 값도 바꾸면 됨 

// request 함수: sns API에 요청을 보내는 함수
const request = async (req, api) => {
    try {
        if(!req.session.jwt) { // 세션에 토큰이 없으면 
            const tokenResult = await axios.post(`${URL}/token`, { // sns-api/routes/v1.js의 post /token 실행
                clientSecret: process.env.CLIENT_SECRET, // clientSecret을 사용해 토큰을 발급받는 요청을 보냄
            });
            req.session.jwt = tokenResult.data.token; // 토큰을 재사용하기 위해 세션에 토큰 저장
        }
        return await axios.get(`${URL}${api}`, { 
            headers: { authorization: req.session.jwt},
        }); // 토큰을 이용해 API 요청 - sns-api/routes/v1.js의 get /api값 실행
    } catch (error) {
        if (error.response.status === 419) { // 토큰 만료 시 419 에러가 발생하는데, 419: sns-api/routes/middlewares.js의 verifyToken내에 정의됨
            delete req.session.jwt; // 토큰을 지우고
            return request(req, api); // 토큰 재발급 받기(만료 시 계속 재귀적으로 실행)
        } // 419 외의 다른 에러면
        return error.response;
    }
};

// API를 사용해 자신이 작성한 포스트를 JSON 형식으로 가져오는 라우터 - 현재는 JSON으로만 응답하지만 템플릿 엔진으로 화면을 렌더링 가능
router.get('/mypost', async (req, res, next) => {
    try {
        const result = await request(req, '/posts/my'); // 위에 선언한 reqquest 함수 실행
        res.json(result.data);
    } catch (error) {
        console.error(error);
        next(error);
    }
});

// API를 사용해 해시태그가 달린 게시글들을 검색를 검색하는 라우터
router.get('/search/:hashtag', async (req, res, next) => {
    try {
        const result = await request(
            req, `/posts/hashtag/${encodeURIComponent(req.params.hashtag)}`,  // 위에 선언한 reqquest 함수 실행
        );
        res.json(result.data);
    } catch (error){
        if (error.code) {
            console.error(error);
            next(error);
        }
    }
});

// nodeplus 서비스가 토큰 인증 과정을 테스트해보는 라우터
router.get('/test', async (req, res, next) => {
    try {
        if (!req.session.jwt) { // 세션에 토큰이 없으면 토큰 발급, jwt는 sns-api/routes/v1.js에서 const token = jwt.sign({ 로 만들어줌
            const tokenResult = await axios.post('http://127.0.0.1:8002/v1/token', {
                clientSecret: process.env.CLIENT_SECRET, // HTTP 요청 본문에 클라이언트 비밀 키를 실어 보냄, const { clientSecret } = req.body; 
            });
            if (tokenResult.data && tokenResult.data.code === 200) { // 토큰 발급 성공, sns-api/routes/v1.js의 200 코드가 return 된 경우
                req.session.jwt = tokenResult.data.token; // 세션에 토큰 저장
            } else { // 토큰 발급 실패
                return res.json(tokenResult.data); // 발급 실패 사유 응답
            }
        }
        // 발급 받은 토큰이 유효한지 테스트
        const result = await axios.get('http://127.0.0.1:8002/v1/test', {
            headers: { authorization: req.session.jwt }, // 인증용 토큰 헤더는 보통 요청 본문 대신에, authorization에 넣어 전송함 
                                                        // 본문에 객체를 붙인 포스트 요청을 보내고
                                                         // 추가적으로 헤더 이름 'Authorization'을 추가하고 로그인 후 sessionStorage에 저장된 jwt 토큰 값을 제공하여 헤더 정보를 전달
                                                         // 출처 : https://medium.com/geekculture/how-to-implement-user-authentication-using-jwt-json-web-token-in-nodejs-and-maintain-user-c5850aed8839
        });
        return res.json(result.data);
    }
    catch (error){
        console.error(error);
        if(error.response.status === 419) {// 토큰 만료 시, sns-api/routes/middlewares.js의 419 코드
            return res.json(error.response.data);
        }
        return next(error);
    }
});

module.exports = router;

4. 버전 바꾸기 (v1 -> v2)

v1 -> v2로 바꾸기
사용량 제한을 추가한 v2를 만들어보자!

express-rate-limit 설치

npm i express-rate-limit

Git [sns-api/routes/middlewares.js]

const RateLimit = require('express-rate-limit'); // api 사용량 제한, sns-api 서버가 재시작되면 사용량이 초기화되므로 실제 서비스에서 사용량을 저장할 데이터베이스를 따로 마련하는 것이 좋음
...
// console.log(RateLimit);
// apiLimiter 미들웨어 - 라우터에 넣으면 라우터에 사용량 제한이 걸림
exports.apiLimiter = new RateLimit({
    windowMs: 60 * 1000, // 기준 시간 - 1분 (1분에 1번 호출 가능)
    max: 10, // 허용 횟수 - 10번
    handler(req, res) { // 제한 초과 시 상태 코드(code)와 함께 허용량을 초과했다는 응답(message)을 전송하는 콜백 함수
        res.status(this.statusCode).json({
            code: this.statusCode, // 기본값 429
            message: '1분에 한 번만 요청할 수 있습니다.',
        });
    },
});

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

Git [sns-api/routes/v2.js]

// api 사용량 제한을 추가한 v2 라우터
const express = require('express');
const jwt = require('jsonwebtoken');
const cors = require('cors');
...

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

const router = express.Router();

...
// // 응답에 Access-Control-Allow-Origin 헤더가 추가됨
// // v2의 모든 라우터에 적용
// router.use(cors({
//     credentials: true, // true: 다른 도메인과 쿠키가 공유됨, 서버 간의 도메인이 다른 경우에는 이 옵션을 활성화하지 않으면 로그인이 되지 않을 수 있음
//                        // axios에서도 도메인이 다른데, 쿠키를 공유해야 하는 경우 withCredentials: true 옵션으로 요청을 보내야 함
// }));

router.post('/token', apiLimiter, async (req, res) => { // apiLimiter: routes/middlewares.js의 사용량 제한 미들웨어 추가
    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: 'sns',
        });
        return res.json({
            code: 200,
            message: '토큰이 발급되었습니다',
            token,
        });
    } catch (error) {
        console.error(error);
        return res.status(500).json({
            code: 500,
            message: '서버 에러',
        });
    }
});

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

router.get('/posts/my', apiLimiter, verifyToken, (req, res) => {
    Post.findAll({ where: { userId: req.decode.id}})
      .then((posts) => {
          console.log(posts);
          res.json({
              code: 200,
              payload: posts,
          });
      })
      .catch((error) => {
          console.error(error);
          return res.status(500).json({
              code: 500,
              message: '서버 에러',
          });
      });
});

router.get('/posts/hashtag/:title', verifyToken, apiLimiter, async (req, res) => {
    try {
        const hashtag = await Hashtag.findOne({ where: { title: req.params.title }});
        if (!hashtag) {
            return res.status(404).json({
                code: 404,
                message: '검색 결과가 없습니다',
            });
        }
        const posts = await hashtag.getPosts();
        return res.json({
            code: 200,
            payload: posts,
        });
    } catch (error) {
        console.error(error);
        return res.status(500).json({
            code: 500,
            message: '서버 에러',
        });
    }
});

module.exports = router;

v2로 바꿨으므로, 기존의 Git [sns-api/routes/v1.js]는 사용을 하지 말라고 에러를 보내줘야 한다.

Git [sns-api/routes/v1.js]에 추가

// deprecated를 추가한다
const { verifyToken, deprecated } = require('./middlewares');
...
router.use(deprecated); // 라우터 앞에 deprecated 미들웨어를 추가하여 v1으로 접근한 모든 요청에 deprecated 응답을 보냄
...

Git [sns-api/routes/index.js]에서 기존의 v1을 지우고 v2를 작성

// const URL = 'http://localhost:8002/v1';
const URL = 'http://localhost:8002/v2';

5. CORS 문제

CORS 문제

  • Cross-Origin Resource Sharing
  • 요청을 보내는 클라이언트와 요청을 받는 서버의 도메인이 다른 경우 발생
  • 브라우저에서 서버로 요청을 보낼 때만 발생(서버->서버 요청은 발생X)
  • ex) 클라이언트(localhost:4000 -요청> localhost:8002)

1. cors 문제 발생 상황

Git [snsplus/routes/index.js] - 프런트엔드 화면(Get /을 렌더링하는 부분 추가

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

module.exports = router;

Git [snsplus/views/main.html] - 프런트엔드 화면(Get /을 렌더링하는 부분 추가

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

2. cors 해결 방법

해결 방법

  • 응답 헤더에 Access-Control-Allow-Origin 헤더를 넣어야 함
    Access-Control-Allow-Origin 헤더: 클라이언트 도메인의 요청을 허락
  • npm의 cors 패키지로 해결

Git [sns-api/routes/v2.js]

...
const url = require('url');
...
router.use(async (req, res, next) => {
    // console.log(req.get('origin'));
    // console.log('==============origin==========')
    const domain = await Domain.findOne({ // 도메인 모델로 클라이언트의 도메인(req.get('origin'))과 호스트가 일치하는 것이 있는지 검사
        where: { host: url.parse(req.get('origin')).host}, // http나 https 같은 프로토콜을 떼어낼 때는 url.parse 메서드를 사용
    });
    if (domain) {
        cors({ // 일치하는 것이 있다면 CORS를 허용해서 다음 미들웨어로 보냄
            origin: req.get('origin'), // origin속성: 허용할 도메인을 적음, *처럼 모든 도메인을 허용하지 않고 기입한 도메인만 허용, 여러 개의 도메인은 배열을 사용
                                       // 특정한 도메인만 허용하므로 허용되지 않은 도메인에서 요청을 보내는 것을 차단
            credentials: true,
        }) (req, res, next);
        // 다음의 두 코드는 같은 역할을 함
        // 1. router.user(cors()); 
        // 2. router.use((req, res, next) => {
        // cors()(req, res, next); 
        // });
    } else { // 일치하는 것이 없으면 CORS 없이 next 호출
        next();
    }
});
...

실행화면 - 매우 긴 토큰이 발급된다

{"code":200,"message":"토큰이 발급되었습니다","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmljayI6IuqwkOyekOqwnOuwnOyekCIsImlhdCI6MTY0NDg0OTc3NiwiZXhwIjoxNjQ0ODUxNTc2LCJpc3MiOiJub2RlYmlyZCJ9.AE9QKTccfl7ZvVqFHKWvPJ_TxZzIpPhKYU_BcAXiih0"}
  • OPTIONS 메서드: 실제 요청을 보내기 전 서버가 이 도메인을 허용하는지 체크

  • 응답 헤더에서 Access-Control-Allow-Origin: *로 되어있음
    *: 모든 클라이언트의 요청을 허용한다는 뜻

cors 설치

npm i cors

Git [sns-api/routes/v2.js] - cors 관련 내용 추가

const url = require('url');
...
router.use(async (req, res, next) => {
    const domain = await Domain.findOne({ // 도메인 모델로 클라이언트의 도메인(req.get('origin'))과 호스트가 일치하는 것이 있는지 검사
        where: { host: url.parse(req.get('origin')).host}, // http나 https 같은 프로토콜을 떼어낼 때는 url.parse 메서드를 사용
    });
    if (domain) {
        cors({ // 일치하는 것이 있다면 CORS를 허용해서 다음 미들웨어로 보냄
            origin: req.get('origin'), // origin속성: 허용할 도메인을 적음, *처럼 모든 도메인을 허용하지 않고 기입한 도메인만 허용, 여러 개의 도메인은 배열을 사용
            credentials: true,
        }) (req, res, next);
        // 다음의 두 코드는 같은 역할을 함
        // 1. router.user(cors()); 
        // 2. router.use((req, res, next) => {
        // cors()(req, res, next); 
        // });
    } else { // 일치하는 것이 없으면 CORS 없이 next 호출
        next();
    }
});
...
  • 응답 헤더가 Access-Control-Allow-Origin: http://localhost:4000으로 변경됨

현재는 클라이언트와 서버에서 같은 비밀 키를 써서 문제가 될 수 있다. 카카오 처럼 키를 여러개 두어서 각각 용도에 맞게 사용할 수 있게 해주면 된다.

+카카오의 키

  • REST API: 서버용 비밀 키
  • JavaScript 키: 클라이언트용 비밀 키

잘못된 정보 수정 및 피드백 환영합니다!!

좋은 웹페이지 즐겨찾기