왜 이걸 만들었을까?
웹 서비스에서는 사용자 활동을 날짜별로 보여줘야 할 일이 종종 있습니다. 단순히 달력만 보여주는 것으론 부족하고, 어떤 날짜에 어떤 활동이 있었는지를 시각적으로 구분할 수 있어야 하죠.
그래서 만들었습니다. 날짜 아래 작은 원 아이콘으로 활동 정보를 시각화하는 커스터마이징 가능한 캘린더를
그리고 또한 이 캘린더는 단순한 일정 확인 용도가 아니라, 유저 참여 유도와 성취감을 제공하는 게이미피케이션 요소로 활용되기 위해 도입되었습니다.
환경은 다음과 같았습니다:
- JSP + jQuery 기반의 기존 웹 서비스
- Vue/React를 쓰기엔 도입 비용이 큼
- 사용자 경험은 놓치고 싶지 않음
🧱 구성 요소 요약
이 캘린더는 크게 두 부분으로 나뉩니다.
drawCalendar(year, month)
→ 달력 UI 생성renderAchievements(data)
→ 활동 정보를 SVG로 렌더링
그리고 이 모든 걸 하나로 묶는 게
getMissionView(year, month)
함수입니다.🖼️ UI 구성
캘린더는 다음과 같은 구조를 가집니다:
- 첫 번째 행: 요일 헤더 (일~토)
- 날짜 셀: 1~31일까지 표시
- 날짜 아래: 활동 뱃지를 원(circle)으로 표시
예를 들어,
- 1일 → 출석, 댓글
- 2일 → 출석, 게시글, 좋아요

🧑💻 핵심 코드 설명
1. drawCalendar - 달력 그리기
function drawCalendar(year, month) {
const calendar = new Date(year, month - 1, 1);
const daysInMonth = new Date(year, month, 0).getDate();
const firstDayOfWeek = calendar.getDay();
const today = new Date();
const calendarBody = document.getElementById("calendar-body");
calendarBody.innerHTML = "";
calendarBody.appendChild(renderHeaderRow());
let row = document.createElement("tr");
// 빈 칸 삽입
for (let i = 0; i < firstDayOfWeek; i++) {
row.appendChild(createEmptyCell());
}
for (let day = 1; day <= daysInMonth; day++) {
const isPast = new Date(year, month - 1, day) < new Date(today.getFullYear(), today.getMonth(), today.getDate());
const td = createDayCell(day, isPast);
row.appendChild(td);
if ((firstDayOfWeek + day) % 7 === 0) {
calendarBody.appendChild(row);
row = document.createElement("tr");
}
}
calendarBody.appendChild(row);
}
new Date(year, month - 1, 1)
로 1일 시작일 계산합니다.- 요일이 일~토로 어떻게 시작되는지에 따라 앞에 빈 칸 생성합니다.
past-day
클래스는 오늘 이전 날짜에 자동으로 적용합니다. (앞선 사진처럼 회색 글씨로 표시하기 위함)
2. renderAchievements - 활동 뱃지 붙이기
function renderAchievements(achieves) {
achieves.forEach(({ date, achievement }) => {
const day = parseInt(date.split("-")[2]);
const cell = document.querySelector(.calendar td[data-day="${day}"]);
if (!cell) return;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "35");
svg.setAttribute("height", "10");
svg.setAttribute("viewBox", "0 0 35 10");
svg.style.position = "absolute";
svg.style.bottom = "0";
svg.style.left = "50%";
svg.style.transform = "translateX(-50%)";
const cxBase = 35 / (achievement.length + 1);
achievement.forEach((key, idx) => {
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", (idx + 1) * cxBase);
circle.setAttribute("cy", "5");
circle.setAttribute("r", "4.6");
circle.setAttribute("fill", MISSION_COLORS[key] || "black");
circle.setAttribute("stroke", "white");
circle.setAttribute("stroke-width", "0.8");
svg.appendChild(circle);
});
cell.appendChild(svg);
});
}
- 각 활동 종류별로 색상이 다릅니다.
- 하나의 날짜에 최대 4개의 활동이 있을 수 있으므로, 위치(
cx
)를 자동 계산하여 나열합니다. - 이처럼 활동이 몇개 완료했나에 따라 위치가 달라집니다.

3. getMissionView - 전체 로직 실행
function getMissionView(year, month) {
drawCalendar(year, month);
$.ajax({
url: "/api/statistics/mission.api",
type: "get",
dataType: "json",
data: {
accessToken: getAccessToken(),
date: ${year}-${month.toString().padStart(2, "0")}
},
success: function (data) {
if (data.result_type === "success") {
renderAchievements(data.result_data.achieves);
} else {
alert(data.result_msg);
}
},
error: function () {
alert("리스트 조회에 실패했습니다.");
}
});
}
- 이 함수는 하나의 엔트리 포인트 역할을 합니다.
- drawCalendar로 UI를 먼저 그리고
- 서버에서 활동 정보를 받아 renderAchievements로 반영합니다.
🎨 SVG를 활용한 시각화
SVG를 선택한 이유는 다음과 같습니다:
- CSS로는 어려운 정밀 위치 조정이 가능
- 가볍고, 해상도 무관
- 인터랙션, 애니메이션도 추후 확장 가능
<circle cx="7" cy="5" r="4.6" fill="#FF5737" stroke="white" stroke-width="0.8"/>
🔄 새로운 활동도 쉽게 추가 가능
이 구조의 큰 장점 중 하나는 새로운 활동 종류를 유연하게 추가할 수 있다는 점입니다.
예를 들어,
SHARE
활동을 추가하고 싶다면?- 색상 매핑만 추가:
const MISSION_COLORS = {
ATTENDANCE: "#FF5737",
REPLY: "#0041FF",
POST: "#002EA4",
LIKE: "#703561",
SHARE: "#009688" // ✅ 신규 활동 추가
};
- 서버 응답에서
achievement
배열에 "SHARE" 포함 → 자동 렌더링됨
하드코딩 없이도 동적으로 처리되기 때문에 유지보수 비용이 적고 확장성도 높습니다.
마무리하며
이번 활동 캘린더 구현은 단순히 UI를 예쁘게 만드는 것을 넘어서, 어떻게 하면 사용자 경험을 높이면서도 유지보수와 확장성을 함께 고려할 수 있을지를 고민한 결과물이었습니다.
특히 jQuery 기반의 프로젝트 구조 내에서도
createElement
, classList
, SVG 동적 렌더링
과 같은 기법을 적극 활용하면서 React에 가까운 구조적 사고를 적용해볼 수 있었습니다.결과적으로 작은 컴포넌트 하나에도 구조화와 분리 설계가 얼마나 중요한지를 다시 한 번 느꼈고, 앞으로도 이런 방식의 컴포넌트 설계를 계속 적용해보고자 합니다.