통계시각화 - Part1 달성률 계산

Project Diary/Next.js + Prisma + MariaDB (KiloFlow)

이 포스팅에서는 음식 및 운동을 등록할 때 하루의 달성률을 계산하고 저장하는 방법을 설명합니다. 하루에 이미 등록된 달성률이 있는지 확인하고, 없으면 생성(Create)하고, 있으면 업데이트(Update)하는 과정을 다룹니다.


 

데이터베이스 스키마

 

achievement 테이블

model achievement {
  id          Int      @id @default(autoincrement())
  user_id     Int      // 해당 기록을 보유한 사용자 ID
  date        DateTime // 달성률이 기록된 날짜
  achievement Int      // 계산된 달성률

  user        users    @relation(fields: [user_id], references: [user_id], onDelete: Cascade)

  @@index([user_id, date], name: "user_date_unique")
}
  • user users @relation(fields: [user_id], references: [user_id], onDelete: Cascade)
    사용자가 삭제될 때 관련 기록도 삭제됩니다.

 

프론트엔드

 

1. 달성률 계산

 

1-1 달성률 계산할 때 필요한 데이터

const [foodData, setFoodData] = useState<TodayFood[]>([]); // 오늘 먹은 음식 데이터를 저장하는 상태
const [exerciseData, setExerciseData] = useState<ExerciseData[]>([]); // 오늘 수행한 운동 데이터를 저장하는 상태
const [dailyCalories, setDailyCalories] = useState(2000); // 하루 권장 칼로리 섭취량을 저장하는 상태

useEffect(() => {
  const fetchData = async () => {
    try {
      const token = localStorage.getItem('token'); // 로컬 스토리지에서 토큰을 가져옴
      if (token) {
        const response = await fetch('/api/auth/me', {
          headers: {
            Authorization: `Bearer ${token}`, // 토큰을 사용하여 인증 헤더 설정
          },
        });
        if (response.ok) {
          const data = await response.json(); // 사용자 데이터를 JSON 형태로 파싱
          setCurrentUserID(data.user.user_id); // 현재 사용자 ID 설정
          setDailyCalories(data.userProfile.daily_calories); // 사용자 프로필의 하루 권장 칼로리 섭취량 설정
          await fetchTodayFoodData(data.user.user_id); // 오늘 먹은 음식 데이터를 가져오는 함수 호출
          await fetchTodayExerciseData(data.user.user_id); // 오늘 수행한 운동 데이터를 가져오는 함수 호출
          setIsTodayDataLoaded(true); // 오늘의 데이터가 로드되었음을 표시하는 상태 설정
        } else {
          throw new Error('데이터를 불러오는 데 실패했습니다.'); // 데이터 로드 실패 시 에러 발생
        }
      }
    } catch (error) {
      console.error('API 요청 에러:', error); // API 요청 에러 시 콘솔에 에러 로그 출력
    }
  };

  const fetchTodayFoodData = async (userId: number) => {
    const today = new Date(); // 현재 날짜를 가져옴
    const isoDate = new Date(
      Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())
    ).toISOString().split('T')[0]; // 오늘 날짜를 ISO 형식으로 변환

    try {
      const res = await fetch(
        `/api/food/todayFood?user_id=${userId}&date=${isoDate}`, // 특정 사용자와 날짜의 오늘 먹은 음식 데이터를 가져오는 API 호출
        {
          method: 'GET',
        }
      );

      if (res.ok) {
        const data = await res.json(); // 응답 데이터를 JSON 형태로 파싱
        setFoodData(data); // 가져온 데이터를 foodData 상태에 설정
      } else {
        alert('오늘의 음식 데이터를 불러오는 데 실패했습니다.'); // 데이터 로드 실패 시 알림
      }
    } catch (err) {
      alert('오늘의 음식 데이터를 불러오는 데 실패했습니다.'); // 데이터 로드 실패 시 알림
    }
  };

  const fetchTodayExerciseData = async (userId: number) => {
    const today = new Date(); // 현재 날짜를 가져옴
    const isoDate = new Date(
      Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())
    ).toISOString().split('T')[0]; // 오늘 날짜를 ISO 형식으로 변환

    try {
      const res = await fetch(
        `/api/exercise/todayExercise?user_id=${userId}&date=${isoDate}`, // 특정 사용자와 날짜의 오늘 수행한 운동 데이터를 가져오는 API 호출
        {
          method: 'GET',
        }
      );

      if (res.ok) {
        const data = await res.json(); // 응답 데이터를 JSON 형태로 파싱
        setExerciseData(data); // 가져온 데이터를 exerciseData 상태에 설정
      } else {
        alert('오늘의 운동 데이터를 불러오는 데 실패했습니다.'); // 데이터 로드 실패 시 알림
      }
    } catch (err) {
      alert('오늘의 운동 데이터를 불러오는 데 실패했습니다.'); // 데이터 로드 실패 시 알림
    }
  };

  fetchData(); // fetchData 함수 호출
}, []);

(설명 : 주석참고)

  • 달성률 계산에 필요한 데이터
    - foodData(사용자가 오늘 먹은 음식 데이터)
    - exerciseData(사용자가 오늘 수행한 운동 데이터)
    - dailyCalories(사용자의 권장 섭취 칼로리)

1-2 달성률 계산 (useAchievement 훅)

import { useState, useEffect } from 'react';

interface Food {
  calorie: number; // 음식의 칼로리 정보를 담는 인터페이스
}

interface Exercise {
  calories: number; // 운동의 소모 칼로리 정보를 담는 인터페이스
}

interface AchievementParams {
  foodData: Food[]; // 음식 데이터 배열
  exerciseData: Exercise[]; // 운동 데이터 배열
  dailyCalories: number; // 하루 권장 칼로리 섭취량
}

const useAchievement = ({ foodData, exerciseData, dailyCalories }: AchievementParams) => {
  const [consumedCalories, setConsumedCalories] = useState(0); // 섭취한 칼로리 상태
  const [burnedCalories, setBurnedCalories] = useState(0); // 소모한 칼로리 상태
  const [achievement, setAchievement] = useState(0); // 달성률 상태
  const [loading, setLoading] = useState(true); // 로딩 상태

  useEffect(() => {
    const totalConsumedCalories = foodData.reduce(
      (total: number, food: any) => total + Number(food.calorie), // 음식 데이터의 칼로리를 합산
      0
    );

    setConsumedCalories(totalConsumedCalories); // 섭취한 칼로리 설정

    const totalBurnedCalories = exerciseData.reduce(
      (total: number, exercise: any) => total + exercise.calories, // 운동 데이터의 소모 칼로리를 합산
      0
    );

    setBurnedCalories(totalBurnedCalories); // 소모한 칼로리 설정

    let newAchievement = 0; // 새로운 달성률 초기화

    if (totalConsumedCalories <= dailyCalories) {
      newAchievement = Math.floor(
        ((totalConsumedCalories + totalBurnedCalories) / dailyCalories) * 100
      ); // 섭취 칼로리가 하루 권장 칼로리보다 적을 경우 달성률 계산
    } else if (totalConsumedCalories > dailyCalories) {
      newAchievement = Math.floor(
        ((dailyCalories + totalBurnedCalories) / totalConsumedCalories) * 100
      ); // 섭취 칼로리가 하루 권장 칼로리보다 많을 경우 달성률 계산
    }

    newAchievement = Math.min(newAchievement, 100); // 달성률은 최대 100%

    setAchievement(newAchievement); // 달성률 설정
    setLoading(false); // 로딩 상태 해제
  }, [dailyCalories, foodData, exerciseData]); // 의존성 배열에 따라 useEffect 실행

  return { achievement, loading, consumedCalories, burnedCalories }; // 달성률, 로딩 상태, 섭취 칼로리, 소모 칼로리 반환
};

export default useAchievement;

(설명 : 주석참고)

  • consumedCalories : 하루에 총 섭취한 칼로리
  • burnedCalories : 하루에 총 소모한 칼로리
  • achievement : 계산된 달성률

 

2. 오늘 먹은 음식 및 오늘 수행한 운동 추가

const addTodayFood = async () => {
  try {
    const res = await fetch('/api/food/todayFood', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        user_id: currentUserId, // 현재 사용자 ID
        food_id: id, // 음식 ID
      }),
    });

    if (res.ok) {
      const rec = await res.json(); // 응답 데이터를 JSON 형태로 파싱
      const newFoodData = [...foodData, { calorie }]; // 새로운 음식 데이터를 기존 foodData 배열에 추가
      setFoodData(newFoodData); // foodData 상태 업데이트

      const newAchievement = achievement; // 새로운 달성률 계산
      const today = new Date(); // 현재 날짜를 가져옴
      const isoDate = new Date(
        Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())
      ).toISOString().split('T')[0]; // 오늘 날짜를 ISO 형식으로 변환

      try {
        const res = await fetch(
          `/api/achievement/get?user_id=${currentUserId}&date=${isoDate}`,
          {
            method: 'GET',
          }
        );

        if (res.ok) {
          await fetch('/api/achievement/update', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              user_id: currentUserId, // 현재 사용자 ID
              date: isoDate, // 오늘 날짜
              achievement: newAchievement, // 새로운 달성률
            }),
          });
          router.back(); // 이전 페이지로 이동
        } else {
          await fetch('/api/achievement/create', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              user_id: currentUserId, // 현재 사용자 ID
              date: isoDate, // 오늘 날짜
              achievement: newAchievement, // 새로운 달성률
            }),
          });
          router.back(); // 이전 페이지로 이동
        }
      } catch (error) {
        console.error('Failed to update or create achievement:', error); // 달성률 업데이트 또는 생성 실패 시 콘솔에 에러 로그 출력
      }

      alert(`${name} ${rec.message}`); // 음식 추가 성공 시 알림
    } else {
      alert('추가에 실패했습니다.'); // 음식 추가 실패 시 알림
    }
  } catch (err) {
    alert('추가에 실패했습니다.'); // 음식 추가 실패 시 알림
  }
};

음식 추가 후, 새로운 달성률을 계산하여 오늘 날짜의 달성률을 업데이트

 

1 ) 특정 사용자가 특정 날짜에 등록된 달성률(achievement) 기록이 있는지 확인
const res = await fetch(
          `/api/achievement/get?user_id=${currentUserId}&date=${isoDate}`,
          {
            method: 'GET',
          }
        );​

 

2) 해당 날짜에 등록된 달성률 기록이 없다면, 새로운 기록을 생성
 if (res.ok) {  // 조회된 데이터 없음
          await fetch('/api/achievement/update', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              user_id: currentUserId, // 현재 사용자 ID
              date: isoDate, // 오늘 날짜
              achievement: newAchievement, // 새로운 달성률
            }),
          });
          router.back(); // 이전 페이지로 이동
        }​

 

3 ) 이미 achievement 기록이 존재한다면, 해당 기록을 업데이트

 else {  // 데이터가 이미 존재
          await fetch('/api/achievement/create', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              user_id: currentUserId, // 현재 사용자 ID
              date: isoDate, // 오늘 날짜
              achievement: newAchievement, // 새로운 달성률
            }),
          });
          router.back(); // 이전 페이지로 이동
        }​

 


 

백엔드

 

1. 달성률 조회 API 엔드포인트

import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    const { user_id, date } = req.query; // 쿼리 파라미터에서 사용자 ID와 날짜를 추출

    console.log('user_id', user_id, date);

    const startOfDay = new Date(date as string);
    startOfDay.setUTCHours(0, 0, 0, 0); // 날짜의 시작 시간 설정

    const endOfDay = new Date(date as string);
    endOfDay.setUTCHours(23, 59, 59, 999); // 날짜의 끝 시간 설정

    try {
      const achievement = await prisma.achievement.findFirst({
        where: {
          user_id: Number(user_id), // 해당 사용자 ID와 날짜의 기록을 조회
          date: {
            gte: startOfDay,
            lte: endOfDay,
          },
        },
      });

      if (!achievement) {
        return res.status(404).json({ error: 'Achievement not found' }); // 기록이 없을 경우 404 반환
      }

      res.status(200).json(achievement); // 기록이 있을 경우 200 반환
    } catch (error) {
      console.error(error);
      res.status(500).json({ error: 'Failed to get achievement' }); // 서버 에러 시 500 반환
    }
  } else {
    res.status(405).json({ error: 'Method not allowed' }); // 허용되지 않은 메서드 요청 시 405 반환
  }
}

 

날짜의 시작 시간과 끝 시간을 설정하는 이유

 const startOfDay = new Date(date as string);
 startOfDay.setUTCHours(0, 0, 0, 0); // 날짜의 시작 시간 설정

 const endOfDay = new Date(date as string);
 endOfDay.setUTCHours(23, 59, 59, 999); // 날짜의 끝 시간 설정
  • 특정 날짜 범위(하루) 내의 achievement 기록을 정확히 조회하기 위함입니다.

 

2. 달성률 생성 API 엔드포인트

import { NextApiRequest, NextApiResponse } from "next";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === "POST") {
    const { user_id, date, achievement } = req.body;

    try {
      const newAchievement = await prisma.achievement.create({
        data: {
          user_id, // 사용자 ID
          date: new Date(date), // 기록 날짜
          achievement, // 달성률
        },
      });

      res.status(200).json(newAchievement); // 생성된 기록 반환
    } catch (error) {
      console.error(error);
      res.status(500).json({ error: "Failed to create achievement" }); // 서버 에러 시 500 반환
    }
  } else {
    res.status(405).json({ error: "Method not allowed" }); // 허용되지 않은 메서드 요청 시 405 반환
  }
}

 

 

3. 달성률 업데이트 API 엔드포인트

import { NextApiRequest, NextApiResponse } from "next";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === "POST") {
    const { user_id, date, achievement } = req.body;

    const startOfDay = new Date(date as string);
    startOfDay.setUTCHours(0, 0, 0, 0); // 날짜의 시작 시간 설정

    const endOfDay = new Date(date as string);
    endOfDay.setUTCHours(23, 59, 59, 999); // 날짜의 끝 시간 설정

    try {
      const updatedAchievement = await prisma.achievement.updateMany({
        where: {
          user_id, // 사용자 ID
          date: {
            gte: startOfDay,
            lte: endOfDay,
          },
        },
        data: {
          achievement, // 업데이트할 달성률
        },
      });

      if (updatedAchievement.count === 0) {
        return res.status(404).json({ error: "Achievement not found" }); // 기록이 없을 경우 404 반환
      }

      res.status(200).json(updatedAchievement); // 업데이트된 기록 반환
    } catch (error) {
      console.error(error);
      res.status(500).json({ error: "Failed to update achievement" }); // 서버 에러 시 500 반환
    }
  } else {
    res.status(405).json({ error: "Method not allowed" }); // 허용되지 않은 메서드 요청 시 405 반환
  }
}

 

 


 

달성률 등록