웹 개발 실전 프로젝트 - 5주차
Yelp Camp 활용한 프로젝트
462. 리뷰 모델 정의하기
- 리뷰 모델 정의
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const ReviewSchema = new Schema({
body: String,
rating: Number,
});
module.exports = mongoose.model('Review', ReviewSchema);
- one to many 관계 - 리뷰들을 캠핑장 하나에 연결하기
- CampgroundSchema 업데이트
const CampgroundSchema = new Schema({
title: String,
image: String,
price: Number,
description: String,
location: String,
reviews: [
{
type: Schema.Types.ObjectId,
ref: 'Review',
},
],
});
463. 리뷰 형식 추가하기

464. 리뷰 작성하기
- 라우트 생성
POST /campgrounds/:id/reviews
const Review = require('./models/review');
app.post('/campgrounds/:id/reviews', catchAsync(async (req, res) => {
const campground = await Campground.findById(req.params.id);
const review = new Review(req.body.review);
campground.reviews.push(review);
await review.save();
await campground.save();
res.redirect(`/campgrounds/${campground._id}`);
})
);
- 리뷰를 작성해보면 성공적으로 저장된다.

465. 리뷰 검증하기
- 부트스트랩 유효성 검사 추가하기
- form에
novalidate
, textarea에required
, class로 validated-form 을 추가해서 클라이언트 측 유효성 검사
- Postman, AJAX 등으로 유효성 검사를 피하는 경우를 막기 위해 Joi 사용
module.exports.reviewSchema = Joi.object({
review: Joi.object({
rating: Joi.number().required().min(1).max(5),
body: Joi.string().required(),
}).required(),
});
const { campgroundSchema, reviewSchema } = require('./schemas');
// ...
const validateReview = (req, res, next) => {
const { error } = reviewSchema.validate(req.body);
if (error) {
const msg = error.details.map((el) => el.message).join(',');
throw new ExpressError(msg, 400);
} else {
next();
}
};
// ...
- 미들웨어를 인자로 전달하면 된다.
app.post('/campgrounds/:id/reviews', validateReview, catchAsync(async (req, res) => { ... });
466. 리뷰 표시하기
- 캠프장 show 페이지의 라우트에 리뷰 데이터 populate 하기
app.get('/campgrounds/:id', catchAsync(async (req, res) => {
const campground = await Campground.findById(req.params.id).populate('reviews');
res.render('campgrounds/show', { campground });
})
);
<% for(let review of campground.reviews) { %>
<div class="card mb-3 ">
<div class="card-body">
<h5 class="card-title">Rating: <%= review.rating %></h5>
<p class="card-text">Review: <%= review.body %></p>
<button class="btn btn-sm btn-danger">Delete</button>
</div>
</div>
<% } %>
- 리뷰를 db에서 전부 삭제하려면
db.reviews.deleteMany({})
467. 리뷰 스타일링

468. 리뷰 삭제하기
<form action="/campgrounds/<%=campground._id%>/reviews/<%=review._id%>?_method=DELETE" method="POST">
<button class="btn btn-sm btn-danger">Delete</button>
</form>
app.delete('/campgrounds/:id/reviews/:reviewId', catchAsync(async (req, res) => {
const { id, reviewId } = req.params;
await Campground.findByIdAndUpdate(id, { $pull: { reviews: reviewId } });
await Review.findByIdAndDelete(reviewId);
res.redirect(`/campgrounds/${id}`);
})
);
$pull
연산자 - Mongo에서 사용하는 배열 수정 연산자- 배열에 있는 모든 인스턴스 중에 특정 조건에 만족하는 값을 지운다.
469. 캠프그라운드의 미들웨어 삭제
- 모델에
findOneAndDelete
쿼리 미들웨어 추가하기 (post 사용)
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.send('ALL SHELTERS');
});
router.post('/', (req, res) => {
res.send('CREATING SHELTER');
});
router.get('/:id', (req, res) => {
res.send('VIEWING ONE SHELTER');
});
router.get('/:id/edit', (req, res) => {
res.send('EDITING ONE SHELTER');
});
module.exports = router;


472. Express 라우터와 미들웨어
const express = require('express');
const router = express.Router();
router.use((req, res, next) => {
if (req.query.isAdmin) {
return next();
}
return res.send('SORRY NOT AN ADMIN!');
});
router.get('/topsecret', (req, res) => {
res.send('THIS IS TOP SECRET');
});
router.get('/deleteeverything', (req, res) => {
res.send('OK DELETED IT ALL!');
});
module.exports = router;
473. 쿠키 개요
- 사용자의 브라우저에 저장할 수 있는 작은 정보 조각으로, 특정 웹 페이지에 연결되어 있다.
- 쿠키는 한 쌍의 키-값이다.
- 웹사이트에 들어온 다음, 요청에 쿠키 정보를 포함하게 된다.
- Personalization - 일부 사용자에 대한 정보를 기억하고, 시간이 흐른 뒤에도 사용자에게 관련 컨텐츠를 보여줄 수 있다.
- 추적 기능
- 크롬 개발자 도구 - Application 탭에서 확인 가능
474. 쿠키 보내기
app.get('/setname', (req, res) => {
res.cookie('name', 'henrietta');
res.cookie('animal', 'harlequin shrimp')
res.send('OK SENT YOU A COOKIE!!!')
})

475. 쿠키 파서 미들웨어
npm i cookie-parser
패키지 설치const cookieParser = require('cookie-parser');
app.use(cookieParser());
app.get('/greet', (req, res) => {
const { name = 'No-name' } = req.cookies;
res.send(`Hey there, ${name}`)
})

- 사용자가 쿠키를 지우거나 다른 브라우저를 열면 그 정보를 사용할 수 없기 때문
- 쿠키는 단순히 요청과 요청 사이에 상태성(statefullness)을 부여하는 것에 가깝다.
476. 쿠키 서명하기
- 쿠키 파서에 비밀 문자열을 전달해 서명된 쿠키 지원을 활성화할 수 있다.
- 사용자에게 쿠키를 보낼 때 키-값을 직접 보내는 대신 서명된 쿠키로 보내는 것
app.use(cookieParser('thisismysecret')); // 비밀 문자열 전달
app.get('/getsignedcookie', (req, res) => {
res.cookie('fruit', 'grape', { signed: true })
res.send('OK SIGNED YOUR FRUIT COOKIE!')
})

req.cookies
에는 일반적인 무서명 쿠키가 들어 있고, 서명된 쿠키는 req.signedcookies
에 들어 있다.


- 보통 환경 변수이기 때문에 변경될 일 X. 안전함
477. 선택: HMAC 서명하기
- 무결성: 데이터가 변함없는 것
- 진정성: 데이터의 출처가 같은 소스 또는 신뢰할 만한 곳


478. 섹션 주제
- 세션을 활용하면 인증을 실행하거나, 사람들을 로그인 상태로 유지하거나, 관련 정보를 기억할 수 있다.
- 세션은 쿠키와 함께 사용된다.
- 페이지에 있는 사용자에게 메시지를 공유하는 도구
479. 세션 개요
- 정보를 저장하는 서버의 데이터베이스와는 목적이 전혀 다르다.
- 세션을 활용하면 본질적으로 무상태 프로토콜인 HTTP에 상태성을 부여할 수 있다.
- 쿠키에 저장할 수 있는 정보량 즉, 크기의 제한
- 서버 측에 정보를 저장하는 것만큼 안전하지 않음
480. Express 세션
npm i express-session
패키지 설치- 이 모듈을 작동하기 위해 cookie-parser를 더이상 사용하지 않아도 된다.
const session = require('express-session');
const sessionOptions = { secret: 'thisisnotagoodsecret' }
app.use(session(sessionOptions));
connect.sid
라는 새로운 쿠키가 추가된다.- Express 세션 쿠키의 이름 (sid는 session id를 뜻함)
- 쿠키 삭제 후 재접속하면 다른 세션 id를 부여받는다.
app.get('/viewcount', (req, res) => {
if (req.session.count) {
req.session.count += 1;
} else {
req.session.count = 1;
}
res.send(`You have viewed this page ${req.session.count} times`)
})

- 실제 DB가 아닌 메모리에 있다.
- Redis 등을 사용하거나 Mongo 세션 저장소를 사용할 수도 있다. (프로덕션)
481. Express 세션 더 알아보기
resave
옵션은 요청 중 수정 사항이 없더라도 세션이 세션 저장소에 다시 저장될 수 있게 하는 것 → false로 설정하기const sessionOptions = { secret: 'thisisnotagoodsecret', resave: false, saveUninitialized: false }
app.get('/register', (req, res) => {
const { username = 'Anonymous' } = req.query;
req.session.username = username;
res.redirect('/greet')
})
app.get('/greet', (req, res) => {
const { username } = req.session;
res.send(`Welcome back, ${username}`)
})
482. 플래시 개요
- 완료 메시지, 확인 메시지 등 한번 뜨고 사라지는 메시지를 말함
- 동작이 끝나고 다른 페이지로 리다이렉트되기 전에 나타나는 것
npm i connect-flash
설치 (express-flash
도 있음)const flash = require('connect-flash');
app.use(flash());
- 작성된 미들웨어나 라우트 핸들러에 포함된 req 객체에 flash 메서드 사용 가능
- 첫 번째 인자로 키 값을 전달하는 방식(error, success, danger, info 등)을 입력
- 두 번째 인자에 메시지 입력
- 리다이렉트 코드보다 먼저 작성해야 한다.
app.get('/farms', async (req, res) => {
const farms = await Farm.find({});
res.render('farms/index', { farms, messages: req.flash('success') })
})
app.post('/farms', async (req, res) => {
const farm = new Farm(req.body);
await farm.save();
req.flash('success', 'Successfully made a new farm!');
res.redirect('/farms')
})
483. Res.locas와 플래시
- 모든 템플릿과 뷰에서 메시지에 접근
app.use((req, res, next) => {
res.locals.messages = req.flash('success');
next();
})
484. 캠프그라운드 경로 빠져나오기
./
→ ../
const express = require('express');
const router = express.Router();
const catchAsync = require('../utils/catchAsync');
const ExpressError = require('../utils/ExpressError');
const Campground = require('../models/campground');
const { campgroundSchema } = require('../schemas');
const validateCampground = (req, res, next) => {
const { error } = campgroundSchema.validate(req.body);
if (error) {
const msg = error.details.map((el) => el.message).join(',');
throw new ExpressError(msg, 400);
} else {
next();
}
};
router.get('/', catchAsync(async (req, res) => { ... }
// ...
const campgrounds = require('./routes/campgrounds');
// ...
app.use('/campgrounds', campgrounds);
485. 리뷰 경로 빠져나오기
const express = require('express');
const router = express.Router({ mergeParams: true });
const catchAsync = require('../utils/catchAsync');
const ExpressError = require('../utils/ExpressError');
const Campground = require('../models/campground');
const Review = require('../models/review');
const { reviewSchema } = require('../schemas');
const validateReview = (req, res, next) => {
const { error } = reviewSchema.validate(req.body);
if (error) {
const msg = error.details.map((el) => el.message).join(',');
throw new ExpressError(msg, 400);
} else {
next();
}
};
router.post('/', validateReview, catchAsync(async (req, res) => {
const reviews = require('./routes/reviews');
../
app.use('/campgrounds/:id/reviews', reviews);
486. 정적 Assets 서비스
app.use(express.static(path.join(_dirname, 'public')));
const forms = document.querySelectorAll('.validated-form');
Array.from(forms).forEach(function (form) {
form.addEventListener('submit', (event) => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated')
}, false)
})
mongoose.connect('mongodb://localhost:27017/yelp-camp', {
useNewUrlParser: true,
useCreateIndex: true,
useUnifiedTopology: true,
useFindAndModify: false,
});

487. 세션 구성하기
npm i express-session
const session = require('express-session');
const sessionConfig = {
secret: 'thisshouldbeabettersecret!',
resave: false,
saveUninitialized: true,
};
app.use(session(sessionConfig));
- 서버 가동을 멈추거나 재개하면 바로 사라진다.

expires
로 만료 기한 설정하기 Date.now()
사용- 밀리초 단위이므로 그에 맞게 일주일 뒤로 설정
const sessionConfig = {
secret: 'thisshouldbeabettersecret!',
resave: false,
saveUninitialized: true,
cookie: {
httpOnly: true,
expires: Date.now() + 1000 * 60 * 60 * 24 * 7,
maxAge: 1000 * 60 * 60 * 24 * 7,
}
};

httpOnly
- (디폴트로 설정되어 있는 옵션이지만 코드에 명확하게 true로 선언함)
- 쿠키에 httpOnly 플래그가 뜨면 클라이언트 측 스크립트에서 해당 쿠키에 접근할 수 없고, XSS에 결함이 있거나 사용자가 결함을 일으키는 링크에 접근하면 브라우저가 제 3자에게 쿠키를 유출하지 않도록 한다.
488. 플래시 설정하기
npm i connect-flash
const flash = require('connect-flash');
// ...
app.use(flash());
- 템플릿으로 항상 success인 키에 자동으로 접근하게 해서 직접 전달할 필요 X.
app.use((req, res, next) => {
res.locals.success = req.flash('success');
next();
});
<%- body %>
위에 <%= success %>
추가하기res.locals.error = req.flash('error');
도 추가489. Flash_Success 파셜
<% if(success && success.length) {%>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<%= success %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% } %>
<!-- <%= success %> 대신 -->
<%- include('../partials/flash')%>
req.flash('success', 'Successfully made a new campground!');
490. Flash_Error 파셜
<% if(error && error.length) {%>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<%= error %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% } %>
router.get('/:id', catchAsync(async (req, res) => {
const campground = await Campground.findById(req.params.id).populate('reviews');
if (!campground) {
req.flash('error', 'Cannot find that campground!');
return res.redirect('/campgrounds');
}
res.render('campgrounds/show', { campground });
})
);

491. 섹션 주제
- Node 앱에서 쉽게 인증을 만들 수 있는 도구
- Facebook, Google, GitHub, Twitter 등 로그인 추가
492. 인증과 권한부여
인증(Authentication)
- 가입 및 로그인
- 보통은 username과 암호로 인증
- 추가 인증으로는 얼굴 인식, 지문 인식 등
권한(Authorization)
- 사용자가 인증된 이후 일어나는 동작들
- 관리자인가 아닌가
- 접근 가능 여부, 편집/삭제가 가능한 대상 확인
493. 패스워드를 저장하거나 하지 않는 방법
494. 암호화된 해시 함수
- 동일한 입력 값은 항상 같은 값으로 출력된다.
- 다양한 조합을 빠른 속도로 시도하지 못하게 하기 위함
495. 패스워드 솔트
- 암호를 해시할 때 암호의 시작이나 끝에 임의의 값을 추가한다.
- 해시 출력 값에 솔트가 추가된다.
- 여러 사이트에서 같은 암호를 쓰는 사람들이나 잘 알려진 쉬운 암호를 사용하는 다수의 사람들을 보호할 수 있다.
496. Bcrypt 개요
- Bcrypt의 B는 복어(Blowfish cipher), crypt는 암호화를 뜻한다.
- bcrypt.js는 JavaScript로만 쓰여져서 클라이언트(브라우저)에서도 실행할 수 있다.
npm i bcrypt
노드 버전 설치.genSalt()
메서드는 암호 솔트를 생성한다.- 첫 번째 인수로 saltRounds를 전달하는데, round는 해시의 난이도로, 숫자가 높을 수록 해시를 계산하는 데 걸리는 시간이 증가한다. (암호를 몇 회 해시할 지를 결정)
- 목적은 과정을 느리게 만드는 데 있다.
.hash()
메서드는 생성된 솔트와 암호를 조합해 해시 암호를 반환한다.const bcrypt = require('bcrypt');
const hashPassword = async (pw) => {
const salt = await bcrypt.genSalt(12);
const hash = await bcrypt.hash(pw, salt);
console.log(salt);
console.log(hash);
};
hashPassword('monkey');

.compare()
메서드로 입력된 암호를 해시해서 데이터베이스에 저장한 값과 비교
const login = async (pw, hashedPw) => {
const result = await bcrypt.compare(pw, hashedPw);
if (result) { // true
console.log('LOGGED YOU IN! SUCCESSFUL MATCH!');
} else { // false
console.log('INCORRECT!');
}
};
login('monkey', '$2b$12$dYG/3tlvvS5Qwp4jesKMSeROTIYWyVqiUPzfa8stWKrrC8U9K7XY6');

const hashPassword = async (pw) => {
const hash = await bcrypt.hash(pw, 12);
console.log(hash);
};
497. Auth Demo: 설정
const { Schema, default: mongoose } = require('mongoose');
const userSchema = new Schema({
username: {
type: String,
required: [true, 'Username cannot be blank'],
},
password: {
type: String,
required: [true, 'Password cannot be blank'],
},
});
module.exports = mongoose.model('User', userSchema);
const express = require('express');
const app = express();
const User = require('./models/user');
app.set('view engine', 'ejs');
app.set('views', 'views');
app.get('/register', (req, res) => {
res.render('register');
});
app.get('/secret', (req, res) => {
res.send('THIS IS SECRET! YOU CANNOT SEE ME UNLESS YOU ARE LOGGED IN!');
});
app.listen(3000, () => {
console.log('SERVING YOUR APP!');
});
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register</title>
</head>
<body>
<h1>Sign up!</h1>
<form action="">
<div>
<label for="username">Enter Username:</label>
<input type="text" name="username" id="username" placeholder="username">
</div>
<div>
<label for="password">Enter Password:</label>
<input type="password" name="password" id="password" placeholder="password">
</div>
<button>Sign Up</button>
</form>
</body>
</html>
498. Auth Demo: 등록
- register.ejs 수정
<form action="/register" method="POST">
const mongoose = require('mongoose');
mongoose
.connect('mongodb://localhost:27017/authDemo', { useNewUrlParser: true })
.then(() => {
console.log('MONGO CONNECTION OPEN!!!');
})
.catch(() => {
console.log('OH NO MONGO CONNECTION ERROR!!!!');
console.log(err);
});
app.use(express.urlencoded({ extended: true })); // req.body 파싱
app.post('/register', async (req, res) => {
res.send(req.body);
})
const bcrypt = require('bcrypt');
app.get('/', (req, res) => {
res.send('THIS IS THE HOME PAGE')
});
app.post('/register', async (req, res) => {
const { password, username } = req.body;
const hash = await bcrypt.hash(password, 12);
const user = new User({
username,
password: hash,
});
await user.save();
res.redirect('/');
});

499. Auth Demo: 로그인
- 암호를 틀렸거나 사용자를 찾을 수 없는 경우, 무엇이 틀렸는지 정확히 알려주면 안된다. ⇒ ‘사용자 이름 또는 암호를 찾을 수 없다’고 해야 함. 힌트 X
app.get('/login', (req, res) => {
res.render('login');
});
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({ username });
const validPassword = await bcrypt.compare(password, user.password);
if (validPassword) {
res.send('YAY WELCOME!!');
} else {
res.send('TRY AGAIN');
}
});


500. Auth Demo: 로그인한 상태로 세션 유지
- 일주일 후에도 재방문하지 않으면 로그아웃되는 식으로 만료 기한을 정할 수 있다.
npm i express-session
const session = require('express-session');
// ...
app.use(session({ secret: 'notagoodsecret' }));
// ...
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({ username });
const validPassword = await bcrypt.compare(password, user.password);
if (validPassword) {
req.session.user_id = user._id;
res.send('YAY WELCOME!!');
} else {
res.send('TRY AGAIN');
}
});
app.get('/secret', (req, res) => {
if (!req.session.user_id) {
res.redirect('/login');
}
res.send('THIS IS SECRET! YOU CANNOT SEE ME UNLESS YOU ARE LOGGED IN!');
});
501. Auth Demo: 로그아웃
- 세션은 전부 서버에 저장되고, 클라이언트로 반환되는 서명된 쿠키가 있다.
- 서명된 쿠키로는 유효성 검사를 할 수 없다.
app.post('/logout', (req, res) => {
req.session.user_id = null;
// req.session.destroy(); 💡 세션을 파기해서 로그아웃 하는 방법
res.redirect('/login');
});
<h1>Secret Page!</h1>
<form action="/logout" method="POST">
<button>Sign Out</button>
</form>
/secret
라우트를 보호해 로그인 한 유저만 접근할 수 있게 하기app.get('/secret', (req, res) => {
if (!req.session.user_id) {
return res.redirect('/login');
}
res.render('secret');
});
502. Auth Demo: 로그인 미들웨어 요구
const requireLogin = (req, res, next) => {
if (!req.session.user_id) {
return res.redirect('/login');
}
next();
}
// ...
app.get('/secret', requireLogin, (req, res) => {
res.render('secret');
});
app.get('/topsecret', requireLogin, (req, res) => {
res.send('TOP SECRET!!!');
});
503. Auth Demo: 모델 메서드 리팩토링
const bcrypt = require('bcrypt');
userSchema.statics.findAndValidate = async function (username, password) {
const foundUser = await this.findOne({ username });
const isValid = await bcrypt.compare(password, foundUser.password);
return isValid ? foundUser : false;
}
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const foundUser = await User.findAndValidate(username, password)
if (foundUser) {
req.session.user_id = foundUser._id;
res.redirect('/secret');
} else {
res.redirect('/login');
}
});
본 스터디는 Udemy의 <【한글자막】 The Web Developer 부트캠프 2022> 강의를 활용해 진행됐습니다. 강의에 대한 자세한 정보는 아래에서 확인하실 수 있습니다.
프밍 스터디는 Udemy Korea와 함께 합니다.