Flower Delivery Website 구현: (7)UI 컴포넌트 구현 /DatePicker

김 주현·2023년 8월 16일
0

이번에는 DatePicker를 만든다. 그동안 DatePicker를 쓸 일이 있으면 딱봐도 만들기 귀찮아서 라이브러리를 가져와서 구현을 했는데, 이번에 직접 구현을 해보니 아주 생각대로 귀찮고 나름 구현하는 재미가 있더라요.

날짜 관련한 로직은 나름대로 알고 있다 생각했는데, 이번에 만들면서 꽤나 부족하다는 걸 느꼈고 다 만들고 나니 실력이 확실히 는 것 같은 느낌이 든다! 이 맛에 직접 구현하는 거죠~

DatePicker

import React, { useState, useRef, useEffect, useCallback } from "react";

import { ReactComponent as ArrowIcon } from "@Static/Icons/chevron-left.svg";
import { ReactComponent as CalenderIcon } from "@Static/Icons/calendar_month.svg";

const MemoedArrowIcon = React.memo(ArrowIcon, (p, n) => p.width === n.width);
const MemoedCalenderIcon = React.memo(
  CalenderIcon,
  (p, n) => p.width === n.width,
);

const DAY_OF_WEEK_NAMES = ["일", "월", "화", "수", "목", "금", "토"];

/* #region 날짜 계산 관련 메소드 */
const getPrevDays = (currentDate: Date) => {
  const tmpDate = new Date(currentDate);
  tmpDate.setDate(1);

  const prevDays = Array(tmpDate.getDay()).fill(null);

  for (let index = prevDays.length; index--; ) {
    prevDays[index] = new Date(tmpDate.setDate(tmpDate.getDate() - 1));
  }

  return prevDays as Array<Date>;
};

const getThisDays = (currentDate: Date) => {
  const tmpDate = new Date(currentDate);
  tmpDate.setDate(1);

  const currentMonth = tmpDate.getMonth();
  const thisDays = [];

  while (tmpDate.getMonth() === currentMonth) {
    thisDays.push(new Date(tmpDate));
    tmpDate.setDate(tmpDate.getDate() + 1);
  }

  return thisDays as Array<Date>;
};

const getNextDays = (currentDate: Date, dayCount: number) => {
  const tmpDate = new Date(currentDate);
  tmpDate.setMonth(tmpDate.getMonth() + 1);
  tmpDate.setDate(1);

  const nextDays = [];

  while (nextDays.length < 42 - dayCount) {
    nextDays.push(new Date(tmpDate));
    tmpDate.setDate(tmpDate.getDate() + 1);
  }

  return nextDays as Array<Date>;
};

const calcuateCalenderDays = (date: Date) => {
  const prevDays = getPrevDays(date);
  const thisDays = getThisDays(date);
  const nextDays = getNextDays(date, prevDays.length + thisDays.length);

  return [...prevDays, ...thisDays, ...nextDays] as Array<Date>;
};

const getFormattedDateString = (date: Date) => {
  return `${date.getFullYear().toString().substring(2)}.${(date.getMonth() + 1)
    .toString()
    .padStart(2, "0")}.${date.getDate().toString().padStart(2, "0")}`;
};
/* #endregion */

const DayOfWeekHeader = React.memo(() => {
  return DAY_OF_WEEK_NAMES.map((month) => (
    <span key={month} className="caption-bold text-gray">
      {month}
    </span>
  ));
});

type DayItemProp = {
  calcuatedDay: Date;
  calenderDate: Date;
  selectedDate: Date;
  onDaySelect: (date: Date) => void;
};

const DayItem = React.memo(
  ({ calcuatedDay, calenderDate, selectedDate, onDaySelect }: DayItemProp) => {
    const textColor =
      calcuatedDay.getMonth() !== calenderDate.getMonth()
        ? "text-gray "
        : calcuatedDay.toISOString() === selectedDate.toISOString()
        ? "bg-black text-white "
        : "bg-white text-black hover:bg-lightgray ";

    return (
      <span
        className={`caption-bold flex h-full w-full items-center justify-center  p-m14 tablet:p-t14 desktop:p-d14 ${textColor}`}
        onClick={() => onDaySelect(calcuatedDay)}
      >
        {calcuatedDay.getDate()}
      </span>
    );
  },
  (p, n) =>
    (p.calcuatedDay.toISOString() === p.selectedDate.toISOString()) ===
      (n.calcuatedDay.toISOString() === n.selectedDate.toISOString()) &&
    p.calcuatedDay.toISOString() === n.calcuatedDay.toISOString(),
);

type CalenderComponentProp = {
  selectedDate: Date;
  onDaySelected: (date: Date) => void;
};

const CalenderComponent = React.memo(
  ({ selectedDate, onDaySelected }: CalenderComponentProp) => {
    const lastCalenderMonth = useRef("");
    const [calenderDate, setCalenderDate] = useState(new Date(selectedDate));
    const [calcuatedDays, setCalcuatedDays] = useState(
      calcuateCalenderDays(calenderDate),
    );

    useEffect(() => {
      setCalenderDate(new Date(selectedDate));
    }, [selectedDate]);

    useEffect(() => {
      if (lastCalenderMonth.current === calenderDate.getMonth().toString()) {
        return;
      }

      lastCalenderMonth.current = calenderDate.getMonth().toString();
      setCalcuatedDays(calcuateCalenderDays(calenderDate));
    }, [calenderDate]);

    const handlePrevMonthClick = useCallback(() => {
      setCalenderDate(
        new Date(calenderDate.setMonth(calenderDate.getMonth() - 1)),
      );
    }, [calenderDate]);

    const handleNextMonthClick = useCallback(() => {
      setCalenderDate(
        new Date(calenderDate.setMonth(calenderDate.getMonth() + 1)),
      );
    }, [calenderDate]);

    return (
      <div className="h-full w-full select-none space-y-m16 border p-m24 tablet:space-y-t16 tablet:p-t24 desktop:space-y-d16 desktop:p-d24">
        <div className="mx-auto flex w-full flex-row items-center justify-between ">
          <MemoedArrowIcon
            className="h-m16 w-m16 tablet:h-t16 tablet:w-t16 desktop:h-d16 desktop:w-d16"
            onClick={() => handlePrevMonthClick()}
          />
          <h6 className="heading6">
            {calenderDate.getFullYear()}{calenderDate.getMonth() + 1}</h6>
          <MemoedArrowIcon
            className="h-m16 w-m16 rotate-180 tablet:h-t16 tablet:w-t16 desktop:h-d16 desktop:w-d16"
            onClick={() => handleNextMonthClick()}
          />
        </div>
        <div className="mx-auto flex w-[90%] items-center justify-between">
          <DayOfWeekHeader />
        </div>
        <div className="grid h-full w-full grid-cols-7 grid-rows-6">
          {calcuatedDays.map((calcuatedDay) => (
            <DayItem
              key={`${calenderDate.toISOString()}-${calcuatedDay.toISOString()}`}
              calcuatedDay={calcuatedDay}
              calenderDate={calenderDate}
              selectedDate={selectedDate}
              onDaySelect={(date) => onDaySelected(date)}
            />
          ))}
        </div>
      </div>
    );
  },
  (prevProps, nextProps) =>
    prevProps.selectedDate.toISOString() ===
    nextProps.selectedDate.toISOString(),
);

type DatePickerProp = {
  onSelect: (date: Date) => void;
};

const DatePicker = ({ onSelect }: DatePickerProp) => {
  const wasSelected = useRef(false);
  const [isOpened, setIsOpened] = useState(false);
  const [selectedDate, setSelectedDate] = useState(new Date());

  const handleDaySelected = (date: Date) => {
    setSelectedDate(date);
    setIsOpened(false);
    onSelect(date);

    if (!wasSelected.current) wasSelected.current = true;
  };

  return (
    <div className="relative w-full">
      <div
        className={
          "flex cursor-pointer select-none items-center justify-between border px-m16 py-m12 tablet:p-t16 desktop:p-d16 " +
          `${
            isOpened || wasSelected.current
              ? "border-black "
              : "border-lightgray"
          }`
        }
        onClick={() => setIsOpened(!isOpened)}
      >
        <span
          className={
            "caption-bold " +
            `${wasSelected.current ? "text-black " : "text-gray "}`
          }
        >
          {getFormattedDateString(selectedDate)}
        </span>
        <MemoedCalenderIcon className="h-m24 w-m24 fill-gray tablet:h-t24 tablet:w-t24 desktop:h-d24 desktop:w-d24" />
      </div>

      {isOpened && (
        <div className="absolute right-0 top-full mt-m8 w-fit tablet:mt-t8 desktop:mt-d8">
          <CalenderComponent
            selectedDate={selectedDate}
            onDaySelected={(date) => handleDaySelected(date)}
          />
        </div>
      )}
    </div>
  );
};

export default DatePicker;

이번 UI Component의 코드는 유난히 간데, 그 이유는 이번엔 나름 렌더 최적화랑 코드 분리를 하려고 노력했기 때문. 그런 것 치곤 아직 분리나 최적화할 게 많이 보이긴 하지만,, 일단 넘어가!

Usage

function App() {
  const [value, setValue] = useState<Date>(new Date());

  return (
    <>
      <div className="mx-auto mt-6 flex h-screen w-[90%] flex-col place-items-center">
        <p>현재 선택된 value: {value.toLocaleDateString()}</p>
        <DatePicker onSelect={(date) => setValue(date)} />
      </div>
    </>
  );
}

구현

기본 동작 원리

처음에 시도한 설계와 나중에 변경한 코드가 존재한다.

처음 시도한 설계

  1. 선택된 날짜의 year, month, day를 따로따로 state로 저장한다.
  2. 현재 보여지고 있는 달의 month를 따로 state로 저장한다.
  3. 현재 보여지고 있는 달의 일들을 state로 저장한다.
    • 이 state는 month에 따라 계산되며, 계산되는 것들은 다음과 같다.
    • 이전 달의 마지막 일들(prevDays)
      • year, month, 1(day)를 가지는 Date 객체를 만들고 시작 요일을 가져온다.
      • setDate를 통해 하루 전날을 구해서 지난 달의 마지막 일을 가져온다.
      • 마지막 일로부터 시작 요일만큼의 일들을 가져온다.
    • 이번 달의 일들(thisDays)
      • year, month + 1, 1(day)를 가지는 Date 객체를 만든다.
      • setDate를 통해 하루 전날을 구해서 이번 달의 마지막 일을 가져온다.
    • 다음 달의 시작 일들(restDays)
      • year, month + 1, 1(day)를 가지는 Date 객체를 만든다.
      • 시작 요일로부터 1씩 증가해서 나머지 일들을 채울 만큼의 일을 가져온다.
  4. 각 요일들을 선택하면 year, month, day를 update한다.

그런데 구현하다보니.. 지난 달의 마지막 요일을 구한다거나, 이번 달의 마지막 요일을 구한다거나, 요일을 선택했을 때 update되는 게 많다던가 하는 식으로 날짜 계산을 위한 변수들이 많았다. 그래서 구현이 좀 빡구현이 된 느낌이 있었다. 곰곰이 생각해보다 각 요일이 Date 객체를 갖는 방식으로 변경하였다.

변경한 설계

  1. 선택된 날짜를 Date 타입으로 받는다.
  2. 현재 보여지고 있는 날(currentDate)을 Date 타입으로 관리한다.
  3. 현재 보여지고 있는 달의 일들을 state로 저장한다.
    • 이전 달의 마지막 일들(prevDays)
      • currentDate를 넘겨받아서 setDate를 이용해 1일로 만든다.
      • 1일의 시작 요일을 구한다.
      • 시작 요일만큼 setDate를 이용해서 -1씩 줄여나가 이전 날들을 구한다.
        prevDays[index] = new Date(tmpDate.setDate(tmpDate.getDate() - 1));
    • 이번 달의 일들(thisDays)
      • currentDate를 넘겨받아서 setDate를 이용해 1일로 만든다.
      • setDate를 이용해서 +1씩 늘려나가며 저장한다.
      • 달이 바뀌면 반환한다.
        while (tmpDate.getMonth() === currentMonth) { ~ }
    • 다음 달의 일들(restDays)
      • currentDate를 넘겨받아서 setMonth와 setDate를 이용해 다음 달 1일로 만든다.
      • prevDays의 개수 + thisDay의 개수를 넘겨받아 42(7 * 6)에서 뺀 만큼 setDate를 이용해 +1씩 늘려나간다.
        while (nextDays.length < 42 - dayCount) { ~ }
  4. 각 요일들을 선택하면 해당 요일이 가지고 있는 date로 update한다.

비교

각 요일이 Date 객체를 가지고 있는 것이 개발하기에 편한 것 같은데 객체 자체를 가지고 있다 보니 메모리가 늘고, 달이 바뀔 때마다 이 객체를 새로 만들어줘야 하는 비용이 꽤나 큰 것 같다.

각 state를 두고 계산할 땐 계산이 빠릿빠릿하긴 했지만 개발 과정이 아주 빡코딩이어서 효율적이진 않았던 것 같다.

그러면 .. 필요할 때만 Date 객체를 만들어주는 것이 최선일 것 같은데, 음! 모르겠다.

const getPrevDays = (currentDate: Date) => {
  const tmpDate = new Date(currentDate);
  tmpDate.setDate(1);

  const prevDays = Array(tmpDate.getDay()).fill(null);

  for (let index = prevDays.length; index--; ) {
    prevDays[index] = new Date(tmpDate.setDate(tmpDate.getDate() - 1));
  }

  return prevDays as Array<Date>;
};

const getThisDays = (currentDate: Date) => {
  const tmpDate = new Date(currentDate);
  tmpDate.setDate(1);

  const currentMonth = tmpDate.getMonth();
  const thisDays = [];

  while (tmpDate.getMonth() === currentMonth) {
    thisDays.push(new Date(tmpDate));
    tmpDate.setDate(tmpDate.getDate() + 1);
  }

  return thisDays as Array<Date>;
};

const getNextDays = (currentDate: Date, dayCount: number) => {
  const tmpDate = new Date(currentDate);
  tmpDate.setMonth(tmpDate.getMonth() + 1);
  tmpDate.setDate(1);

  const nextDays = [];

  while (nextDays.length < 42 - dayCount) {
    nextDays.push(new Date(tmpDate));
    tmpDate.setDate(tmpDate.getDate() + 1);
  }

  return nextDays as Array<Date>;
};

const calcuateCalenderDays = (date: Date) => {
  const prevDays = getPrevDays(date);
  const thisDays = getThisDays(date);
  const nextDays = getNextDays(date, prevDays.length + thisDays.length);

  return [...prevDays, ...thisDays, ...nextDays] as Array<Date>;
};

달력 표현하기

달력은 7*6의 그리드로 표현하였다. 왜 6줄이냐면, 시작하는 요일과 마지막 날에 따라서 6줄까지 갈 때가 있기 때문이다. 예를 들어 23년 7월 같은 달.

먼저~ 7개씩 끊어서 표현하는 것보단 분명히 그리드로 표현하는 것이 개발하는 입장에서 편하다고 생각했다. 그냥 아이템들은 쭈욱 나열해놓고, 부모 컨테이너로 레이아웃을 제어해주는 느낌으로다가.

        <div className="grid h-full w-full grid-cols-7 grid-rows-6">
          {calcuatedDays.map((calcuatedDay) => (
            <DayItem
              key={`${calenderDate.toISOString()}-${calcuatedDay.toISOString()}`}
              calcuatedDay={calcuatedDay}
              calenderDate={calenderDate}
              selectedDate={selectedDate}
              onDaySelect={(date) => onDaySelected(date)}
            />
          ))}
        </div>

gird를 주고, col과 row를 주어 표현해줌으로써 달력 레이아웃은 끝!

컴포넌트 최적화

각 요일마다 Date 객체가 존재하기 때문에, 한 번 일들을 계산하는 게 꽤나 비싼 비용이다. 그러므로 필요할 때만 필요한 컴포넌트만 rendering 되는 것이 비용을 최대한 줄일 수 있는 방법이라고 생각했다.

DayItem으로 분리

원래 Calender 컴포넌트에서 렌더링해줬던 days를 따로 DayItem Component를 만들어주어 같은 Prop이면 렌더링되지 않게 만들어주었다.

이전

      <div className="grid h-full w-full grid-cols-7 grid-rows-6">
        {prevDays.map((day) => (
          <span
            key={day}
            className="caption-bold flex h-full w-full items-center justify-center  p-m14 text-gray hover:bg-lightgray tablet:p-t14 desktop:p-d14"
          >
            {day}
          </span>
        ))}
        {thisDays.map((day) => (
          <span
            key={day}
            className={`caption-bold flex h-full w-full items-center justify-center  p-m14 tablet:p-t14 desktop:p-d14 ${
              month === selectedDate.getMonth() &&
              day === selectedDate.getDate()
                ? "bg-black text-white "
                : "bg-white text-black hover:bg-lightgray "
            }`}
            onClick={() =>
              onDaySelected(
                new Date(
                  `${year}-${(month + 1).toString().padStart(2, "0")}-${day}`,
                ),
              )
            }
          >
            {day}
          </span>
        ))}
        {restDays.map((_, index) => (
          <span
            key={index}
            className="caption-bold flex h-full w-full items-center justify-center  p-m14 text-gray  hover:bg-lightgray tablet:p-t14 desktop:p-d14"
          >
            {index + 1}
          </span>
        ))}
      </div>

ㅋㅋ 진짜 막짰네

이후

DayItem Component

const DayItem = React.memo(
  ({ calcuatedDay, calenderDate, selectedDate, onDaySelect }: DayItemProp) => {
    const textColor =
      calcuatedDay.getMonth() !== calenderDate.getMonth()
        ? "text-gray "
        : calcuatedDay.toISOString() === selectedDate.toISOString()
        ? "bg-black text-white "
        : "bg-white text-black hover:bg-lightgray ";

    return (
      <span
        className={`caption-bold flex h-full w-full items-center justify-center  p-m14 tablet:p-t14 desktop:p-d14 ${textColor}`}
        onClick={() => onDaySelect(calcuatedDay)}
      >
        {calcuatedDay.getDate()}
      </span>
    );
  },
  (p, n) =>
    p.calcuatedDay.toISOString() === n.calcuatedDay.toISOString() &&
    (p.calcuatedDay.toISOString() === p.selectedDate.toISOString()) ===
      (n.calcuatedDay.toISOString() === n.selectedDate.toISOString()),
);

같은 prop이면 리-렌더링이 되지 않도록 React.memo로 HOC해줬다. 이때 비교하는 방법은 다음과 같다.

  1. calcuatedDay의 이전과 이후가 같냐?
  2. calcuatedDay(해당 일이 가지고 있는 Date 객체)와 selectedDate(선택된 Date)를 비교한 결과 값이, 다음의 calcuatedDay과 selectedDate를 비교한 결과 값과 같냐?

이 두 가지의 비교를 만족하면 같은 컴포넌트라고 판단한다. 2번째 비교 조건을 뒤로 뺀 이유는 비교 계산하는 비용이 조금 더 크기 때문이다.

Days Rendering

        <div className="grid h-full w-full grid-cols-7 grid-rows-6">
          {calcuatedDays.map((calcuatedDay) => (
            <DayItem
              key={`${calenderDate.toISOString()}-${calcuatedDay.toISOString()}`}
              calcuatedDay={calcuatedDay}
              calenderDate={calenderDate}
              selectedDate={selectedDate}
              onDaySelect={(date) => onDaySelected(date)}
            />
          ))}
        </div>

그럼 깔끔하게 표현 가능! 물론 tailwind는 빼고^^**,,

DayItem 말고도 여러 컴포넌트라든가, callback도 memorized해주었지만 비슷한 맥락이므로 생략!

후기

실제 계산하는 값과 보여지는 값

평소에도 생각하던 거였긴 했는데, 으음. 예를 들어 이번 상황과 같이 Days를 보여주고 있다고 해보자.

return (
  <div>
  {
    days.map(day => (
      <DayItem day={day} />
    ))
  }
  </div>
)

그리고 이 days는 month에 의존적이고, re-calcuate될 때 꽤 비용이 든다고 해보자.

const [month, setMonth] = useState(8);
const [days, setDays] = useState([]);

useEffect(() => {
  // 비싼 계산
  ...
  
  setDays(newDays)
}, [month])

그러면~ 사용자가 month를 조작한 행위를 하고 나서 조금의 딜레이가 생긴 뒤 days가 업데이트될 것이다. 이 조금의 딜레이에 대한 피드백이 있어야 조금 더 UX가 높아질 거라고 생각하는데~ 그러려면 내부적으로 계산하는 값과 실제 DOM에 보여지는 값이 달라져야 할 것 같다는 생각이 든다.

이에 대한 게 Fetching Data의 pending status와도 비슷한 비유...인 것 같기도? 데이터를 로드하고 있을 때의 피드백이 필요한 상황이니까.

--> 아~ 찾아보니까 이게 Optimistic update 개념과 얼추 맞아보인다. 내가 하고 싶은 게 실제 계산되는 동안 유저에게 피드백을 해줄 방법을 찾는 거였는데, 요게 좀 그래 보이네.

Optimistic Update

유저가 트리거한 액션을 클라이언트 쪽에서 예상한 기대값으로 일단 업데이트 시켜놓고, 서버에서 응답이 오면 기대값과 비교 후 롤백을 하든 반영을 하든 어쩌든 하는 방식(ㅋㅋ)

Tailwind에 대한... 회의감(ㅋㅋ)

UI Component를 개발할 땐 반드시 emotion이나 styled-component 같은 CSS in JS를 사용하자...

profile
FE개발자 가보자고🥳

1개의 댓글

comment-user-thumbnail
2023년 8월 16일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

답글 달기