[BookShop 프로젝트] 백엔드 마무리하기

1. 계속 반복되고 있는 코드인 회원 인증부분을 모듈화를 해봅시다. 

 

auth.js

const jwt = require('jsonwebtoken'); //jwt 모듈
const dotenv = require('dotenv'); //dotenv 모듈
dotenv.config(); //dotenv 설정

const ensureAuthorization = (req, res) => {
    try {
        let receivedJwt = req.headers["authorization"];
        console.log(receivedJwt);

        let decodedJwt = jwt.verify(receivedJwt, process.env.PRIVATE_KEY);
        console.log(decodedJwt);

        return decodedJwt;
    } catch (err) {
        console.log(err.name);
        console.log(err.message);

        return err;
    }

}

module.exports = ensureAuthorization;

 

2.  장바구니 아이템 목록 조회 -> 내 장바구니 보기

현재의 코드에서는 무조건 req.body에서 날라오는 selected가 있어야지 답이 들어갑니다.

selected에 값이 없으면, 장바구니 아이템목록이 아예 안나옵니다. 

 

// 장바구니 아이템 목록 조회 
const getCartItems = (req, res) => {
    const {selected} = req.body; // selected = [1,3]

    let authorization = ensureAuthorization(req, res);

    if(authorization instanceof jwt.TokenExpiredError) {
        return res.status(StatusCodes.UNAUTHORIZED).json({
            "message" : "로그인 세션이 만료되었습니다. 다시 로그인 하세요."
        });
    } else if(authorization instanceof jwt.JsonWebTokenError) {
        return res.status(StatusCodes.BAD_REQUEST).json({
            "message" : "잘못된 토큰입니다."
        });
    }
    else {
        let sql = `SELECT cartItems.id, book_id, title, summary, quantity, price 
                        FROM cartItems LEFT JOIN books 
                        ON cartItems.book_id = books.id
                        WHERE user_id = ? AND cartItems.id IN (?);`;
        let values = [authorization.id, selected];
        conn.query(sql, values, 
            (err, results) => {
                if(err) {
                    console.log(err);
                    return res.status(StatusCodes.BAD_REQUEST).end(); 
                }

                return res.status(StatusCodes.OK).json(results);
        })    
    }


}

 

selected 없이 자신의 장바구니 목록을 전부 보여줘야합니다.

// 장바구니 아이템 목록 조회 
const getCartItems = (req, res) => {
    const {selected} = req.body; // selected = [1,3]

    let authorization = ensureAuthorization(req, res);

    if(authorization instanceof jwt.TokenExpiredError) {
        return res.status(StatusCodes.UNAUTHORIZED).json({
            "message" : "로그인 세션이 만료되었습니다. 다시 로그인 하세요."
        });
    } else if(authorization instanceof jwt.JsonWebTokenError) {
        return res.status(StatusCodes.BAD_REQUEST).json({
            "message" : "잘못된 토큰입니다."
        });
    }
    else {
        let sql = `SELECT cartItems.id, book_id, title, summary, quantity, price 
                        FROM cartItems LEFT JOIN books 
                        ON cartItems.book_id = books.id
                        WHERE user_id = ?`;
        let values = [authorization.id, selected];
        if(selected) { // 주문서 작성 시 '선택한 장바구니 목록 조회'
            sql += ` AND cartItems.id IN (?)`;
            values.push(selected);
        } 

        conn.query(sql, values, 
            (err, results) => {
                if(err) {
                    console.log(err);
                    return res.status(StatusCodes.BAD_REQUEST).end(); 
                }

                return res.status(StatusCodes.OK).json(results);
        })    
    }
}

query문에서 selected내용이 있을 경우에만 `AND cartItems.id IN (?)`를 추가하는 것으로 수정하였습니다. 

 

3. 주문 API JWT 추가

    let authorization = ensureAuthorization(req, res);
    if(authorization instanceof jwt.TokenExpiredError) {
        return res.status(StatusCodes.UNAUTHORIZED).json({
            "message" : "로그인 세션이 만료되었습니다. 다시 로그인 하세요."
        });
    } else if(authorization instanceof jwt.JsonWebTokenError) {
        return res.status(StatusCodes.BAD_REQUEST).json({
            "message" : "잘못된 토큰입니다."
        });
    }
    else {

이전에 모듈화를 이미 해주었기 때문에, 이 코드만 추가하면 됩니다. 

const ensureAuthorization = require('../auth'); 
const jwt = require('jsonwebtoken'); //jwt 모듈
const mariadb = require('mysql2/promise'); // mysql 모듈 (promise 버전)
const {StatusCodes} = require('http-status-codes'); //status code 모듈

const order = async (req, res) => {
    const conn = await mariadb.createConnection({
            host: '127.0.0.1',
            user : 'root',
            password : 'root',
            database : 'BookShop',
            dateStrings : true
    });

    let authorization = ensureAuthorization(req, res);
    if(authorization instanceof jwt.TokenExpiredError) {
        return res.status(StatusCodes.UNAUTHORIZED).json({
            "message" : "로그인 세션이 만료되었습니다. 다시 로그인 하세요."
        });
    } else if(authorization instanceof jwt.JsonWebTokenError) {
        return res.status(StatusCodes.BAD_REQUEST).json({
            "message" : "잘못된 토큰입니다."
        });
    }
    else {
        const {items, delivery, totalQuantity, totalPrice, firstBookTitle} = req.body;

        // delivery 테이블 삽입
        let sql = "INSERT INTO delivery (address, receiver, contact) VALUES (?, ?, ?)";
        let values = [delivery.address, delivery.receiver, delivery.contact];
        let [results] = await conn.execute(sql, values);
        let delivery_id = results.insertId;   

        //orders 테이블 삽입 
        sql = `INSERT INTO orders (book_title, total_quantity, total_price, user_id, delivery_id) 
                VALUES (?, ?, ?, ?, ?);`;
        values = [firstBookTitle, totalQuantity, totalPrice, authorization.id, delivery_id];
        [results] = await conn.execute(sql, values);
        let order_id = results.insertId;


        // items를 가지고, 장바구니에서 book_id, quantity 조회
        sql = `SELECT book_id, quantity FROM cartItems WHERE id IN (?);`;
        let [orderItems, fields] = await conn.query(sql, [items]);
        

        //orderedBook 테이블 삽입
        sql = `INSERT INTO orderedBook(order_id, book_id, quantity) VALUES ?;`;

        //items.. 배열 : 요소들을 하나씩 꺼내서 (foreach문 돌려서)
        values = [];
        orderItems.forEach((item) => {
            values.push([order_id, item.book_id, item.quantity]);
        })

        results = await conn.query(sql, [values]);

        let result = await deleteCartItems(conn, items);

        return res.status(StatusCodes.OK).json(result);
    }

}

module.exports = {
    order,
    getOrders,
    getOrderDetail
}

 

4. 로그인이 된 상태에서만 접근가능한 것을 고려

1) 개별 도서 조회 

도서별로 user가 liked했는지 확인해줘야합니다.

로그인이 된 상태에서 조회하면, liked를 포함해서 보내줘야하고,

로그인이 되지 않은 상태에서 조회하면 liked를 포함하지 않아야합니다. 

 

여기서 '로그인이 되지 않은 상태'는 어떻게 조건을 줘야할까요?

const bookDetail = (req, res)=> {

    // 로그인 상태가 아니면 => liked 빼고 보내줌

    // 로그인 상태면 => liked 포함해서 보내줌 (user_id 필요)
    let authorization = ensureAuthorization(req, res);
    if(authorization instanceof jwt.TokenExpiredError) {
        return res.status(StatusCodes.UNAUTHORIZED).json({
            "message" : "로그인 세션이 만료되었습니다. 다시 로그인 하세요."
        });
    } else if(authorization instanceof jwt.JsonWebTokenError) {
        return res.status(StatusCodes.BAD_REQUEST).json({
            "message" : "잘못된 토큰입니다."
        });
    }
    else {
        let book_id = req.params.id;

        let sql = `SELECT *,
                        (SELECT count(*) FROM likes WHERE liked_book_id=books.id) AS likes,
                        (SELECT EXISTS (SELECT * FROM likes WHERE user_id=? AND liked_book_id=?)) AS liked
                    FROM books
                    LEFT JOIN category ON books.category_id = category.category_id
                    WHERE books.id = ?;`;
        let values = [authorization.id, book_id, book_id];
        conn.query(sql, values,
            (err, results) => {
            if(err) {
                console.log(err);
                return res.status(StatusCodes.BAD_REQUEST).end(); 
            }

            if(results[0]){
                return res.status(StatusCodes.OK).json(results[0]);
            }
        
            else {
                return res.status(StatusCodes.NOT_FOUND).end();
            }
        })
    }
};

이 코드를 실행하고, Authorization은 넣어주지 않았을 때의 오류 메시지를 확인해보겠습니다. 

postman의 response를 봐서는

    } else if(authorization instanceof jwt.JsonWebTokenError) {

이 조건에 걸리는 것을 알 수 있고,

 

receivedJwt 가 undefined라는 것을 터미널에서 알 수 있습니다.

 

그래서 Auth.js에서 애초에 receivedJwt가 undefined일 경우의 예외처리를 해주었습니다. 

const jwt = require('jsonwebtoken'); //jwt 모듈
const dotenv = require('dotenv'); //dotenv 모듈
dotenv.config(); //dotenv 설정

const ensureAuthorization = (req, res) => {
    try {
        let receivedJwt = req.headers["authorization"];
        console.log("receivedJwt:", receivedJwt);

        if(receivedJwt){
            let decodedJwt = jwt.verify(receivedJwt, process.env.PRIVATE_KEY);
            console.log("decodedJwt:", decodedJwt);

            return decodedJwt;
        } else {
            throw new ReferenceError("jwt must be provided");
        }

    } catch (err) {
        console.log(err.name);
        console.log(err.message);

        return err;
    }

}

module.exports = ensureAuthorization;

이럴 경우, receivedJwt가 없을 때 ReferenceError가 발생하고, 최종적으로 catch문에서 이 err를 반환합니다. 

 

const bookDetail = (req, res)=> {

    // 로그인 상태가 아니면 => liked 빼고 보내줌

    // 로그인 상태면 => liked 포함해서 보내줌 (user_id 필요)
    let authorization = ensureAuthorization(req, res);
    if(authorization instanceof jwt.TokenExpiredError) {
        return res.status(StatusCodes.UNAUTHORIZED).json({
            "message" : "로그인 세션이 만료되었습니다. 다시 로그인 하세요."
        });
    } else if(authorization instanceof jwt.JsonWebTokenError) {
        return res.status(StatusCodes.BAD_REQUEST).json({
            "message" : "잘못된 토큰입니다."
        });
    } else if(authorization instanceof ReferenceError) {
        let book_id = req.params.id;

        let sql = `SELECT *,
                        (SELECT count(*) FROM likes WHERE liked_book_id=books.id) AS likes,
                    FROM books
                    LEFT JOIN category ON books.category_id = category.category_id
                    WHERE books.id = ?;`;
        let values = [book_id];
        conn.query(sql, values,
            (err, results) => {
            if(err) {
                console.log(err);
                return res.status(StatusCodes.BAD_REQUEST).end(); 
            }

            if(results[0]){
                return res.status(StatusCodes.OK).json(results[0]);
            }
        
            else {
                return res.status(StatusCodes.NOT_FOUND).end();
            }
        })
    }
    else {

그러면 이렇게 ReferenceError일 경우 분기를 하나 더 만들어 따로 관리해줄 수 있게됩니다.

로그인없는 상태에서 조회하면 liked 없이 response가 날라옵니다!

 

5. Authorization 필요한 API에 추가

    let authorization = ensureAuthorization(req, res);
    if(authorization instanceof jwt.TokenExpiredError) {
        return res.status(StatusCodes.UNAUTHORIZED).json({
            "message" : "로그인 세션이 만료되었습니다. 다시 로그인 하세요."
        });
    } else if(authorization instanceof jwt.JsonWebTokenError) {
        return res.status(StatusCodes.BAD_REQUEST).json({
            "message" : "잘못된 토큰입니다."
        });
    }
    else {

 

6. 전체 도서 조회 pagination 조회 

SELECT * FROM BookShop.books LIMIT 4 OFFSET 0;
SELECT count(*) FROM BookShop.books;

이는 이전에 배운 것을 적용하여 페이지네이션을 위해 데이터 조회와 전체 개수 조회를 각각 수행하는 방식입니다.

SELECT SQL_CALC_FOUND_ROWS * FROM BookShop.books LIMIT 4 OFFSET 0;
SELECT found_rows();

이 두가지는 결과는 똑같지만,

 

첫번째의 코드를 사용할 때에는 books를 2번 셀렉트한 거에 반해, 

두 번째 방식은 첫 번째 SELECT 실행 시 전체 row 수까지 함께 계산하고, 이후 FOUND_ROWS()를 통해 이미 계산된 값을 가져오는 구조입니다.

성능 속도를 위해 두 번째를 사용하는 것이 좋겠습니다.

 

 

SELECT found_rows() 의 res.json(results) 결과

// (카테고리 별, 신간 여부) 전체 도서 목록 조회
const allBooks = (req, res)=> {
    let allBooksRes = {};
    let {category_id, news, limit, currentPage} = req.query;

    // limit : page 당 도서 수          ex. 3
    // currentPage : 현재 몇 페이지 인지. ex. 1, 2, 3...
    // offset :                       ex. 0, 3, 6, 9, 12...
    //                                limit * (currentPage - 1)
    let offset = limit * (currentPage - 1);

    let sql = "SELECT SQL_CALC_FOUND_ROWS *, (SELECT count(*) FROM likes WHERE liked_book_id=books.id) AS likes FROM books";
    let values = [];
    if(category_id&&news){
        sql += " WHERE category_id = ? AND pub_date BETWEEN DATE_SUB(NOW(), INTERVAL 1 MONTH) AND NOW()";
        values = [category_id];
    }
    else if(category_id) {
        sql += " WHERE category_id = ?";  
        values = [category_id];
    }      
    else if(news) {
        sql += " WHERE pub_date BETWEEN DATE_SUB(NOW(), INTERVAL 1 MONTH) AND NOW()"
    }

    sql += " LIMIT ? OFFSET ?"; //WHERE절 뒤에 있어야함
    values.push(parseInt(limit), offset);

    // 카테고리 별 시간 조회 
    conn.query(sql, values,
        (err, results) => {
        if(err) {
            console.log(err);
            //return res.status(StatusCodes.BAD_REQUEST).end(); 
        }
        console.log(results);
        if(results.length){
            allBooksRes.books = results;
        }
    
        else {
            return res.status(StatusCodes.NOT_FOUND).end();
        }
    })

    sql = "SELECT found_rows()";
    conn.query(sql, 
        (err, results) => {
        if(err) {
            console.log(err);
            return res.status(StatusCodes.BAD_REQUEST).end(); 
        }

        let pagination = {};
        pagination.currentPage = parseInt(currentPage);
        pagination.totalCount = results[0]['found_rows()'];

        allBooksRes.pagination = pagination;

        return res.status(StatusCodes.OK).json(allBooksRes);
 
    });
};

 

7. response 포맷 통일하기 -> 카멜로! 

sql에서 별칭을 하나하나 지정해주어도 되지만,

            results.map(function(result){
                result.pubDate = result.pub_date;
                delete result.pub_date;
            });

이렇게 pubDate(바꾸고싶은 이름)에 복사해 넣고, 이전의 데이터를 지워서 쉽게 할 수 있습니다.

 

8. StatusCode 확인 

    const {email, password} = req.body;

    let sql = 'UPDATE users SET password=?, salt=? WHERE email=?';
    
    // 암호화된 비밀번호와 salt 값을 같이 DB에 저장
    const salt = crypto.randomBytes(10).toString('base64'); 
    const hashPassword = crypto.pbkdf2Sync(password, salt, 1000, 10, 'sha512').toString('base64'); 

    let values = [hashPassword, salt, email]; 
    conn.query(sql, values,
        (err, results) => {
            if(err) {
                console.log(err);
                return res.status(StatusCodes.BAD_REQUEST).end();
            }
            else {
                return res.status(StatusCodes.OK).json(results);    
            }   
        }
    )

현재 코드에서는 sqlerr만 안나오면 무조건 200OK를 보내주지만, 여러 에러가 있을 수 있습니다.

            if(err) {
                console.log(err);
                return res.status(StatusCodes.BAD_REQUEST).end();
            }
            if(results.affectedRows == 0) {
                return res.status(StatusCodes.BAD_REQUEST).end();
            }
            else {
                return res.status(StatusCodes.OK).json(results);    
            }

이렇게 하면 괜찮겠습니다! 이 코드는 반복이 될테니 모듈화하면 좋을 것같습니다. 앞으로 고도화할 만한 것들을 정리해보겠습니다.

 

[프로젝트 과제] 🔥 코드 퀄리티를 올려보자

  • response 포맷 통일(snake>camel), status code 나머지 다 바꾸기 
  • 데이터베이스 중복 코드 -> 모듈화
    • ex. UserController => User(데이터 모듈=Model) - CRUD
    • cf. DB 모듈 : mysql => 몽구스, 시퀄라이즈 
  • 패키지 구조
    • 1) Router : 경로(URI, URL) 와 HTTP method로 요청에 따른 경로를 찾아주는 역할
    • 2) Controller : 길 매니저 - 요청을 환영, 직접 일하지는 않음
    • 3) Service : 직접 일을 하고 (ex. 어떤 쿼리를 부를지)
    • 4) Model : 데이터 베이스와 소통 (query 집합)
  • 예외처리(try/catch) 더 해줄 곳 없는지
  • 유효성 검사 추가
  • jwt : access token이 만료되면, 로그인 연장? 
    • 로그인 시 access token(ex 30m), refresh token(ex 24h) 발급. -> 30분 지나도 refesh 물어봐서 refresh token 발급해줄 수 있음
    • 1) access token: 로그인 인증
    • 2) refresh token: 로그인 유효기간 연장 토큰
  • 랜덤 데이터 (외부)API 활용해서 isbn 샘플 데이터 채워보기 
  • nodemon 모듈 ...