최근 프로젝트 요구사항에 맞춰 Calendar(DatePicker) 컴포넌트를 전반적으로 개선했습니다.
처음에는 단순한 UI 개선 작업이라고 생각했지만,
구조적 리팩토링(PR #110)을 진행하는 과정에서
UX 문제와 타입 설계의 한계가 자연스럽게 연결되어 있다는 점을 확인하게 되었습니다.
이 글은 Date Picker UX 개선을 계기로,
Calendar 컴포넌트의 구조와 타입 설계를 함께 재정비하게 된 과정을 정리한 기록입니다.
구조 개선 전반에 대한 논의는
관련 Discussion에 정리되어 있으며,
본 글에서는 UX에 직접적인 영향을 준 문제와 그 해결 과정에 집중합니다.
문제 발견 — 사용하면서 드러난 UX 한계
이전 PR(#55)에서
shadcn Calendar 기반으로 날짜/시간 선택 UI를 구성했습니다.
기본 기능은 동작했지만, 실제 사용 과정에서 다음과 같은 UX 문제가 확인되었습니다.
- 단일/범위 선택 UX가 직관적이지 않음
(예:2~10선택 후3클릭 시3~10이 아닌2~3으로 축소) - 지난 날짜(disabled)가 hover / pointer로 인해 클릭 가능한 요소처럼 보임
- DayButton 크기가 반응형 환경에서 유지되지 않아 range 배경이 끊겨 보임
- today / range / outside 스타일 우선순위 충돌
이 문제들은 모두 날짜 선택 경험과 직결되어 있었고,
단순한 스타일 보완보다는 날짜 선택 규칙 자체를 다시 정의해야 하는 문제로 판단했습니다.
UX 개선 방향
Range 선택 UX 개선 — Start-Date First Selection
기존 구현에서는 range가 선택된 상태에서 날짜를 다시 클릭할 경우,
사용자의 의도와 다른 방식으로 범위가 재조정되는 문제가 있었습니다.
예를 들어,
- 기존 선택:
2~10 3클릭- 기대:
3~10 - 실제 동작:
2~3
이 동작은 내부 로직상 일관성은 있었지만,
사용자 입장에서는 다음 동작을 예측하기 어려운 UX로 느껴졌습니다.
이를 해결하기 위해 range 선택 규칙을 다음과 같이 단순화했습니다.
- 첫 번째 클릭 → Start-Date 설정
{ from: 2, to: undefined } - 두 번째 클릭 → Range 확정
{ from: 2, to: 10 } - 기존 range가 있는 상태에서 새 날짜 클릭
→ 기존 range 초기화 후 Start-Date 재설정
이 규칙은 onSelect 핸들러에서 다음과 같이 코드로 명시됩니다.
import type { DateRange, OnSelectHandler } from 'react-day-picker'
const handleSelect: OnSelectHandler<DateRange | undefined> = ( _selected, triggerDate,) => { const current = selected
if (!current || (!current.from && !current.to)) { onSelect?.({ from: triggerDate, to: undefined }) return }
const anchor = current.from
if (anchor && !current.to) { const from = triggerDate < anchor ? triggerDate : anchor const to = triggerDate < anchor ? anchor : triggerDate onSelect?.({ from, to }) return }
onSelect?.({ from: triggerDate, to: undefined })}UX 규칙이 조건문 구조 그대로 드러나며,
“지금 어떤 상태에서 어떤 클릭이 들어왔는지”를 코드만 보고도 파악할 수 있도록 구성했습니다.
지난 날짜 선택 불가(disablePastDates)
지난 날짜는 모임 생성 시 사용할 수 없기 때문에,
단순히 선택을 막는 수준이 아니라
완전히 비활성화된 상태로 인식되어야 했습니다.
이를 위해 disable 조건을 CalendarRoot 단계에서 병합했습니다.
import { DayPicker, getDefaultClassNames, type Matcher } from 'react-day-picker'
type CalendarRootProps = React.ComponentProps<typeof DayPicker> & { disablePastDates?: boolean}
function CalendarRoot({ className, classNames, components, disablePastDates, ...props}: CalendarRootProps) { const defaultClassNames = getDefaultClassNames()
const disabledMatchers: Matcher[] = []
if (disablePastDates) { disabledMatchers.push({ before: today }) }
const disabled = disabledMatchers.length > 0 ? disabledMatchers : undefined
return ( <DayPicker //... classNames={{ disabled: cn('text-gray-400', defaultClassNames.disabled), ...classNames, }} /> )}이 방식은 개별 mode 로직에서 처리하는 대신,
Calendar 공통 레이어에서 UX 규칙을 강제하는 구조입니다.
그 결과 지난 날짜는
- 클릭 불가
- hover / pointer 미노출
- range 시작점으로도 선택 불가
라는 규칙이 구조 차원에서 보장됩니다.
DayButton 레이아웃 보완
반응형 환경에서 DayButton이 정사각형을 유지하지 못하면서
range 배경이 끊겨 보이는 문제가 확인되었습니다.
이를 해결하기 위해 DayButton을 wrapper + button 구조로 분리했습니다.
function CalendarDayButton({ className, day, modifiers, ...props}: React.ComponentProps<typeof DayButton>) { //... return ( <div className={cn( 'relative aspect-square w-full overflow-hidden', isRange && 'bg-brand-800', isStart && 'rounded-l-lg', isEnd && 'rounded-r-lg', )} > <button className={cn( defaultClassNames.day, //... )} //... /> </div> )}range 배경과 radius를 wrapper에서 처리하면서,
버튼 크기와 무관하게 연속적인 range 표현이 유지되도록 구성했습니다.
today / range / outside 스타일 우선순위 재정의
기존에는 today, range, outside 상태가 겹칠 경우
시각적으로 어떤 상태가 우선되는지 명확하지 않았습니다.
이를 다음과 같은 우선순위로 재정의했습니다.
- range (최우선)
- today (range가 아닐 때만 강조)
- outside (항상 가장 낮은 우선순위)
이를 통해 날짜 상태가 보다 명확하게 구분되도록 개선했습니다.
UX 개선을 구현하며 드러난 구조적 문제
UX 개선 방향 자체는 비교적 명확했지만,
이를 코드로 구현하는 과정에서 예상보다 큰 장벽을 마주하게 되었습니다.
바로 Calendar 컴포넌트의 구조와 타입 설계 문제였습니다.
기존 Calendar는 단일 날짜 선택(single)과
범위 선택(range)을 하나의 컴포넌트에서 처리하는 구조였고,
range UX를 구현하기 위해 onSelect 로직을 커스터마이징하기 시작하면서
이 구조의 한계가 점점 드러나기 시작했습니다.
문제의 본질 — mode 기반 Union 타입의 한계
react-day-picker는 mode에 따라 selected 타입이 완전히 달라집니다.
mode="single"→Date | undefinedmode="range"→DateRange | undefined-
mode="multiple"→Date[] | undefined
하지만 Calendar 내부에서는 selected가 항상 다음과 같이 인식됩니다.
Date | DateRange | Date[] | undefinedTypeScript 입장에서는 mode와 관계없이 selected가 “여러 타입 중 하나”가 되며
mode 조건만으로 타입을 안전하게 좁힐 수 없는 구조인 것입니다.
이로 인해 다음과 같은 코드에서 문제가 발생했습니다.
function CalendarRoot({ className, classNames, components, disablePastDates, onSelect, selected, mode, ...props}: React.ComponentProps<typeof DayPicker> & { disablePastDates?: boolean }) { const defaultClassNames = getDefaultClassNames();
const today = new Date(); const disabledInterval = disablePastDates ? { before: today } : undefined;
const rangeOnSelect = mode === 'range' ? (onSelect as OnSelectHandler<DateRange | undefined> | undefined) : undefined; // ❌ 3) onSelect 제네릭이 전달되지 않음
const handleRangeSelect = React.useCallback< OnSelectHandler<DateRange | undefined> >( (value, day, modifiers, e) => { if (rangeOnSelect && day) { const current = selected as DateRange | undefined; // ❌1) selected가 Date | DateRange | undefined → narrowing 실패 const start = current?.from; const end = current?.to;
// 이미 전체 범위가 선택된 상태에서 사용자가 시작 날짜 이후를 클릭하면 해당 날짜부터 새 범위를 시작 if (start && end && day.getTime() > start.getTime()) { // ❌ 2) 비교 연산 타입 에러 rangeOnSelect({ from: day, to: undefined }, day, modifiers, e); return; } }
rangeOnSelect?.(value, day, modifiers, e); }, [rangeOnSelect, selected] );TypeScript는 current가 Date일 가능성을 배제할 수 없기 때문에
from, to 접근과 날짜 비교 모두 타입 에러로 이어졌습니다.
결국 캐스팅이 필요해졌고,
const current = selected as DateRange | undefined이런 코드가 늘어날수록 “타입을 우회하고 있다”는 느낌이 강해졌습니다.
저는 이 문제를 단순한 타입 단정이나 조건문으로 해결할 수 있는 문제가 아니라,
구조적 제약에서 비롯된 문제로 이해하게 되었습니다.
정리해보면, 문제의 핵심은 다음과 같았습니다.
- react-day-picker는 mode에 따라 완전히 다른 타입을 제공합니다.
- Calendar는 모든 mode를 하나의 컴포넌트에서 처리하고 있습니다.
- TypeScript는 런타임 조건만으로 타입을 좁히지 않습니다.
이 세 가지 조건이 동시에 존재하는 한,
range UX처럼 상태 전이가 복잡한 로직을
타입 안정성을 유지한 채 구현하는 데에는 한계가 있다고 판단했습니다.
해결 — Calendar.Single / Calendar.Range 분리
이에 따라 Calendar를 단일 책임 원칙에 따라 분리했습니다.
<Calendar.Single selected={single} onSelect={setSingle} /><Calendar.Range selected={range} onSelect={setRange} />이 구조에서는 Calendar.Range 내부에서
selected가 항상 DateRange | undefined로 보장되기 때문에,
- from / to 접근
- 날짜 비교
- UX 로직 커스터마이징
모두 타입 단정 없이 안전하게 처리할 수 있었습니다.
이는 단순히 타입 에러를 피하기 위한 선택이 아니라,
UX 요구사항을 안정적으로 구현하기 위한 구조적 결정이었습니다.
정리
이번 Date Picker 개선 작업은
단순한 UI 수정이 아니라
UX 규칙 정의 → 구조 문제 인식 → 타입 설계 개선으로 이어진 과정이었습니다.
특히,
- UX 문제는 구현 단계에서 구조와 타입 설계 문제로 이어질 수 있고
- 타입 안정성은 단순한 DX 차원이 아니라
- 사용자 경험을 안정적으로 구현하기 위한 기반이라는 점을 다시 한 번 확인할 수 있었습니다.