한국의 산과 나무의 이미지
📝 한줄 요약
근태앱의 기간 필터를 “프리셋 버튼 많은” 레이아웃에서 “달력 + 타이핑” 미니멀 디자인으로 완전히 바꿨습니다.
Before: 프리셋 목록(All, Daily, Weekly, Monthly, Custom)이 별도 버튼으로 흩어져 있음
After: 기간 선택은 한 줄에, 날짜는 달력이나 텍스트 입력으로 직접 지정
👥 이런 분께 추천
React 상태 관리와 날짜 기반 필터링을 구현하려는 분
미국식 대시보드 UI 패턴에 관심 있는 분
사용자 피드백을 받고 UI를 단순화한 경험 을 보고 싶은 분
달력 컴포넌트를 직접 만들어본 분
🙋 내 스펙 & 환경
항목
내용
직업
초과근무관리 앱 제작
코딩 경험
React / TypeScript / Supabase
개발 스택
Next.js 14 / Tailwind CSS
버전 관리
Git / Vercel 배포
진행 방법
1. 왜 하게 되었나
근태앱 Records 페이지의 기간 필터가 복잡했습니다. “전체, 일간, 주간, 월간, 커스텀” 버튼들이 따로 떨어져 있고, 달력 선택 UI가 너무 많은 프리셋 목록으로 무거웠어요.
회계 담당자가 “정산일 기준으로 이 주의 건수를 빠르게 확인”하려면, 필터가 가볍고 직관적이어야 했습니다. 미국 대시보드들을 보니 대부분 프리셋보다 달력 + 직접 입력을 선호하더라고요.
2. 첫 번째 벽: 프리셋의 필요성 판단
처음엔 모든 프리셋을 유지하려고 했습니다. “All, Daily, Weekly, Monthly, Custom 다 있어야 쓰기 편할 거야” 했거든요.
그런데 Custom 팝오버를 만들면서 생각했어요. Custom 안에서 달력으로 주/월을 선택할 수 있으니, 따로 “Weekly” 버튼이 정말 필요할까요?
사용자 피드백: “프리셋은 필요 없어. 달력과 타이핑으로 충분해”
그 말이 떨어지자마자 결정했습니다. 기간 필터 드롭다운은 “일간(Day) | 주간(Week) | 월간(Month) | 커스텀(Custom)“의 4개 탭만. Period Picker 안에서 달력으로 직접 선택하는 방식으로.
학습: 기능을 “만들 수 있으니까 다 넣기”가 아니라, “사용자가 진짜 필요한 건 뭐야?”를 먼저 묻는 게 맞습니다.
3. 두 번째 벽: 필터 순서의 UX
프리셋을 빼고 나니 인터페이스는 깔끔해졌는데, 필터들의 순서가 문제였습니다.
원래는:
[상태 탭] [기간 드롭다운] [달력 버튼들] [다음 줄]
[검색] [직원]이렇게 두 줄이었어요. 회계 담당자가 말씀하셨어요.
“기간으로 먼저 좁혀야 하니까, 순서를 ‘상태 | 기간 | 검색 | 직원’으로 해주면 좋을 것 같아”
이 말에서 깨달았습니다. 단순히 “레이아웃” 문제가 아니라 사용자 심리 흐름이 중요하다는 거요. “먼저 기간을 정하고, 그 안에서 검색하고, 특정 직원만 볼까”하는 순서가 자연스럽다는 뜻이었어요.
필터 순서를 재배치:
[상태 탭] [기간 드롭다운 + 날짜 컨트롤] [검색] [직원]한 줄로 정렬하고, 모바일에서도 flex-wrap으로 자연스럽게 내려가도록.
4. 세 번째 벽: Period Picker 컴포넌트 설계
달력으로 일/주/월을 선택하려면, 각각 다른 UI가 필요했습니다.
Day 모드: 달력 그리드, 아무 날짜나 클릭
Week 모드: 달력 그리드, 한 주 전체가 하이라이트되며 선택
Month 모드: 12개월 그리드, 좌우 화살표로 연도 변경
먼저 하나의 통합 Period Picker를 만들었는데, 코드가 복잡해지니 세 가지 모드를 함수로 분리했습니다.
// src/lib/period.ts에 유틸리티 함수들
- startOfPeriod(date, granularity)
- endOfPeriod(date, granularity)
- periodLabel(start, end, granularity)
- isSamePeriod(date1, date2, granularity)이 함수들이 있으니, Period Picker와 RangePicker는 “달력을 어떻게 보여줄까”에만 집중할 수 있었어요.
핵심 로직: 사용자가 어떤 날짜를 클릭하든, 그 날짜가 속한 일/주/월의 시작과 끝을 자동으로 계산합니다.
5. Custom Range 설계: 프리셋 제거
Custom 팝오버 안에 처음엔 “Last 7 days, Last 30 days, This month” 같은 프리셋을 넣으려고 했어요.
사용자 피드백: “프리셋 없이, 달력과 입력칸 두 개(from, to)로 충분해”
그래서 RangePicker는:
두 개의 날짜 입력칸 (from, to)
달력 UI에서 범위를 시각적으로 선택 가능
“Clear” 버튼 (범위 초기화)
에러 메시지 (start > end일 때)
프리셋이 없으니 공간도 절약되고, 사용자는 자유도가 높아졌습니다.
6. 네 번째 벽: Summary와 Report에도 동일하게 적용
기간 필터는 Records뿐만 아니라 Summary(회계용)와 Report(대표용)에도 있었어요.
처음 생각: “각 화면마다 따로 구현하면 돼”
그런데 생각해보니 “회계가 보는 주별 요약”과 “대표가 보는 일/주/월 요약”이 같은 표 컴포넌트를 쓴다면?
그래서 MeetingSummaryCard라는 공용 컴포넌트를 만들었어요.
<MeetingSummaryCard
rows={data}
title="Meeting Summary"
lockedBasis="confirmed" // 정산일 기준으로 고정
granularityOptions={["week"]} // 주간만 선택 가능
initialGranularity="week"
/>이 컴포넌트를 Summary와 Report에서 다르게 설정하기:
Summary (회계용): lockedBasis=“confirmed”, 주간만 고정
Report (대표용): lockedBasis를 “calendar”와 “confirmed” 중 선택 가능, 일/주/월/커스텀 모두 선택 가능
결과: Custom 기능을 한 번 만들었더니, 양쪽 화면에 자동으로 들어갔습니다. 유지보수도 한 곳만.
7. 최종 흐름
전체 기간 필터 시스템이 이렇게 작동합니다:
사용자 선택
↓
Records: Status 탭 → Period 드롭다운 (Day/Week/Month/Custom) → PeriodPicker 달력
↓
filterMeetings() 함수가 기간 필터링
↓
Summary/Report: MeetingSummaryCard에서 같은 granularity와 basis로 자동 동기화Key Point: 세 화면(Records, Summary, Report)이 같은 Period Picker와 같은 필터링 로직을 공유합니다.
8. 검증: 제대로 작동하는가
Records 페이지에서 각 기간 모드를 테스트했습니다:
Day: 특정 날짜만 필터링 ✓
Week: 그 주의 월~금(또는 시작끝) ✓
Month: 그 달의 1일~마지막날 ✓
Custom: 사용자가 입력한 범위 정확히 필터링 ✓
모바일(375px)에서도 테스트:
필터들이 flex-wrap으로 자연스럽게 내려감 ✓
달력 팝오버가 뷰포트를 벗어나지 않음 ✓
날짜 입력칸이 작은 화면에서도 터치 가능한 크기 ✓
🛠 사용한 도구
도구
역할
React Hooks (useState, useEffect)
필터 상태 관리, 기간별 데이터 재계산
TypeScript
Period, Granularity, Basis 타입 정의로 혼동 방지
Tailwind CSS
반응형 레이아웃, 달력 그리드 스타일링
period.ts 유틸리티
날짜 계산 (startOfPeriod, endOfPeriod 등)
Supabase
정산일과 미팅일 기준으로 조건 쿼리
배운 점
💡 핵심 교훈
1. 기능 ≠ 필요성
프리셋 버튼을 만들 수 있었어요. 그런데 사용자는 원하지 않았어요. “만들 수 있으니까 다 넣자”는 발상에서 벗어나, 핵심 흐름을 먼저 질문하는 게 맞습니다.
2. 사용자 심리 흐름이 레이아웃을 결정한다
필터 순서를 바꾼 이유는 “미적 감각”이 아니라 “사용자가 먼저 기간을 좁혀야 한다”는 작업 순서였어요. 기술 레이아웃이 아니라 작업 심리를 먼저 본다는 게 중요합니다.
3. 공용 컴포넌트의 power
Summary, Report, Records가 모두 기간 필터를 쓰니, MeetingSummaryCard 한 곳을 수정하면 모든 화면이 따라갔어요. 컴포넌트 설계가 탄탄하면, 새 기능 추가가 1/3 시간 이 됩니다.
4. 달력 UI는 상태 계산이 90%
달력 UI 자체는 간단한데, “사용자가 클릭한 날짜가 어느 주인가?”, “지난 주와 다음 주는?” 같은 날짜 계산 로직이 전체 복잡도의 90%였어요. 이걸 period.ts로 따로 분리하니 UI 컴포넌트는 오히려 간결해졌습니다.
5. 타입으로 버그를 원천 차단한다
Granularity = “day” | “week” | “month”, Basis = “confirmed” | “calendar” 이렇게 엄격하게 타입을 정의했더니, 런타임 버그가 거의 없었어요.
적용할 점
🎯 배포 결과
항목
이전
이후
필터 줄 수
2줄 (또는 더)
1줄 (모바일: 자동 wrap)
기간 선택 스텝
프리셋 클릭 or Custom 팝오버
드롭다운 + 달력 1회
사용자 선택지
5개 (프리셋)
4개 (Day/Week/Month/Custom)
Summary/Report 동기화
독립적 구현
MeetingSummaryCard 공유
📋 다음에 재사용할 패턴
1. Period 유틸리티 함수
다른 프로젝트에서 “주별, 월별 집계”를 해야 한다면, 이 period.ts를 그대로 복사해 쓸 수 있어요.
// 복붙 가능한 핵심 함수
startOfPeriod(date: Date, granularity: Granularity): Date
endOfPeriod(date: Date, granularity: Granularity): Date2. 공용 컴포넌트 설계
여러 화면에서 같은 데이터를 다르게 보여줘야 할 때:
Props로
lockedBasis,granularityOptions같은 제어 옵션을 받기같은 계산 로직을 공용 함수로 분리하기
각 화면은 자기 역할에 맞는 설정만 하기
3. 사용자 피드백의 우선순위
프리셋을 없애야 한다는 피드백이 없었으면, 지금도 복잡한 인터페이스를 유지했을 거예요. 사용자 피드백 > 기술적 예상입니다.
🔜 다음 계획
Settings 화면에서 기본 주차 설정 — 미국은 월요일 시작, 한국은 일요일 시작. 지역에 맞게 시작일 설정 가능하게
상대 기간 필터 — “지난 7일”, “이번 달” 같은 상대 범위도 한 번에 선택 가능하게
내보내기 기능 — 선택한 기간의 데이터를 CSV로 내보내기
솔직한 소감
처음에는 “기간 필터 따위 간단하지 ”라고 생각했는데, 정산일 vs 미팅일, 일/주/월 각각의 날짜 계산, 반응형 레이아웃까지 고려하니 꽤 복잡했어요.
그런데 사용자 피드백을 받으면서 생각이 바뀌었습니다. “내가 필요하다고 생각한 게 사용자는 원하지 않는다”는 경험이 가장 큰 배움이었어요.
기간 필터를 미니멀하게 재설계한 후 회계 담당자가 “이제 훨씬 빠르다”고 했을 때, 기술을 뽐내는 것보다 사용자 작업 효율을 먼저 보는 게 진짜 설계라는 걸 깨달았습니다.
프리셋을 지우는 데 5분, 필터 순서 재배치하는 데 10분이면 되는데, 그 작은 변화가 사용 경험을 크게 바꾼다는 게 신기했어요.