공부의 숲 프로젝트 회고록

2025. 7. 11. 13:48·프로젝트

 

프로젝트 개요

공부의 숲에서 스터디를 만들어서 습관들을 관리하고 기록을 볼 수 있습니다.

 

 

담당 작업

백엔드

  • 오늘의 습관 수정
const modifyHabits = async (data) => {
  const habits = data.habits;
  const result = [];
  await Promise.all(
    habits.map(async (habitElement) => {
      if (habitElement.id) {
        const isHabit = await prisma.habit.findUnique({
          where: { id: habitElement.id },
        });

        if (isHabit) {
          // update
          result.push(
            await prisma.habit.update({
              where: {
                id: habitElement.id,
              },
              data: {
                name: habitElement.name,
                deletedAt: habitElement.deletedAt,
              },
            })
          );
        }
      } else if (habitElement.studyId) {
        result.push(
          await prisma.habit.create({
            data: {
              studyId: habitElement.studyId,
              name: habitElement.name,
            },
          })
        );
      }
    })
  );
  return result;
};

기존에는 habit 목록을 수정하고 삭제하는 기능을 하는 api 였습니다.

프론트 쪽의 요청으로 삭제, 수정, 생성을 한번의 요청으로 처리할 수 있도록 변경하였습니다.

오늘의 습관 수정

  • 오늘의 습관 완료 여부 수정
const modifyDailyHabitCheck = async (habitId, status, start, end) => {
  const now = new Date();
  const dailyHabitCheck = await prisma.dailyHabitCheck.findFirst({
    where: {
      habitId,
      date: {
        gte: new Date(start),
        lte: new Date(end),
      },
    },
  });

  if (dailyHabitCheck) {
    return await prisma.dailyHabitCheck.update({
      where: {
        id: dailyHabitCheck.id,
      },
      data: {
        status,
        date: new Date(now),
      },
    });
  } else {
    return await prisma.dailyHabitCheck.create({
      data: {
        habitId,
        date: new Date(now),
      },
    });
  }
};

오늘의 습관 완료 여부는 start, end를 받아서 오늘 하루 동안의 기간을 설정하고

해당 범위 내에 습관이 존재하면 완료여부를 변경하고, 없으면 완료 상태의 데이터를 생성합니다.

 

 

프론트엔드

(스터디 상세 페이지)

  • [FE] 이모지 리액션 기능
import EmojiTag from "@/common/EmojiTag";
import EmojiPicker from "emoji-picker-react";
import emojiCreateImg from "../assets/icons/emoji_create.png";
import plusImg from "../assets/icons/ic_plus.png";
import { useEffect, useRef, useState } from "react";
import { getReactions, patchReaction, postReaction } from "@/api/reactionApi";
import useDebounceCallback from "@/hooks/useDebounceCallback";

function EmojiForm({ studyId }) {
  const [emojis, setEmojis] = useState([]);
  const [isAddMod, setIsAddMod] = useState(false);
  const [isShowAll, setIsShowAll] = useState(false);
  const [isChanged, setIsChanged] = useState(true);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const fetchReactions = async () => {
      const data = await getReactions(studyId);
      setEmojis(data.reactionList);
      setIsChanged(false);
      setTimeout(()=> {
        setIsLoading(false)
      },600)
    };
    if (isChanged) fetchReactions();
  }, [isChanged]);

  const onShowAllClick = () => {
    setIsShowAll(!isShowAll);
  };

  const onEmojiTagClick = useDebounceCallback(async (emoji) => {
    setIsLoading(true);
    const findEmoji = emojis.find((element) => element.emoji === emoji);
    await patchReaction(studyId, findEmoji.id, { counts: 1 });
    setIsChanged(true);
  }, 200);

  const onEmojiClick = useDebounceCallback(async (emojiData) => {
    setIsLoading(true);
    const isEmoji = emojis.find((element) => emojiData.emoji === element.emoji);
    if (isEmoji) {
      const patchData = { counts: 1 };
      const reactionId = isEmoji.id;
      await patchReaction(studyId, reactionId, patchData);
    } else {
      const newEmoji = emojiData.emoji;
      await postReaction(studyId, newEmoji);
    }
    setIsChanged(true);
    setIsAddMod(false);
  }, 200);

  useEffect(() => {
    if (isChanged) {
      const sortedEmojis = [...emojis].sort((a, b) => b.counts - a.counts);
      setEmojis(sortedEmojis);
      setIsChanged(false);
    }
  }, [isChanged]);

  return (
    <div className="flex gap-4">
      {!isLoading ? (
        <div className="flex gap-1">
          {emojis.map((element, index) => {
            if (index < 3)
              return (
                <div
                  className="cursor-pointer"
                  key={index}
                  onClick={() => onEmojiTagClick(element.emoji)}
                >
                  <EmojiTag
                    emoji={element.emoji}
                    count={element.counts}
                    size={"base"}
                  />
                </div>
              );
          })}
        </div>
      ) : (
        <div className="flex gap-1 animate-pulse bg-gradient-to-r from-red-400 via-yellow-400 to-blue-400 bg-[length:200%_100%] bg-clip-text text-transparent">
          <div className="flex items-center gap-1 px-2 py-1 bg-f-black bg-opacity-40 rounded-full h-[31px] w-[56px]"></div>
          <div className="flex items-center gap-1 px-2 py-1 bg-f-black bg-opacity-40 rounded-full h-[31px] w-[56px]"></div>
          <div className="flex items-center gap-1 px-2 py-1 bg-f-black bg-opacity-40 rounded-full h-[31px] w-[56px]"></div>
          <div className="flex items-center gap-1 px-2 py-1 bg-f-black bg-opacity-40 rounded-full h-[31px] w-[45px] ml-4"></div>
          <div className="flex items-center gap-1 px-2 py-1 bg-f-black bg-opacity-40 rounded-full h-[31px] w-[65px] ml-4"></div>
        </div>
      )}

      <div className="flex">
        {(emojis.length > 3 && !isLoading) && (
          <div className="lg:block hidden">
            <div
              className="cursor-pointer flex gap-1 p-2 h-8 rounded-[50px] items-center text-[14px] text-white bg-black opacity-20"
              onClick={onShowAllClick}
            >
              <img src={plusImg} />
              {emojis.length - 3}..
            </div>
          </div>
        )}
        {(isShowAll&&!isLoading) && (
          <div className="lg:visible invisible pl-5 -translate-x-52 border p-4 gap-1 mt-12 absolute bg-white grid grid-cols-4 place-items-center rounded-[20px] ">
            {emojis.map((element, index) => {
              return (
                <div
                  className="h-10 flex items-center cursor-pointer"
                  draggable="false"
                  onClick={() => onEmojiTagClick(element.emoji)}
                >
                  <EmojiTag
                    emoji={element.emoji}
                    count={element.counts}
                    size={"base"}
                  />
                </div>
              );
            })}
          </div>
        )}
      </div>
      <div>
        {!isLoading && (
          <>
            <div className={emojis.length === 0 && "relative right-8"}>
              <div
                className="border flex lg:w-[70px] md:w-[65px] h-8 text-16pt gap-2 items-center lg:p-1 p-1 rounded-[50px] cursor-pointer"
                onClick={() => setIsAddMod(!isAddMod)}
              >
                <img src={emojiCreateImg} />
                <div className="md:text-sm lg:text-lg">추가</div>
              </div>
              {isAddMod && (
                <div className={`absolute mt-3 md:translate-x-0 z-20 ${(emojis.length > 1) ? "-translate-x-36" : "translate-x-0"}`}>
                  <EmojiPicker onEmojiClick={onEmojiClick} />
                </div>
              )}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

export default EmojiForm;

 

 

이모지 태그를 클릭하거나, 이모지 피커에서 이모지를 클릭하면 카운트가 추가되면서

이모지 태그에 존재하면 +1, 없으면 새로 이모지, 카운트:1 이런 식으로 새로운 태그가 만들어집니다.

이모지 태그를 구현할 때 가장 고민했던 부분이 사용자가 과도하게 클릭했을 때

여러 요청이 들어가지 않게 하는 것이었고 그것을 해결하기 위해 useDebounceCallback 커스텀 훅을

사용하였습니다. 그런데 문제가 클릭하고 patch 요청을 보내고 다시 get 요청을 하면서 사용자 입장에서

잠시 이모지 태그가 멈췄다가 증가하는 것처럼 보이게 되었습니다.

그래서 그 사이에 스켈레톤 UI를 적용하여 UX를 좀 더 향상시켰습니다.

 

 

[FE] 패스워드 검증 기능

import { useState } from "react";
import hideIcon from "../assets/icons/btn_visibility_on.svg";
import openIcon from "../assets/icons/btn_visibility_off.svg";

const PasswordValidation = ({
  id,
  label,
  placeholder,
  validateFn = () => "", // 기본값
  onChange,
  onValidate,
}) => {
  const [value, setValue] = useState("");
  const [error, setError] = useState("");
  const [isVisible, setIsVisible] = useState(false);

  const handleBlur = () => {
    const validationError = validateFn(value);
    setError(validationError);
    onValidate?.(validationError);
  };

  const handleChange = (e) => {
    const newValue = e.target.value || "";
    setValue(newValue);
    if (error) setError("");
    onChange?.(e);
  };
  return (
    <div className="flex flex-col space-y-1 relative w-full">
      <label className="text-lg font-semibold" htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        type={isVisible ? "text" : "password"}
        value={value}
        placeholder={placeholder}
        onChange={handleChange}
        onBlur={handleBlur}
        className={`border h-12 rounded-xl p-3 w-full ${
          error ? "border-f-error" : "border-gray-200"
        }`}
      />
      <button
        type="button"
        onClick={() => setIsVisible(!isVisible)}
        className="absolute right-3 top-[42px] w-6 h-6"
      >
        <img
          src={isVisible ? hideIcon : openIcon}
          alt={isVisible ? "비밀번호 숨기기" : "비밀번호 보이기"}
        />
      </button>
      {error && <span className="text-f-error text-sm">{error}</span>}
    </div>
  );
};

export default PasswordValidation;

 

패스워드 검증은 api에 요청을 보내서 검증 여부를 체크하는데,

여기서 페이지 이동을 할 때 고민했던 부분이 링크 주소에 studyId를 넣게 되면

studyId만 알면 습관을 수정할 수 있게 되고, 타이머에도 접근할 수 있게 되어서

보안상 좋지 않다고 생각했습니다.

그래서 state로 studyData와 password를 넘겨서 이동한 페이지에서 그 값을 useLocation으로 다시 불러오는 방식으로 구현했습니다.

 

 

[FE] 공유하기 기능

import { useState } from "react";
import CopyToClipboard from "react-copy-to-clipboard";
import {
  EmailIcon,
  FacebookIcon,
  FacebookShareButton,
  LineIcon,
  LineShareButton,
  LinkedinIcon,
  LinkedinShareButton,
} from "react-share";

function ShareModal({ onClose, title, description }) {
  const currentUrl = window.location.href;
  const [copied, setCopied] = useState(false);
  const mailBody = encodeURIComponent(`${description}\\n\\n${currentUrl}`)
  const mailLink = `mailto:?subject=${title}&body=${mailBody}`

  const handleEmailShare = () => {
    window.open(mailLink, "_blank");
  }

  return (
    <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-70 z-50">
      <div className="bg-white rounded-[20px] shadow-xl p-6 w-full max-w-[648px] h-[369px] font-sans relative flex flex-col mx-[19px]">
        <div className="flex items-center justify-center relative mb-6">
          <h2 className="text-[24px] max-[743px]:text-[18px] font-extrabold text-center flex-1">
            공유하기
          </h2>
          <div className="min-[744px]:block absolute right-0  mt-4 text-[#578246] hover:text-green-700 ">
            <button onClick={onClose} isCancel>
              나가기
            </button>
          </div>
        </div>

        <div className="mt-14 flex justify-center gap-6">
          <div onClick={handleEmailShare} className="cursor-pointer"><EmailIcon size={72} round={true} /></div>
          <FacebookShareButton
            url={currentUrl}
          >
            <FacebookIcon size={72} round={true} />
          </FacebookShareButton>
          <LinkedinShareButton
            url={currentUrl}
          >
            <LinkedinIcon size={72} round={true} />
          </LinkedinShareButton>
          <LineShareButton url={currentUrl}>
            <LineIcon size={72} round={true} />
          </LineShareButton>
        </div>

        <div className="flex justify-center text-14pt mt-14 gap-6">
          <CopyToClipboard text={currentUrl} onCopy={() => setCopied(true)}>
            {copied ? (
              <div className="flex justify-center w-60 border p-2 rounded-3xl">
                복사 완료되었습니다! ✅
              </div>
            ) : (
              <div className="flex justify-center w-60 border p-2 rounded-3xl cursor-pointer hover:bg-stone-100">
                공유 링크 복사하기 🔗
              </div>
            )}
          </CopyToClipboard>
        </div>
      </div>
    </div>
  );
}

export default ShareModal;

 

 

CopyToClipboard와 react-share 라이브러리를 사용하여 구현했습니다.

처음에는 공유하기를 누르면 링크 복사 되는 기능만 구현하려고 했다가,

실제 서비스의 공유하기에서 많이 사용하는 방식의 공유를 구현해보고 싶어서

링크와, description, title 정보를 넘겨서 공유 페이지에서도 사용할 수 있도록 했습니다.

 

 

기술 스택

  • 자바스크립트
  • React
  • Prisma
  • PostgreSQL
  • express

 

 

문제점 해결

[배포] netlify 새로고침 시 404에러

[frontend] 스터디 상세 페이지 접속 시 흰 화면

 

 

협업 및 피드백

문제점이나 변경된 사항에 대하여 팀원 분들과 의사소통하며 어떤 방식으로 해결해나가면 좋을 지 생각해볼 수 있었습니다. 그래서 다음에 문제가 발생했을 때는 어떻게 해야 하고, 제 의사소통 능력에서 어떤 부분이 부족한 지 생각해볼 수 있는 계기가 되었습니다.

특히나 프론트엔드 작업과 백엔드 작업을 분리해서 서로 겹치지 않게 작업이 배분 되어서, 실제로 프로젝트 진행할 때 어떤 방식으로 대화하는 지, 경험을 해본 것이 좋았습니다.

깃허브 프로젝트를 칸반 보드로 사용하여 이슈와 할 일을 관리해볼 수 있었는데 이 경험도 현업에서 일을 할 때 많은 도움이 될 거 같습니다.

 

 

향후 개선해나가야 할 문제

실제 유저 입장에서 어떤 부분이 부족한 지 생각해보면서 수정해나갈 생각입니다.

 

 

향후 해 볼 생각이 있는 것들

  • 이모지 태그 애니메이션 효과
  • 타입스크립트 마이그레이션

'프로젝트' 카테고리의 다른 글

무빙(Moving) - 이사 서비스 프로젝트 회고록  (3) 2025.06.23
독스루 프로젝트 회고록  (0) 2025.04.16
'프로젝트' 카테고리의 다른 글
  • 무빙(Moving) - 이사 서비스 프로젝트 회고록
  • 독스루 프로젝트 회고록
hyuk-dev
hyuk-dev
개발 과정에서 얻은 지식과 문제 발생 시 해결 과정을 기록하기 위한 블로그입니다.
  • hyuk-dev
    이동혁 기술 블로그
    hyuk-dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (14)
      • 알고리즘 (3)
      • 프로젝트 (3)
      • 개발 (5)
        • WEB (2)
        • HTML (1)
        • CSS (1)
        • JavaScript (1)
        • React.js (0)
        • Next.js (0)
        • Nest.js (0)
      • 경험 (4)
        • 코드잇 스프린트 (1)
      • 협업 (2)
        • Git (1)
        • Notion (1)
  • hELLO· Designed By정상우.v4.10.5
hyuk-dev
공부의 숲 프로젝트 회고록
상단으로

티스토리툴바