Skip to main content

Command Palette

Search for a command to run...

NextJS에서 캘린더 컴포넌트 개발하기

NextJS 프로젝트에서 useCalendar 훅을 이용해 캘린더 컴포넌트를 작성하는 과정을 기록했습니다.

Updated
5 min read

요구사항

캘린더 컴포넌트를 개발하게 된 계기는 다음과 같다.

  • 공개된 라이브러리는 프로젝트에 맞게 UI를 변경하기 까다롭다.

  • 서버 사이드 렌더링 프레임워크의 경우 렌더링에서 에러가 발생하는 경우가 종종 있다.

  • 라이브러리마다 지원하는 기능의 범위가 다르다.

Next.js 프로젝트를 세팅하고 기본적인 달력 기능을 하는 페이지를 만들어 본 과정을 기록했다.

컴포넌트를 작성하기 전 다음과 같은 작업을 거쳤다.

  • create-next로 Next.js 프로젝트 생성

  • Tailwind CSS 설치 및 세팅

  • lucide-react 아이콘 라이브러리 설치

  • date-fns 날짜 라이브러리 설치

작업

우선 패키지를 하나 설치한다.

npm install @nwleedev/use-calendar

이 패키지에서 불러올 수 있는 useCalendar Hook은 다음과 같은 기능을 제공한다.

  • Hook에 Date 객체를 전달함으로써 초기 상태를 지정할 수 있다.

  • 현재 상태에 대해서 일 목록, 월 목록, 10년 주기의 연 목록을 가져올 수 있다.

  • 캘린더의 상태를 업데이트할 수 있는 함수가 존재한다.

기본적인 달력 페이지를 작성했다.

  • 캘린더 헤더에서 좌우 화살표를 통해 이전 달, 다음 달로 이동할 수 있다.

  • 헤더 중앙에는 현재 캘린더가 가리키는 연도와 월이 표시된다.

  • 캘린더에는 현재 캘린더의 날짜들만 보이게 된다.

  • 사용자가 클릭한 날짜는 검은색 원으로 표시된다.

"use client";

import useCalendar, { DateLibs } from "@nwleedev/use-calendar";
import { format } from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

const Calendar = () => {
  const [selectedDate, setSelectedDate] = useState(new Date());
  const { days, date, onMonthChange } = useCalendar({
    defaultValue: selectedDate,
  });
  const month = date.getMonth();

  return (
    <div className="w-full h-full gap-y-4 flex flex-col justify-center items-center">
      <div className="w-full h-10 flex justify-center items-center gap-x-2">
        <button onClick={() => onMonthChange(month - 1)}>
          <ChevronLeft />
        </button>
        <h2 className="text-xl font-semibold">{format(date, "MMM, yyyy")}</h2>
        <button onClick={() => onMonthChange(month + 1)}>
          <ChevronRight />
        </button>
      </div>
      <div className="grid grid-cols-7 gap-y-1 gap-x-2">
        {days.map((day) => {
          const classNames = getClassNames(date, day, selectedDate);
          if (!classNames) {
            return <div key={day.getTime()} className="w-10 h-10" />;
          }
          return (
            <button
              key={day.getTime()}
              className={classNames?.div}
              onClick={() => setSelectedDate(day)}
            >
              <span className={classNames?.span}>{day.getDate()}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
};

const getClassNames = (date: Date, day: Date, selectedDate: Date) => {
  if (!DateLibs.isMonthEqual(date, day)) {
    return;
  }
  if (
    DateLibs.isYearEqual(day, selectedDate) &&
    DateLibs.isMonthEqual(day, selectedDate) &&
    DateLibs.isDateEqual(day, selectedDate)
  ) {
    return {
      div: "flex justify-center items-center w-10 h-10 rounded-full bg-black",
      span: "text-white",
    };
  }
  if (day.getDay() === 0) {
    return {
      div: "flex justify-center items-center w-10 h-10",
      span: "text-red-500",
    };
  }
  if (day.getDay() === 6) {
    return {
      div: "flex justify-center items-center w-10 h-10",
      span: "text-blue-600",
    };
  }
  return {
    div: "flex justify-center items-center w-10 h-10",
    span: "text-gray-700",
  };
};
export default Calendar;

기초적인 캘린더에서 다음과 같은 기능을 추가할 수 있다.

  • 헤더 중앙을 클릭하면 현재 연도에 대해서 달을 선택할 수 있는 화면으로 전환되어야 한다.

  • 월 목록 화면에서 헤더 좌우 화살표를 클릭하면 이전 연도, 다음 연도로 이동할 수 있다.

  • 각 월을 클릭하면 캘린더에는 현재 연도 & 클릭한 월에 해당하는 날짜 목록이 보여지게 된다.

  • 캘린더의 상태는 stage로 관리할 수 있다. 스테이지 값에 따라서 날짜 목록을 표시할지 월 목록을 표시할지 결정된다.

"use client";

import useCalendar, { CalendarStage, DateLibs } from "@nwleedev/use-calendar";
import { format } from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

const Calendar = () => {
  const [selectedDate, setSelectedDate] = useState(new Date());
  const {
    days,
    date,
    months,
    stage,
    onMonthChange,
    onYearChange,
    onStageChange,
  } = useCalendar({
    defaultValue: selectedDate,
  });
  const month = date.getMonth();
  const year = date.getFullYear();

  return (
    <div className="w-full h-full gap-y-4 flex flex-col justify-center items-center">
      {stage === CalendarStage.DAYS && (
        <div className="w-full h-10 flex justify-center items-center gap-x-2">
          <button onClick={() => onMonthChange(month - 1)}>
            <ChevronLeft />
          </button>
          <h2
            className="text-xl font-semibold"
            role="button"
            onClick={() => {
              onStageChange(CalendarStage.MONTHS);
            }}
          >
            {format(date, "MMM, yyyy")}
          </h2>
          <button onClick={() => onMonthChange(month + 1)}>
            <ChevronRight />
          </button>
        </div>
      )}
      {stage === CalendarStage.DAYS && (
        <div className="grid grid-cols-7 gap-y-1 gap-x-2">
          {days.map((day) => {
            const classNames = getClassNames(date, day, selectedDate);
            if (!classNames) {
              return <div key={day.getTime()} className="w-10 h-10" />;
            }
            return (
              <button
                key={day.getTime()}
                className={classNames?.div}
                onClick={() => setSelectedDate(day)}
              >
                <span className={classNames?.span}>{day.getDate()}</span>
              </button>
            );
          })}
        </div>
      )}
      {stage === CalendarStage.MONTHS && (
        <div className="w-full h-10 flex justify-center items-center gap-x-2">
          <button onClick={() => onYearChange(year - 1)}>
            <ChevronLeft />
          </button>
          <h2 className="text-xl font-semibold">{format(date, "yyyy")}</h2>
          <button onClick={() => onYearChange(year + 1)}>
            <ChevronRight />
          </button>
        </div>
      )}
      {stage === CalendarStage.MONTHS && (
        <div className="grid grid-cols-3 gap-y-1 gap-x-2 w-full max-w-[320px]">
          {months.map((month) => {
            return (
              <button
                key={month.getTime()}
                className="h-10"
                onClick={() => {
                  onMonthChange(month.getMonth());
                  onStageChange(CalendarStage.DAYS);
                }}
              >
                <span>{format(month, "MMMM")}</span>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
};

const getClassNames = (date: Date, day: Date, selectedDate: Date) => {
  // 이전 코드의 함수와 같음
};
export default Calendar;

월간 날짜 목록만 아니라 주간 날짜 목록을 표시하는 데에도 useCalendar Hook을 도입할 수 있다.

  • 헤더에서 좌우 버튼을 클릭하면 이전 주간, 다음 주간으로 달력이 업데이트된다.
"use client";

import useCalendar, { DateLibs } from "@nwleedev/use-calendar";
import { format, getWeek } from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

const Calendar = () => {
  const [selectedDate, setSelectedDate] = useState(new Date());
  const { date, week, onWeekChange } = useCalendar({
    defaultValue: selectedDate,
  });

  return (
    <div className="w-full h-full gap-y-4 flex flex-col justify-center items-center">
      <div className="w-full h-10 flex justify-center items-center gap-x-2">
        <button onClick={() => onWeekChange(getWeek(date) - 1)}>
          <ChevronLeft />
        </button>
        <h2 className="text-xl font-semibold">{format(date, "MMM, yyyy")}</h2>
        <button onClick={() => onWeekChange(getWeek(date) + 1)}>
          <ChevronRight />
        </button>
      </div>
      <div className="grid grid-cols-7 gap-x-2">
        {week.map((day) => {
          const classNames = getClassNames(day, selectedDate);
          return (
            <button
              className={classNames?.div}
              key={day.getTime()}
              onClick={() => {
                setSelectedDate(day);
              }}
            >
              <span className={classNames?.span}>{format(day, "dd")}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
};

const getClassNames = (day: Date, selectedDate: Date) => {
  if (
    DateLibs.isYearEqual(day, selectedDate) &&
    DateLibs.isMonthEqual(day, selectedDate) &&
    DateLibs.isDateEqual(day, selectedDate)
  ) {
    return {
      div: "flex justify-center items-center w-10 h-10 rounded-full bg-black",
      span: "text-white",
    };
  }
  if (day.getDay() === 0) {
    return {
      div: "flex justify-center items-center w-10 h-10",
      span: "text-red-500",
    };
  }
  if (day.getDay() === 6) {
    return {
      div: "flex justify-center items-center w-10 h-10",
      span: "text-blue-600",
    };
  }
  return {
    div: "flex justify-center items-center w-10 h-10",
    span: "text-gray-700",
  };
};
export default Calendar;

이렇게 useCalendar 훅을 통해서 유연한 캘린더 컴포넌트를 작성할 수 있다.

패키지

useCalendar 훅은 다음 패키지를 설치하면 사용할 수 있다.

More from this blog

AI 에이전트에서 작업 맥락을 관리하기 위해 사용한 방법

AI 에이전트의 불편함 Codex CLI와 같은 AI 코딩 에이전트를 통해서 프로젝트 개발 속도를 높일 수 있다. 그리고 혼자 작업했을 때는 놓칠 수 있는 부분도 발생할 수 있는데 AI 에이전트를 통해 보완할 수 있었다. 컴포넌트에 대해서 접근성 태그 작성 API 호출하는 비동기 코드 작성할 때 에러 처리 그리고 AI 에이전트를 잘 활용하면 불가능하

Mar 8, 202610 min read

지시사항을 분할하여 Codex로 효율적인 작업하기

이슈 Codex와 같은 AI 기반 코드 에이전트는 출력 토큰의 수가 제한되어 있다. 그래서 다양한 작업을 하나의 프롬프트에 전부 몰아서 작성하면 원하는 결과가 나오지 않을 수 있다. Codex와 같은 LLM 기반 서비스는 출력 토큰 수를 초과하게 되면 일부 단계가 누락되거나 특정 요소를 과하게 요약할 수 있고 이는 전체적인 답변 퀄리티를 낮출 수 있기 때문이다. 그래서 다양한 AI 공식문서는 특정 작업을 더 작은 단위로 분할하는 방식을 강조한다...

Nov 3, 20255 min read

Key를 활용하여 컴포넌트 상태를 초기화하기

개요 컴포넌트의 상태가 업데이트되어도 컴포넌트 내부에 배치된 컴포넌트의 상태는 그대로 유지되는 경우가 있었다. 예전에는 Props 전달 및 내부에서 useEffect를 통해서 상태를 초기 상태로 업데이트하도록 했는데 useEffect를 남용하게 되면 상태 변화 추적이 어려워지는 문제가 있었다. 그런데 useEffect 외에도 Key를 통해서 컴포넌트 상태를 초기화할 수 있다는 사실을 알게 되었다. 리스트 렌더링 React 프론트엔드에서 목록 형...

Sep 21, 20253 min read

내가 AGENTS.md를 작성하는 방법

Codex CLI 요즘 프로젝트 개발에 Codex CLI를 활용하는 이유는 다음과 같다. 특정 프로젝트를 작업할 때 Claude Code는 종종 사용량을 초과한 것과 다르게 Codex CLI는 ChatGPT Plus 요금제만으로도 토큰 사용량 초과 걱정 없이 충분하게 활용할 수 있었다. 타 CLI 기반 AI 개발 도구에 비해서 요구사항을 더 정확하게 구현하고 꼭 필요한 작업만 진행하기 때문에 코드를 검토하는 시간을 줄일 수 있다. 반복적인 코드...

Sep 21, 20254 min read
N

Nowon Lee

22 posts