유튜브 클론코딩 복습노트-6

Hyuno Choi·2021년 8월 1일
0
post-thumbnail

2021년 7월 31일

이번 포스팅에서도 데이터베이스를 활용하여 페이지의 기능을 계속 업데이트 하겠습니다. 지금까지 만든 비디오 스키마에는 스키마 구조를 보호하기 위한 아무런 장치가 없습니다. 즉, 사용자가 특정 필드를 입력하지 않고 제출해도 저장이 되는 형태입니다. 혹은 비디오 제목 란에 말도 안 되게 긴 문자열을 작성해도 그대로 저장됩니다. 이러한 일을 서버와 HTML에서 이중으로 방지해보겠습니다.

입력값 검사

스키마

const videoSchema = new mongoose.Schema({
  title: String,
  description: String,
  createdAt: Date,
  hashtags: [{ type: String }],
  meta: {
    views: Number,
    rating: Number,
  },
});

현재 비디오 스키마의 코드입니다. 데이터 타입만을 정의하고 있을 뿐, 이렇다 할 보호 기능이 없습니다. title 부터 안전장치를 하나씩 만들어보겠습니다.

title: {
    type: String,
    required: true,
    trim: true,
    maxLength: 80,
  }
  • type: 이전과 동일한 String 입니다.
  • required: true 로 설정해줍니다. 이제 제목을 입력하지 않으면 데이터베이스에 저장되는 대신 오류가 발생합니다.
  • trim: 자바스크립트의 트림과 동일한 기능입니다. 문자열 앞뒤에 불필요하게 들어간 공백을 제거합니다. 예를 들어, " 안녕 반가워. "라는 문자열을 "안녕 반가워."와 같이 만들어줍니다.
  • maxLength: 비디오 제목의 최대 길이를 설정합니다. 저는 80자로 했습니다.

description도 같은 논리로 만들어줍니다.

description: {
    type: String,
    required: true,
    trim: true,
    minLength: 20,
  }
  • type, required, trim: title 과 동일합니다.
  • minLength: 설명의 최소 길이를 설정합니다. 저는 20자로 하겠습니다.
createdAt: {
    type: Date,
    required: true,
    default: Date.now,
  }
  • type, required: 제목과 동일합니다.
    • default: 데이터의 기본값을 설정합니다. 사용자가 값을 입력하지 않으면 기본값으로 저장됩니다. createdAt의 기본값으로 Date.now 를 통해 현재 시각을 넣어줍니다.

나머지 해쉬태그와 메타데이터의 스키마도 동일하게 처리합니다.

hashtags: [
    {
      type: String,
      trim: true,
    },
  ],
  meta: {
    views: {
      type: Number,
      default: 0,
      required: true,
    },
    rating: {
      type: Number,
      default: 0,
      required: true,
    }

이제 불충분한 데이터나 비정상적인 값이 데이터베이스에 저장될 여지가 줄었들었습니다. 다음으로 업로드 페이지를 구성하는 upload.pug 파일에도 똑같이 입력값 제한 기능을 넣겠습니다.

pug 파일

HTML에도 input 태그의 입력값을 검사하는 속성을 추가할 수 있습니다. upload.pug 파일의 입력폼을 조금 더 안전하게 만들어보겠습니다.

input(name="title", placeholder="Title", required, type="text", maxlength=80)

비디오 제목 입력폼에 제목 스키마와 똑같이 maxlength 속성을 통해 최대 길이를 80자로 설정합니다. 이렇게 설정된 입력란에 80자 이상을 적으려고 하면 입력이 무시되기 때문에 폼을 전송하기 전에 사용자가 입력값을 수정할 수 있습니다.

마찬가지로 비디오 설명을 작성하는 input 태그의 속성에도 최소 길이 제한을 달아줍니다.

input(name="description", placeholder="Description", required, type="text", minlength=20)

대부분의 브라우저에서 입력란이 요구하는 최소 길이를 채우지 않고 제출 버튼을 누르면 최소 길이를 채우라는 안내를 해줍니다.

이제 데이터베이스에 저장되는 비디오 데이터 입력값은 HTML과 서버 모두에서 검사를 받습니다. 따라서 악의적인 사용자가 HTML을 조작해 이상한 데이터를 보내더라도 서버에서 차단할 수 있습니다.

업로드 컨트롤러 수정

비디오 스키마를 만들 때 각 입력값의 디폴트 값을 설정했기 때문에 업로드 컨트롤러에서 비디오 데이터를 만들 때 초기값을 넣어주지 않아도 됩니다. postUpload 컨트롤러에서 필요 없어진 코드를 삭제합니다.

export const postUpload = async (req, res) => {
  const { title, description, hashtags } = req.body;
  await Video.create({
    title,
    description,
    hashtags: hashtags.split(",").map((tag) => `#${tag}`),
  });
  // createdAt, meta 초기화 코드 삭제
  return res.redirect("/");
};

업로드 오류 처리

위에서 비디오 데이터는 이러이러해야한다고 스키마에서 추가로 규칙을 만들었습니다. 따라서 해당 규칙에 맞지 않는 데이터가 입력값으로 들어오면 데이터베이스에서 오류가 출력될 것입니다. 자바스크립트의 try catch 구문으로 오류 처리를 구현하겠습니다.

export const postUpload = async (req, res) => {
  const { title, description, hashtags } = req.body;
  try {
    await Video.create({
      title,
      description,
      hashtags: hashtags.split(",").map((tag) => `#${tag}`),
    });
    return res.redirect("/");
  } catch (error) {
    console.log(error);
    return res.render("upload", {
      pageTitle: "Upload Video",
      errorMessage: error._message,
    });
  }
};

우선 POST 요청이 들어오면 try 안의 데이터 저장을 시도합니다. 만약 에러가 발생하면 콘솔에 에러 로그를 찍고 upload.pug 를 다시 랜더하면서 에러 메시지를 넘겨줍니다. 이제 upload.pug 파일에 저 에러메시지가 들어갈 부분을 만들어주겠습니다.

extends base

block content
    if errorMessage
        span=errorMessage
    form(method="POST")
         ...

pug 조건문을 사용하여 에러메시지를 선택적으로 표시합니다. 만약 에러가 발생해 에러메시지가 upload.pug 에 넘어왔다면 입력폼 위에 해당 메시지를 출력합니다. 이제 고의로 잘못된 데이터를 제출하면 에러메시지가 폼 위에 보이게 됩니다.

video mixin 수정

입력값 검사는 일단 이것으로 마무리하고, 홈 화면을 살짝 수정해보겠습니다. 지금 홈 화면에 표시되는 비디오 데이터 리스트는 데이터베이스가 없을 때 임시로 만든 것이기 때문에 살짝 어색하게 보입니다. 비디오 리스트를 만드는 mixins/video.pug 파일을 수정합니다.

mixin video(video)
    div
        h4
            a(href=`/videos/${video.id}`)=video.title
        p=video.description
        small=video.createdAt
        hr

현재까지는 홈 페이지에 비디오 설명과 비디오 업로드 날짜만 보여주도록 하겠습니다. 그리고 각 비디오는 hr 태그를 통해 수평선으로 구분해줍니다.

이제 조금은 깔끔하게 보이는 것 같습니다.

비디오 시청페이지 다듬기

비디오 라우터 수정

비디오 제목을 누르면 나오는 비디오 시청 페이지와 시청 페이지에서 들어갈 수 있는 비디오 수정 페이지는 아직 연결되지 않은 상태입니다. pug 파일에서 각 비디오 제목은 다음과 같이 만들어져 있습니다.

a(href=`/videos/${video.id}`)=video.title

즉, 저 a 태그를 누르면 /video/비디오ID 주소로 이동하게 됩니다. 몽고DB는 각 비디오 객체를 저장할 때 _id 값을 자동으로 생성해줍니다. 참고로 _id 값은 위의 코드처럼 id 로도 접근할 수 있습니다.

이 아이디 값은 24자리 16진수로 이루어져 있습니다. 따라서 우선 비디오 라우터에 비디오 아이디 주소를 감지할 수 있는 정규식을 만들어주겠습니다. watch 페이지와 edit 페이지를 각각 다음과 같이 처리할 수 있을 것입니다.

videoRouter.get("/:id([0-9a-f]{24})", watch);
videoRouter.route("/:id([0-9a-f]{24})/edit").get(getEdit).post(postEdit);

가능한 문자열은 0에서 9까지, a에서 f까지이며 총 24자리이므로 정규식을 [0-9a-f]{24} 이렇게 적어줍니다.

pug 파일 수정🐶

이제 비디오 시청 페이지를 구성하는 watch.pug 파일을 새로운 데이터베이스에 맞게 수정해줍시다.

extends base

block content
    div
        p=video.description 
        small=video.createdAt
    a(href=`${video.id}/edit`) Edit Video →

마찬가지로 비디오 설명과 만든 시간 정도만 화면에 표시하겠습니다.

watch 컨트롤러 수정

이제 pug 파일에서 필요한 정보인 video 데이터를 컨트롤러에서 넘겨주도록 하겠습니다. watch 컨트롤러는 이미 URL에 포함된 id 값을 알고 있습니다. 따라서 해당 아이디를 가지고 DB에서 비디오 데이터를 찾아올 수 있게 mongoose의 API인 findById() 를 사용하겠습니다. 마지막으로 찾아온 비디오 데이터를 watch.pug 파일을 렌더할 때 함께 넘겨줍니다.

export const watch = async (req, res) => {
  const { id } = req.params;
  const video = await Video.findById(id);
  return res.render("watch", {
    pageTitle: `Watching: ${video.title}`,
    video,
  });
};

비디오 데이터를 DB에서 찾아오는 동안 시간이 필요하므로 await 를 사용해 기다려줍니다. 이제 홈페이지에서 비디오 제목을 누르면 비디오 시청 페이지를 볼 수 있습니다.

404 페이지 만들기⛔️

현재 비디오 시청 페이지는 비디오 id 값을 기준으로 해당하는 비디오를 보여주게 됩니다. 만약 웹 페이지 사용자가 URL의 비디오 아이디 부분을 임의로 조작하여 요청을 보내면 해당 비디오가 존재하지 않는다는 404 페이지를 띄울 수 있어야 합니다.

우선 404 페이지의 구조가 되는 pug 파일을 간단하게 만들겠습니다. src/views 폴더 내에 404.pug 파일을 만듭니다.

extends base

"Video not found"라는 제목만 띄울 것이므로 base.pug 파일을 불러오기만 합니다. 이제 watch 페이지를 렌더하는 컨트롤러에 간단한 조건문을 추가해줍니다. video 데이터를 불러오는 데 성공하면 정상적으로 시청 페이지를 렌더하고, 만약 데이터가 undefined 라면 404 페이지를 렌더하는 방식입니다.

export const watch = async (req, res) => {
  const { id } = req.params;
  const video = await Video.findById(id);
  if (!video) {
    res.render("404", { pageTitle: "Video Not Found." });
  } else {
    return res.render("watch", {
      pageTitle: `Watching: ${video.title}`,
      video,
    });
  }
};

이제 정상적인 비디오 시청 페이지에 들어간 다음, URL의 비디오 아이디 부분을 임의로 수정하면 404 페이지가 뜨게 됩니다.

비디오 수정 페이지 다듬기

이제 비디오 수정 페이지를 다듬어주겠습니다. 수정 사항이 실제 데이터베이스에 반영되어야 합니다.

pug 파일 수정

우선 간단하게 만들었던 edit.pug 파일의 폼 양식을 비디오 스키마 구조에 맞게 수정합니다.

form(method="POST")
        input(name="title", placeholder="Video Title", required, type="text", maxlength=80, value=video.title)
        input(name="description", placeholder="Description", required, type="text", minlength=20, value=video.description)
        input(name="hashtags", placeholder="Hashtags, separated by comma", required, type="text", value=video.hashtags.join())
        input(value="Save", type="submit")

upload.pug 에서 사용한 폼과 동일합니다. 각 input 태그에 value 속성으로 기존 비디오데이터가 가지고 있는 값을 넣어서 입력란에 해당 값이 미리 들어가있도록 해줍니다.

getEdit 컨트롤러 수정

이제 edit 페이지 렌더를 담당하는 컨트롤러가 URL의 아이디를 사용해 해당 비디오 데이터를 DB에서 가져올 수 있게 만들어주겠습니다. 역시 mongoose의 findById() API를 사용합니다. 여기서도 조건문을 만들어 만약 비디오 데이터를 가져오는 데 실패할 경우 404 페이지를 렌더하도록 합니다.

export const getEdit = async (req, res) => {
  const { id } = req.params;
  const video = await Video.findById(id);
  if (!video) {
    res.render("404", { pageTitle: "Video Not Found." });
  } else {
    return res.render("edit", {
      pageTitle: `Editing`,
      video,
    });
  }
};

findById() 가 비디오 데이터를 가져올 동안 await 로 잠시 기다려줍니다. 만약 비디오 데이터가 undefined 라면 404를 렌더하고, 아니라면 비디오 데이터와 함께 edit.pug 를 렌더합니다. 이제 해당 비디오 페이지에서 Edit 버튼을 누르면 수정 페이지와 입력폼이 정상적으로 보입니다.

postEdit 컨트롤러 수정

아직까지는 입력폼을 수정해서 제출 버튼을 눌러도 데이터베이스에 수정 사항이 반영되지 않습니다. 이제 실제 데이터 수정 기능을 구현해보겠습니다. 우선 req.body 에서 전송된 폼 데이터를 가져옵니다. 가져올 데이터는 title, description, hashtags 입니다.

const { title, description, hashtags } = req.body;

그리고 수정하려는 비디오가 데이터베이스 안에 존재하는지 확인합니다. 만약 비디오 데이터가 존재하지 않는다면 비디오 데이터 전체를 불러올 필요가 없습니다. 따라서 특정 데이터의 존재 여부를 부울 값으로 반환해주는 mongoose의 exists() API를 사용합니다.

const isVideoExists = Video.exists({ _id: id });

exists() 는 쿼리를 인자로 받으므로 id 쿼리를 전달합니다. 이제 해당 아이디의 비디오가 존재하는지의 여부에 따라 조건문을 만듭니다. 만약 비디오가 존재하지 않는다면 404 페이지를 렌더하고, 존재한다면 정상적으로 데이터를 업데이트합니다.

데이터 업데이트는 mongoose의 findByIdAndUpdate() API를 사용합니다. 첫 번째 인자로 id 값을 넘겨주고, 두 번째 인자로 해당 데이터에서 업데이트 될 부분을 객체 형태로 전달하면 됩니다.

await Video.findByIdAndUpdate(id, {
      title,
      description,
      hashtags: hashtags.split(",").map((tag) => `#${tag}`),
    });

titledescription 은 그대로 전달해주면 되지만 hashtags 는 조금 고민이 필요합니다. 비디오 업로드 당시에 이미 #을 붙여준 상태이기 때문에 해당 코드를 그대로 사용하여 업데이를 진행하면 업데이트를 할 때마다 #이 하나씩 늘어나게 됩니다. 따라서 자바스크립트의 삼항 연산자를 사용하여 #의 여부에 따라 처리를 달리 해주겠습니다.

hashtags: hashtags
        .split(",")
        .map((word) => (word.startsWith("#") ? word : `#${word}`)),
    });

자바스크립트에서는 startsWith() 이라는 매우 편리한 메소드를 제공합니다. 말 그대로 해당 문자열이 특정 문자열로 시작하는지의 여부에 따라 부울값을 반환하는 메소드입니다. 만약 각각의 해쉬태그(word)가 #으로 시작하면 해쉬태그를 그대로 사용하고 #이 없다면 해쉬태그 앞에 #를 붙여 저장합니다.

일련의 과정을 거쳐 만들어진 postEdit 컨트롤러의 전체 코드는 다음과 같습니다.

export const postEdit = async (req, res) => {
  const { id } = req.params;
  const { title, description, hashtags } = req.body;
  const isVideoExists = await Video.exists({ _id: id });
  if (!isVideoExists) {
    res.render("404", { pageTitle: "Video Not Found." });
  } else {
    await Video.findByIdAndUpdate(id, {
      title,
      description,
      hashtags: hashtags
        .split(",")
        .map((word) => (word.startsWith("#") ? word : `#${word}`)),
    });
    return res.redirect(`/videos/${id}`);
  }
};

이번 포스팅에서는 업로드 과정에서 입력값 검사 기능을 추가했고, 더미 페이지였던 watch와 edit를 실제 데이터베이스와 연동했습니다. 다음 포스팅에서도 데이터베이스를 활용하여 페이지의 기능을 추가해보겠습니다.


<참고 문서>
profile
프론트엔드 웹 개발자를 목표로 하고 있습니다.

0개의 댓글