식단 schema에 필요한 필드는 식단의 주인 user, 날짜, 그 주인이 그 날에 먹은 음식들(아침 / 점심 / 저녁 / 간식) 정도가 되겠다.
쉽게 말해서 user와 food schema를 연결하는 과정이 필요한 것이다.
1. diet 스키마 구성
diet 스키마 구조는 아래와 같이 작성했다.
// models/diet.js
const dietSchema = new Schema({
user: {
type: Schema.Types.ObjectId,
ref: "User",
required: true,
},
breakfast: {
description: {
type: String,
},
foods: [
{
name: String,
calories: Number,
carbs: Number,
sugar: Number,
protein: Number,
fat: Number,
saturated: Number,
amount: Number,
serving: String,
},
],
},
lunch: {
description: {
type: String,
},
foods: [
{
name: String,
calories: Number,
carbs: Number,
sugar: Number,
protein: Number,
fat: Number,
saturated: Number,
amount: Number,
serving: String,
},
],
},
dinner: {
description: {
type: String,
},
foods: [
{
name: String,
calories: Number,
carbs: Number,
sugar: Number,
protein: Number,
fat: Number,
saturated: Number,
amount: Number,
serving: String,
},
],
},
snack: {
description: {
type: String,
},
foods: [
{
name: String,
calories: Number,
carbs: Number,
sugar: Number,
protein: Number,
fat: Number,
saturated: Number,
amount: Number,
serving: String,
},
],
},
date: {
type: Date,
required: true,
},
});
user, 각 시간대에 맞는 food(와 descript) list, date로 구성했다.
그런데 각각 끼니의 object에서 foods : [ { type: Schema.Types.ObjecId, ref: "Food"} ] 로 해야 논리적으로 더 명확하지 않을까? 싶기도 하다. 음식의 정보와 그 양이 있으면 populate+instance method를 이용해서 원하는 음식 정보를 모두 가져올 수 있지 않냐 싶은 것이다.
그러지 않은 이유는 두가지가 있다. 첫번째로 식단 스키마를 짤 때 getNuts 구현을 하기 전이어서 일단 저렇게 짰는데 그래도 필요한 내용이 다 있어서 굳이 수정하지 않았다.
두번째로, index 페이지에선 오늘하루 아침~간식으로 먹은 모든 음식 각각에 대하여 그것들을 모두 getNuts()로 계산하면 쓸데없는 계산이 너무 많아질 것 같기 때문이었다. 물론 막 한 사람이 하루에 메뉴를 1만개씩 먹고 그러는건 아니므로 계산 부하는 얼마 걸리지 않겠지만 매번 음식의 특정 양을 계산하는 것보다는 있는걸로 쓰는게 더 효율적이지 않겠나.. 싶다.
그러나 나중에 코드 리팩토링을 한다하면 이부분은 확실히 populate를 하는 방향으로 수정해야 할 것 같다.
2. 식단 구현
일단 메인 페이지에 들어가면 이렇게 오늘 하루 각 끼니에 맞는 식단과 각 끼니의 영양소, 그리고 오늘 먹은 총 영양소를 보여주고 싶다.
유저가 메인 페이지에 들어가면 로그인을 했는지 확인하고(하지 않았으면 /user/login 페이지로 리다이렉트) 오늘의 식단을 렌더링한다.
// routes/diets.js
const express = require("express");
const router = express.Router();
const { isLoggedIn } = require("../utils/middlewares");
const catchAsync = require("../utils/catchAsync");
const dietController = require("../controllers/diet");
router.get("/", isLoggedIn, catchAsync(dietController.renderTodayDiet));
유저가 특정 날짜에 홈페이지에 접속하면 그 특정 날짜의 식단을 보여줘야한다. 만약 해당 날짜에 처음 들어와 식단이 없는 경우 그 유저와 날짜에 맞는 식단 객체(아래 코드에선 ate)를 새로 생성한다.
// routes/diets.js
const Diet = require("../models/diet");
const moment = require("moment-timezone");
module.exports.renderTodayDiet = async (req, res, next) => {
const dateSeoul = moment.tz(Date.now(), "Asia/Seoul").format("YYYY-MM-DD");
let ate = await Diet.findOne({
user: req.user._id,
date: dateSeoul,
});
if (!ate) {
ate = new Diet({ user: req.user._id, date: dateSeoul });
await ate.save();
}
res.render("diets/index", { ate });
};
nodejs 서버의 date와 한국의 date가 맞지 않아 moment-timezone 모듈을 사용했다. dateSeoul의 날짜에 맞는 식단이 있다면 그걸 그대로 index.ejs에 보내주고 없다면 user과 date를 알맞게 설정한 Diet 객체를 만들어 전달한다.
이제 각 끼니에 식단을 추가할 수 있는 버튼을 만들었다. 버튼을 누르면 음식 사전 페이지로 가게되고, 음식 카드에 "해당 끼니에 현재 음식 추가"버튼을 보이게 할 것이다.
<a type="button" href="/add/breakfast" class="btn btn-outline-info">+</a>
href="/add/breakfast"의 breakfast 부분에 점심이면 lunch, 저녁이면 dinner, 간식이면 snack을 넣었다.
// routes/diets.js
router.get("/add/:time", isLoggedIn, dietController.setTime);
router.get("/addfood", isLoggedIn, catchAsync(dietController.addFood));
이를 받는 식단 라우터이다. 끼니 시간 부분이 time이라는 req.params로 구성된다.
// controllers/diets.js
module.exports.setTime = (req, res) => {
req.session.adding = req.params.time;
res.redirect("/food");
};
setTime은 서버의 세션의 adding키에 현재 time(breakfast/lunch/dinner/snack)을 설정하도록 했다.
/food는 음식 목록 index를 렌더링하는 path인데, req.session.adding이 존재하면 음식 사전에서 현재 식단 추가 버튼을 볼 수 있도록 ejs파일을 설정할 것이다.
// controllers/foods.js
module.exports.showFood = async (req, res, next) => {
const adding = req.session.adding;
if (req.session.anotherfood) {
const food = { ...req.session.anotherfood._doc };
if (adding) {
req.session.curFood = food;
}
delete req.session.anotherfood;
res.render("foods/show", { food, adding });
} else {
const food = await Food.findById(req.params.id);
if (adding) {
req.session.curFood = food;
}
res.render("foods/show", { food, adding });
}
};
참고로 req.session.anotherfood는 저번 포스트에서 설명했던 g/개/회에 따라 달라진 음식의 영양정보이다. 만약 DB에서 그대로 가져온게 아닌, 양이 변경된 음식 정보가 있다면 변경된 음식을 식단에 가져와야한다. 그래서 anotherfood의 유무에 따라
req.session.adding이 존재하면 req.session.curFood에 음식정보를 담는다. curFood는 유저가 '식단 추가'버튼을 눌렀을 때 현재 식단에 추가할 음식을 미리 준비해놓는 것이다.
<%if (adding) {%>
<a href="/addfood" class="btn btn-outline-info m-1">식단 추가</a>
<%}%>
views/foods/show.ejs파일의 한부분이다. adding이 존재해야만 식단 추가 버튼이 렌더링될 것이다. 버튼을 누르면 아래 controller 코드가 실행된다.
// controllers/diet.js
module.exports.addFood = async (req, res, next) => {
const curFood = { ...req.session.curFood };
const dateSeoul = moment.tz(Date.now(), "Asia/Seoul").format("YYYY-MM-DD");
delete req.session.curFood;
// 1. 알맞은 식단 찾기
const diet = await Diet.findOne({
user: req.user._id,
date: dateSeoul,
});
// 2. 알맞은 시간에 식단 추가
const foods = [...diet[req.session.adding].foods];
const idx = foods.findIndex(function (item) {
return item.name === curFood.name;
});
if (idx > -1) {
// 2-1. 이미 식단에 음식이 존재함 -> food[idx]에 그대로 더함
foods[idx].calories += curFood.calories;
foods[idx].amount += curFood.amount;
foods[idx].carbs += curFood.carbs;
foods[idx].sugar += curFood.sugar;
foods[idx].protein += curFood.protein;
foods[idx].fat += curFood.fat;
foods[idx].saturated += curFood.saturated;
} else {
// 2-2. 새로운 음식 -> food 추가
const newFood = {
name: curFood.name,
calories: curFood.calories,
carbs: curFood.carbs,
sugar: curFood.sugar,
protein: curFood.protein,
fat: curFood.fat,
saturated: curFood.saturated,
amount: curFood.amount,
serving: curFood.serving,
};
foods.push(newFood);
}
// 3. update & saving
diet[req.session.adding].foods = foods;
await diet.save();
delete req.session.adding;
delete req.session.curFood;
res.redirect("/");
};
이번 프로젝트에서 가장 더러운 코드..가 나왔다. 해당 시간에 음식이 존재하냐의 여부에 따라 새로운 object 생성 여부를 나눴는데 그때문에 코드가 길엉진 것 같다. 또 diet model에 있는 음식들은 food모델과 완전히 같은 것도 아니라서 일일이 값을 따와야되기 때문인 것도 있지만.
코드 주석에 설명된대로 진행 + 마지막 session clearing까지 완료하면 addFood 끝이다.
협업을 한다면 기존 식단에 음식을 추가하는 것도 모듈화 할 수 있겠다.
3. 날짜 검색 기능
오늘의 식단을 렌더링 하는 것과 똑같이 구현하면 된다. 다만 date가 유저가 폼을 통해 작성한 date라는 점을 제외하고는.
views/diets/search.ejs에 다음 폼을 구성했다.
<form action="/search" class="needs-validation" method="post" novalidate>
<div class="row justify-content-center align-items-center mb-3">
<div class="col-md-3 col-12">
<select class="form-select" name="year" required>
<option <%=(inputDate)?"":"selected"%> value="" disabled>년</option>
<%for (let i=2022;i<=2022;i++) {%>
<option value="<%=i%>" <%=(inputDate&&parseInt(inputDate.slice(0,4))===i)?"selected":""%>><%=i%></option>
<%}%>
</select>
</div>
<div class="col-md-2 col-4">
<select class="form-select" name="month" required>
<option <%=(inputDate)?"":"selected"%> value="" disabled>월</option>
<%for (let i=1;i<=12;i++) {%>
<option value="<%=i%>" <%=(inputDate&&parseInt(inputDate.slice(5,7))===i)?"selected":""%>><%=i%></option>
<%}%>
</select>
</div>
<div class="col-md-2 col-4">
<select class="form-select" name="day" required>
<option <%=(inputDate)?"":"selected"%> value="" disabled>일</option>
<%for (let i=1;i<=31;i++) {%>
<option value="<%=i%>" <%=(inputDate&&parseInt(inputDate.slice(8))===i)?"selected":""%>><%=i%></option>
<%}%>
</select>
</div>
<div class="col-md-2 col-4 d-flex justify-content-center">
<button type="submit" class="btn btn-outline-info">검색!</button>
</div>
</div>
</form>
그리고 버튼이 눌리면 아래 코드가 동작한다.
// controllers/diet.js
module.exports.searchPrevDiet = async (req, res, next) => {
const { year, month, day } = req.body;
const inputDate = moment
.tz(new Date(year, month - 1, day), "Asia/Seoul")
.format("YYYY-MM-DD");
const ate = await Diet.findOne({
user: req.user._id,
date: inputDate,
});
res.render("diets/search", { ate, inputDate });
};
다시 말하지만 node 서버의 new Date()와 현재 한국의 시간이 달라 moent-tiemzone 모듈을 사용했다.
해당 날짜의 식단이 있으면 ate 객체를 그대로 넘겨주고, 없으면 null값인 ate를 ejs 파일에서 어떻게 잘 처리할 수 있도록 했다.
세세한 attribut에 관한 표시는 포스트에서 다룰만한 내용이 아닌 것 같아 많이 생략했다(...)
나중에 잊어버렸을 때를 대비해 블로그에 정리해놓을만한 내용은 전부 다 적은 것 같다.
이제 새로운 프로젝트 / 프론트단에서 유저 인풋에 alert 넣기 등의 작업을 해야겠다..