간단 대시보드
클래스로 나누어 데이터를 모아 보여 줍니다.
무엇을 만드나
판매 기록을 모아 요약 숫자와 막대 그림으로 보여 주는 화면을 만듭니다. 기간을 고르면 그 기간만 다시 셉니다.
화면에 있을 것
- 요약 카드 — 총 매출 · 건수 · 평균
- 상품별 막대 그림
- 기간을 고르는 자리
지켜야 할 규칙
- 세는 일과 그리는 일을 나눕니다.
- 기간을 바꾸면 세는 것부터 다시 합니다.
- 기록이 없으면 빈 화면 대신 안내를 냅니다.
3부(집계) · 4부(DOM) · 6부(클래스)를 사용합니다. 마지막 프로젝트이므로 나누는 연습이 중심입니다.
1단계 — 세는 일을 따로 둡니다
화면과 상관없는 계산은 화면을 모르는 함수로 둡니다. 그래야 따로 시험할 수 있고, 화면이 바뀌어도 그대로 씁니다(7.3).
const SALES = [ { date: "2026-08-01", product: "연필", amount: 3000 }, { date: "2026-08-01", product: "공책", amount: 9000 }, { date: "2026-08-02", product: "연필", amount: 5000 }, { date: "2026-08-03", product: "지우개", amount: 2000 } ]; function summarize(sales) { const total = sales.reduce((sum, s) => sum + s.amount, 0); return { total: total, count: sales.length, average: sales.length === 0 ? 0 : Math.round(total / sales.length) }; } function byProduct(sales) { const map = new Map(); sales.forEach(s => { map.set(s.product, (map.get(s.product) ?? 0) + s.amount); }); return [...map].map(([product, amount]) => ({ product, amount })) .sort((a, b) => b.amount - a.amount); } console.log(summarize(SALES)); console.log(byProduct(SALES)); console.log(summarize([]));
Map 으로 상품별로 모았습니다(6.5).
?? 0 이 처음 나오는 상품을 다룹니다 —
undefined + 3000 은 NaN
입니다(1.6).
빈 배열도 함께 확인했습니다. 0으로 나누는 자리를 먼저 걸러 두었습니다.
2단계 — 기간으로 거르기
const SALES = [ { date: "2026-08-01", product: "연필", amount: 3000 }, { date: "2026-08-02", product: "연필", amount: 5000 }, { date: "2026-08-03", product: "지우개", amount: 2000 } ]; function between(sales, from, to) { return sales.filter(s => s.date >= from && s.date <= to); } console.log(between(SALES, "2026-08-01", "2026-08-02").length); console.log(between(SALES, "2026-08-03", "2026-08-31").length); console.log(between(SALES, "2026-09-01", "2026-09-30").length);
날짜를 "2026-08-01" 모양으로 담아 두면
글자 그대로 견줄 수 있습니다. 앞자리부터 큰 단위라 차례가 날짜 차례와
같습니다. Date 로 바꾸지 않아도 됩니다.
3단계 — 요약 카드 그리기
const cards = document.querySelector("#cards"); function won(n) { return n.toLocaleString("ko-KR") + "원"; } function drawCards(summary) { cards.replaceChildren(); const items = [ { label: "총 매출", value: won(summary.total) }, { label: "건수", value: `${summary.count}건` }, { label: "평균", value: won(summary.average) } ]; items.forEach(item => { const card = document.createElement("div"); card.className = "card"; const label = document.createElement("div"); label.className = "label"; label.textContent = item.label; const value = document.createElement("div"); value.className = "value"; value.textContent = item.value; card.append(label, value); cards.append(card); }); } drawCards({ total: 19000, count: 4, average: 4750 }); console.log(cards.children.length); console.log(cards.querySelector(".value").textContent);
toLocaleString 은 숫자에 자릿점을 찍어 줍니다. 직접
만들지 않아도 됩니다.
4단계 — 막대 그림 그리기
차트 라이브러리 없이 가장 큰 값을 100%로 잡고 나머지의 너비를 비율로 정하면 됩니다(4.4).
const chart = document.querySelector("#chart"); function drawChart(rows) { chart.replaceChildren(); if (rows.length === 0) { chart.textContent = "보여 줄 기록이 없습니다"; return; } const max = rows.reduce((m, r) => Math.max(m, r.amount), 0); rows.forEach(row => { const line = document.createElement("div"); line.className = "row"; const name = document.createElement("span"); name.className = "name"; name.textContent = row.product; const bar = document.createElement("div"); bar.className = "bar"; bar.style.width = `${Math.round(row.amount / max * 100)}%`; const amount = document.createElement("span"); amount.className = "amount"; amount.textContent = row.amount.toLocaleString("ko-KR"); line.append(name, bar, amount); chart.append(line); }); } drawChart([ { product: "공책", amount: 8000 }, { product: "연필", amount: 4000 } ]); console.log(chart.children.length); console.log(chart.querySelector(".bar").style.width); drawChart([]); console.log(chart.textContent);
비어 있을 때를 먼저 걸러 냈습니다. 걸러 내지 않으면
max 가 0 이 되어
0 으로 나누게 됩니다(1.6).
클래스로 묶어 합치기
세는 함수들을 클래스 하나로 묶습니다(6.1). 데이터는 감추고, 밖에서는 기간을 정하고 결과를 읽기만 합니다.
const SALES = [ { date: "2026-08-01", product: "연필", amount: 3000 }, { date: "2026-08-01", product: "공책", amount: 9000 }, { date: "2026-08-02", product: "연필", amount: 5000 }, { date: "2026-08-02", product: "지우개", amount: 2000 } ]; // 세는 일 — 화면을 모릅니다 class SalesReport { #sales; constructor(sales) { this.#sales = sales; } between(from, to) { return new SalesReport(this.#sales.filter(s => s.date >= from && s.date <= to)); } get summary() { const total = this.#sales.reduce((sum, s) => sum + s.amount, 0); const count = this.#sales.length; return { total, count, average: count === 0 ? 0 : Math.round(total / count) }; } get byProduct() { const map = new Map(); this.#sales.forEach(s => map.set(s.product, (map.get(s.product) ?? 0) + s.amount)); return [...map].map(([product, amount]) => ({ product, amount })) .sort((a, b) => b.amount - a.amount); } } // 그리는 일 — 세는 방법을 모릅니다 const cards = document.querySelector("#cards"); const chart = document.querySelector("#chart"); const range = document.querySelector("#range"); const won = n => n.toLocaleString("ko-KR") + "원"; function drawCards(summary) { cards.replaceChildren(); [ { label: "총 매출", value: won(summary.total) }, { label: "건수", value: `${summary.count}건` }, { label: "평균", value: won(summary.average) } ].forEach(item => { const card = document.createElement("div"); card.className = "card"; const label = document.createElement("div"); label.className = "label"; label.textContent = item.label; const value = document.createElement("div"); value.className = "value"; value.textContent = item.value; card.append(label, value); cards.append(card); }); } function drawChart(rows) { chart.replaceChildren(); if (rows.length === 0) { chart.textContent = "보여 줄 기록이 없습니다"; return; } const max = rows.reduce((m, r) => Math.max(m, r.amount), 0); rows.forEach(row => { const line = document.createElement("div"); line.className = "row"; const name = document.createElement("span"); name.className = "name"; name.textContent = row.product; const bar = document.createElement("div"); bar.className = "bar"; bar.style.width = `${Math.round(row.amount / max * 100)}%`; const amount = document.createElement("span"); amount.className = "amount"; amount.textContent = row.amount.toLocaleString("ko-KR"); line.append(name, bar, amount); chart.append(line); }); } // 잇는 일 const all = new SalesReport(SALES); function update() { const picked = range.value; const report = picked === "all" ? all : all.between(`2026-08-0${picked}`, `2026-08-0${picked}`); drawCards(report.summary); drawChart(report.byProduct); } range.addEventListener("change", update); update(); console.log(cards.children.length); console.log(chart.children.length); console.log(all.summary.total);
between 이 새 SalesReport
를 돌려줍니다. 원본은 그대로이므로 기간을 몇 번 바꿔도 데이터가 줄어들지
않습니다(3.3 의 filter 와 같은 생각입니다).
세는 쪽은 화면을 모르고, 그리는 쪽은 세는 방법을 모릅니다. 둘을 잇는 것은
update 하나뿐입니다. 6.4 에서 파일로 나눈다면 이
경계가 그대로 파일 경계가 됩니다.
미리보기 탭에서 기간을 바꿔 보세요.
더 해 볼 것
상품별 말고 날짜별로도 볼 수 있게 하세요.
byProduct 를 본떠 byDate 를 두면 됩니다. 모으는 열쇠만 s.date 로 바꿉니다. drawChart 는 product 라는 이름을 보므로, 두 곳이 같은 이름을 쓰도록 맞춰야 합니다.
2026-08-01,연필,3000 형태의 글자를 여러 줄
붙여 넣으면 그것으로 다시 그리게 하세요.
split("\n")) 쉼표로 다시 자릅니다(1.5). 숫자 칸은 Number 로 바꾸고(1.4), 칸 수가 맞지 않는 줄은 filter 로 걸러 냅니다.직접 적은 데이터 대신 서버에서 받아 오게 하고, 불러오는 중과 실패도 다루세요.
이 프로젝트에서 배운 것
- 세는 일과 그리는 일을 나눕니다. 세는 쪽은 화면을 모릅니다.
- 날짜를
"2026-08-01"모양으로 담으면 글자 그대로 견줄 수 있습니다. - 비어 있을 때와 0으로 나누는 자리를 먼저 걸러 냅니다.
- 거르는 메서드는 원본을 바꾸지 말고 새것을 돌려줍니다.
- 나눠 둔 경계가 곧 파일 경계가 됩니다.