JS 8.4 · 8부. 종합 프로젝트

간단 대시보드

클래스로 나누어 데이터를 모아 보여 줍니다.

예상 학습 시간 25분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 실전
요구사항

무엇을 만드나

판매 기록을 모아 요약 숫자막대 그림으로 보여 주는 화면을 만듭니다. 기간을 고르면 그 기간만 다시 셉니다.

화면에 있을 것

  • 요약 카드 — 총 매출 · 건수 · 평균
  • 상품별 막대 그림
  • 기간을 고르는 자리

지켜야 할 규칙

  • 세는 일과 그리는 일을 나눕니다.
  • 기간을 바꾸면 세는 것부터 다시 합니다.
  • 기록이 없으면 빈 화면 대신 안내를 냅니다.

3부(집계) · 4부(DOM) · 6부(클래스)를 사용합니다. 마지막 프로젝트이므로 나누는 연습이 중심입니다.

단계별 구현

1단계 — 세는 일을 따로 둡니다

화면과 상관없는 계산은 화면을 모르는 함수로 둡니다. 그래야 따로 시험할 수 있고, 화면이 바뀌어도 그대로 씁니다(7.3).

script.js
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([]));
출력
{ total: 19000, count: 4, average: 4750 } [{ product: "공책", amount: 9000 }, { product: "연필", amount: 8000 }, { product: "지우개", amount: 2000 }] { total: 0, count: 0, average: 0 }

Map 으로 상품별로 모았습니다(6.5). ?? 0처음 나오는 상품을 다룹니다 — undefined + 3000NaN 입니다(1.6).

빈 배열도 함께 확인했습니다. 0으로 나누는 자리를 먼저 걸러 두었습니다.

단계별 구현

2단계 — 기간으로 거르기

script.js
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);
출력
2 1 0

날짜를 "2026-08-01" 모양으로 담아 두면 글자 그대로 견줄 수 있습니다. 앞자리부터 큰 단위라 차례가 날짜 차례와 같습니다. Date 로 바꾸지 않아도 됩니다.

단계별 구현

3단계 — 요약 카드 그리기

script.js
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);
출력
3 19,000원

toLocaleString 은 숫자에 자릿점을 찍어 줍니다. 직접 만들지 않아도 됩니다.

단계별 구현

4단계 — 막대 그림 그리기

차트 라이브러리 없이 가장 큰 값을 100%로 잡고 나머지의 너비를 비율로 정하면 됩니다(4.4).

script.js
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);
출력
2 100% 보여 줄 기록이 없습니다

비어 있을 때를 먼저 걸러 냈습니다. 걸러 내지 않으면 max0 이 되어 0 으로 나누게 됩니다(1.6).

완성 코드

클래스로 묶어 합치기

세는 함수들을 클래스 하나로 묶습니다(6.1). 데이터는 감추고, 밖에서는 기간을 정하고 결과를 읽기만 합니다.

script.js
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);
출력
3 3 19000

betweenSalesReport 를 돌려줍니다. 원본은 그대로이므로 기간을 몇 번 바꿔도 데이터가 줄어들지 않습니다(3.3 의 filter 와 같은 생각입니다).

세는 쪽은 화면을 모르고, 그리는 쪽은 세는 방법을 모릅니다. 둘을 잇는 것은 update 하나뿐입니다. 6.4 에서 파일로 나눈다면 이 경계가 그대로 파일 경계가 됩니다.

미리보기 탭에서 기간을 바꿔 보세요.

확장 과제

더 해 볼 것

1. 날짜별 막대 더하기 난이도 하

상품별 말고 날짜별로도 볼 수 있게 하세요.

byProduct 를 본떠 byDate 를 두면 됩니다. 모으는 열쇠만 s.date 로 바꿉니다. drawChartproduct 라는 이름을 보므로, 두 곳이 같은 이름을 쓰도록 맞춰야 합니다.
2. CSV 붙여 넣기 난이도 중

2026-08-01,연필,3000 형태의 글자를 여러 줄 붙여 넣으면 그것으로 다시 그리게 하세요.

줄로 자르고(split("\n")) 쉼표로 다시 자릅니다(1.5). 숫자 칸은 Number 로 바꾸고(1.4), 칸 수가 맞지 않는 줄은 filter 로 걸러 냅니다.
3. 서버에서 받아 오기 난이도 상

직접 적은 데이터 대신 서버에서 받아 오게 하고, 불러오는 중과 실패도 다루세요.

8.2 의 모양을 그대로 가져옵니다 — 받아서 내 모양으로 바꾸는 함수를 두고, 세 상태를 화면에 냅니다(5.6). 세는 쪽과 그리는 쪽은 고치지 않아도 됩니다. 나눠 둔 값이 여기서 드러납니다.
요약

이 프로젝트에서 배운 것

  • 세는 일과 그리는 일을 나눕니다. 세는 쪽은 화면을 모릅니다.
  • 날짜를 "2026-08-01" 모양으로 담으면 글자 그대로 견줄 수 있습니다.
  • 비어 있을 때와 0으로 나누는 자리를 먼저 걸러 냅니다.
  • 거르는 메서드는 원본을 바꾸지 말고 새것을 돌려줍니다.
  • 나눠 둔 경계가 곧 파일 경계가 됩니다.