
현재 프로젝트에서 기존 예약 관리 화면은 탭과 검색, 그리고 예약 카드 리스트 중심으로 구성되어 있었다.
탭별로 요청, 진행 중, 완료, 취소/거절 상태를 나눠 조회할 수 있었지만, 사용자는 예약을 시간 흐름으로 파악하기 어려웠다.
특히 다음과 같은 불편함이 있었다.
그래서 예약 관리 화면에 월간 달력을 추가하고, 현재 탭/검색 조건에 맞는 예약을 달력에도 함께 표시하는 구조를 만들기로 했다.
처음에는 단순한 날자 선택기 성격의 라이브러리도 고려했지만, 원하는 UI는 단순한 date picker가 아니었다.
내가 원한건:
즉, 단순히 날짜를 고르는 컴포넌트보다 일정을 보여주는 월간 캘린더가 필요했다.
그래서 날짜 선택기보다는 이벤트 렌더링이 중심인 FullCalendar가 더 적합하다고 판단했다.
npm install @fullcalendar/core @fullcalendar/react @fullcalendar/daygrid @fullcalendar/interaction
이번 구현에서는 월간 달력이 필요했기 때문에 daygrid 플러그인을 사용했고, 날짜 클릭 기능을 위해 interaction 플러그인도 함께 설치했다.
core : 캘린더 자체의 기본 동작 기반
react : React 컴포넌트로 화면에 렌더
daygrid : 월간 달력 UI 표시(initialView="dayGridMonth)
interaction : 날짜 클릭, 이벤트 클릭 같은 동작 연결
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin, {
type DateClickArg,
} from "@fullcalendar/interaction";
import koLocale from "@fullcalendar/core/locales/ko";
import type { EventClickArg, EventInput } from "@fullcalendar/core";
FullCalendar : React에서 실제로 렌더링할 캘린더 컴포넌트dayGridPlugin : 월간 달력처럼 칸 형태로 보여주는 뷰interactionPlugin : 날짜 클릭, 선택 같은 상호작용 처리koLocale : 달력을 한국어로 표시하기 위한 localeEventInput, EventClickArg, DateClickArg : 이벤트 데이터와 클릭 핸들러 타입 정의FullCalendar는 <FullCalendar /> 컴포넌트 옵션을 props 형태로 전달해 사용하는 구조다.
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
locales={[koLocale]}
locale="ko"
initialView="dayGridMonth"
height="auto"
events={events}
dateClick={handleDateClick}
eventClick={handleEventClick}
headerToolbar={{
left: "prev,next today",
center: "title",
right: "",
}}
buttonText={{
today: "오늘",
}}
dayMaxEvents={2}
fixedWeekCount={false}
/>
여기서 자주 사용한 옵션은 다음과 같다.
plugins : 사용할 플러그인 등록initialView : 처음 보여줄 달력 형태 지정 (dayGridMonth)events : 달력에 표시할 이벤트 데이터 배열dateClick : 날짜 칸 클릭 시 실행할 함수eventClick : 일정 클릭 시 실행할 함수headerToolbar : 상단 이전/다음/오늘 버튼과 제목 배치dayMaxEvents : 한 날짜에 일정이 많을 때 최대 몇 개까지 보여줄지 설정fixedWeekCount : 항상 6주를 채우지 않고 실제 주 수만큼만 렌더링즉, FullCalendar는 정적인 달력 컴포넌트가 아니라
옵션과 데이터로 동작을 조합하는 캘린더 엔진에 가깝다.
이번 구현에서는 클릭 이벤트를 두 가지로 나눠서 사용했다.
날짜 칸 제체를 클릭하면, 그 날짜를 선택된 날짜 상태로 저장하고 리스트에서는 그 날짜의 예약만 다시 보여주도록 했다.
const handleDateClick = (arg: DateClickArg) => {
const clickedDate = arg.dateStr;
onSelectDate(selectedDate === clickedDate ? null : clickedDate);
};
즉, 날짜를 한 번 클릭하면 해당 날짜로 필터링되고,
같은 날짜를 다시 클릭하면 선택이 해제되도록 만들었다.
달력 안에 표시된 일정 이벤트를 클릭하면
기존에 카드 클릭 시 사용하던 예약 상세 모달을 그대로 열도록 연결했다.
const handleEventClick = (arg: EventClickArg) => {
const reservationId = Number(arg.event.extendedProps.reservationId);
if (Number.isNaN(reservationId)) return;
onClickEvent(reservationId);
};
여기서 reservationId는 이벤트 생성 시 extendedProps에 넣어둔 값을 다시 꺼낸 것이다.
해당 방식 덕분에 두 경우 모두 같은 예약 상세 흐름을 재사용할 수 있었다.
이번 구현에서 가장 중요했던 건
달력과 리스트가 같은 데이터를 봐야 한다는 점이었다.
그래서 예약 데이터는 아래 순서로 가공했다.
viewItems : 탭, 날짜 범위 검색, 정렬까지 반영된 최종 예약 목록calendarEvents : viewItems를 FullCalendar용 이벤트 배열로 변환한 값visibleItems : 사용자가 달력에서 특정 날짜를 선택했을 때 - 그 날짜에 해당하는 예약만 다시 걸러낸 목록 const viewItems = useMemo(() => {
// 탭 + 검색 + 정렬 적용
return items;
}, [...]);
const calendarEvents = useMemo(() => {
return toReservationCalendarEvents(viewItems);
}, [viewItems]);
const visibleItems = useMemo(() => {
if (!selectedDate) return viewItems;
return viewItems.filter(
(item) =>
pickYmdFromLocalDateTime(item.timeSlot.startDt) === selectedDate,
);
}, [viewItems, selectedDate]);
이렇게 구성하면
즉, 같은 데이터를 두 방식으로 보여주되 역할은 다르게 나누는 구조가 된다.
FullCalendar는 단순히 달력 UI만 담당한 것이 아니라, 예약 관리 화면 안에서 다음 역할을 맡도록 설계했다.
즉, 기존 리스트형 화면에 달력을 하나 더 붙인 것이 아니라
같은 예약 데이터를 다른 방식으로 시각화하는 보조 뷰로 사용했다.
처음부터 페이지 안에 FullCalendar를 직접 넣기보다, 달력 섹션을 별도 컴포넌트로 분리했다.
예약 관리 페이지는 이미 다음 책임을 가지고 있었다.
여기에 달력까지 직접 넣으면 페이지 컴포넌트가 너무 비대해지기 때문에 역할을 나눴다.
심지어 유저와 아티스트의 페이지가 나눠져있어 동일한 컴포넌트를 사용해야했었기 때문에 분리는 더욱 필요했다.
ArtistReservationPage / MyReservationPageReservationCalendarSectionreservationCalendarUtilsPage : 상태와 데이터 가공에 집중
Component : 표현과 인터랙션에 집중
FullCalendar는 이벤트 데이터를 다음과 같은 형태로 받는다.
{
id: "1",
title: "확정 · 트럼펫 레슨",
start: "2026-04-15T10:00:00",
end: "2026-04-15T11:00:00"
}
하지만 내가 갖고 있던 예약 데이터는 예약 도메인 중심 DTO였다.
그래서 예약 응답을 FullCalendar 이벤트 형식으로 변환하는 유틸을 만들었다.
변환 시 사용한 값들은 기본적으로 예약 아이디, 상태, 레슨 제목, 시작일, 종료일이었고,
기존 상태칩 UI에서 쓰던 상수와 컬러그룹을 재사용해 달력 이벤트 색상도 예약 상태와 일관되게 맞췄다.
처음에는 캘린더 유틸의 인자 타입을 ArtistReservationSummaryResp[] 정도로 고정해두었다.
하지만 유저 예약화면에는 쓰이지 않는 타입이라 타입 에러가 발생했다.
문제는 캘린더가 실제로 필요한 필드는 많지 않은데, 유틸 설계를 특정 화면의 DTO에 너무 치우쳐 설계되었다는 점이다.
그래서 상단에 언급한 값들을 들고가며 캘린더에 필요한 최소 공통 필드만 가지는 타입으로 유틸 입력을 일반화시켰다.
이 과정을 통해 "화면용 DTO를 그대로 유틸에 사용"하는 것보다 "실제로 필요한 최소 구조를 기준으로 타입을 설계하는 것"이 공통 요소에서는 더 낫다는것을 느꼈다.
이번 구현에서 FullCalendar의 인터렉션은 두 가지였다.
날짜를 클릭하면 selectedDate 상태를 바꾸고,
리스트에서는 이 날짜와 일치하는 예약만 다시 보여주도록 했다.
즉,
이런 역할 분리가 되도록 구성했다.
달력에 표시된 예약 이벤트를 클릭하면
기존에 카드 클릭 시 열리던 예약 상세 모달을 그대로 재사용했다.
이벤트의 extendeedProps 에 reservationId를 넣어두고,
이 값을 이용해 기존 예약 상세 흐름과 연결했다.
이렇게 하니 카드에서 보든 달력에서 보든 결국 같은 상세 모달로 진입하게 되어 UX가 자연스러워 졌다.
이번 작업은 단순히 달력을 하나 붙이는 작업이라기보다, 기존 예약 리스트를 시간 흐름으로 다시 보여주는 구조를 만드는 과정에 가까웠다.
FullCalendar를 처음 적용해보면서 생각보다 고려할 부분이 많았지만, 그 과정 덕분에 데이터 가공 기준과 공통 컴포넌트 분리에 대해 더 많이 정리해볼 수 있었다.
다음에는 이번 흐름을 바탕으로 특정 레슨 일정 강조나 시간 충돌 확인 같은 기능도 붙여보려고 한다.