React 달력 컴포넌트 구현하기

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

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

See the Pen react-calendar by CodingCitron (@codingcitron) on CodePen.

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>

완성된 모습 (이미지)

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다