나의개발일지

[express+cloudinary] 이미지를 저장하자!!! 본문

NodeJS.Express.MongoDB

[express+cloudinary] 이미지를 저장하자!!!

kdy9kdy 2023. 1. 27. 21:37

 

 

 

본 글은 작성자가 어디선가 주워듣고 이해한 내용들을 개인적인 언어로 작성한 게시물입니다.

잘못된 내용이 존재할 수 있으니, 읽게 되신다면 이점을 감안해 주세요!!!

 

우리는 결국 자신이 가진 이야기로 상대방을 이해할 수 있을 뿐이다.-

 

 

 

 

 

인스타그램이나 블로그에 이미지를 업로드하게 되면, 해당 이미지는 어디에 저장되는 것일까?라는 의문을 가져본 적이 있는가???? 나는 사실 별달리 생각이 없었고 당연히 데이터베이스에 저장이 되는 것이 아닌가라고 생각했다. 하지만, 내 생각과는 달리 이미지나 동영상과 같이 용량이 큰 데이터의 경우 데이터베이스에 저장하지 않는다. 어찌 저지 저장을 할 수 있을 수도 있으나 그것은 효율적이지 않다. 또한 데이터베이스에 따라서 허용되는 데이터의 용량이 한정되어 있어서 데이터를 저장하는 것 자체가 불가능할 수 있다. 그렇다면, 우리는 어디에다가 이미지와 동영상과 같은 데이터를 저장해야 할까??? 그런 문제를 해결해 주는 하나의 방법이 s3와 같은 서비스 들이다. aws에서 제공하는 하나의 서비스 중 s3가 있는데, 이는 simple storage service의 약자로 간단하게 사용할 수 있는 저장소 역할을 하는 곳이다. s3 이외에 이번에 사용할 서비스는 cloudinary라는 서비스이다. 유료 버전도 있지만, 무료로도 사용을 할 수 있기 때문에 이번 기회에 한번 사용해 보았다. cloudinary서비스를 이용하기 위해서는 세팅할 내용이 필요하기 때문에 하나하나 따라가면서 간단한 이미지 업로드 프로젝트를 진행해보고자 한다.

 

 

 

프로젝트개요

프로젝트는 동물의 이미지를 포함한 사전을 만드는 것이다. 로그인하지 않으면 동물사전을 볼 수 있다. 로그인한 사용자에 한해서 동물사전에 새로운 동물데이터를 넣을 수 있다. 편집과 삭제는 로그한 사용자여야 하며, 해당 데이터를 생성한 사용자에 한해서 기능을 수행할 수 있다. 그러므로 크게 인증과 인가 기능, 이미지를 포함한 동물 데이터 CRUD가 프로젝트의 큰 진행사항이다. 인증과 인가는 세션을 이용해서 처리하고 데이터베이스는 mongoDB를 이용하며, 마지막으로 이미지처리는 cloudinary를 이용한다.

프로젝트 초기 세팅

if (process.env.NODE_ENV !== "production"){
    require("dotenv").config();
}

const express = require("express");
const app = express()
const mongoose = require("mongoose");
const path = require("path");
const methodOverride = require("method-override");
const session = require("express-session");
const userRouter = require("./routers/users");
const animalRouter = require("./routers/animals");
const User = require("./models/user")

mongoose.connect('mongodb://127.0.0.1:27017/AnimalDictionary',{
    // username의 unique를 위한 설정
    autoIndex: true, //make this also true
    })
    .then(()=>{
        console.log("mongoDB CONNECT");
    })
    .catch((err)=>{
        console.log("OH mongoDB ERR")
        console.log(err)
    });

app.set("views",path.join(__dirname, "views"));
app.set("view engine", "ejs");

app.use(express.urlencoded({extended:true}));
app.use(express.json());
app.use(methodOverride("_method"));


const sessionCongif = {
    secret:"secret",
    resave:false,
    saveUninitialized:true,
    cookie:{
        httpOnly: true,
        expires : Date.now() + 1000 * 60 * 60 * 24 *7, // 만료되는 시점 설정
        maxAge  : 1000 * 60 * 60 * 24 *7 // 얼마동안 유지되는지 설정
    }
}
app.use(session(sessionCongif));

app.use(async(req, res, next)=>{
    const currentUser = await User.findById(req.session.user_id);
    res.locals.currentUser = currentUser;
    req.user = currentUser;
    next()
})

app.get("/homepage", (req,res)=>{
    res.render("homepage")
})

app.use("/", userRouter);
app.use("/animal", animalRouter);

app.use((err, req, res, next)=>{
    const { status=500 } = err;
    if(!err.message){
        err.message = "Something is Wrong";
    }
    res.status(status).render("error", {err});
})

app.listen(3000, ()=>{
    console.log("SERVER START")
})

 

 

회원가입 & 로그인 & 로그아웃

bcrypt를 이용해서 입력받은 패스워드를 암호화하고 express-session을 이용해서 로그인한 유저에게 세션id를 넘겨준다.

//user모델
const mongoose = require("mongoose");
const bcrypt = require("bcrypt");

//스키마 작성
const UserSchema = new mongoose.Schema({
    nickname:{
        type:String,
        unique:true,
        requried:true
    },
    password:{
        type:String,
        required:true
    }
})

//정적메서드로 비밀번호를 비교하는 메서드 작성
UserSchema.statics.findOneAndVaildate = async function(nickname, password){
    const founduser = await this.findOne({nickname});
    if(founduser){
        const isValid = await bcrypt.compare(password, founduser.password);
        return isValid ? founduser : false; 
    }else{
        return false;
    }
};

//유저를 저장하기 데이터베이스 저장하기 전에 bcrypt로 암호화
UserSchema.pre("save", async function(next){
    if(!this.isModified("password")) return next(); 
    this.password = await bcrypt.hash(this.password, 12)
    next();
})

const User = mongoose.model("User", UserSchema);
module.exports = User;

모델을 생성하고 모델에서 수행할 수 있는 미들웨어와 정적메서드를 작성해 주었다. 로직자체를 따로 분리 해도 되지만, 모델에 위와 같이 기능을 분리해서 넣을 수 있다.

//schema.js
const Joi = require("joi")

module.exports.UserSchema = Joi.object({
    nickname:Joi.string().required(),
    password:Joi.string().required(),
})

//middleware.js
module.exports.validateAnimal = (req,res,next)=>{
    const { error } = AnimalSchema.validate(req.body);
    if (error) {
        const msg = error.details.map(el => el.message).join(',')
        throw new ExpressError(msg, 400)
    } else {
        next();
    }
}

Joi모듈을 이용한 body로 들어오는 데이터에 대한 유효성 검사를 실행한다. 스키마를 작성하고 해당 스키마를 이용해서 미들웨어를 작성한다. 해당 미들웨어를 이용해서 라우트핸들러에 콜백함수로 집어넣어 준다.

//user controller
const User = require("../models/user");

module.exports.renderRegister = (req,res)=>{
    res.render("users/register")
}

module.exports.register = async(req,res)=>{
    const {nickname, password} = req.body;
    const user = new User({nickname, password});
    await user.save();
    req.session.user_id = user._id;    
    res.redirect("/homepage")
}

module.exports.renderLogin = (req,res)=>{
    res.render("users/login")
}

module.exports.login = async(req,res)=>{
    const {nickname, password} = req.body;
    const user = await User.findOneAndVaildate(nickname, password);
    if (user){
        req.session.user_id = user._id
        res.redirect("/homepage")
    }else{
        res.redirect("/login")
    }
}

module.exports.logout = async(req,res)=>{
    req.session.destroy()
    res.redirect("/login");
}
// user 라우터
const express = require("express");
const router = express.Router();
const {validateUser} = require("../middleware"); 
const wrapAsync = require("../utils/wrapAsync");
const user = require("../controllers/users")

router.get("/register", user.renderRegister);
router.post("/register", validateUser, wrapAsync(user.register));
router.get("/login", user.renderLogin);
router.post("/login", validateUser, wrapAsync(user.login));
router.post("/logout", wrapAsync(user.logout));

module.exports = router

 

 

클라우디너리 세팅

const cloudinary = require('cloudinary').v2;
const { CloudinaryStorage } = require('multer-storage-cloudinary');
const express = require('express');
//cloudinary의 계정과 코드에서 생성되는 cloudinary의 인스턴스를 연결해준다.
cloudinary.config({
    cloud_name:process.env.CLOUDINARY_CLOUD_NAME,
    api_key:process.env.CLOUDINARY_KEY,
    api_secret:process.env.CLOUDINARY_SECRET
})

// 저장공간에 대한 설정이다.
const storage = new CloudinaryStorage({
  cloudinary: cloudinary,
  params: {
    folder: 'Animal',
    allowedFormats:["jpeg", "png","jpg"],
  },
});
 
module.exports={
    cloudinary,
    storage
}

클라우디너리를 세팅하는 것이다. 클라우디너리로 이루어지는 전체적인 흐름은 다음과 같다. 우선 현재 내 컴퓨터에 있는 이미지 파일을 업로드해서 데이터를 클라우드 너 리에 저장한다. 그리고 클라우디너리로부터 해당 데이터에 대한 path(url)을 받는다. 돌려받은 url을 mongoose와 같은 기존의 데이터베이스에 저장한다. 이번 프로젝트의 경우 animal모델에 images가 있고 해당 images에는 url과 filename이 있다. 둘 다 클라우디너리로부터 돌려받는 데이터들이다. 그리고 필요한 경우 해당 url을 로딩하면 클라우디너리에 저장된 데이터가 불려 오게 된다. 이게 클라우디너리의 전체적인 흐름이라고 할 수 있다. 하지만 클라우디를 사용하기 위해서는 기본적인 세팅이 필요하다.

 

먼저 현재 우리가 주고 받는 일반적인 데이터의 형태로는 이미지를 업로드할 수 없다. 폼으로 주고받는 데이터의 형태는 application/x-www-form-urlencoded 또는 application/json형태이다. 그래서 앞에서 데이터를 파싱 할 수 있게 해 주었다. 이미지 데이터를 전송하기 위해서는 multipart/form-data로 설정을 해주어야 한다.  인코딩타입 속성과 관련된 문제인 것이다. 이를 위해서 사용되는 모듈이 multer이다. multer를 이용해서 multipart/form-data를 파싱 할 수 있게 된다. 그리고 또 같이 사용할 수 있는 모듈이  multer-stroage-cloudinary 모듈이다. 해당 모듈을 이용해서 저장공간에 대한 설정을 한다.

 

위의 세팅에서는 dotenv모듈을 인스톨해서 클라우디너리를 사용할 때 필요한 config를 설정해 준다. 그리고 설정한 클라우드너리 인스턴스를 multer-stroage-cloudinary에  저장공간의 설정으로 넣어준다. 이렇게 하면 클라우드 너 리에 대한 설정은 끝이 난다. 실제 데이터를 집어넣는 과정에서 multer를 이용해서 데이터를 파싱하고 파싱 한 데이터는 메서드에 따라 req.file 또는 req.files에 들어오게 된다.

 

Animal CRUD

//animal 모델
const mongoose = require("mongoose");
const {cloudinary} = require("../cloudinary/index");

// 아래의 ImageSchema를 넣는 경우
const ImageSchema = new mongoose.Schema({
    url:String,
    filename:String
})

// 이미지 크기 조정을 위한 가상속성 설정
ImageSchema.virtual("arrageImageSize").get(function(){
    return this.url.replace("/upload", "/upload/w_300");
})

const AnimalSchema = new mongoose.Schema({
    name:String,
    description:String,
    lifespan:Number,
    // images:{
    //     url:String,
    //     filename:String
    // }
    images : [ImageSchema],
    user :{
        type:mongoose.Schema.Types.ObjectId,
        ref:"User"
    }
})

AnimalSchema.post("findOneAndDelete", async function(doc){
    for (let image of doc.images){
        await cloudinary.uploader.destroy(image.filename);
    }
})

const Animal = mongoose.model("Animal", AnimalSchema);
module.exports = Animal;

Animal모델을 정의했다. 모델에서 중요한 사항은 images에 대한 정의를 별도의 스키마를 작성해서 넣었다는 것이다. 다중파일을 넣기 위해서 해당 images는 리스트 형태로 각각의 이미지 오브젝트가 들어오게 된다. 다른 중요한 점은 가상의 속성을 설정해서 이미지의 크기를 불러올 때 변경해서 불러올 수 있게 작성했다. 다음은 로직 상에서 findByIdAndDelete를 사용했는데 해당 메서드가 트리거로서 미들웨어가 불리는 형태이다. 해당 로직은 클라우디너리에 있는 데이터를 삭제하기 위한 로직이다. 동물과 관련된 내용이 삭제되면 해당 동물과 관련된 클라우디너리의 데이터도 삭제해야 한다.

//animal라우터
const express = require("express");
const router = express.Router();
const wrapAsync = require("../utils/wrapAsync")
const animals = require("../controllers/animals")
const {isLogin, isValidUser} = require("../middleware");
//설정한 cloudinary를 불러온다.
const {storage} = require("../cloudinary/index") 

//multer설정 저장 위치를 설정한다. 현재는 로컬로 되어 있다.
const multer = require("multer");

// const upload = multer({dest:"uploads/"})
const upload = multer({storage});
// upload에는 single와 array 등이 있다.
// 위의 메서드를 사용하게 되면 multer가 body를 파싱하게 된다.

router.get("/", wrapAsync(animals.animalList));
router.get("/new", isLogin, animals.renderCreateAnimal);
router.post("/", isLogin, upload.array("image"),  wrapAsync(animals.createAnimal));
router.get("/:id", wrapAsync(animals.detailAnimal));
router.get("/:id/edit", isLogin, isValidUser, wrapAsync(animals.renderEditAnimal));
router.put("/:id", isLogin, isValidUser,  upload.array("image"), wrapAsync(animals.editAnimal))
router.delete("/:id", isLogin, isValidUser, wrapAsync(animals.deleteAnimal));

module.exports = router;

위에서 클라우드너리의 저장공간 세팅 한 것을 불러와서 multer를 이용해서 저장할 위치에 넣어준다. 이렇게 하면 multer에 의해 파싱 돼서 저장되는 위치가 클라우드너리가 된다. 다음은 실제 post나 put을 수행 시 multer를 이용해서 파일을 업로드하는 것이다. 단일 파일의 경우는 upload.single("image") 다중 파일의 경우는 upload.array("image")를 이용한다. "image"는 폼에서 이름에 해당한다. 

 

//animal 컨트롤러
const Animal = require("../models/animal");
const {cloudinary} = require("../cloudinary/index");

module.exports.animalList = async(req,res)=>{
    const animals = await Animal.find({});
    res.render("animals/index", {animals});
}

module.exports.renderCreateAnimal = (req,res)=>{
    res.render("animals/new");
}

module.exports.createAnimal = async(req,res)=>{
    const animal = new Animal(req.body)
    // 단일 파일을 받는 경우라면 현재의 모델로 수행하고 다중 파일이라면 아래의 코드를 실행해야 한다.
    // const {path, filename} = req.file;
    // animal.images.url = path
    // animal.images.filename = filename
    
    // files로 받는 경우라면 모델에서 images를 배열로 만들고 아래를 수행해야 한다.
    animal.images = req.files.map(f=>({url:f.path, filename:f.filename}))
    animal.user = req.user; 
    await animal.save()
    res.redirect("/animal")
}

module.exports.detailAnimal = async(req,res)=>{
    const {id} = req.params;
    const animal = await Animal.findById(id).populate("user","-password");
    res.render("animals/detail", {animal})
}

module.exports.renderEditAnimal = async(req,res)=>{
    const {id} = req.params;
    const animal = await Animal.findById(id);
    res.render("animals/edit", {animal})
}

module.exports.editAnimal = async(req,res)=>{
    const {id} = req.params;
    const animal = await Animal.findByIdAndUpdate(id,{...req.body}, {new:true})
    const imgs = req.files.map(f=>({url:f.path, filename:f.filename}));
    animal.images.push(...imgs);
    if(req.body.deleteImages){
        // cloudly 이미지 삭제하기
        for (let filename of req.body.deleteImages){
            await cloudinary.uploader.destroy(filename);
        }
        await animal.updateOne({$pull:{images:{filename:{$in:req.body.deleteImages}}}})
    }
    await animal.save();  
    res.redirect(`/animal/${id}`)
}

module.exports.deleteAnimal = async(req,res)=>{
    const {id} = req.params;
    const animal = await Animal.findByIdAndDelete(id)
    res.redirect("/animal")
}

createAnimal을 보면 req.files에서 받아온 데이터를 map을 이용해서 하나씩 꺼내어 images에 넣어준다. 그리고 권한 설정을 위해서 animal.user에 session에서 req.user를 넣어준다. req.user는 직접 설정해 준 것으로 프로젝트 세팅 부분에 보면 미들웨어를 이용해서 설정해 주었다. editAnimal에서도 이미지를 추가해 주는 것은 위와 같은 맥락이다. 단, 기존에 이미지가 있기 때문에 새로운 이미지를 array에 push 해준다. 그리고 편집 시 기존의 이미지를 삭제할 수도 있는데 이는 updateOne() 메서드를 이용해서 deleteImages 리스트에 담긴 filename에 해당하는 것들을 삭제하는 것이다. 이때 데이터베이스뿐 만 아니라 클라우드너리에서도 삭제해 준다. deleteAnimal의 경우는 findByIdAndDelete가 위에서 정의한 미들웨어를 불러서 삭제 후에 해당 animal에 있는 클라우드너리 이미지 파일을 모두 삭제하게 한다.

 

 

미들웨어 

// 로그인 확인 미들웨어
module.exports.isLogin = (req,res, next)=>{
    if(!req.session.user_id){
        return res.redirect("/login")
    }
    next();
}

// 권한 확인 미들웨어
module.exports.isValidUser = async(req,res,next)=>{
    const {id} = req.params;
    const animal = await Animal.findById(id);
    if (!animal.user.equals(req.user._id)){
        return res.redirect(`/animal/${id}`)
    }
    next()    
}

세션을 이용한 로그인 처리를 했기 때문에 sesison에 있는 user_Id가 없는 경우는 로그인이 되지 않은 상태이므로 로그인을 요구하게 된다. 해당 미들웨어는 라우트핸들러에 콜백함수로 넣어 줄 수 있다. 다음음 권한 확인 미들웨어로 Animal을 만든 유저와 현재 session에 저장된 유저가 일치하지 않다면 권한이 없다는 로직을 구현했다. 해당 미들웨어 역시 라우트핸들러에 콜백함수로 전달할 수 있다.

 

 

 

정리하자면

사실 클라우디너리를 사용하는 방법을 중점적으로 알아보려고 프로젝트를 진행하려고 했는데, 어쩌다 보니까 인증과 권한 확인이 메인이 된 것 같다. 그런데 이외에 클라우디너리와 관련돼서 작성하고자 하는 내용이 없기 때문에(아마 모르기 때문에) 이것으로 마무리해야겠다. 간단한 프로젝트를 진행하면서 삭제나 편집 시 단순히 데이터베이스에 대한 처리 이외에 외부 저장소를 사용하게 되면 해당 저장소(클라우디너리)에 대한 처리 역시 해주어야 한다는 것을 이해하게 되었다. 

 

 

 

 


본글은 작성자가 공부하면서 정리하고자 작성한 글입니다.

잘못된 내용과 부족한 내용이 많이 포함될 수 있습니다.

오늘도 재밌게 했는데 뭔가 설명보다는 코드만 늘어놓은 거 같네.....

힘들다 힘들다.