[2024.05.16 TIL] 내일배움캠프 22일차 (개인 과제, Express, CRUD, 배포)

My_Code·2024년 5월 16일
0

TIL

목록 보기
28/112
post-thumbnail

본 내용은 내일배움캠프에서 활동한 내용을 기록한 글입니다.


💻 TIL(Today I Learned)

📌 Today I Done

✏️ 요구 사항 파악 및 스키마 정의

  • MongoDB 스키마 정의
import mongoose from 'mongoose';

const productsSchema = mongoose.Schema({
    name: {
        type: String,
        required: true,
    },
    description: {
        type: String,
        required: true,
    },
    manager: {
        type: String,
        required: true,
    },
    password: {
        type: String,
        required: true,
    },
    status: {
        type: String,
        required: false,
    },
    createdAt: {
        type: Date,
        required: false,
    },
    updatedAt: {
        type: Date,
        required: false,
    },
});

export default mongoose.model('Products', productsSchema);

✏️ 상품 생성 API

  • 상품명, 상품 설명, 담당자, 비밀번호를 Request body(req.body)로 전달 받음

  • 상품 ID는 전달 받지 않고, 자동으로 생성 (_id)

  • 상품 상태는 판매 중(FOR_SALE)및 판매 완료(SOLD_OUT)만 가능

  • 상품 등록 시 기본 상태는 판매 중(FOR_SALE)

  • 생성 일시, 수정 일시를 자동으로 생성

// 상품 등록 API
router.post('/products', async (req, res, next) => {
    try {
        // 유효성 검사를 마친후 상품 객체를 반환 받음
        const validation = await createSchema.validateAsync(req.body);
        // 객체 구조 분해 할당
        const { name, description, manager, password } = validation;

        // 같은 상품명 있는지 체크
        const searchData = await Products.find({ name }).exec();
        if (searchData.length !== 0) {
            return res.status(400).json({ status: 400, message: '이미 등록 된 상품입니다.' });
        }

        // 상품 등록하기
        const product = new Products({
            name,
            description,
            manager,
            password,
            status: 'FOR_SALE',
            createdAt: new Date(),
            updatedAt: null,
        });
        await product.save();

        // 비밀번호는 제외하고 출력하기 위해 사용
        const copyProduct = JSON.parse(JSON.stringify(product));
        delete copyProduct.password;

        return res.status(201).json({ status: 201, message: '상품 생성에 성공했습니다.', data: copyProduct });
    } catch (err) {
        next(err);
    }
});

✏️ 상품 목록 조회 API

  • 상품 ID, 상품명, 상품 설명, 담당자, 상품 상태, 생성 일시, 수정 일시 를 조회

  • 비밀번호를 포함X

  • 생성 일시를 기준으로 내림차순(최신순) 정렬

// 상품 목록 조회 API
router.get('/products', async (req, res, next) => {
    // 데이터베이스에서 상품 목록 데이터 가져오기 (password는 빼고)
    const productsList = await Products.find({}, { password: 0 }).sort('-createdAt').exec();

    return res.status(200).json({ status: 200, message: '상품 목록 조회에 성공했습니다.', data: productsList });
});

✏️ 상품 상세 조회 API

  • 상품 ID를 Path Parameter(req.params)로 전달 받음

  • 상품 ID, 상품명, 상품 설명, 담당자, 상품 상태, 생성 일시, 수정 일시 를 조회

  • 비밀번호를 포함X

// 상품 상세 조회 API
router.get('/products/:productId', checkProductMiddleware, async (req, res, next) => {
    // url에서 productId 값 가져오기
    const { productId } = req.params;

    // 데이터베이스에서 productId 값 기반으로 상품 데이터 가져오기 (password는 빼고)
    const product = await Products.findById(productId, { password: 0 }).exec();

    return res.status(200).json({ status: 200, message: '상품 상세 조회에 성공했습니다.', data: product });
});

✏️ 상품 수정 API

  • 상품 ID를 Path Parameter(req.params)로 전달 받음

  • 상품명, 상품 설명, 담당자, 상품 상태, 비밀번호를 Request body(req.body)로 전달 받음

  • 수정할 상품과 비밀번호 일치 여부를 확인

// 상품 정보 수정 API (patch는 일부 수정할 때 사용)
router.patch('/products/:productId', checkProductMiddleware, async (req, res, next) => {
    try {
        const { productId } = req.params;

        // 유효성 검사를 마친후 상품 객체를 반환 받음
        const validation = await updateSchema.validateAsync(req.body);
        // 객체 구조 분해 할당
        const { name, description, manager, password, status } = validation;

        // 데이터베이스에서 productId 기반으로 데이터 가져오기
        const product = await Products.findById(productId).exec();

        // 입력한 비밀번호와 상품 비밀번호가 같은지 확인
        if (password !== product.password) {
            return res.status(401).json({ status: 401, message: '비밀번호가 일치하지 않습니다.' });
        }

        // 같은 상품명 있는지 체크
        const searchData = await Products.find({ name }).exec();
        if (searchData.length !== 0) {
            return res.status(400).json({ status: 400, message: '이미 등록 된 상품입니다.' });
        }

        // 상품 정보 수정
        // 비밀번호를 제외한 나머지는 필수가 아니기에 기입이 되지 않으면 기본 값 사용
        product.name = name ? name : product.name;
        product.description = description ? description : product.description;
        product.manager = manager ? manager : product.manager;
        product.status = status ? status : product.status;
        product.updatedAt = new Date();
        await product.save();

        // 비밀번호는 제외하고 출력하기 위해 사용
        const copyProduct = JSON.parse(JSON.stringify(product));
        delete copyProduct.password;

        return res.status(200).json({ status: 200, message: '상품 수정에 성공했습니다.', data: copyProduct });
    } catch (err) {
        next(err);
    }
});

✏️ 상품 삭제 API

  • 상품 ID를 Path Parameter(req.params)로 전달 받음

  • 비밀번호를 Request body(req.body)로 전달 받음

  • 삭제할 상품과 비밀번호 일치 여부를 확인

// 상품 정보 삭제 API
router.delete('/products/:productId', checkProductMiddleware, async (req, res, next) => {
    try {
        const { productId } = req.params;

        // 유효성 검사를 마친후 상품 객체를 반환 받음
        const validation = await deleteSchema.validateAsync(req.body);
        // 객체 구조 분해 할당
        const { password } = validation;

        const product = await Products.findById(productId).exec();

        // 입력한 비밀번호와 상품 비밀번호가 같은지 확인
        if (password !== product.password) {
            return res.status(401).json({ status: 401, message: '비밀번호가 일치하지 않습니다.' });
        }

        // 해당 상품 삭제
        await Products.deleteOne({ _id: productId });

        return res.status(200).json({ status: 200, message: '상품 삭제에 성공했습니다.', data: { id: productId } });
    } catch (err) {
        next(err);
    }
});

✏️ 에러 처리 (에러 처리 미들웨어)

  • 상품 상세 조회, 수정, 삭제 시 상품이 없는 경우에 사용하는 에러 처리 미들웨어
import Products from '../schemas/products.schema.js';

export default async (req, res, next) => {
    const { productId } = req.params;

    try {
        const product = await Products.findById(productId).exec();

        if (!product) {
            return res.status(404).json({ status: 404, message: '상품이 존재하지 않습니다.' });
        }
        next();
    } catch (err) {
        next(err);
    }
};

✏️ 유효성 검증 (Joi)

  • 상품 등록, 수정, 삭제 시 사용하는 유효성 검사가 달라서 따로 구현
import Joi from 'joi';

// Joi 라이브러리를 이용한 상품 등록 유효성 검사
export const createSchema = Joi.object({
    name: Joi.string().min(1).max(10).required().messages({
        'string.base': 'name은 문자열이어야 합니다.',
        'string.max': 'name은 최대 10글자여야 합니다.',
        'string.min': 'name은 최소 1글자여야 합니다.',
        'string.empty': 'name을 입력해주세요.',
        'any.required': 'name을 입력해주세요.',
    }),
    description: Joi.string().min(1).max(100).required().messages({
        'string.base': 'description은 문자열이어야 합니다.',
        'string.max': 'description은 최대 100글자여야 합니다.',
        'string.min': 'description은 최소 1글자여야 합니다.',
        'string.empty': 'description을 입력해주세요.',
        'any.required': 'description을 입력해주세요.',
    }),
    manager: Joi.string().min(2).max(10).required().messages({
        'string.base': 'manager는 문자열이어야 합니다.',
        'string.max': 'manager는 최대 10글자여야 합니다.',
        'string.min': 'manager는 최소 2글자여야 합니다.',
        'string.empty': 'manager를 입력해주세요.',
        'any.required': 'manager를 입력해주세요.',
    }),
    password: Joi.string()
        .required()
        .pattern(new RegExp('^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$'))
        .messages({
            'string.base': 'password는 문자열이어야 합니다.',
            'string.empty': 'password를 입력해주세요.',
            'any.required': 'password를 입력해주세요.',
            'string.pattern.base': 'password가 형식에 맞지 않습니다. (영문, 숫자, 특수문자 포함 8~15자)',
        }),
    status: Joi.string().default('FOR_SALE').valid('FOR_SALE', 'SOLD_OUT').messages({
        'string.base': 'status는 문자열이어야 합니다.',
        'any.only': 'status는 [FOR_SALE, SOLD_OUT] 중 하나여야 합니다.',
    }),
});

// Joi 라이브러리를 이용한 상품 정보 수정 유효성 검사
export const updateSchema = Joi.object({
    name: Joi.string().min(1).max(10).messages({
        'string.base': '상품명(name)은 문자열이어야 합니다.',
        'string.max': '상품명(name)은 최대 10글자여야 합니다.',
        'string.min': '상품명(name)은 최소 1글자여야 합니다.',
    }),
    description: Joi.string().min(1).max(100).messages({
        'string.base': '상품설명(description)은 문자열이어야 합니다.',
        'string.max': '상품설명(description)은 최대 100글자여야 합니다.',
        'string.min': '상품설명(description)은 최소 1글자여야 합니다.',
    }),
    manager: Joi.string().min(2).max(10).messages({
        'string.base': '관리자(manager)는 문자열이어야 합니다.',
        'string.max': '관리자(manager)는 최대 10글자여야 합니다.',
        'string.min': '관리자(manager)는 최소 2글자여야 합니다.',
    }),
    password: Joi.string()
        .required()
        .pattern(new RegExp('^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$'))
        .messages({
            'string.base': '비밀번호(password)는 문자열이어야 합니다.',
            'string.empty': '비밀번호(password)를 입력해주세요.',
            'any.required': '비밀번호(password)를 입력해주세요.',
            'string.pattern.base': '비밀번호(password)가 형식에 맞지 않습니다. (영문, 숫자, 특수문자 포함 8~15자)',
        }),
    status: Joi.string().valid('FOR_SALE', 'SOLD_OUT').messages({
        'string.base': '상품상태(status)는 문자열이어야 합니다.',
        'any.only': '상품상태(status)는 [FOR_SALE, SOLD_OUT] 중 하나여야 합니다.',
    }),
});

// Joi 라이브러리를 이용한 상품 삭제 유효성 검사
export const deleteSchema = Joi.object({
    password: Joi.string()
        .required()
        .pattern(new RegExp('^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$'))
        .messages({
            'string.base': '비밀번호(password)는 문자열이어야 합니다.',
            'string.empty': '비밀번호(password)를 입력해주세요.',
            'any.required': '비밀번호(password)를 입력해주세요.',
            'string.pattern.base': '비밀번호(password)가 형식에 맞지 않습니다. (영문, 숫자, 특수문자 포함 8~15자)',
        }),
});

✏️ Insomnia로 API 테스트하기

  • 상품 등록 API
  • 상품 목록 조회 API
  • 상품 상세 조회 API
  • 상품 수정 API
  • 상품 삭제 API

✏️ AWS E2C + Gabia를 통해 배포하기

  • AWS EC2 인스턴스에 프로젝트를 배포

  • PM2를 이용해 터미널을 종료하더라도 서버가 실행될 수 있도록 설정

  • Gabia 또는 AWS Route 53을 이용해 도메인 주소를 연결

  • http://mymycode.shop:3000/api/products/


📌 Tomorrow's Goal

✏️ 개인 과제 문서 작업 후 제출하기

  • 깃허브 readme 파일 작성하기

  • 오늘 작성한 TIL를 기반으로 작성하면 될 것 같음

  • 그리고 과제로 나온 10가지 문항에 대한 답변도 적기

  • 마지막으로 제출 전 체크 리스트 살펴보기



📌 Today's Goal I Done

✔️ 개인 과제 구현하기

  • 오늘은 기존에 목표한 상품 CRUD API를 모두 구현함

  • 구현 자체는 강의 내용을 기반으로 하기에 크게 어렵진 않았음

  • 오히려 유효성 검사를 구현하는 곳에서 시간이 오래 걸림

  • 그리고 중복되는 에러 처리들을 미들웨어로 빼서 사용하는 방법에 대해서도 약간이지만 알게 되는 시간이었음



⚠️ 구현 시 발생한 문제

✔️ 에러처리 중 중복된 코드가 보임

  • 상품의 ID 값을 req.params로 받아서 사용함

  • 상품의 ID를 이용해서 데이터베이스에서 해당 상품이 있는지 판별함

  • 이 코드가 짧긴해도 상품 상세 조회, 상품 수정, 상품 삭제 API에서 모두 사용되는 에러 처리 코드

  • 그래서 이 중복되는 코드를 미들웨어로 빼서 공통적으로 적용시키는 방법을 모색함

  • 튜터님께서 말씀해주신 방법은 해당 라우터가 실행되기 전에 미들웨어로 에러처리를 하라고 하셨음

  • 하지만 클라이언트에게 중복되게 출력하는 문제 때문에 다른 방법을 찾음

  • 바로 직접 router.get(...) 코드 안에 미들웨어를 넣어서 동작시키는 방법을 사용함

// /middlewares/check.product.middleware.js

import Products from '../schemas/products.schema.js';

export default async (req, res, next) => {
    const { productId } = req.params;

    try {
        const product = await Products.findById(productId).exec();

        if (!product) {
            return res.status(404).json({ status: 404, message: '상품이 존재하지 않습니다.' });
        }
        next();
    } catch (err) {
        next(err);
    }
};
// /routes/products.route.js

// 상품 상세 조회 API
router.get('/products/:productId', checkProductMiddleware, async (req, res, next) => {
    // url에서 productId 값 가져오기
    const { productId } = req.params;

    // 데이터베이스에서 productId 값 기반으로 상품 데이터 가져오기 (password는 빼고)
    const product = await Products.findById(productId, { password: 0 }).exec();

    return res.status(200).json({ status: 200, message: '상품 상세 조회에 성공했습니다.', data: product });
});
  • 위 코드처럼 checkProductMiddleware를 사용하면 라우터가 실행되기 전에 checkProductMiddleware 미들웨어가 동작 후 라우터 안의 코드들이 동작함

✔️ 입력받은 내용마다 에러처리를 해야해서 코드가 길어짐

  • 기존에는 Joi 라이브러리를 통해서 Joi가 생성해주는 에러 메시지를 사용함

  • 하지만 조금 더 가독성 좋은 에러 메시지를 위해서 다른 방법을 모색함

  • 결국 Joi 라이브러리에서 커스텀 메시지를 작성하는 메서드를 제공함

  • 그래서 에러 처리 핸들러에서 isJoi를 통해서 에러가 Joi를 통해서 들어온 에러인지 판별함

  • Joi의 커스텀 에러 메시지를 사용하면 각 에러마다 나만의 에러 메시지를 사용할 수 있음

  • 하지만 내용에 따라서 길어질 수 있기에 파일을 따로 관리하는 게 좋음

  • https://velog.io/@mero/joi-messages-%EA%B8%B0%EB%8A%A5-%ED%99%9C%EC%9A%A9

  • https://joi.dev/api/?v=17.13.0

import Joi from 'joi';

// Joi 라이브러리를 이용한 상품 등록 유효성 검사
export const createSchema = Joi.object({
    name: Joi.string().min(1).max(10).required().messages({
        'string.base': 'name은 문자열이어야 합니다.',
        'string.max': 'name은 최대 10글자여야 합니다.',
        'string.min': 'name은 최소 1글자여야 합니다.',
        'string.empty': 'name을 입력해주세요.',
        'any.required': 'name을 입력해주세요.',
    }),
    description: Joi.string().min(1).max(100).required().messages({
        'string.base': 'description은 문자열이어야 합니다.',
        'string.max': 'description은 최대 100글자여야 합니다.',
        'string.min': 'description은 최소 1글자여야 합니다.',
        'string.empty': 'description을 입력해주세요.',
        'any.required': 'description을 입력해주세요.',
    }),
    manager: Joi.string().min(2).max(10).required().messages({
        'string.base': 'manager는 문자열이어야 합니다.',
        'string.max': 'manager는 최대 10글자여야 합니다.',
        'string.min': 'manager는 최소 2글자여야 합니다.',
        'string.empty': 'manager를 입력해주세요.',
        'any.required': 'manager를 입력해주세요.',
    }),
    password: Joi.string()
        .required()
        .pattern(new RegExp('^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$'))
        .messages({
            'string.base': 'password는 문자열이어야 합니다.',
            'string.empty': 'password를 입력해주세요.',
            'any.required': 'password를 입력해주세요.',
            'string.pattern.base': 'password가 형식에 맞지 않습니다. (영문, 숫자, 특수문자 포함 8~15자)',
        }),
    status: Joi.string().default('FOR_SALE').valid('FOR_SALE', 'SOLD_OUT').messages({
        'string.base': 'status는 문자열이어야 합니다.',
        'any.only': 'status는 [FOR_SALE, SOLD_OUT] 중 하나여야 합니다.',
    }),
});

// Joi 라이브러리를 이용한 상품 정보 수정 유효성 검사
export const updateSchema = Joi.object({
    name: Joi.string().min(1).max(10).messages({
        'string.base': '상품명(name)은 문자열이어야 합니다.',
        'string.max': '상품명(name)은 최대 10글자여야 합니다.',
        'string.min': '상품명(name)은 최소 1글자여야 합니다.',
    }),
    description: Joi.string().min(1).max(100).messages({
        'string.base': '상품설명(description)은 문자열이어야 합니다.',
        'string.max': '상품설명(description)은 최대 100글자여야 합니다.',
        'string.min': '상품설명(description)은 최소 1글자여야 합니다.',
    }),
    manager: Joi.string().min(2).max(10).messages({
        'string.base': '관리자(manager)는 문자열이어야 합니다.',
        'string.max': '관리자(manager)는 최대 10글자여야 합니다.',
        'string.min': '관리자(manager)는 최소 2글자여야 합니다.',
    }),
    password: Joi.string()
        .required()
        .pattern(new RegExp('^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$'))
        .messages({
            'string.base': '비밀번호(password)는 문자열이어야 합니다.',
            'string.empty': '비밀번호(password)를 입력해주세요.',
            'any.required': '비밀번호(password)를 입력해주세요.',
            'string.pattern.base': '비밀번호(password)가 형식에 맞지 않습니다. (영문, 숫자, 특수문자 포함 8~15자)',
        }),
    status: Joi.string().valid('FOR_SALE', 'SOLD_OUT').messages({
        'string.base': '상품상태(status)는 문자열이어야 합니다.',
        'any.only': '상품상태(status)는 [FOR_SALE, SOLD_OUT] 중 하나여야 합니다.',
    }),
});

// Joi 라이브러리를 이용한 상품 삭제 유효성 검사
export const deleteSchema = Joi.object({
    password: Joi.string()
        .required()
        .pattern(new RegExp('^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$'))
        .messages({
            'string.base': '비밀번호(password)는 문자열이어야 합니다.',
            'string.empty': '비밀번호(password)를 입력해주세요.',
            'any.required': '비밀번호(password)를 입력해주세요.',
            'string.pattern.base': '비밀번호(password)가 형식에 맞지 않습니다. (영문, 숫자, 특수문자 포함 8~15자)',
        }),
});

✔️ 리눅스에서 app.js 서버를 켜면 몽고DB 에러 발생

  • 리눅스 서버를 통해서 서버를 실행하려고 하니 위와 같은 에러가 발생함

  • 튜터님께 여쭤보니 .env 파일을 통해서 몽고DB의 URI값을 관리하는데, .env 파일은 .gitignore에 등록했기 때문에 리눅스 서버 상에는 존재하지 않아서 에러가 발생한다고 말씀하셨음

  • 즉, 리눅스 서버에도 .env 파일이 필요하다는 뜻

  • 그래서 리눅스 명령어를 통해서 직접 .env 파일을 만들어서 몽고DB의 URI값을 넣어줌

  • vim .env //파일이 없으면 생성, 있으면 수정 또는 추가

i ##입력모드로 전환
:q ## 종료한다
:q!	##저장하지 않고 강제로 종료
:wq	##저장하고 종료한다.
profile
조금씩 정리하자!!!

0개의 댓글