[TIL] 211205

63906 단어 node.jsmongodbTILTIL

📝 오늘 한 것

  1. 비디오 파일 업로드 - multer middleware

  2. 사용자 프로필 - populate() ( video owner / user videos )


📚 배운 것

user profile

1. video 업로드

1) input(type="file")

//- upload.pug

extends base

block content
  if errorMessage
    span=errorMessage
form(method="POST", enctype="multipart/form-data")
  label(for="video") Video File
  input(name="video", type="file", accept="videos/*", id="video")
//- 이하 생략

2) multer middleware

전에 만들었던 uploadFiels middleware를 각각 파일 크기 제한 옵션을 추가하여 uploadAvatar와 uploadVideo middleware로 나누어 만들었다.

// middlewares.js
export const uploadAvatar = multer({ dest: "uploads/avatars/"}, { limits: { filesize: 5000000 }}); // 5MB 제한

export const uploadVideo = multer({ dest: "uploads/videos/"}, { limits: { filesize: 10000000 }}); // 10MB 제한

3) videoRouter

// videoRouter.js
import { uploadVideo } from "../middlewares";

videoRouter.route("/upload").all(protectorMiddleware).get(getUpload).post(uploadVideo.single("video"), postUpload);

4) videoSchema, postUpload 컨트롤러

Video.js 파일에서 videoSchema에 fileUrl을 추가했다.

// Video.js
const videoSchema = new mongoose.Schema({
  title: { type: String, required: true, trim: true, maxLength: 80 },
  fileUrl: { type: String, required: true },
  description: { type: String, required: true, trim: true, minLength: 20 },
  createdAt: { type: Date, required: true, default: Date.now },
  hashtags: [{ type: String, trim: true }],
  mata: {
    views: { type: Number, required: true, default: 0 },
    rating: { type: Number, required: true, default: 0 },
  },
});

videoController.js 파일에서 postUpload 컨트롤러를 수정했다.

// videoController.js
export const postUpload = async (req, res) => {
  const {
    body: { title, description, hashtags },
    file, // 추가 ❗
  } = req;
  try {
    await Video.create({
      title,
      fileUrl: file.path, // 추가 ❗
      description,
      hashtags,
    });
  } catch(error) {
    return res.render("upload", { pageTitle: "Upload Video", errorMessage: error._message });
  }
  return res.redirect("/");
};

이는 ES6 문법을 이용해 다음과 같이 바꿔볼 수도 있다.

export const postUpload = async (req, res) => {
  const { path: fileUrl } = req.file; // 수정 ❗
  const { title, description, hashtags } = req.body; // 수정 ❗
  try {
    await Video.create({
      title,
      fileUrl, // 수정 ❗
      description,
      hashtags,
    });
  } catch(error) {
    return res.render("upload", { pageTitle: "Upload Video", errorMessage: error._message });
  }
  return res.redirect("/");
};

5) video mixins, watch.pug 수정

video.pug와 watch.pug 파일에 video 태그를 추가했다.

//- video.pug

mixin video(video)
  div 
    h4
      a(href=`/videos/${video.id}`)=video.title
    video(src="/" + video.fileUrl, width="800", controls)
    p=video.description
    ul
      each hashtag in video.hashtags
        li=hashtag
    small=video.createdAt
    hr
//- watch.pug

extends base

block content
  video(src="/" + video.fileUrl, width="800", controls)
  div
    p=video.description
    small=video.createdAt
  a(href=`${video.id}/edit`) Edit Video →
  br
  a(href=`${video.id}/delete`) Delete Video →

이제 video 파일을 업로드 하면 video가 뜨는 것을 확인할 수 있다.


2. 사용자 프로필

사용자가 다른 사용자의 프로필을 클릭하면 해당 사용자의 이름, 아바타, 업로드한 영상 등을 볼 수 있도록 만들 것이다.

영상 밑에 영상을 올린 사용자의 이름을 표시하고, 영상을 올린 사용자가 아니라면 그 밑의 edit / delete 링크는 볼 수 없도록 할 것이다.

userSchema에 videoList를 추가하고, videoScheam에 owner를 추가해야 한다.

1) my profile 링크 추가

base.pug 파일에 my profile 메뉴를 추가했다.
이 url은 로그인한 user의 정보를 기반으로 그 _id를 가져와 만들었지만, 누구나 접근할 수 있도록 해당 페이지는 모두에게 공개할 것이다.

//- base.pug

li
  a(href=`/users/${loggedInUser._id}`) My Profile

2) userRouter

// userRouter.js
userRouter.get("/:id([0-9a-f]{24})", see);

3) see 컨트롤러

누구나 다른 user의 profile을 볼 수 있도록 하기 위해 req.session.user에서 _id를 가지고 오는 게 아니라 req.params에서 id를 가지고 와야 한다.

// userController.js
export const see = async (req, res) => {
  const { id } = req.params;
  const user = await User.findById(id);
  if (!user) {
    return res.render("404", { pageTitle: "User not found" });
  }
  reutrn res.render("profile", { pageTitle: user.name, user });
};

4) profile.pug 생성

//- profile.pug

extends base

5) video와 user를 연결

video owner / videoList

user에는 해당 user가 업로드한 모든 video의 id를 저장한다.
video에는 해당 video을 업로드한 user의 id를 저장한다.

(1) videoSchema에 owner를 추가

video에 해당 video를 업로드한 user의 id를 저장하려고 한다.
이를 위해 Video.js 파일에서 videoSchema에 owner를 추가했다.

type은 ObjectId가 아니라 mongoose.Schema.Types.ObjectId라고 써야 한다.
ref는 mongoose에게 owner에 User model의 id를 저장하겠다고 알려주기 위해 추가해야 한다.

// Video.js
const videoSchema = new mongoose.Schema({
  // 생략
  owner: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
});

(2) videoController.js에서 postUpload 컨트롤러 수정

video.owner에 현재 로그인 한 user의 _id를 넣어준다 (video와 user 연결)

// videoController.js
export const postUpload = (req, res) = {
  const {
    session: {
      user: { _id }, // 추가 ❗
    },
    body: { title, description, hashtags },
    file,
  } = req;
  try {
    await Video.create({
      fileUrl: file.path,
      title,
      description,
      hashtags: Video.formatHashtags(hashtags),
      owner: _id, // 추가 ❗
    });
  } catch (error) {
    return res.status(400).render("upload", {
      pageTitle: "Upload Video",
      errorMessage: error._message,
    });
  }
  return res.redirect("/");
};

이제 video를 upload 한 후 DB를 확인해보면 owner가 추가된 것을 확인할 수 있다.
그 값은 video를 upload 한 user의 _id 값이다.
video와 user가 연결되었다!

> db.users.find()
{
  "_id" : ObjectId("61ac2ca850178cd5ec48b727"),
  "email" : "[email protected]",
  "avatarUrl" : "https://avatars.githubusercontent.com/u/89576038?v=4",
  "username" : "LeeSyong",
  "password" : "$2b$05$Ph6WZ/R1cdrM4wcZ0NUBAucc7BNXEEDornqN6sGz703aUvKUtctrO",
  "socialOnly" : true,
  "name" : "LeeSyong",
  "location" : null,
  "__v" : 0
}

> db.videos.find()
{
  "_id" : ObjectId("61ac2e2365004e56d46c9966"),
  "title" : "겨울",
  "fileUrl" : "uploads/vidoes/375d678307db8686e3eaa0e92b227416",
  "description" : "크리스마스 분위기의 따뜻한 영상입니다",
  "hashtags" : [ "#winter", "#video", "#christmas" ],
  "mata" : { "views" : 0, "rating" : 0 },
  "owner" : ObjectId("61ac2ca850178cd5ec48b727"),
  "createdAt" : ISODate("2021-12-05T03:12:35.713Z"),
  "__v" : 0
}

(3) edit / delete 링크 숨기기

String(video.owner) === loggedInUser._id라면, 해당 video를 upload 한 user와 현재 로그인한 user가 같은 사람이란 뜻이므로 edit / delete 링크를 볼 수 있도록 한다.

이때 video.owner은 ObjectId인 데 반해, loggedInUser._id는 String이므로 video.owner에 String()을 씌워야 한다.

//- watch.pug

if String(video.owner) === loggedInUser._id
  a(href=`${video.id}/edit`) Edit Video →
  br
  a(href=`${video.id}/delete`) Delete Video →

(4) video owner 표시하기

다음으로 video를 보러 들어갔을 때 video을 upload 한 user의 이름을 표시하기 위해 watch 컨트롤러와 watch.pug 파일을 다음과 같이 수정하였다.

// videoController.js
export const watch = (req, res) => {
  const { id } = req.params;
  const video = await Video.findById(id);
  if (!video) {
    return res.render("404", { pageTitle: "Video not found" });
  }
  const owner = await User.findById(video.owner);
  return res.render("watch", { pageTitle: video.title, video, owner });
};
//- watch

extends base

block content
  video(src="/" + video.fileUrl, width="800", controls)
  div
    p=video.description
    small=video.createdAt
  div
    small Uploaded by 
    a(href=`/users/${owner._id}`) #{owner.name}
  if String(video.owner) === loggedInUser._id
    a(href=`${video.id}/edit`) Edit Video →
    br
    a(href=`${video.id}/delete`) Delete Video →

그러나, videoSchema의 ref: "User"에 의해 video.owner가 곧 해당 video를 올린 user라는 것을 아는데, User DB에서 다시 검색을 해주는 것은 너무 비효율적이다.
이 점을 개선하기 위해 두 번째 DB 검색 결과는 지우고, 대신 populate("owner")를 사용할 수 있다.

// videoController.js
export const watch = (req, res) => {
  const { id } = req.params; 
  const video = await Video.findById(id).populate("owner"); // 수정 ❗
  if (!video) {
    return res.render("404", { pageTitle: "Video not found" });
  }
  return res.render("watch", { pageTitle: video.title, video });
};

populate()를 사용하면, video.owner에 video를 올린 user의 _id가 아니라 해당 user object가 통째로 들어오게 된다.
mongoose는 video.owner에 들어와야 할 값이 ObjectId인데 그 값이 User로부터 온다는 것을 알고 있으므로 video.owner를 해당 user object로 바꿔주는 것이다.

console.log(video)를 통해 살펴보면 다음과 같다

{
  mata: { views: 0, rating: 0 },
  _id: new ObjectId("61ac2e2365004e56d46c9966"),
  title: '겨울',
  fileUrl: 'uploads/vidoes/375d678307db8686e3eaa0e92b227416',
  description: '크리스마스 분위기의 따뜻한 영상입니다',
  hashtags: [ '#winter', '#video', '#christmas' ],
  owner: {
    _id: new ObjectId("61ac2ca850178cd5ec48b727"),
    email: '[email protected]',
    avatarUrl: 'https://avatars.githubusercontent.com/u/89576038?v=4',
    username: 'LeeSyong',
    password: '$2b$05$Ph6WZ/R1cdrM4wcZ0NUBAucc7BNXEEDornqN6sGz703aUvKUtctrO',
    socialOnly: true,
    name: 'LeeSyong',
    location: null,
    __v: 0
  },
  createdAt: 2021-12-05T03:12:35.713Z,
  __v: 0
}

(5) user videos 보여주기

userController.js 파일에서 see 컨트롤러(profile 페이지를 보여줌)를 수정한다.
req.params로부터 id를 얻어온 후 이를 통해 DB에서 해당 profile의 주인인 user를 찾는다.
DB에서 video.owner가 id(req.params) 혹은 user._id와 같은 video들을 찾는다.
찾은 videos를 profile 템플릿에 넘겨준 후 profile 템플릿에서 mixins/video를 include 하면 profile 페이지에서 해당 user가 업로드한 video들을 볼 수 있다.

export const see = async (req, res) => {
  const { id } = req.params;
  const user = await User.findById(id);
  const videos = await Video.find({ owner: user._id }); // 혹은 { owner: id } 라고 써도 됨
  if (!user) {
    return res.status(404).render("404", { pageTitle: "User not found" });
  }
  return res.render("profile", { pageTitle: user.name, user, videos });
};
//- profile.pug

extends base
include mixins/video

block content
  each video in videos
    +video(video)
  else
    li No Videos

한편, 여기서도 위와 마찬가지로 populate("videos")를 사용할 수 있다.

user에 해당 user가 업로드한 video를 업로드할 때마다 그 video의 id를 저장하려고 한다.
이를 위해 User.js 파일에서 userSchema에 videos를 추가했다.
videos는 ObjectId를 값으로 가지는 배열이다.

// User.js
const userSchema = new mongoose.Schema({
  // 생략
  videos: [{ type: mongoose.Schema.Types.ObjectId, ref: "Video" }],
});

video를 업로드 할 때 해당 video를 업로드 한 user의 videos에 그 video의 _id가 추가되도록, videoController.js 파일에서 postUpload 컨트롤러를 수정한다.

// videoController.js
export const postUpload = async (req, res) => {
  const {
    session: {
      user: { _id },
    },
    body: { title, description, hashtags },
    file,
  } = req;
  try {
    const newVideo = await Video.create({
      file: file.path,
      title,
      description,
      hashtags: Video.formatHashtags(hashtags),
      owner: _id,
    });
    const user = await User.findById(_id);
    user.videos.push(newVideo._id);
    user.save();
  } catch(error) {
    return res.status(400).render("upload", { pageTitle: "Upload Video", errorMessage: error._message });
  }
  return res.redirect("/");
};

populate()를 이용하면 user를 가져올 때 user.videos의 _id 값들을 그 _id 값을 가진 video object로 바꿔준다.

// userController.js
export const see = async (req, res) => {
  const { id } = req.params;
  const user = await User.findById(id).populate("videos");
  // console.log(user);
  if (!user) {
    return res.status(404).render("404", { pageTitle: "User not found" });
  }
  return res.render("profile", { pageTitle: user.name, user });
};

console.log(user)를 통해 살펴보면 다음과 같다.

{
  _id: new ObjectId("61ac7c5f048c24da7e9df72b"),
  email: '[email protected]',
  avatarUrl: 'https://avatars.githubusercontent.com/u/89576038?v=4',
  username: 'LeeSyong',
  password: '$2b$05$DINZAMOVle5hwNO87rnVZekEJmF/vIMEvap8G7qBzi7va/Dulw86y',
  socialOnly: true,
  name: 'LeeSyong',
  location: null,
  videos: [
    {
      mata: [Object],
      _id: new ObjectId("61ac7c81048c24da7e9df72e"),
      title: '겨울',
      fileUrl: 'uploads/vidoes/49acd210e1f4c16ae6891addf62409f0',
      description: '겨울겨울겨울겨울겨울겨울겨울겨울겨울겨울',
      hashtags: [Array],
      owner: new ObjectId("61ac7c5f048c24da7e9df72b"),
      createdAt: 2021-12-05T08:46:57.617Z,
      __v: 0
    },
    {
      mata: [Object],
      _id: new ObjectId("61ac8160bd22b2db227bbc44"),
      title: '쓰담쓰담',
      fileUrl: 'uploads/vidoes/33aa31574d8b086666e5fafc519fdb91',
      description: '쓰담쓰담쓰담쓰담쓰담쓰담쓰담쓰담쓰담쓰담',
      hashtags: [Array],
      owner: new ObjectId("61ac7c5f048c24da7e9df72b"),
      createdAt: 2021-12-05T09:07:44.008Z,
      __v: 0
    }
  ],
  __v: 3
}

6) 버그 수정

(1) 영상을 올릴 때마다 패스워드가 해싱되는 문제 해결

video를 upload 할 때 해당 video를 upload 하는 user 데이터의 videos에 그 video의 _id를 전해주면서 user.save()를 하는데 이때 User.js 파일의 pre save middleware에 의해 password가 hashing 된다.

그런데 이렇게 하면 계정을 생성할 때 만들었던 password의 해싱 값이 또 한번 해싱됨으로써 해당 user는 로그아웃을 하는 순간부터는 이전과 같은 password로 로그인할 수 없게 된다.

문제를 해결하기 위해 isModified()를 이용해 password가 수정될 때만 hashing 과정을 거치도록 해야 한다.

// User.js
userSchema.pre("save", async function() {
  // 저장하려는 user의 password가 수정된 경우에만 저장 전에 hashing을 거치도록 함 ❗
  if (this.isModified("password")) {
    this.password = await bcrypt.hash(this.password, 5);
  }
});

this란 저장하려는 user document를 말한다.
this.isModified("password")는 user object의 password property가 수정되었다면 true를 return 한다.

(2) owner가 아닌 로그인 user도 video 수정/삭제가 가능한 문제 해결

현재는 로그인만 되어 있으면 해당 video의 owner가 아니어도 주소 창에 url을 직접 입력하여 edit video와 delete video 페이지에 접근이 가능하다.
로그인한 user라 하더라도 video의 owner가 아니라면 해당 페이지에 접근할 수 없도록 해야 한다.

먼저, videoController.js 파일에서 getEdit와 postEdit 컨트롤러를 모두 수정한다. (postEdit 컨트롤러 또한 백엔드를 보호하기 위해 똑같이 수정해줘야 한다. 수정한 postEdit 컨트롤러는 적지 않았다.)

※ 이때 ObjectId(객체)와 req.session.user._id(문자열)는 데이터 타입이 다르다는 것에 주의하여 String()을 이용해야 한다.

// videoController.js
export const getEdit = async (req, res) => {
  const {
    session: {
      user: { _id }, // 추가 ❗
    },
  } = req;
  const { id } = req.params;
  const video = await Video.findById(id);
  // video가 있는지 확인
  if (!video) {
    return res.status(404).render("404", { pageTitle: "Video not found" });
  }
  // video의 owner인지 확인 ❗
  if (String(video.owner) !== _id) {
    return.status(403).redirect("/");
  } // status(403)은 forbidden을 뜻함
  return res.render("edit", { pageTitle: `Edit ${video.title}`, video });
};

마찬가지로, videoController.js 파일에서 deleteVideo 컨트롤러를 수정한다.

// videoController.js
export const deleteVideo = async (req, res) => {
  const {
    session: {
      user: { _id }, // 추가 ❗
    },
  },
  const { id } = req.params;
  const video = await Video.findById(id);
  // video가 있는지 확인
  if (!video) {
    return res.status(404).render("404", { pageTitle: "Video not found" });
  }
  // video의 owner인지 확인 ❗
  if (video.owner !== _id) {
    return res.status(403).redirect("/");
  }
  // video 삭제
  await Video.findByIdAndDelete(id);
  return res.redirect("/");
};

정리하면,

  1. 해당 video가 존재하는지 확인하고
  2. 작업을 진행하려는 user가 video owner인지 확인한 후에
  3. edit video 혹은 delete video 해야 한다.

✨ 내일 할 것

  1. user profile 처음부터 다시 구현해보기

  2. 강의 계속 듣기

좋은 웹페이지 즐겨찾기