React 달력 컴포넌트 구현하기

React에서 달력을 직접 구현하는 것은 다양한 기술적 요소를 익히기에 좋은 연습이 될 수 있습니다. 달력은 단순한 UI 요소처럼 보일 수 있지만, 날짜 연산, 상태 관리, 사용자 입력 처리 등 여러 가지 개념이 포함됩니다.

본 글에서는 React를 활용하여 달력 컴포넌트를 직접 만들어보고, 구현 과정을 정리했습니다.

React 달력 컴포넌트의 기능 구성

React로 구현할 달력 컴포넌트의 주요 기능은 다음과 같습니다.

  1. 현재 년도와 월을 표시
  2. 이전 달, 다음 달로 이동할 수 있는 버튼 제공
  3. 달력을 6주(42일) 기준으로 구성하여 이전 달, 현재 달, 다음 달의 날짜를 표시
  4. 표시된 년월이 현재 날짜와 일치하는 경우, 오늘 날짜를 강조
  5. 특정 날짜 클릭 시 해당 날짜를 강조
윈도우 컴퓨터 하단에 달력의 구성을 가져감

달력 구현을 위한 날짜 연산 정리

  • 년, 월, 일, 요일 가져오기
  • 특정 달의 첫 번째 요일 구하기
  • 특정 달의 마지막 날짜 구하기
  • 특정 연도, 월, 일에 해당하는 날짜 객체를 가져오기
  • 이전/다음 달 이동 시 연도와 월 조정하기
  • 달력에 표시되는 이전 달 구하기
  • 달력에 표시되는 다음 달 구하기

년, 월, 일, 요일 가져오기

const today = new Date(); // 현재 날짜 객체 생성
console.log(today.getFullYear()); // 현재 연도 출력
console.log(today.getMonth() + 1); // 현재 월 (0부터 시작하므로 +1 필요)
console.log(today.getDate()); // 현재 일(day)
console.log(today.getDay()); // 요일 (0 = 일요일, 1 = 월요일 ...)
  • getFullYear() → 연도 가져오기
  • getMonth() → 월 가져오기 (0부터 시작하므로 +1 필요)
  • getDate() → 일(day) 가져오기
  • getDay() → 요일 가져오기 (일요일: 0, 월요일: 1 …)

특정 달의 첫 번째 요일 구하기

해당 함수는 현재 선택된 달의 첫 번째 날짜(1일)의 요일을 반환합니다. 예를 들어, 2025년 2월이 선택된 경우, 2월 1일은 토요일이므로 [6, “토”] 값을 반환합니다. 이 값을 활용하여 달력의 시작 위치를 정할 수 있습니다.

const DAY_OF_WEEK = "일,월,화,수,목,금,토".split(","); // ["일", "월", "화", "수", "목", "금", "토", "일"]

function getFirstDayOfWeekOfCurrentMonth(
  year: number,
  month: number
): [number, string] {
  const date = new Date(`${year}/${month}/1`);
  const index = date.getDay();
  return [index, DAY_OF_WEEK[index]];
}


console.log(getFirstDayOfWeek(2025, 2)); // 2025년 2월 1일의 요일 [6, "토"]
console.log(getFirstDayOfWeek(2025, 4)); // 2025년 4월 1일의 요일 [2, "화"]

특정 달의 마지막 날짜 구하기

특정 달의 첫 번째 요일을 구한 후, 마지막 날짜를 계산하면 그 달의 전체 날짜를 구성할 수 있습니다. 이를 활용하면 해당 월을 달력에 표시할 수 있습니다.

function getMonthLastDay(year, month) {
  return new Date(year, month, 0).getDate();
}

console.log(getMonthLastDay(2024, 2)); // 2024년 2월의 마지막 날짜 (29)
console.log(getMonthLastDay(2024, 4)); // 2024년 4월의 마지막 날짜 (30)

특정 연도, 월, 일에 해당하는 날짜 객체 가져오기

이 함수는 주어진 연도(year), 월(month), 일(day)에 해당하는 Date 객체를 생성합니다. 달력의 날짜 데이터를 생성할 때 사용되며, 잘못된 날짜(예: 2월 30일) 입력 시 자동으로 보정됩니다.

function getDate(year: number, month: number, day: number) {
  return new Date(year, month - 1, day);
}

이전/다음 달 이동 시 연도와 월 조정하기

이 함수는 이전 달 또는 다음 달로 이동할 때 연도와 월을 올바르게 조정합니다. 예를 들어, 12월에서 다음 달로 이동하면 연도가 증가하고 1월로 변경되며, 1월에서 이전 달로 이동하면 연도가 감소하고 12월로 변경됩니다.

function adjustYearMonth(
  year: number,
  month: number
): { year: number; month: number } {
  if (month > 12) {
    year += 1;
    month -= 12;
  } else if (month < 1) {
    year -= 1;
    month += 12;
  }

  return { year, month };
}

달력 컴포넌트 구현

지금까지 달력 구현에 필요한 기본적인 날짜 연산 및 조작 함수를 작성했습니다. 이제 이 함수들을 활용하여 실제 달력 컴포넌트를 구성하고, 화면에 표시하는 작업을 진행하겠습니다.

전체 코드

import clsx from "clsx";
import { useState } from "react";
import { twMerge } from "tailwind-merge";

// 요일을 한국어로 정의
const DAY_OF_WEEK = "일,월,화,수,목,금,토".split(",");
// 총 6주간의 캘린더 항목 길이를 정의
const CALENDAR_ITEM_LENGTH = 42;

// 이전 달 또는 다음 달로 이동할 때 연도와 월을 조정하는 함수
function adjustYearMonth(
  year: number,
  month: number
): { year: number; month: number } {
  if (month > 12) {
    year += 1;
    month -= 12;
  } else if (month < 1) {
    year -= 1;
    month += 12;
  }

  return { year, month };
}

// 현재 월의 첫 번째 요일을 얻는 함수
function getFirstDayOfWeekOfCurrentMonth(
  year: number,
  month: number
): [number, string] {
  const date = new Date(`${year}/${month}/1`);
  const index = date.getDay();
  return [index, DAY_OF_WEEK[index]];
}

// 현재 월의 마지막 날짜를 얻는 함수
function getMonthLastDay(year: number, month: number) {
  const date = new Date(year, month, 0);
  return date;
}

// 주어진 연도, 월, 일에 해당하는 날짜 객체를 얻는 함수
function getDate(year: number, month: number, day: number) {
  return new Date(year, month - 1, day);
}

// 날짜 상태를 위한 타입 정의
type Date = {
  year: number;
  month: number;
};

function App() {
  // 현재 날짜를 가져오기
  const today = new Date();
  // 선택된 날짜와 포커스를 위한 상태 초기화
  const [date, setDate] = useState<Date>({
    year: new Date().getFullYear(),
    month: new Date().getMonth() + 1,
  });
  const [focus, setFocus] = useState(0);

  // 현재 월의 첫 번째 요일을 가져오기
  const day = getFirstDayOfWeekOfCurrentMonth(date.year, date.month);

  // 캘린더에 표시할 이전 달의 날짜들 가져오기
  const prevMonthDays = Array.from({ length: day[0] }, (v, k) => {
    return getDate(date.year, date.month, -k);
  }).reverse();

  // 현재 월의 날짜들 가져오기
  const monthDays = Array.from(
    { length: getMonthLastDay(date.year, date.month).getDate() },
    (v, k) => {
      return getDate(date.year, date.month, k + 1);
    }
  );

  // 캘린더에 표시할 다음 달의 날짜들 가져오기
  const nextMonthDays = Array.from(
    { length: CALENDAR_ITEM_LENGTH - prevMonthDays.length - monthDays.length },
    (v, k) => {
      return getDate(date.year, date.month + 1, k + 1);
    }
  );

  // 모든 날짜들을 합쳐서 캘린더에 표시할 날짜 배열 생성
  const days = prevMonthDays.concat(monthDays).concat(nextMonthDays);

  // 이전 달로 이동하는 함수
  function prevMonth() {
    setDate((prev) => {
      return {
        ...prev,
        ...adjustYearMonth(prev.year, prev.month - 1),
      };
    });
  }

  // 다음 달로 이동하는 함수
  function nextMonth() {
    setDate((prev) => {
      return {
        ...prev,
        ...adjustYearMonth(prev.year, prev.month + 1),
      };
    });
  }

  // 현재 상태에 따라 클래스 이름을 생성하는 함수
  function classNames(time: number) {
    return twMerge(
      clsx("flex items-center justify-center p-3 bg-slate-200 h-[50px]", {
        "border border-red-500": time === focus,
        "bg-red-500 text-white":
          time ===
          new Date(
            today.getFullYear(),
            today.getMonth(),
            today.getDate()
          ).getTime(),
      })
    );
  }

  // 포커스를 처리하는 함수
  function handleFocus(time: number) {
    setFocus(time);
  }

  return (
    <div className="h-screen flex justify-center items-center">
      <div className="max-w-[600px] w-full">
        <div className="mb-2">
          {date.year}{date.month}</div>
        <div className="border p-1 rounded-md">
          <header>
            <ul className="grid grid-cols-7 gap-1">
              {DAY_OF_WEEK.map((day) => (
                <li
                  key={day}
                  className="p-3 flex items-center justify-center h-[50px]"
                >
                  {day}
                </li>
              ))}
            </ul>
          </header>
          <ul className="grid grid-cols-7 gap-1">
            {days.map((date) => (
              <li
                key={date.getTime()}
                className={classNames(date.getTime())}
                onClick={() => handleFocus(date.getTime())}
              >
                {date.getDate()}
              </li>
            ))}
          </ul>
        </div>
        <div className="flex justify-end gap-2 mt-2">
          <button onClick={prevMonth}>이전달</button>
          <button onClick={nextMonth}>다음달</button>
        </div>
      </div>
    </div>
  );
}

export default App;

현재 날짜 및 상태 관리

function App() {
  // 현재 날짜를 가져오기
  const today = new Date();
  
  // 선택된 연도와 월을 관리하는 상태
  const [date, setDate] = useState<Date>({
    year: new Date().getFullYear(),
    month: new Date().getMonth() + 1,
  });

  // 클릭된 날짜를 강조하기 위한 상태
  const [focus, setFocus] = useState(0);

useState를 사용하여 현재 연도와 월을 상태로 관리합니다.
focus 상태를 추가하여 사용자가 선택한 날짜를 강조할 수 있도록 합니다.

캘린더의 날짜 데이터 생성

1일의 요일을 기준으로 이전 달의 일부 날짜를 계산

const day = getFirstDayOfWeekOfCurrentMonth(date.year, date.month);
const prevMonthDays = Array.from({ length: day[0] }, (v, k) => {
  return getDate(date.year, date.month, -k);
}).reverse();

현재 월의 첫 번째 요일을 기준으로 이전 달의 날짜를 계산하여 채움

현재 월의 날짜 생성

const monthDays = Array.from(
  { length: getMonthLastDay(date.year, date.month).getDate() },
  (v, k) => {
    return getDate(date.year, date.month, k + 1);
  }
);

현재 월의 마지막 날짜를 구한 후, 1일부터 해당 월의 마지막 날까지의 데이터를 생성

다음 달의 날짜를 6주(42일) 기준으로 생성

const nextMonthDays = Array.from(
  { length: CALENDAR_ITEM_LENGTH - prevMonthDays.length - monthDays.length },
  (v, k) => {
    return getDate(date.year, date.month + 1, k + 1);
  }
);

6주(42일) 기준으로 부족한 날짜를 다음 달의 날짜로 채움

모든 날짜를 하나의 배열로 결합

const days = prevMonthDays.concat(monthDays).concat(nextMonthDays);

이전 달, 현재 달, 다음 달의 날짜를 하나의 배열로 합쳐 달력 데이터를 완성

이전/다음 달 이동 기능

function prevMonth() {
  setDate((prev) => {
    return {
      ...prev,
      ...adjustYearMonth(prev.year, prev.month - 1),
    };
  });
}

function nextMonth() {
  setDate((prev) => {
    return {
      ...prev,
      ...adjustYearMonth(prev.year, prev.month + 1),
    };
  });
}

<button>을 클릭하면 setDate()를 사용해 연도/월 상태를 변경

달력 UI 렌더링

연도 및 월을 표시하는 헤더

<div className="mb-2">
  {date.year}{date.month}</div>

요일 표시 영역

<ul className="grid grid-cols-7 gap-1">
  {DAY_OF_WEEK.map((day) => (
    <li key={day} className="p-3 flex items-center justify-center h-[50px]">
      {day}
    </li>
  ))}
</ul>

달력 날짜 출력 영역

<ul className="grid grid-cols-7 gap-1">
  {days.map((date) => (
    <li key={date.getTime()} className={classNames(date.getTime())} onClick={() => handleFocus(date.getTime())}>
      {date.getDate()}
    </li>
  ))}
</ul>

이전/다음 달 이동 버튼

<div className="flex justify-end gap-2 mt-2">
  <button onClick={prevMonth}>이전달</button>
  <button onClick={nextMonth}>다음달</button>
</div>

완성된 모습 (이미지)

Leave a Comment