ㆍProject Diary/Next.js + Prisma + MariaDB (KiloFlow)
사용자는 로그인 후 키, 몸무게, 목표 몸무게, 감량 난이도를 설정하여 맞춤형 권장 섭취 칼로리를 계산받을 수 있습니다. 초기 설정이 완료된 후에는 더 이상 초기 설정 페이지가 뜨지 않으며, 회원가입 후 초기 설정은 한 번만 할 수 있습니다.
데이터베이스 스키마
1. users 테이블
model users {
user_id Int @id @default(autoincrement())
email String @unique
password String
nickname String
profile_image String @default("default_image_url")
isInitialSetupComplete Boolean @default(false)
created_at DateTime @default(now())
userProfile UserProfile? // 추가된 부분
@@index([user_id])
}
관계 설정
userProfile UserProfile? // 추가된 부분
- UserProfile 모델과의 관계를 정의합니다.
- 이 필드는 각 사용자가 하나의 프로필 정보를 가지도록 설정하는 데 필요합니다.
2. UserProfile 모델
model UserProfile {
id Int @id @default(autoincrement())
user_id Int @unique
height Float
weight Float
target_weight Float
difficulty String
daily_calories Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user users @relation(fields: [user_id], references: [user_id])
@@index([user_id])
}
관계 설정
user users @relation(fields: [user_id], references: [user_id])
- users 모델과의 관계를 정의합니다.
- user_id를 참조하여 사용자와 프로필 간의 관계를 설정합니다.
왜 관계 설정 필드가 필요한가?
- 관계 설정
- users모델의 userProfile 필드는 UserProfile 모델과의 일대일 관계를 설정하여, 각 사용자가 고유한 프로필 정보를 가지도록 보장합니다. 이는 users 테이블과 UserProfile 테이블 간의 참조 관계를 명확하게 정의합니다.
- UserProfile 모델의 user 필드는 users 모델과의 관계를 설정하여, user_id를 참조합니다. 이를 통해 사용자가 프로필 정보를 가지며, 두 테이블 간의 관계가 명확해집니다. - 데이터 접근의 용이성
- users모델의 userProfile 필드를 통해 사용자의 프로필 정보를 쉽게 접근할 수 있습니다. 예를 들어, 특정 사용자의 프로필 정보를 가져오려면 users 테이블의 userProfile 필드를 통해 바로 접근할 수 있습니다.
- UserProfile 모델의 user 필드를 통해 특정 프로필의 사용자 정보를 쉽게 접근할 수 있습니다. - 데이터 무결성
- userProfile 필드는 각 사용자마다 유일한 프로필을 가지도록 보장하여 데이터 무결성을 유지합니다.
- user 필드는 프로필 정보가 정확한 사용자와 연결되도록 보장합니다.
프론트엔드
초기 설정 페이지 (initialSetting.tsx)
1. 사용자 입력 상태 관리
const [step, setStep] = useState(0);
const [height, setHeight] = useState(160);
const [weight, setWeight] = useState(60);
const [targetWeight, setTargetWeight] = useState(60);
const [difficulty, setDifficulty] = useState("");
const [dailyCalories, setDailyCalories] = useState<number | null>(null);
const [totalDays, setTotalDays] = useState<number | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const chatContainerRef = useRef<HTMLDivElement>(null);
2. 챗봇 메시지 처리
useEffect(() => {
if (step < questions.length && step <= 4) {
// 타이머를 설정하여 일정 시간 후 챗봇 메시지를 자동으로 전송
const timer = setTimeout(() => {
setMessages((prevMessages) => [
// 기존 메시지 목록에 새로운 챗봇 메시지를 추가
...prevMessages,
{ type: "bot", text: questions[step].text },
]);
// 단계(step)를 하나 증가시켜 다음 메시지로 이동
setStep((prevStep) => prevStep + 1);
}, questions[step].delay); // 각 질문마다 다른 지연 시간 설정
// 타이머를 정리하여 메모리 누수를 방지
return () => clearTimeout(timer);
} else if (step < questions.length) {
// 초기 단계가 지나면 타이머 없이 바로 챗봇 메시지 전송
setMessages((prevMessages) => [
...prevMessages,
{ type: "bot", text: questions[step].text },
]);
}
}, [step]);
위 코드를 순서대로 자세히 설명하겠습니다.
2-1
useEffect(() => {
// 챗봇 메시지 처리
}, [step]);
컴포넌트가 마운트되거나 step 상태가 변경될 때마다 실행됩니다.
2-2
if (step < questions.length && step <= 4)
현재 step이 questions 배열의 길이보다 작고, 4 이하인 경우에만 실행됩니다. 즉, 초기 4단계에만 해당 로직이 적용됩니다.
초기 4단계에는 사용자의 응답을 받을 필요가 없기 때문입니다.
(4단계 이전의 메시지들이 시간지연을 갖고 전송되게)
2-3
const timer = setTimeout(() => { ... }, questions[step].delay);
일정 시간(questions[step].delay) 후에 setTimeout 내부의 함수가 실행됩니다.
2-4
setMessages((prevMessages) => [ ...prevMessages, { type: "bot", text: questions[step].text }, ]);
이전 메시지 목록에 새로운 챗봇 메시지 { type: "bot", text: questions[step].text }을 추가합니다.
2-5
setStep((prevStep) => prevStep + 1);
현재 단계(step)를 하나 증가시킵니다.
2-6
else if (step < questions.length) { ... }
step이 questions 배열의 길이보다 작지만, 4보다 큰 경우에 실행됩니다.
2-7
setMessages((prevMessages) => [ ...prevMessages, { type: "bot", text: questions[step].text }, ]);
이전 메시지 목록에 새로운 메시지를 추가합니다.
단계에 따라 챗봇 메시지를 처리하는 이유
- 1,2,3,4 단계는 프로그램에 대해 설명해주는 메세지로 사용자의 답변이 필요하지 않습니다.
- 4딘계 이하는 사용자의 답변을 받지않아도 자동으로 전송되게 하거 5단계 부터는 사용자의 답변이 입력되면 바로 전송되도록 하기 위함입니다..
3. 사용자 입력 처리 및 다음 단계로 이동(5단계 이후)
const handleNextStep = (userInput: string) => {
// 사용자가 입력한 값을 메시지 목록에 추가
setMessages((prevMessages) => [
...prevMessages,
{ type: "user", text: userInput },
]);
// 각 단계에 따라 사용자가 입력한 값을 상태에 저장
if (step === 5) setHeight(parseInt(userInput)); // 키 설정
if (step === 6) setWeight(parseInt(userInput)); // 몸무게 설정
if (step === 7) setTargetWeight(parseInt(userInput)); // 목표 몸무게 설정
if (step === 8) {
// 감량 난이도 설정 및 권장 섭취 칼로리 계산
setDifficulty(userInput);
const bmr = calculateBMR(weight, height); // 기초 대사량 계산
const { dailyCalories, totalDays } = calculateDailyCalories(
weight,
targetWeight,
userInput,
bmr
); // 일일 권장 섭취 칼로리와 총 감량 기간 계산
setDailyCalories(dailyCalories); // 일일 권장 섭취 칼로리 설정
setTotalDays(totalDays); // 총 감량 기간 설정
// 결과 메시지를 사용자에게 보여줌
setMessages((prevMessages) => [
...prevMessages,
{ type: "bot", text: `너의 하루 권장 섭취 칼로리는 ${dailyCalories} kcal이야!` },
{ type: "bot", text: `총 감량 기간은 ${totalDays}일이야!` },
{ type: "bot", text: "그럼 초기 설정을 끝낼까?" },
]);
}
// 단계를 하나 증가시킴
setStep((prevStep) => prevStep + 1);
setInputValue(""); // 입력 필드를 초기화
};
(설명 : 주석 참고)
4. 초기 설정 완료 요청
const completeInitialSetting = async () => {
setLoading(true);
setError("");
try {
const token = localStorage.getItem("token");
const res = await fetch("/api/auth/complete-initial-setting", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
height,
weight,
targetWeight,
difficulty,
dailyCalories,
totalDays,
}),
});
if (res.ok) {
router.push("/");
} else {
setError("초기 설정 완료에 실패했습니다.");
}
} catch (error) {
setError("초기 설정 완료 중 오류가 발생했습니다.");
console.error("초기 설정 완료 중 오류가 발생했습니다.", error);
} finally {
setLoading(false);
}
};
사용자가 입력한 초기 설정 정보를 서버에 전송하여 저장을 요청합니다.
백엔드
초기 설정 API 엔드포인트 (complete-initial-setting.ts)
1. JWT 토큰 검증 및 데이터 파싱
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "../../../lib/prisma";
import jwt from "jsonwebtoken";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({ message: "Unauthorized" });
}
try {
const decoded: any = jwt.verify(token, process.env.JWT_SECRET || "secret");
const { height, weight, targetWeight, difficulty, dailyCalories } = req.body;
// jwt.verify : JWT 토큰을 검증하고, 유저 정보를 디코드합니다.
// req.body : 클라이언트로부터 받은 초기 설정 정보
요청 메소드가 POST인지 확인하고, JWT 토큰을 검증하며, 클라이언트로부터 받은 초기 설정 정보를 파싱합니다.
2. 프로필 정보 저장 및 초기 설정 완료 표시
await prisma.userProfile.create({
data: {
user_id: decoded.userId,
height,
weight,
target_weight: targetWeight,
difficulty,
daily_calories: dailyCalories,
},
});
// prisma.userProfile.create : 프로필 정보를 데이터베이스에 저장
await prisma.users.update({
where: { user_id: decoded.userId },
data: { isInitialSetupComplete: true },
});
// prisma.users.update : isInitialSetupComplete 값을 true로 업데이트하여 초기 설정 완료 표시
return res.status(200).json({ message: "Initial setup complete" });
} catch (error) {
console.log("서버에러", error);
return res.status(500).json({ message: "Internal server error" });
}
}
데이터베이스에 프로필 정보를 저장하고, 초기 설정 완료 상태를 업데이트합니다.
초기 설정 상태 설명
isInitialSetupComplete 상태가 true로 변경되면, 이후 로그인 시 더 이상 초기 설정 페이지가 뜨지 않습니다. 따라서 회원가입 후 초기 설정은 한 번만 수행됩니다.
요약
- 프론트엔드
- 사용자가 입력한 정보를 단계별로 처리하고, 초기 설정 완료 시 서버에 요청을 보냅니다.
- 초기 설정이 완료되면 isInitialSetupComplete 상태가 true로 변경되어, 이후에는 초기 설정 페이지가 뜨지 않습니다. - 백엔드
- 초기 설정 정보를 받아 JWT 토큰을 검증한 후, 데이터베이스에 저장하고 초기 설정 완료 상태를 업데이트합니다.