React와 Day.js를 활용한 공수 캘린더 직접 구현하기

React와 Day.js를 활용한 공수 캘린더 직접 구현하기

1. 공수 캘린더를 직접 구현하게 된 배경

웹 서비스에서 달력은 날짜를 선택하는 단순한 UI처럼 보이지만, 실제로 구현하려면 월별 날짜 수, 요일 정렬, 빈 날짜 칸, 날짜별 정보 배치 등 여러 요소를 고려해야 합니다. 특히 공수 캘린더는 날짜만 표시하는 일반적인 달력과 달리 공수와 금액 등의 업무 정보를 함께 확인할 수 있어야 하므로, 서비스의 요구사항에 맞는 구조가 필요했습니다.

이번 작업에서는 반장노트 서비스에서 사용하는 공수 캘린더를 직접 구현했습니다. 기본적인 날짜 표시뿐만 아니라 날짜별 정보를 추가할 수 있고, 여러 화면에서 재사용할 수 있도록 컴포넌트의 역할을 구분하는 데 중점을 두었습니다.

달력의 기본 구조를 구현하면서 중요하게 생각한 점은 날짜 계산과 화면 표현을 분리하는 것이었습니다. 날짜 계산이 정확하더라도 화면에서 요일에 맞게 배치되지 않으면 달력을 정상적으로 사용할 수 없습니다. 반대로 화면 구조가 잘 구성되어 있어도 날짜 계산이 잘못되면 날짜가 누락되거나 잘못된 위치에 표시될 수 있습니다. 따라서 날짜를 계산하는 로직과 계산 결과를 화면에 배치하는 구조를 함께 고려했습니다.

2. React 컴포넌트와 날짜 데이터 구조

먼저 전체 달력을 관리하는 CustomCalendar, 한 달의 날짜를 표시하는 CalendarMonthGrid, 날짜 배열을 생성하는 buildWeeks 함수로 역할을 구분했습니다.

CustomCalendar는 달력 전체의 상태와 화면 구성을 관리하고, CalendarMonthGrid는 전달받은 월의 요일과 날짜를 렌더링합니다. buildWeeks는 특정 월의 날짜를 계산해 일주일 단위로 나누는 역할을 담당합니다.

이처럼 기능을 분리하면 날짜 계산 로직과 화면 구조를 독립적으로 확인할 수 있습니다. 날짜 생성 방식을 변경할 때 화면의 다른 기능까지 수정해야 하는 범위를 줄이고, UI를 수정할 때도 날짜 계산 로직에 미치는 영향을 줄일 수 있습니다.

날짜별 정보는 CalendarDayData 타입으로 정의했습니다.

export type CalendarDayData = {
  effort?: number;
  amount?: number;
  holidayName?: string;
  totalWorkers?: number;
  attendedWorkers?: number;
  memo?: string;
};

각 속성에 ?를 붙인 이유는 날짜마다 가지고 있는 정보가 다를 수 있기 때문입니다. 어떤 날짜에는 공수만 있고, 다른 날짜에는 공휴일 이름이나 메모가 있을 수 있으므로 필요한 정보만 선택적으로 전달할 수 있도록 구성했습니다.

날짜별 데이터는 날짜 문자열을 키로 사용하는 객체로 관리합니다.

type WorkdaysCalendarProps = {
  value: Dayjs | null;
  onChange: (date: Dayjs | null) => void;
  data?: Record<string, CalendarDayData>;
};

Record<string, CalendarDayData>는 문자열 키와 날짜별 정보 객체를 연결하는 타입입니다. 날짜를 키로 사용하면 해당 날짜에 필요한 정보를 바로 조회할 수 있습니다.

const calendarData: Record<string, CalendarDayData> = {
  '2026-10-01': {
    effort: 1,
  },
  '2026-10-02': {
    effort: 0.5,
    memo: '오전 근무',
  },
};

이 구조를 사용하려면 날짜 키의 형식을 통일해야 합니다. 2026-10-01과 2026.10.01은 서로 다른 문자열이므로, 날짜를 생성하는 부분과 데이터를 조회하는 부분에서 같은 형식을 사용해야 합니다. 이번 구현에서는 Day.js의 format()을 활용해 YYYY-MM-DD 형식으로 날짜를 통일했습니다.

3. Day.js를 활용한 날짜 계산

달력 구현에서 핵심이 되는 부분은 특정 월의 날짜를 정확하게 계산하는 것입니다. 월마다 날짜 수가 다르고, 첫날의 요일에 따라 앞쪽에 빈칸을 배치해야 하므로 날짜를 단순히 1일부터 나열하는 것만으로는 달력 구조를 만들 수 없습니다.

이번 작업에서는 날짜 계산을 단순화하기 위해 Day.js를 사용했습니다. Day.js는 날짜 생성, 월별 날짜 수 확인, 날짜 이동, 날짜 비교, 문자열 변환 등의 기능을 제공하는 JavaScript 라이브러리입니다.

먼저 dayjs()로 현재 날짜를 생성하고, startOf()로 특정 단위의 시작점을 구했습니다.

const [visibleMonth, setVisibleMonth] = useState(value ?? dayjs());

const next = visibleMonth
  .add(delta, 'month')
  .startOf('month');

visibleMonth는 현재 표시할 월을 나타냅니다. 부모 컴포넌트에서 날짜가 전달되면 해당 날짜를 기준으로 시작하고, 그렇지 않으면 현재 날짜를 사용합니다.

월을 이동할 때는 add()로 개월 수를 더한 뒤 startOf('month')로 해당 월의 첫날을 기준으로 맞춥니다. 예를 들어 2026년 10월 16일에 한 달을 더하면 11월 16일이 되지만, startOf('month')를 적용하면 11월 1일을 기준으로 하는 날짜가 됩니다. 월 단위로 날짜를 관리할 때 일(day)까지 유지하지 않아도 되므로, 월 변경 기준을 명확하게 만들 수 있습니다.

daysInMonth()는 해당 월의 날짜 수를 반환합니다. 이를 활용하면 월별 날짜 수를 직접 조건문으로 구분할 필요가 없습니다.

for (let day = 1; day <= month.daysInMonth(); day += 1) {
  days.push(month.startOf('month').date(day));
}

반복문은 1일부터 해당 월의 마지막 날짜까지 실행됩니다. date(day)는 날짜의 일(day)을 지정하는 메서드로, 월의 첫날을 기준으로 각 날짜를 생성합니다. 2월처럼 연도에 따라 날짜 수가 달라지는 경우에도 daysInMonth()가 실제 날짜 수를 반환하므로 같은 로직으로 처리할 수 있습니다.

요일을 계산할 때는 day()를 사용합니다. 이 메서드는 일요일을 0, 월요일을 1로 표현하며 토요일은 6을 반환합니다. 월의 첫날이 무슨 요일인지 확인하면 날짜 앞에 몇 개의 빈칸이 필요한지 계산할 수 있습니다.

날짜를 문자열로 변환하는 format()도 중요한 역할을 합니다.

const dateKey = date.format('YYYY-MM-DD');
const monthTitle = visibleMonth.format('YYYY년 M월');

첫 번째 코드는 날짜별 데이터를 조회하기 위한 키를 생성하고, 두 번째 코드는 화면에 표시할 월 제목을 생성합니다. MM은 월을 항상 두 자리로 표현하지만 M은 한 자리 월을 앞에 0 없이 표현합니다. 따라서 데이터 키에는 형식이 일정한 YYYY-MM-DD를 사용하고, 화면 제목에는 읽기 쉬운 YYYY년 M월을 사용했습니다.

날짜 비교에는 isSame()을 활용했습니다.

const isSelected = value?.isSame(date, 'day');
const isToday = date.isSame(dayjs(), 'day');

isSame()의 두 번째 인자로 'day'를 전달하면 시각이 아니라 같은 날짜인지를 비교합니다. 월이 같은지 확인할 때는 'month'를 지정할 수 있습니다. 이처럼 비교 단위를 목적에 맞게 설정하면 날짜와 월을 구분해 처리할 수 있습니다.

Day.js의 또 다른 특징은 불변성입니다. add()와 startOf()는 기존 날짜 객체를 직접 변경하지 않고 계산 결과를 반환합니다. 따라서 기존 날짜를 유지하면서 변경된 날짜를 별도로 관리할 수 있습니다. 날짜를 계산할 때 원본 값이 예기치 않게 변경되는 것을 방지하고, React 상태에서도 변경 흐름을 명확하게 관리하는 데 도움이 됩니다.

4. 날짜 배열을 주 단위로 구성하기

날짜를 계산한 다음에는 이를 실제 달력 형태로 배치할 수 있도록 배열을 구성해야 합니다. 이번 구현에서는 buildWeeks 함수를 사용해 날짜 배열을 만들고, 일주일에 해당하는 일곱 개의 요소로 나누었습니다.

const buildWeeks = (month: Dayjs) => {
  const days: (Dayjs | null)[] = Array.from(
    { length: month.startOf('month').day() },
    () => null,
  );

  for (let day = 1; day <= month.daysInMonth(); day += 1) {
    days.push(month.startOf('month').date(day));
  }

  return Array.from(
    { length: Math.ceil(days.length / 7) },
    (_, index) => days.slice(index * 7, index * 7 + 7),
  );
};

첫 번째 단계에서는 해당 월의 첫날 앞에 필요한 빈칸을 생성합니다. startOf('month').day()로 첫날의 요일을 구하고, 그 수만큼 null을 배열에 추가합니다. 첫날이 일요일이면 빈칸이 필요하지 않고, 수요일이면 앞에 세 칸이 필요합니다.

여기서 Array.from()은 지정한 길이만큼 배열을 생성하는 데 사용했습니다. 날짜가 없는 위치는 null로 표현하며, 배열의 타입을 (Dayjs | null)[]로 지정해 실제 날짜와 빈칸이 함께 존재할 수 있음을 나타냈습니다.

두 번째 단계에서는 daysInMonth()로 구한 날짜 수만큼 반복하면서 날짜를 추가합니다. 마지막으로 전체 배열의 길이를 7로 나누고 Math.ceil()을 적용해 필요한 주의 개수를 계산합니다. slice()는 배열을 일곱 개씩 나누는 데 사용했습니다.

이 과정을 거치면 날짜는 한 줄로 나열된 배열에서 주 단위 배열로 변환됩니다. 화면에서는 바깥쪽 반복문으로 각 주를 만들고, 안쪽 반복문으로 각 날짜 칸을 렌더링할 수 있습니다.

다만 현재 구현은 월의 첫날 앞에 필요한 빈칸을 추가하지만, 마지막 주의 남은 칸을 채우는 뒤쪽 빈칸은 별도로 생성하지 않습니다. 따라서 마지막 주의 배열 길이는 일곱 개보다 짧을 수 있습니다. 모든 행을 동일한 형태로 표시해야 한다면 날짜 배열에 마지막 빈칸을 추가하거나 CSS 레이아웃에서 행과 열의 크기를 일정하게 유지하는 방식을 고려할 수 있습니다.

이처럼 날짜 배열을 어떤 구조로 생성하는지는 화면의 정렬에도 영향을 줍니다. 달력의 정렬 문제가 발생했을 때 CSS만 확인하기보다, 날짜 배열에 빈칸이 올바르게 들어 있는지와 각 주의 요소 개수가 어떻게 구성되어 있는지도 함께 확인해야 합니다.

5. CSS Grid를 활용한 달력 레이아웃

날짜 계산을 마친 뒤에는 계산된 데이터를 실제 달력 형태로 표현해야 합니다. 이번 구현에서는 요일 영역과 날짜 영역을 분리하고, 각 주를 별도의 행으로 구성했습니다.

<div className="custom-calendar-weekdays">
  {WEEKDAYS.map((weekday) => (
    <div key={weekday} className="custom-calendar-weekday">
      {weekday}
    </div>
  ))}
</div>

<div className="custom-calendar-grid">
  {buildWeeks(month).map((week, weekIdx) => (
    <div className="custom-calendar-week" key={weekIdx}>
      {week.map((date, idx) => {
        if (!date) {
          return (
            <div
              key={`empty-${weekIdx}-${idx}`}
              className="custom-calendar-day empty"
            />
          );
        }

        return (
          <div
            key={date.format('YYYY-MM-DD')}
            className="custom-calendar-day"
          >
            <div className="custom-calendar-day-number">
              {date.date()}
            </div>
          </div>
        );
      })}
    </div>
  ))}
</div>

요일은 WEEKDAYS 배열을 순회해 렌더링하고, 날짜 영역은 buildWeeks()가 반환한 주 단위 배열을 순회해 렌더링합니다. 데이터 배열의 구조와 화면의 구조를 대응시키면 날짜 계산 결과가 어떤 형태로 표시되는지 쉽게 파악할 수 있습니다.

빈칸도 별도의 요소로 렌더링하는 것이 중요합니다. 날짜가 없는 요소를 단순히 건너뛰면 실제 날짜가 앞쪽으로 당겨져 잘못된 요일에 배치될 수 있습니다. 따라서 빈칸에도 날짜 칸과 동일한 레이아웃 흐름을 적용해 날짜가 올바른 위치에 표시되도록 해야 합니다.

CSS Grid를 활용한 달력에서는 일곱 개의 열을 일정하게 유지하는 것이 중요합니다. 날짜별 정보의 길이가 다르더라도 각 날짜 칸의 너비가 달라지지 않아야 하며, 날짜 번호와 공수 정보가 표시되는 위치도 일관되어야 합니다.

테두리 역시 고려해야 할 부분입니다. 인접한 날짜 칸마다 모든 방향의 테두리를 적용하면 경계가 겹쳐 두꺼워 보일 수 있습니다. 반대로 일부 테두리를 제거하는 방식만으로 해결하면 마지막 행이나 열에서 선이 누락될 수 있습니다. 따라서 각 칸과 전체 영역 중 어느 요소가 테두리를 담당할지 정하고, 빈칸을 포함한 모든 요소가 동일한 규칙을 따르도록 구성해야 합니다.

달력의 마지막 행은 날짜 배열의 구조에 따라서도 달라질 수 있습니다. 앞서 설명한 것처럼 마지막 주에 빈칸이 추가되지 않았다면 CSS가 해당 행을 어떻게 배치하는지에 따라 화면의 형태가 달라집니다. 따라서 원하는 레이아웃을 만들기 위해서는 CSS뿐만 아니라 날짜 데이터 생성 방식도 함께 검토해야 합니다.

이번 구현을 통해 달력 UI에서는 데이터 생성과 화면 배치를 별개로 생각하기 어렵다는 점을 확인했습니다. 날짜 계산 결과가 정확한지, 빈칸이 올바르게 표현되는지, 각 행과 열이 일정한 규칙으로 배치되는지를 함께 고려해야 안정적인 달력 레이아웃을 만들 수 있습니다.

6. 구현을 통해 배운 점

공수 캘린더를 직접 구현하면서 달력은 단순히 날짜를 나열하는 UI가 아니라 날짜 계산, 데이터 구조, 컴포넌트 설계, CSS 레이아웃이 함께 동작해야 하는 기능이라는 점을 배웠습니다.

Day.js를 활용하면 월의 시작점과 날짜 수를 구하고, 요일을 확인하고, 날짜를 생성하고, 필요한 형식으로 변환하는 과정을 명확하게 표현할 수 있습니다. 특히 startOf(), daysInMonth(), day(), date(), format(), add(), isSame()을 각각의 목적에 맞게 사용하면서 날짜 계산에 필요한 조건문을 줄이고 코드의 의도를 분명하게 만들 수 있었습니다.

또한 날짜 계산 로직을 별도 함수로 분리하고, 이를 화면을 구성하는 컴포넌트와 연결한 방식은 유지보수 측면에서도 의미가 있습니다. 날짜 생성 방식과 화면 표현을 구분하면 각 부분을 독립적으로 확인할 수 있고, 이후 기능을 추가할 때 수정 범위를 파악하기도 쉬워집니다.

무엇보다 날짜 배열의 구조가 화면 정렬에 직접적인 영향을 준다는 점이 중요했습니다. 날짜가 올바른 요일에 배치되려면 첫날 앞의 빈칸을 정확하게 계산해야 하고, 마지막 행의 형태까지 고려해야 합니다. 화면에서 발생한 문제를 해결할 때 CSS만 수정하기보다 데이터가 생성되는 과정부터 확인하는 접근이 필요합니다.

이번 작업은 디자인에 맞춰 화면을 구성하는 것을 넘어, UI가 정확하게 동작하기 위해 필요한 계산 원리와 데이터 구조를 이해하는 계기가 되었습니다. 라이브러리의 메서드를 사용하는 데 그치지 않고, 각 메서드가 어떤 값을 반환하며 구현 과정에서 왜 필요한지 이해하는 것이 안정적이고 유지보수하기 좋은 코드를 작성하는 데 중요하다는 점을 배웠습니다.

KKAMJJING

Site footer