이번에는 순수 자바스크립트, 즉 바닐라 JS만으로 달력 형태의 날짜 선택기(Date Picker)를 만들어 보기로 했다. 라이브러리를 사용하면 금방이겠지만, 직접 구현해보면서 DOM 조작과 자바스크립트의 Date 객체에 대해 깊이 이해하고 싶다는 생각이 들었다. 솔직히 시작 전에는 꽤 복잡할 것 같아 막막하기도 했지만, 그만큼 배우는 것도 많을 거라 기대하며 도전했다. 이 글은 그 제작 과정과 삽질의 기록이다.
1. 무엇을 만들 것인가? (기능 정의 및 설계)
먼저 어떤 형태의 데이트 피커를 만들지 구체적으로 구상했다.
- 기본 UI:
- 날짜를 표시하고 선택 결과를 보여줄 <input type="text"> 필드.
- 입력 필드를 클릭하면 나타나는 달력 팝업.
- 달력 팝업은 헤더(현재 년/월 표시, 이전/다음 달 이동 버튼)와 본문(요일 표시, 날짜 그리드)으로 구성된다.
- 핵심 기능:
- 현재 달 기준으로 달력 그리드를 동적으로 생성한다.
- 이전/다음 달 이동 버튼으로 표시되는 달력을 변경할 수 있다.
- 날짜 셀을 클릭하면 해당 날짜가 선택되고, 입력 필드에 YYYY-MM-DD 형식으로 표시된다.
- 선택된 날짜는 달력 상에서 시각적으로 구분되어야 한다.
- (선택 사항) 오늘 날짜도 시각적으로 구분해주면 좋다.
이런 기능들을 염두에 두고 기본적인 HTML 구조부터 잡기 시작했다.
2. 뼈대 만들기: HTML 구조와 CSS 스타일링
간단한 HTML 구조를 먼저 만들었다. 입력 필드와, 그 아래에 숨겨져 있다가 나타날 달력 컨테이너를 배치했다.
<!DOCTYPE html>
<html>
<head>
<title>Vanilla JS Date Picker</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="date-picker-container">
<input type="text" id="date-input" placeholder="날짜 선택" readonly>
<div id="calendar-popup" class="calendar hidden">
<div class="calendar-header">
<button id="prev-month"><</button>
<span id="current-month-year"></span>
<button id="next-month">></button>
</div>
<div class="calendar-weekdays">
<div>일</div><div>월</div><div>화</div><div>수</div><div>목</div><div>금</div><div>토</div>
</div>
<div id="calendar-dates" class="calendar-dates">
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
CSS는 기본적인 레이아웃과 모양만 잡았다. 달력 그리드는 CSS Grid를 사용하면 편리하게 구현할 수 있다. (CSS 코드는 여기서는 생략한다. 핵심은 calendar-dates 영역을 display: grid; grid-template-columns: repeat(7, 1fr); 로 설정하는 것이다.) hidden 클래스로 초기에는 달력을 숨겨두었다.
/* style.css 예시 (아주 기본적인 구조만) */
.calendar {
border: 1px solid #ccc;
padding: 10px;
display: inline-block; /* 또는 다른 레이아웃 방식 */
background-color: white;
position: absolute; /* 입력 필드 아래에 위치시키기 위함 */
z-index: 1000;
}
.hidden {
display: none;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.calendar-weekdays, .calendar-dates {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
gap: 5px; /* 셀 간격 */
}
.calendar-weekdays div {
font-weight: bold;
}
.calendar-dates div {
padding: 5px;
cursor: pointer;
border-radius: 4px;
}
.calendar-dates div:hover {
background-color: #eee;
}
.calendar-dates .date-cell.prev-month,
.calendar-dates .date-cell.next-month {
color: #ccc; /* 이전/다음 달 날짜는 흐리게 */
}
.calendar-dates .date-cell.today {
background-color: #ffeb3b; /* 오늘 날짜 강조 */
}
.calendar-dates .date-cell.selected {
background-color: #007bff; /* 선택된 날짜 강조 */
color: white;
}
3. 핵심 로직 구현: 자바스크립트 Date 객체와 달력 렌더링
이제 자바스크립트로 실제 달력을 그리는 로직을 구현할 차례다. 가장 중요한 것은 자바스크립트의 내장 Date 객체를 다루는 것이다.
상태 관리: 먼저 현재 달력이 보여주고 있는 년도와 월, 그리고 사용자가 선택한 날짜를 저장할 변수가 필요하다.
// script.js
const dateInput = document.getElementById('date-input');
const calendarPopup = document.getElementById('calendar-popup');
const currentMonthYear = document.getElementById('current-month-year');
const calendarDates = document.getElementById('calendar-dates');
const prevMonthBtn = document.getElementById('prev-month');
const nextMonthBtn = document.getElementById('next-month');
let currentDate = new Date(); // 현재 날짜 기준
let currentMonth = currentDate.getMonth(); // 현재 월 (0-11)
let currentYear = currentDate.getFullYear(); // 현재 년도
let selectedDate = null; // 선택된 날짜 저장 변수
Date 객체 활용: Date 객체는 날짜 계산에 필수적이다. 몇 가지 핵심 메서드를 기억해야 한다.
- new Date(year, month, day): 특정 날짜의 Date 객체 생성. 주의: month는 0부터 시작 (0=1월, 11=12월).
- getFullYear(): 년도 반환.
- getMonth(): 월 반환 (0-11).
- getDate(): 일 반환 (1-31).
- getDay(): 요일 반환 (0=일요일, 6=토요일).
- new Date(year, month + 1, 0).getDate(): 특정 year, month의 마지막 날짜(즉, 총 일수)를 구하는 트릭. month + 1은 다음 달을 의미하고, 0일은 다음 달의 0번째 날, 즉 이번 달의 마지막 날을 의미한다.
달력 렌더링 함수 (renderCalendar): 이 함수가 데이트 피커의 핵심이다. 주어진 년도와 월에 해당하는 달력 그리드를 HTML로 생성한다.
function renderCalendar(year, month) {
calendarDates.innerHTML = ''; // 기존 날짜들 초기화
currentMonthYear.textContent = `${year}년 ${month + 1}월`; // 헤더 업데이트 (월은 +1)
const firstDayOfMonth = new Date(year, month, 1).getDay(); // 이번 달 1일의 요일 (0=일, 6=토)
const daysInMonth = new Date(year, month + 1, 0).getDate(); // 이번 달 총 일수
const today = new Date(); // 오늘 날짜 비교용
// 이전 달의 마지막 날짜 구하기 (그리드 앞부분 채우기용)
const daysInPrevMonth = new Date(year, month, 0).getDate();
// 1. 이전 달의 날짜들로 그리드 앞부분 채우기
for (let i = firstDayOfMonth - 1; i >= 0; i--) {
const dateCell = document.createElement('div');
dateCell.classList.add('date-cell', 'prev-month');
dateCell.textContent = daysInPrevMonth - i;
calendarDates.appendChild(dateCell);
}
// 2. 이번 달 날짜들 채우기
for (let day = 1; day <= daysInMonth; day++) {
const dateCell = document.createElement('div');
dateCell.classList.add('date-cell');
dateCell.textContent = day;
dateCell.dataset.date = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; // YYYY-MM-DD 형식으로 데이터 저장
// 오늘 날짜 표시
if (year === today.getFullYear() && month === today.getMonth() && day === today.getDate()) {
dateCell.classList.add('today');
}
// 선택된 날짜 표시
if (selectedDate &&
year === selectedDate.getFullYear() &&
month === selectedDate.getMonth() &&
day === selectedDate.getDate()) {
dateCell.classList.add('selected');
}
calendarDates.appendChild(dateCell);
}
// 3. 다음 달의 날짜들로 그리드 뒷부분 채우기 (총 42개 셀 기준, 6주)
const totalCells = 42; // 7열 * 6행 = 42
const renderedCells = firstDayOfMonth + daysInMonth;
const remainingCells = totalCells - renderedCells;
for (let i = 1; i <= remainingCells; i++) {
const dateCell = document.createElement('div');
dateCell.classList.add('date-cell', 'next-month');
dateCell.textContent = i;
calendarDates.appendChild(dateCell);
}
}
이 renderCalendar 함수는 년도와 월을 받아서, 해당 달의 1일이 무슨 요일인지, 총 며칠인지를 계산한다. 그리고 루프를 돌며 날짜 셀(div)을 생성하고 calendar-dates 영역에 추가한다. 이전 달과 다음 달의 날짜도 일부 표시하여 그리드를 채우는 로직도 추가했다. 오늘 날짜와 선택된 날짜에는 특별한 CSS 클래스를 부여하여 시각적으로 구분한다.
4. 달력 탐색: 이전/다음 달 이동 구현
이전 달, 다음 달 버튼에 이벤트 리스너를 추가하여 currentMonth, currentYear 상태를 변경하고 renderCalendar를 다시 호출하도록 한다.
prevMonthBtn.addEventListener('click', () => {
currentMonth--;
if (currentMonth < 0) { // 1월에서 이전으로 가면
currentMonth = 11; // 12월로
currentYear--; // 년도 감소
}
renderCalendar(currentYear, currentMonth);
});
nextMonthBtn.addEventListener('click', () => {
currentMonth++;
if (currentMonth > 11) { // 12월에서 다음으로 가면
currentMonth = 0; // 1월로
currentYear++; // 년도 증가
}
renderCalendar(currentYear, currentMonth);
});
월이 0보다 작아지거나 11보다 커지면 년도를 조절하는 로직이 중요하다.
5. 날짜 선택 기능 구현
날짜 셀들을 담고 있는 calendar-dates 영역에 이벤트 위임(Event Delegation)을 사용하여 클릭 이벤트를 처리한다. 이렇게 하면 각 날짜 셀에 개별적으로 이벤트 리스너를 달 필요 없이 효율적으로 관리할 수 있다.
calendarDates.addEventListener('click', (event) => {
const target = event.target;
// 클릭된 요소가 날짜 셀이고, 이전/다음 달 날짜가 아닌 경우에만 처리
if (target.classList.contains('date-cell') &&
!target.classList.contains('prev-month') &&
!target.classList.contains('next-month')) {
// 이전에 선택된 날짜의 'selected' 클래스 제거 (만약 있다면)
const previouslySelected = calendarDates.querySelector('.selected');
if (previouslySelected) {
previouslySelected.classList.remove('selected');
}
// 새로 클릭된 날짜에 'selected' 클래스 추가
target.classList.add('selected');
// 선택된 날짜 정보 업데이트
const dateString = target.dataset.date; // YYYY-MM-DD
selectedDate = new Date(dateString.replace(/-/g, '/')); // Date 객체로 변환 (브라우저 호환성 위해 '/' 사용)
// 입력 필드에 날짜 표시
dateInput.value = dateString;
// 달력 팝업 숨기기 (선택 후 바로 닫기)
calendarPopup.classList.add('hidden');
}
});
클릭된 요소가 유효한 날짜 셀인지 확인하고, dataset에 저장된 날짜 문자열(YYYY-MM-DD)을 가져온다. 이전에 선택된 셀이 있다면 하이라이트를 제거하고, 새로 클릭된 셀에 하이라이트를 적용한다. selectedDate 변수를 업데이트하고, 입력 필드에도 값을 넣어준다. 마지막으로 달력 팝업을 닫는다.
6. 입력 필드 클릭 시 달력 토글
마지막으로 입력 필드를 클릭했을 때 달력 팝업이 나타나거나 사라지도록 토글 기능을 추가한다.
dateInput.addEventListener('click', () => {
calendarPopup.classList.toggle('hidden');
});
// (선택사항) 달력 외부 클릭 시 닫기
document.addEventListener('click', (event) => {
// 클릭된 요소가 date-picker-container 내부 요소가 아니고, calendarPopup이 보이는 상태라면
if (!event.target.closest('.date-picker-container') && !calendarPopup.classList.contains('hidden')) {
calendarPopup.classList.add('hidden');
}
});
7. 구현 중 겪었던 어려움과 배운 점
- Date 객체의 월(Month) 인덱스: 0부터 시작한다는 점을 계속 상기해야 했다. 렌더링 시 month + 1을 해주는 것을 잊어서 날짜가 계속 틀리는 실수를 반복했다.
- getDay()의 반환 값: 일요일이 0이라는 점도 헷갈렸다. 달력 그리드의 시작 위치를 계산할 때 주의해야 했다.
- 이전/다음 달 날짜 계산: 그리드를 채우기 위해 이전 달과 다음 달의 날짜를 계산하는 로직이 은근히 복잡했다. 특히 경계(1월, 12월) 처리 시 실수가 잦았다.
- 상태 관리: currentYear, currentMonth, selectedDate 같은 상태 변수들을 명확하게 관리하고, 상태 변경 시 UI가 올바르게 다시 렌더링 되도록 하는 것이 중요했다. renderCalendar 함수를 재활용하는 구조가 도움이 되었다.
- 이벤트 위임: 날짜가 동적으로 생성되므로, 부모 요소에 이벤트 리스너를 하나만 다는 이벤트 위임 방식이 매우 효과적이었다.