CSV 데이터로 매일 요약 리포트(HTML) 자동 만들기 — 105만 행 2.8초, 표준 라이브러리만 (실측)

매일 쌓이는 주문·방문·로그 CSV를 엑셀로 열어 합계를 내고, 전날과 비교하고, 표를 정리해 공유하는 일은 하루 10분이어도 한 달이면 다섯 시간이 넘습니다. 이 글에서는 CSV 리포트 자동 생성을 파이썬 표준 라이브러리만으로 만들어 봅니다. 결과물은 브라우저로 바로 열리는 HTML 파일 하나이고, 매출 합계·전일 대비·최근 7일 평균 대비·분류별 비중·상품 TOP 5·시간대별 막대그래프가 들어갑니다. 이 노트북(윈도우 11, Python 3.12.10)에서 직접 돌려 보니 처음엔 파일을 열자마자 UnicodeDecodeError로 멈췄고, 고친 뒤에는 105만 행(58MB)짜리 CSV를 2.81초에 처리했습니다. 다만 그 과정에서 메모리가 841MB까지 올라가는 문제를 발견해 0.15MB로 줄였습니다. 실행한 순서대로 정리합니다.

준비: 테스트용 주문 CSV 만들기

실제 업무 데이터를 쓸 수는 없어서, 10월 1일부터 18일까지 하루 평균 300건의 주문이 들어 있는 CSV를 만들었습니다. 열은 주문일시·상품명·분류·수량·단가 다섯 개입니다. 상품명 중 일부에는 일부러 <1m>와 &를 넣었습니다. 나중에 HTML로 옮길 때 문제가 되는 문자이기 때문입니다.

"""테스트용 주문 CSV 만들기: 2026-10-01 ~ 10-18, 하루 200~400건"""
import csv
import random
import sys
from datetime import datetime, timedelta

random.seed(1018)
rows_per_day = int(sys.argv[1]) if len(sys.argv) > 1 else 300
out = sys.argv[2] if len(sys.argv) > 2 else "orders.csv"

products = [
    ("키보드", "주변기기", 39000), ("마우스", "주변기기", 18000),
    ("USB-C 케이블 <1m>", "케이블", 6900), ("HDMI 케이블", "케이블", 8900),
    ("모니터암", "책상용품", 45000), ("노트북 거치대", "책상용품", 29000),
    ("웹캠", "주변기기", 52000), ("멀티탭 & 타이머", "전원", 15000),
]

start = datetime(2026, 10, 1)
with open(out, "w", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    w.writerow(["주문일시", "상품명", "분류", "수량", "단가"])
    for d in range(18):
        day = start + timedelta(days=d)
        n = random.randint(int(rows_per_day * 0.7), int(rows_per_day * 1.3))
        for _ in range(n):
            t = day + timedelta(seconds=random.randint(0, 86399))
            name, cat, price = random.choice(products)
            w.writerow([t.strftime("%Y-%m-%d %H:%M:%S"), name, cat, random.randint(1, 3), price])
print(f"{out} 저장")

python make_sample.py로 실행하면 5,725행, 315KB짜리 orders.csv가 생깁니다. 인자를 주면 하루 건수와 파일 이름을 바꿀 수 있어서, 뒤에서 105만 행짜리 파일도 이 스크립트로 만들었습니다.

첫 실행에서 만난 오류: 윈도우는 cp949로 읽습니다

처음 짠 버전은 open(path, newline="")처럼 인코딩을 지정하지 않고 파일을 열었습니다. 결과는 0.06초 만에 오류였습니다.

$ python report.py orders.csv
  File "...\Lib\csv.py", line 102, in fieldnames
    self._fieldnames = next(self.reader)
UnicodeDecodeError: 'cp949' codec can't decode byte 0xec in position 6: illegal multibyte sequence

파일은 UTF-8로 저장했는데, 윈도우의 파이썬은 인코딩을 따로 주지 않으면 시스템 기본값인 cp949로 읽습니다. encoding="utf-8-sig"를 주자 정상 동작했습니다. utf-8 대신 utf-8-sig를 쓴 이유는, 파일 맨 앞에 BOM(눈에 안 보이는 표식)이 붙은 UTF-8 파일이면 첫 열 이름이 \ufeff주문일시처럼 읽혀 row["주문일시"]를 찾지 못하기 때문입니다. BOM을 붙여 다시 저장한 파일로 확인해 보니 utf-8로 열면 첫 열 이름이 실제로 '\ufeff주문일시'로 나왔습니다. utf-8-sig는 BOM이 있으면 떼고, 없으면 그냥 UTF-8로 읽습니다.

현실의 CSV는 깨끗하지 않습니다

리포트를 매일 자동으로 돌리면 사람이 손댄 파일이 섞여 들어옵니다. 그래서 같은 데이터로 두 가지 변형을 만들어 첫 버전을 다시 돌려 봤습니다. 하나는 같은 내용을 cp949로 저장한 파일(엑셀에서 “CSV(쉼표로 분리)”로 저장한 경우를 흉내 낸 것), 다른 하나는 수량이 빈 행, 단가가 "39,000"처럼 천 단위 쉼표가 들어간 행, 상품명에 <b>신상</b>이 들어간 행을 한 줄씩 끼워 넣은 파일입니다.

$ python -X utf8 report_v1.py orders_cp949.csv
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc1 in position 0: invalid start byte

$ python -X utf8 report_v1.py orders_dirty.csv
    row["금액"] = int(row["수량"]) * int(row["단가"])
ValueError: invalid literal for int() with base 10: ''

인코딩을 utf-8-sig로 고정했더니 이번엔 반대로 cp949 파일이 열리지 않았습니다. 그리고 5,700여 행 중 빈 칸 하나 때문에 리포트 전체가 만들어지지 않았습니다. 정기 실행 스크립트에서 가장 곤란한 상황입니다. 그날 리포트가 통째로 빠지고, 어느 줄이 문제였는지도 남지 않습니다.

고친 방향은 세 가지입니다.

  • 인코딩은 순서대로 시도: utf-8-sig로 읽다가 UnicodeDecodeError가 나면 처음부터 cp949로 다시 읽습니다.
  • 숫자는 쉼표를 빼고 변환: "39,000"은 정상 값이므로 살립니다.
  • 그래도 안 되는 행은 행 번호와 함께 건너뛰기: 리포트는 만들고, 건너뛴 행은 콘솔과 리포트 하단에 빨간 글씨로 남깁니다.

완성 코드: report.py

외부 패키지 없이 csv, html, collections, datetime, pathlib만 씁니다. pandas를 설치할 수 없는 회사 PC나 서버에서도 그대로 돌아갑니다. 그래프는 이미지 파일 대신 HTML 안에 SVG 막대를 직접 그려 넣어서 결과가 파일 하나로 끝납니다.

"""CSV 주문 데이터로 하루치 요약 리포트(HTML) 만들기 — 표준 라이브러리만 사용

사용법: python report.py orders.csv [2026-10-18]   (날짜를 빼면 파일의 마지막 날짜)
"""
import csv
import sys
from html import escape
from collections import defaultdict
from datetime import date, timedelta
from pathlib import Path


def to_int(text):
    return int(text.replace(",", "").strip())


class Day:
    """하루치 합계만 들고 있는 상자. 행 자체는 저장하지 않습니다."""

    def __init__(self):
        self.매출 = self.주문 = 0
        self.분류 = defaultdict(int)
        self.상품 = defaultdict(int)
        self.시간대 = [0] * 24

    def add(self, r):
        self.매출 += r["금액"]
        self.주문 += 1
        self.분류[r["분류"]] += r["금액"]
        self.상품[r["상품명"]] += r["금액"]
        self.시간대[r["시"]] += r["금액"]

    def result(self):
        return {
            "매출": self.매출,
            "주문": self.주문,
            "분류": sorted(self.분류.items(), key=lambda x: -x[1]),
            "상품": sorted(self.상품.items(), key=lambda x: -x[1])[:5],
            "시간대": self.시간대,
        }


def summarize(rows):
    d = Day()
    for r in rows:
        d.add(r)
    return d.result()


def read_rows(path, enc, by_day, skipped):
    with open(path, newline="", encoding=enc) as f:
        reader = csv.DictReader(f)
        for row in reader:
            try:
                row["금액"] = to_int(row["수량"]) * to_int(row["단가"])
                row["시"] = int(row["주문일시"][11:13])
                date.fromisoformat(row["주문일시"][:10])
            except (ValueError, TypeError, AttributeError) as e:
                skipped.append(f"{reader.line_num}행: {e}")
                continue
            by_day[row["주문일시"][:10]].add(row)


def load(path):
    for enc in ("utf-8-sig", "cp949"):          # 엑셀 'CSV(쉼표로 분리)' 저장본은 cp949
        by_day, skipped = defaultdict(Day), []
        try:
            read_rows(path, enc, by_day, skipped)
            break
        except UnicodeDecodeError:
            continue
    else:
        raise SystemExit(f"{path}: utf-8도 cp949도 아닌 인코딩입니다")
    print(f"인코딩 {enc}, 건너뛴 행 {len(skipped)}개")
    for s in skipped[:10]:
        print("  ", s)
    return by_day, skipped


def bar_svg(values, width=720, height=160):
    top = max(values) or 1
    bw = width / len(values)
    bars = []
    for i, v in enumerate(values):
        h = v / top * (height - 20)
        bars.append(f'<rect x="{i * bw + 2:.1f}" y="{height - 20 - h:.1f}" '
                    f'width="{bw - 4:.1f}" height="{h:.1f}" fill="#3b82f6"/>')
        bars.append(f'<text x="{i * bw + bw / 2:.1f}" y="{height - 5}" '
                    f'font-size="10" text-anchor="middle">{i}</text>')
    return f'<svg width="{width}" height="{height}">{"".join(bars)}</svg>'


def change(now, before):
    if not before:
        return "-"
    pct = (now - before) / before * 100
    return f'{"▲" if pct >= 0 else "▼"} {abs(pct):.1f}%'


def render(day, s, prev, week_avg, skipped):
    cats = "".join(f"<tr><td>{escape(k)}</td><td>{v:,}원</td><td>{v / s['매출'] * 100:.1f}%</td></tr>"
                   for k, v in s["분류"])
    items = "".join(f"<tr><td>{i}</td><td>{escape(k)}</td><td>{v:,}원</td></tr>"
                    for i, (k, v) in enumerate(s["상품"], 1))
    note = (f"<p style='color:#b91c1c'>읽지 못해 건너뛴 행 {len(skipped)}개: "
            f"{escape(', '.join(skipped[:5]))}</p>" if skipped else "")
    return f"""<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>{day} 매출 리포트</title>
<style>body{{font-family:sans-serif;max-width:760px;margin:24px auto;color:#222}}
table{{border-collapse:collapse;width:100%;margin-bottom:20px}}
td,th{{border:1px solid #ddd;padding:6px 10px;text-align:right}}
td:first-child,th:first-child{{text-align:left}}.kpi{{display:flex;gap:12px}}
.kpi div{{flex:1;background:#f3f4f6;padding:12px;border-radius:8px}}
.kpi b{{display:block;font-size:22px}}</style></head><body>
<h1>{day} 매출 리포트</h1>
<div class="kpi">
<div>매출<b>{s['매출']:,}원</b>전일 대비 {change(s['매출'], prev['매출'] if prev else 0)}</div>
<div>주문<b>{s['주문']:,}건</b>전일 대비 {change(s['주문'], prev['주문'] if prev else 0)}</div>
<div>최근 7일 평균 대비<b>{change(s['매출'], week_avg)}</b>평균 {week_avg:,.0f}원</div>
</div>
<h2>분류별 매출</h2><table><tr><th>분류</th><th>매출</th><th>비중</th></tr>{cats}</table>
<h2>상품 TOP 5</h2><table><tr><th>순위</th><th>상품</th><th>매출</th></tr>{items}</table>
<h2>시간대별 매출</h2>{bar_svg(s['시간대'])}
{note}</body></html>"""


def main():
    src = sys.argv[1]
    by_day, skipped = load(src)
    day = sys.argv[2] if len(sys.argv) > 2 else max(by_day)
    if day not in by_day:
        raise SystemExit(f"{day} 데이터가 없습니다 (파일 범위: {min(by_day)} ~ {max(by_day)})")
    d = date.fromisoformat(day)
    s = by_day[day].result()
    prev_day = by_day.get((d - timedelta(days=1)).isoformat())
    prev = prev_day.result() if prev_day else None
    week = [by_day[k].매출
            for k in ((d - timedelta(days=i)).isoformat() for i in range(1, 8)) if k in by_day]
    week_avg = sum(week) / len(week) if week else 0
    out = Path(f"report-{day}.html")
    out.write_text(render(day, s, prev, week_avg, skipped), encoding="utf-8")
    print(f"{out} 저장: 주문 {s['주문']:,}건, 매출 {s['매출']:,}원")


if __name__ == "__main__":
    main()

실행 방법은 python report.py orders.csv입니다. 날짜를 빼면 파일에 있는 가장 마지막 날짜로 만들고, python report.py orders.csv 2026-10-15처럼 날짜를 주면 그날 리포트를 만듭니다. 네 가지 파일로 다시 돌린 결과입니다.

$ python -X utf8 report.py orders.csv
인코딩 utf-8-sig, 건너뛴 행 0개
report-2026-10-18.html 저장: 주문 363건, 매출 19,789,500원

$ python -X utf8 report.py orders_cp949.csv
인코딩 cp949, 건너뛴 행 0개
report-2026-10-18.html 저장: 주문 363건, 매출 19,789,500원

$ python -X utf8 report.py orders_dirty.csv
인코딩 utf-8-sig, 건너뛴 행 1개
   3001행: invalid literal for int() with base 10: ''
report-2026-10-18.html 저장: 주문 364건, 매출 20,149,500원

$ python -X utf8 report.py orders.csv 2026-10-25
인코딩 utf-8-sig, 건너뛴 행 0개
2026-10-25 데이터가 없습니다 (파일 범위: 2026-10-01 ~ 2026-10-18)

cp949 파일은 자동으로 cp949로 읽혀 원본과 똑같은 숫자가 나왔습니다. 지저분한 파일에서는 빈 칸이 있는 3001행만 건너뛰었고, 쉼표가 들어간 "39,000" 행은 정상 집계됐습니다. 10월 18일 숫자가 364건으로 하나 늘어난 건 끝에 끼워 넣은 파우치 주문(30개 × 12,000원 = 360,000원)이 더해졌기 때문입니다. 데이터에 없는 날짜를 주면 빈 리포트 대신 파일의 날짜 범위를 알려 주고 멈춥니다.

만들어진 리포트 내용

orders.csv로 만든 report-2026-10-18.html은 4,875바이트입니다. 외부 CSS나 CDN을 쓰지 않아서 메일에 첨부하거나 사내 공유 폴더에 올려도 그대로 열립니다. 화면에 나오는 글자만 옮기면 이렇습니다.

2026-10-18 매출 리포트
매출 19,789,500원   전일 대비 ▼ 12.6%
주문 363건          전일 대비 ▼ 6.4%
최근 7일 평균 대비 ▲ 9.4%  (평균 18,093,100원)

분류별 매출
주변기기  9,723,000원  49.1%
책상용품  6,814,000원  34.4%
전원      1,725,000원   8.7%
케이블    1,527,500원   7.7%

상품 TOP 5
1 웹캠 4,836,000원 / 2 모니터암 4,320,000원 / 3 키보드 3,393,000원
4 노트북 거치대 2,494,000원 / 5 멀티탭 & 타이머 1,725,000원

시간대별 매출 (0~23시 막대그래프)

전일 대비는 감소, 7일 평균 대비는 증가로 엇갈립니다. 하루치 숫자만 보면 “어제보다 12.6% 떨어졌다”로 끝나지만, 7일 평균을 같이 두면 어제가 유난히 높았던 날이라는 것까지 한눈에 보입니다. 매일 보는 리포트에 비교 기준을 두 개 넣은 이유입니다.

주의 1: 상품명은 반드시 html.escape

CSV 값을 HTML에 그대로 끼워 넣으면, 값 안의 <, >, &가 태그나 특수문자로 해석됩니다. 상품명에 <b>신상</b>이 들어간 행으로 첫 버전과 최종본의 HTML을 비교했습니다.

$ python -X utf8 check_html.py
v1: <b> 태그 4개 | <td>1</td><td><b>신상</b> 파우치</td><td>360,000원</td></tr></tabl
최종: <b> 태그 3개 | <td>1</td><td>&lt;b&gt;신상&lt;/b&gt; 파우치</td><td>360,000원</td

정상적인 <b>는 KPI 상자 숫자에 쓴 3개뿐이어야 하는데, 첫 버전은 상품명이 태그로 해석되어 하나가 더 생겼습니다. 굵게 표시되는 정도로 끝나면 다행이지만, 값에 </table>이나 스크립트 태그가 들어오면 표가 깨지거나 리포트를 연 사람의 브라우저에서 코드가 실행될 수 있습니다. 최종본은 html.escape()로 감싸 글자 그대로 &lt;b&gt;로 들어갔습니다.

주의 2: 105만 행에서 메모리 841MB

하루 5만 5천 건으로 18일치, 1,055,711행(58.07MB)짜리 파일을 만들어 돌려 봤습니다. 처음 버전은 3.13초에 끝나 속도는 괜찮았는데, tracemalloc으로 파이썬이 잡은 최대 메모리를 재 보니 841MB였습니다. 모든 행을 딕셔너리로 바꿔 날짜별 리스트에 담아 두었기 때문입니다. 58MB 파일이 메모리에서는 14배 넘게 불어난 셈이고, 날마다 행이 쌓이는 파일이라면 언젠가 노트북 메모리를 넘깁니다.

리포트에 필요한 건 행 자체가 아니라 날짜별 합계입니다. 그래서 위 완성 코드처럼 Day 객체에 매출·주문 수·분류별·상품별·시간대별 합계만 누적하고, 읽은 행은 바로 버리도록 바꿨습니다. 파일도 통째로 읽지 않고 한 줄씩 읽습니다.

# 모든 행을 dict로 들고 있던 버전
$ python -X utf8 mem.py big.csv
report-2026-10-18.html 저장: 주문 66,904건, 매출 3,572,259,000원
최대 메모리(tracemalloc): 841 MB

# 날짜별 합계만 누적하는 최종본
$ python -X utf8 mem.py big.csv
report-2026-10-18.html 저장: 주문 66,904건, 매출 3,572,259,000원
최대 메모리(tracemalloc): 0.15 MB

결과 숫자는 똑같고, 메모리는 841MB에서 0.15MB로 줄었습니다. 시간도 3.13초에서 2.81초로 조금 빨라졌습니다. 5,725행짜리 작은 파일도 0.15MB였으니, 이제 메모리 사용량은 행 수가 아니라 날짜·상품 종류 수에만 비례합니다.

버전105만 행 처리 시간최대 메모리지저분한 파일
첫 버전––빈 칸 1행에 전체 실패
행 전체 보관3.13초841MB건너뛰고 완료
합계만 누적(최종)2.81초0.15MB건너뛰고 완료

매일 자동으로 돌리기

리포트 스크립트는 날짜를 생략하면 파일의 마지막 날짜로 만들기 때문에, 정해진 시각에 python report.py orders.csv 한 줄만 실행하면 됩니다. 윈도우라면 작업 스케줄러, 리눅스·맥이라면 cron에 등록하는 방식이 일반적입니다. 이 블로그의 발행 자동화도 노트북에서 매일 정해진 시각에 스크립트 하나를 실행하는 구조로 돌고 있습니다. 운영해 보니 정기 작업이 여러 곳에 흩어지면 같은 일이 중복 등록되거나 같은 시각에 겹치기 쉬워서, 실행 등록은 한곳에 모아 두는 편이 관리하기 좋았습니다.

자동 실행으로 돌릴 때 챙길 점을 정리하면 이렇습니다.

  1. 스크립트 안의 상대 경로는 실행 위치에 따라 달라집니다. 스케줄러에는 작업 폴더를 지정하거나 CSV·출력 경로를 절대 경로로 넘기세요.
  2. 윈도우 콘솔에서 한글 출력이 ����처럼 깨지면 python -X utf8 report.py ...로 실행합니다. 이번 실측에서도 첫 출력은 깨져 나왔고 이 옵션으로 해결했습니다. 깨지는 건 화면 출력뿐이고 HTML 파일은 encoding="utf-8"로 저장하므로 영향이 없습니다.
  3. “건너뛴 행”이 0이 아닌 날은 원본 데이터에 문제가 있다는 신호입니다. 리포트 하단 빨간 줄을 확인하는 습관을 들이면 데이터 입력 실수를 일찍 잡을 수 있습니다.

CSV 리포트 자동화에서 시간을 잡아먹는 건 계산이 아니라 예외입니다. 인코딩 두 가지, 빈 칸, HTML 특수문자, 쌓이는 파일 크기만 처음부터 대비해 두면 그다음은 매일 한 줄 실행으로 끝납니다.

마무리

가장 작은 첫걸음은 지금 매일 엑셀로 열어 보는 CSV 하나를 골라, 위 코드의 열 이름 다섯 개(주문일시·상품명·분류·수량·단가)만 그 파일에 맞게 바꿔 실행해 보는 것입니다. 합계와 TOP 5만 나와도 아침마다 하던 확인 작업 하나가 사라집니다. 다음 글에서는 이번 스크립트처럼 파일 이름과 날짜를 인자로 받는 파이썬 스크립트에 argparse로 명령줄 옵션을 제대로 붙이는 방법을 직접 실행해 보며 정리하겠습니다.

댓글 남기기