이 포스팅은 노마드 코더의 "유튜브 클론코딩" 강의를 바탕으로 작성되었습니다. https://nomadcoders.co
썸네일 이미지 출처: https://developers.google.com/youtube
2021년 7월 28일
지난 시간에는 데이터베이스가 있다고 가정하고 비디오 업로드, 수정, 시청 등의 기능을 간단하게 구현해보았습니다. 이번 포스팅부터는 실제 데이터베이스를 구현해보겠습니다. 이 프로젝트에서는 MongoDB 를 사용합니다.
우선 MongoDB를 설치하기 위해 홈페이지로 이동합니다. https://www.mongodb.com
그리고 네이게이션 바에서 [Docs - Server] 로 들어갑니다.
그리고 왼쪽 네비게이션 바의 [Installation - Install MongoDB Community Editon] 을 클릭합니다. 커뮤니티 에디션의 경우 무료로 사용할 수 있습니다.
그리고 나오는 페이지에서 자신의 OS 환경에 맞는 링크로 들어갑니다.
저는 macOS 환경에서 프로젝트를 진행하고 있으므로 해당 링크로 들어가겠습니다. macOS에서는 brew
를 통해 MongoDB를 설치하게 됩니다. 만약 brew
가 설치되어있지 않다면 페이지의 안내에 따라 brew
를 먼저 설치해주세요.🍺
brew
가 설치되었다면 커맨드 라인 사용을 위해 $ Xcode command-line tools
를 다운받아야 합니다. 이 툴은 본인의 macOS에서 개발환경을 이미 갖췄다면 설치되어 있을 수도 있습니다.
이제 brew
가 다운받을 수 있는 레포지토리에 MongoDB 레포지토리를 확장시켜줘야합니다. $ brew tap mongodb/brew
명령어를 실행합니다.
이제 마지막으로 $ brew install mongodb-community@5.0
명령어를 통해 MongoDB를 설치합니다. 2021년 7월을 기준으로 5.0 버전이 안내되고 있습니다. 명령어 맨 끝의 버전은 다를 수 있습니다.
설치가 끝나고 $ mongod
명령어를 입력했을 때 데이터들이 출력된다면 설치가 정상적으로 끝난 것입니다.
맨 처음 설치를 마치고 $ mongo
명령어를 입력하면 이런 오류메시지가 출력됩니다.
Error: couldn't connect to server 127.0.0.1:27017, connection attempt failed: SocketException: Error connecting to 127.0.0.1:27017 :: caused by :: Connection refused :
아직 MongoDB를 설치하기만 하고 DB 서버를 실행해주지 않아서 그렇습니다. MongoDB의 사용은 모두 로컬 DB 서버와의 상호작용으로 이루어집니다. MongoDB 서버를 다음 명령어로 켜줍니다. $ brew services restart mongodb-community
성공했다는 메시지가 출력되면 서버가 정상적으로 켜진 것입니다.
저사양 환경에서 개발을 진행할 경우 백그라운드에서 서버를 계속 돌리고 있는 것이 부담이 될 수 있습니다. macOS에서는 top
명령어를 통해 DB 서버의 프로세스 식별 번호(PID)를 얻어 $ kill <PID>
명령어로 서버를 종료하는 방법과 mongo shell
의 명령어를 사용하는 방법이 있습니다.
그중 조금 더 직관적인 두 번째 방법으로 서버를 종료해보겠습니다. $ mongo
명령어로 mongo shell
에 진입합니다. 그리고 use admin
명령어로 어드민 권한을 얻습니다. 그리고 db.shutdownServer()
명령어로 서버를 종료합니다. 마지막으로 exit
를 입력해 쉘에서 나오면 끝입니다. 이미 서버와의 연결은 끊긴 상태이므로 에러메시지가 출력되니 신경쓰지 않아도 됩니다.
다음번에 다시 DB를 사용해 개발을 진행할 때 다시 시작 명령어를 입력해주세요.
MongoDB를 Node.js에서 사용하기 위해서는 mongoose를 추가로 설치해야 합니다. mongoose는 NPM 모듈이므로 NPM 명령어를 통해 설치합니다. npm i mongoose
를 실행해주세요. packge.json
의 Dependencies
목록에 정상적으로 추가되었다면 설치가 끝난 것입니다.
이제 본격적으로 MongoDB를 사용해보겠습니다. $ brew services restart mongodb-community
명령어로 DB 서버를 대기시켜주세요. 그리고 프로젝트 폴더 내의 src
폴더 안에 db.js
파일을 생성합니다. 파일명은 데이터베이스를 담당한다는 것만 알 수 있게 지어주면 됩니다.
이제 db.js
에서 mongoose를 불러옵니다.
import mongoose from "mongoose";
그리고 설치한 MongoDB 서버와 mongoose를 연동합니다. 이를 위해 먼저 local DB 서버의 주소를 알아야 합니다. 터미널에 $ mongo
를 입력해주세요.
MongoDB shell version v5.0.1
connecting to: mongodb://127.0.0.1:27017/?compressors=disabled&gssapiServiceName=mongodb
Implicit session: session { "id" : UUID("620d175a-34a7-46bd-9f9a-a4377a9207c2") }
MongoDB server version: 5.0.1
DB에 오류가 없다면 이런 메시지가 출력됩니다. 여기서 connecting to: mongodb://127.0.0.1:27017/?
라는 부분이 우리가 알아야 할 서버 주소입니다. connection to: 이후부터 주소를 복사합니다.
mongoose.connect(서버 주소/프로젝트명)
을 통해 DB와 연결합니다. db.js
에 다음 내용을 추가해주세요.
mongoose.connect("mongodb://127.0.0.1:27017/wetube");
이제 db.js
파일을 server.js
파일에서 import
하여 사용할 수 있도록 합니다. server.js
파일 윗줄에 import
문을 추가합니다.
import ./db;
그리고 $ npm run dev
명령어로 서버를 시작합니다. 서버를 시작하면 콘솔에 경고 메시지가 출력됩니다.
(node:22509) DeprecationWarning: current URL string parser is deprecated, and will be removed in a future version. To use the new parser, pass option { useNewUrlParser: true } to MongoClient.connect.
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:22509) [MONGODB DRIVER] Warning: Current Server Discovery and Monitoring engine is deprecated, and will be removed in a future version. To use the new Server Discover and Monitoring engine, pass option { useUnifiedTopology: true } to the MongoClient constructor.
connect()
를 사용할 때 디폴트로 지정된 옵션들이 추후 업데이트에서 사라질 예정인가봅니다. 경고 메시지에서 알려준대로 최신 옵션을 적용해보겠습니다.
mongoose.connect("mongodb://127.0.0.1:27017/wetube", {
useNewUrlParser: true,
useUnifiedTopology: true
})
이렇게 저장하고 서버를 재시작하면 더 이상 경고 메시지가 출력되지 않습니다. 해당 경고 메시지는 현재 포스팅하고 있는 시점의 버전에서만 나타날 수 있으니, 버전이 다르다면 해당 버전에 맞게 초기 세팅을 해주세요.
서버가 시작될 때 서버가 구동한다는 메시지와 함께 포트 번호를 출력했던 것처럼 MongoDB에 연결될 때와 에러가 발생했을 때 해당 메시지를 콘솔에 출력할 수 있는 핸들러를 구현해보겠습니다.
그러기 위해 우선 connection
인스턴스를 생성합니다.
import mongoose from "mongoose";
mongoose.connect("mongodb://127.0.0.1:27017/wetube", {
useNewUrlParser: true,
useUnifiedTopology: true,
});
const db = mongoose.connection; // connection 인스턴스 생성
그리고 에러 핸들러와 연결 핸들러를 각각 만들어서 db
에 달아줍니다.
import mongoose from "mongoose";
mongoose.connect("mongodb://127.0.0.1:27017/wetube", {
useNewUrlParser: true,
useUnifiedTopology: true,
});
const db = mongoose.connection;
const handleOpen = () => {
console.log("Connect to DB 🦢");
};
const handleError = (error) => {
console.log("DB Error", error);
};
db.on("error", handleError); // error가 발생하면 콜백 실행
db.once("open", handleOpen); // DB에 연결될 때 한 번만 콜백 실행
db.on()
은 마치 이벤트 리스너처럼 해당되는 이벤트가 발생하면 콜백을 실행합니다. 여기서는 error
가 발생하면 handleError
를 실행하도록 했습니다.
db.once()
는 해당 이벤트가 처음 일어났을 때 한 번만 실행됩니다. DB가 성공적으로 연결되면 해당 메시지를 출력해주도록 했습니다. 파일을 저장하면 DB 연결 메시지가 정상적으로 출력됩니다.
비디오를 데이터베이스에 저장하기 위해서는 먼저 비디오가 어떤 형태로 이루어진 데이터인지 정의해주어야 합니다. src
폴더 내에 models
폴더를 만듭니다. 이 폴더 안에는 데이터베이스에 저장할 데이터의 모델을 정의하는 파일이 들어가게 됩니다. 그리고 models
폴더 안에 Video.js
파일을 생성합니다.
모델을 만들기 위해 mongoose를 불러옵니다.
import mongoose from "mongoose";
이제 비디오 데이터의 구조를 설계합니다. mongoose에서 모든 모델은 해당 모델의 스키마를 만드는 것부터 시작합니다. 스키마는 스키마 이름과 스키마 타입 쌍으로 사용합니다.
import mongoose from "mongoose";
const videoSchema = new mongoose.Schema({
title: String,
description: String,
createdAt: Date,
hashtags: [{ type: String }],
meta: {
views: Number,
rating: Number,
},
});
new mongoose.Schema()
에 스키마와 타입을 쌍으로 묶어 객체 형태로 전달합니다. createdAt: Date
에서 볼 수 있듯이 프로그래밍 언어에서 사용하는 일반적인 자료형과 mongoose 스키마 타입은 다릅니다. mongoose에서 사용할 수 있는 데이터 타입 리스트는 아래와 같습니다.
각각의 데이터 타입에 관한 자세한 설명은 이 링크를 참고해주세요. https://mongoosejs.com/docs/guide.html
또한 hashtags: [{ type: String }]
처럼 문자열의 리스트로 데이터가 저장되게 스키마를 설계할 수도 있고, meta
처럼 스키마를 중복해서 설계할 수도 있습니다.
이제 만들어진 스키마 구조를 가지고 mongoose 모델을 만들고, 최종적으로 모델을 export
해줍니다.
import mongoose from "mongoose";
const videoSchema = new mongoose.Schema({
title: String,
description: String,
createdAt: Date,
hashtags: [{ type: String }],
meta: {
views: Number,
rating: Number,
},
});
const Video = mongoose.model("Video", videoSchema);
export default Video;
mongoose.model()
에 모델 이름과 스키마 구조를 인자로 넘겨줍니다.
이제 만들어진 모델을 import "./models/video";
를 통해 서버에서 사용할 수 있도록 불러옵니다. 그런데 server.js
파일에 데이터베이스 관련 코드를 쓰고 싶지는 않습니다. init.js
파일을 새로 만들어서 초기화와 관련된 모든 부분을 다루도록 하고, 서버 파일은 서버 구동과 관련된 부분만을 다루도록 하겠습니다. src
폴더 내부에 init.js
파일을 생성합니다.
그리고 서버 시작을 담당하는 코드를 복사해 init.js
파일에 넣습니다.
import app from "./server";
const PORT = 4000;
const handleListening = () => {
console.log(`Server listening on http://localhost:${PORT}`);
};
app.listen(PORT, handleListening);
물론 app
어플리케이션을 사용할 수 있도록 server.js
에서 export
해줘야 합니다. 그리고 데이터베이스 및 모델을 불러오는 import
문도 여기에 넣습니다.
import "./db";
import "./models/video";
import app from "./server";
const PORT = 4000;
const handleListening = () => {
console.log(`Server listening on http://localhost:${PORT}`);
};
app.listen(PORT, handleListening);
이제 server.js
파일은 순수하게 서버 구동과 관련된 코드만을 가지게 되었습니다.
import express from "express";
import morgan from "morgan";
import globalRouter from "./routers/globalRouter";
import userRouter from "./routers/userRouter";
import videoRouter from "./routers/videoRouter";
const app = express();
const logger = morgan("dev");
app.set("view engine", "pug");
app.set("views", process.cwd() + "/src/views");
app.use(logger);
app.use(express.urlencoded({ extended: true }));
app.use("/", globalRouter);
app.use("/users", userRouter);
app.use("/videos", videoRouter);
export default app;
그리고 package.json
파일의 script
부분에서 $ npm run dev
명령어를 통해 실행하고자 하는 파일을 server.js
에서 init.js
로 바꿔줍니다.
"scripts": {
"dev": "nodemon --exec babel-node src/init"
},
이제 $ npm run dev
명령어를 실행하여 서버가 정상적으로 작동하는지 확인합니다.
이제 진짜 데이터베이스가 생겼기 때문에 가짜 videos
객체는 필요 없습니다. videoContoller.js
파일에서 videos
객체 및 해당 객체를 사용하는 코드를 모두 제거합니다.
export const trending = (req, res) => {
res.render("home", {
pageTitle: "Home",
});
};
export const watch = (req, res) => {
const { id } = req.params;
return res.render("watch", {
pageTitle: `Watching`,
});
};
export const getEdit = (req, res) => {
const { id } = req.params;
return res.render("edit", {
pageTitle: `Editing`,
});
};
export const postEdit = (req, res) => {
const { id } = req.params;
return res.redirect(`/videos/${id}`);
};
export const getUpload = (req, res) => {
return res.render("upload");
};
export const postUpload = (req, res) => {
const { title } = req.body;
return res.redirect("/");
};
비디오 만들기 기능을 구현하기 전에 비디오 가져오기 기능을 먼저 구현하겠습니다. 이렇게 되면 나중에 비디오를 만들었을 때 저절로 홈 화면에 표시될 것입니다. videoContoller.js
의 trending
컨트롤러에 작업을 해주겠습니다.
우선 pug 파일과 이름을 맞추기 위해 home
으로 컨트롤러 이름을 바꿉니다. 그리고 비디오 모델을 사용하기 위해 모델 파일을 import
해줍니다.
import Video from "../models/video"
이제 데이터베이스에 저장된 비디오를 불러오는 코드를 작성합니다. Video
모델로 만들어진 데이터를 찾으려면 Video.find()
를 사용합니다. find()
안에는 쿼리를 스키마 형태로 전달할 수 있습니다. 예를 들어 비디오 제목이 "first"인 비디오 데이터를 찾고 싶다면 이렇게 쿼리를 전달할 수 있습니다.
Video.find({
title: "first"
});
지금은 모든 비디오(아직은 데이터가 없지만)를 불러오게 하겠습니다. 쿼리에 빈 객체를 전달하면 모든 비디오를 찾아 반환합니다.
import Video from "../models/Video";
export const trending = async (req, res) => {
const videos = await Video.find({});
console.log(videos); // [] 반환
return res.render("home", {
pageTitle: "Home",
videos,
});
};
데이터베이스 서버에서 데이터를 가져오는 작업은 시간이 필요하므로 비동기 처리를 위해 async await
를 사용합니다. 데이터를 모두 가져올 때까지 기다릴 것입니다. Video.find({})
의 결과를 콘솔에 찍어보면 정상적으로 빈 배열을 반환하는 것을 확인할 수 있습니다.
이전에 home.pug
파일을 만들 때 비디오 객체의 정보를 화면에 보여주되, 만약 빈 배열이면 Sorry를 출력하도록 했습니다. 홈 페이지를 보면 Sorry가 잘 들어가 있습니다.
이제 데이터베이스에 들어갈 동영상을 업로드하는 기능을 실제 데이터베이스와 연동해보겠습니다. 우선 설계한 비디오 스키마대로 업로드 폼을 수정합니다.
extends base
block content
form(method="POST")
input(name="title", placeholder="Title", required, type="text")
input(name="description", placeholder="Description", required, type="text")
input(name="hashtags", placeholder="Hashtags, separated by comma", required, type="text")
input(type="submit", value="Upload Video")
비디오 스키마에서 사용자에게 직접 받는 데이터는 title
, description
, hashtags
입니다. 이것들을 적을 수 있는 input
을 만들어줍니다. 해쉬태그의 경우 각 태그를 쉼표로 구분하도록 placeholder
에 명시해줍니다.
이제 videoContoller.js
의 postUpload
컨트롤러에서 사용자 입력값을 가져오겠습니다.
export const postUpload = (req, res) => {
const { title, description, hashtags } = req.body;
console.log(title, description, hashtags);
return res.redirect("/");
};
리퀘스트 바디에서 정상적으로 값을 가져왔는지 콘솔에 찍어 확인해봅니다. 출력값이 잘 나온다면 이제 비디오 스키마의 나머지 부분을 만듭니다.
export const postUpload = (req, res) => {
const { title, description, hashtags } = req.body;
const video = {
title,
description,
hashtags: hashtags.split(",").map((tag) => `#${tag}`),
createdAt: Date.now(),
meta: {
views: 0,
rating: 0,
},
};
console.log(video);
return res.redirect("/");
};
title
, description
부분은 사용자의 입력값을 그대로 사용합니다. hashtags
의 경우 input
에서 각 태그를 쉼표로 구분하라고 명시했습니다. 따라서,
.split(",")
으로 쉼표를 기준으로 각 태그들을 분할해 리스트에 저장한 다음.map((tag) => `#${tag}`)
을 통해 각 태그 앞에 해쉬마크(#)를 달아줍니다.2번의 경우 사용자가 미리 해쉬태그를 달아서 업로드할 경우도 대비해야 합니다만, 입력값 검사는 추후 구현하도록 하겠습니다.
createdAt
에는 자바스크립트의 Date.now()
를 이용해 시각 정보를 저장합니다. meta
속에 들어있는 views
와 rating
은 초기값으로 0을 설정해줍니다. 비디오 데이터 생성을 완료하고 console.log
로 확인해봅니다.
{
title: 'First video',
description: 'This is First video',
hashtags: [ '#first', '#video' ],
createdAt: 1627396487109,
meta: { views: 0, rating: 0 }
}
입력값이 스키마 구조에 담겨 잘 출력된 것을 볼 수 있습니다.
이제 스키마 구조대로 만든 video
도큐멘트를 DB에 저장하겠습니다. 위에서 만든 비디오 데이터 객체를 Video.create()
에 인자로 전달합니다. 데이터베이스 저장을 마치고 redirect
가 일어나야 하므로 컨트롤러를 async
함수로 만들고 await
를 사용하여 저장을 기다려줍니다.
export const postUpload = async (req, res) => {
const { title, description, hashtags } = req.body;
await Video.create({
title,
description,
hashtags: hashtags.split(",").map((tag) => `#${tag}`),
createdAt: Date.now(),
meta: {
views: 0,
rating: 0,
},
});
return res.redirect("/");
};
이제 업로드 페이지에서 폼을 작성하고 업로드 버튼을 누르면 비디오 데이터가 DB에 실제로 저장됩니다. 폼을 작성하고 업로드 버튼을 누르면 비디오 데이터가 정상적으로 홈 화면에 불러와진 것을 확인할 수 있습니다.
또한 터미널에서 $ mongo
명령어로 mongo shell에 진입한 후 show dbs
명령을 입력하면 정상적으로 wetube
항목이 생긴 것을 볼 수 있습니다.
> show dbs
admin 0.000GB
config 0.000GB
local 0.000GB
wetube 0.000GB
이번 포스팅에서는 MongoDB와 mongoose의 기초에 대해 배웠고, 아직은 텍스트 뿐이지만 실제로 비디오 데이터를 데이터베이스에 저장하고 불러와보았습니다. 다음 포스팅에서도 서버와 데이터베이스와의 연동에 대해 계속해서 공부해보겠습니다.
<참고 문서>