줍줍 프로젝트는 슬랙 메시지를 아카이빙하는 서비스입니다.
프로젝트 github
우선 줍줍 프로젝트에 달력 컴포넌트가 필요했던 이유는,
사용자들이 저장된 메시지를 찾을 때 특정 날짜를 기준으로 메시지를 탐색할 수 있도록 보다 나은 사용자 경험을 제공하기 위해 달력 컴포넌트를 적용했다.
최종적으로 달력 컴포넌트가 줍줍 프로젝트 내부에서 어떻게 동작하는지 확인하고 싶다면 글의 최하단을 먼저 확인해보는 것도 좋다.
프로젝트에서 사용할 달력 컴포넌트를 만들면서 Date 객체에 대해 많이 들여다보게 되는 계기가 되었던 것 같다.
다음은 프로젝트 내부에서 사용한 Date 객체의 메서드들에 대한 간략한 설명이다.
const date = new Date();
console.log(date.getFullYear());
Date 객체의 getFullYear() 메서드는 현재 년도를 출력한다.
const date = new Date();
console.log(date.getMonth());
Date 객체의 getMonth() 메서드는 현재 월의 -1 된 값을 출력한다. 예를 들어, 현재 월이 8월일 경우 7을 출력한다. 따라서, 현재 월을 사용하고 싶을 경우 date.getMonth() + 1 을 해줘야한다.
const date = new Date();
console.log(date.getDate());
Date 객체의 getDate() 메서드는 현재 일을 반환한다. 예를들어 오늘이 26일 일경우 26을 반환한다.
const days = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
const date = new Date();
console.log(days[date.getDay()]);
Date 객체의 getDay() 메서드는 현재 요일을 0(일요일) ~ 6(토요일)로 출력한다. 예를들어 현재 요일이 금요일일 경우 숫자 5를 출력한다.
따라서, getDay 메서드를 사용하여 요일을 출력하고 싶은 경우 배열을 사용하여 해당 요일을 출력해주는게 사용하기 편하다.
const date = new Date();
date.setDate(1);
console.log(date.getDate());
Date 객체의 setDate() 메서드는 현재 일을 파라미터로 전달한 숫자 값으로 변환한다. 예를 들어, setDate(1)이렇게 사용할 경우 현재 일이 26일이더라도 1일로 초기화 하게 된다.
const lastDate = new Date(2022, 2, 0).getDate();
console.log(lastDate);
Date 객체의 첫번째 파라미터는 년도, 두번재 파라미터는 월, 세번째 파라미터는 일을 의미한다.
예를 들어, 2022년의 2월 의 마지막 일을 알고싶다면 위와 같이 작성하면 된다.
두번째 파라미터의 2는 2월이 아닌 3월을 의미한다. 세번째 파라미터는 1 ~ 31의 숫자가 들어갈 수 있지만, 0을 입력할 경우 이전달의 마지막 일을 의미한다.
따라서 위의 코드조각과 같이 lastDate를 출력했을 경우 2022년 2월의 마지막 일인 28일이 출력된다.
이제 실제 코드를 보며 한 번 살펴보도록 하자.
현재 줍줍 프로젝트에서는 Calendar Component에서 useCalendar라는 custom hook에 비즈니스 로직을 분리한 후 사용하고 있다.
function Calendar() {
const {
date,
getCurrentDays,
isFutureMonth,
isCurrentMonth,
handleDecrementMonth,
handleIncrementMonth,
} = useCalendar();
return (
// ... calendar code
)
}
우선 비즈니스 로직이 담겨있는 useCalendar custom hook의 내부에 코드를 한줄 씩 확인해보자.
function useCalendar(): ReturnType {
const date = useRef(new Date());
const [_, setRerender] = useState(false);
date.current.setDate(1);
// some code...
}
export default useCalendar;
우선 useRef를 사용해 Date 객체를 받아준다. useRef에 담아주는 이유는 Date 객체의 값이 변경되더라도 리렌더링이 되지 않도록 해주기 위해서이다.
다음 줄에 있는 setRerender는 월이 변경될 때 리렌더링을 해주기 위해 일종의 flag를 만들어 준 것이다.
세번째 줄에 있는 setDate 코드는 useRef에 담겨있는 Date 객체의 date를 1로 초기화 해준 코드이다.
function useCalendar(): ReturnType {
// some code...
const isFutureMonth = () =>
date.current.getFullYear() >= new Date().getFullYear() &&
date.current.getMonth() >= new Date().getMonth();
const isCurrentMonth = () =>
date.current.getFullYear() === new Date().getFullYear() &&
date.current.getMonth() === new Date().getMonth();
// some code ...
}
export default useCalendar;
지금 다시 보니 Date 객체를 따로 관리할 수 있게 리펙터링을 진행해줘도 좋을 것 같다. 무려 4군데에서 Date 객체를 새로 불러 사용하고 있는 것은 비효율적인 것 처럼 보인다.
isFutureMonth, isCurrentMonth 함수 명에서도 알 수 있듯 해당하는 월이 미래의 달인지 현재 달인지에 대한 값을 boolean으로 리턴한다.
function useCalendar(): ReturnType {
const date = useRef(new Date());
const [_, setRerender] = useState(false);
date.current.setDate(1);
// some code ...
const handleDecrementMonth = () => {
setRerender((prev) => !prev);
date.current.setMonth(date.current.getMonth() - 1);
};
const handleIncrementMonth = () => {
setRerender((prev) => !prev);
date.current.setMonth(date.current.getMonth() + 1);
};
// some code ...
}
export default useCalendar;
해당하는 함수들을 사용자가 이전 달 다음 달을 클릭 했을 때 handling되는 함수이다. 따라서 이전 달 혹은 다음 달이 클릭 되었을 때 리렌더링 될 수 있도록 setRerender를 통해 값을 변경해주고, useRef에 담겨 있는 Date 객체의 달을 - 1, + 1 해줌으로써 해당하는 달의 달력을 볼 수 있도록 해준다.
function useCalendar(): ReturnType {
// some code ...
const getCurrentDates = () => {
const blankCount = date.current.getDay();
const lastDay = new Date(
date.current.getFullYear(),
date.current.getMonth() + 1,
0
).getDate();
return [
...Array.from({ length: blankCount }, () => ""),
...Array.from({ length: lastDay }, (_, index) => index + 1),
];
};
// some code ...
}
export default useCalendar;
해당하는 함수가 하는 일은 해당하는 년도와 월에 맞게 일자를 배열에 담아 반환하는 역할을 한다.
lastDay를 구하는 것에 대해서는 위에서 설명했으니 생략하고 blankCount를 구하는 이유에 대해 설명하도록 하겠다.
blankCount가 필요한 이유는?
우선 프로젝트 내부에서 dates를 그릴때 배열을 map 함수를 통해 돌며 그린다.
따라서, 2022년 7월 1일은 금요일인데, blankCount값을 추가하지 않고 그릴 경우 일요일 부터 그려지게 된다.
따라서, 달력을 올바르게 그리기 위해 blankCount를 추가해 2022년 7월 1일을 금요일에 그려주는 것이다.
blankCount를 사용해 배열안에 blankCount 만큼의 공백을 넣어 공백일 경우에는 빈 UI를 그릴 수 있도록 styled-component를 설계했다.
여기서 고민한 부분은 blankCount를 어떻게 계산할 수 있을까? 였다.
Date 객체의 일을 setDate(1)를 통해 현재 달의 일자를 1일로 만들어 준 후 getDay()를 통해 요일의 값을 0~6을 받아 이를 배열로 만들어 체크할 수 있도록 만들어 주었다.
useCalendar의 custom hook 내부는 모두 들여다 보았으니, 다음은 Calendar component를 한 번 들여다 보자.
useCalendar custom hook 내부에 비즈니스 로직이 모두 분리가 되어있다보니, useCalendar custom hook을 이해하셨다면, 해당하는 Calendar component는 사실 별게 없다.
그에 맞게 스타일링 해준 것 뿐이니…
const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"] as const;
const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const;
// some code ...
function Calendar({ channelId, handleCloseCalendar }: Props) {
// some code ...
return (
<Styled.Container>
<Styled.Month>
<WrapperButton kind="smallIcon" onClick={handleDecrementMonth}>
<LeftArrowIcon width="24px" height="24px" fill="#8B8B8B" />
</WrapperButton>
<Styled.Title>
{date.getFullYear()}년 {MONTHS[date.getMonth()]}월
</Styled.Title>
<WrapperButton
kind="smallIcon"
onClick={handleIncrementMonth}
isFuture={isFutureMonth()}
disabled={isFutureMonth()}
>
<RightArrowIcon width="24px" height="24px" fill="#8B8B8B" />
</WrapperButton>
</Styled.Month>
<Styled.Weekdays>
{WEEKDAYS.map((weekDay) => (
<Styled.Weekday key={weekDay}>{weekDay}</Styled.Weekday>
))}
</Styled.Weekdays>
<Styled.Days>
{getCurrentDays().map((day, index) => (
<Link
key={index}
to={`/feed/${channelId}/${ISOConverter(
`${date.getFullYear()}-${MONTHS[date.getMonth()]}-${day}`
)}`}
>
<Styled.Day
isBlank={day === ""}
isCurrentDay={day === new Date().getDate() && isCurrentMonth()}
isFuture={day > new Date().getDate() && isFutureMonth()}
onClick={handleCloseCalendar}
>
{day}
<div></div>
</Styled.Day>
</Link>
))}
</Styled.Days>
</Styled.Container>
);
}
export default Calendar;
마지막으로 줍줍 프로젝트의 달력 컴포넌트가 어떻게 동작하는지 GIF로 확인하며 글을 마무리 하려한다.