openpyxl로 엑셀 막대 차트를 만들고 범위를 검산하는 방법

엑셀 차트에 막대가 네 개 보인다고 해서 올바른 네 셀을 읽었다고 할 수는 없습니다. 이 예제는 openpyxl로 월별매출 시트와 세로 막대 차트 하나를 만들고, 저장된 차트가 어떤 셀을 참조하는지 확인합니다. 꺾은선 차트를 동시에 만드는 예제는 아닙니다.

입력은 실제 매출이 아닌 가상 단위의 숫자 네 개입니다. Python 3.12.14에서 파일 생성·재읽기·차트 XML 검사를 실행했습니다. Microsoft Excel 앱에서 열거나 인쇄한 결과, 실제 업무에서의 처리 속도는 측정하지 않았습니다.

월별매출의 가상 네 값과 계열 이름 B1, 범주 A2:A5, 값 B2:B5 및 차트 D2 배치를 구분한 설명도
설명용 도해: 가상 입력 120·150·90·180의 합계는 540입니다. 계열 이름 B1을 제외한 범주 A2:A5와 값 B2:B5를 사용해 세로 막대 차트 하나를 D2에 저장하며 값 축은 0부터입니다. 실제 Excel 렌더링은 확인하지 않았습니다.

4행 입력과 차트의 세 범위

ZIP의 data.csv는 아래와 같습니다. 헤더는 A1의 월, B1의 매출에 들어갑니다.

월 매출
1월 120
2월 150
3월 90
4월 180

범주는 A2:A5, 계열 이름은 B1, 값은 B2:B5입니다. titles_from_data=True로 B1을 이름으로 사용하므로 값 네 개에 머리글이 섞이지 않습니다. 차트의 왼쪽 위 배치는 D2이고, 데이터 A:B와 사이에 빈 C열을 둡니다. 막대 값 축은 0에서 시작합니다.

openpyxl의 BarChart는 type을 col로 지정해 세로 막대 차트를 만들 수 있습니다. 이 예제에서는 범주·값·계열 이름을 각각 지정합니다.

실행할 파일과 준비 사항

Python 3.12.14와 openpyxl을 준비하고 ZIP을 빈 폴더에 풉니다. 라이브러리 설치는 사전 준비이며 실행 코드에는 웹 요청이 없습니다.

  1. 터미널에서 ZIP을 푼 폴더로 이동합니다.

  2. python build_chart.py로 bar-chart.xlsx를 만듭니다.

  3. 콘솔의 rows=4, sum=540, charts=1을 확인합니다.

  4. 출력의 참조 목록이 B1, $A$2:$A$5, $B$2:$B$5인지 확인합니다.

  5. Excel에서 직접 열어 쓰려면 네 범주 이름과 축을 한 번 더 대조합니다. 이 단계는 독자의 앱 환경에서 확인할 부분입니다.

설명용 도해: CSV 입력 검사, 새 XLSX 생성, 셀·차트 XML 대조와 별도 실제 앱 확인
설명용 도해: 다운로드 ZIP의 보완 build_chart.py와 5개 회귀 테스트를 사용해 범주를 텍스트로 기록합니다. 저장 파일의 B1·A2:A5·B2:B5 참조와 D2의 0기준 좌표 열3·행1은 파일 구조로 대조하고 화면·글꼴·인쇄는 실제 앱에서 별도 확인합니다. 실제 Excel 렌더링 화면이 아닙니다.

차트에 앞서 CSV 입력을 검사하고 XLSX 저장값을 다시 읽는 흐름이 낯설다면 CSV 점포 수량을 XLSX로 저장하고 다시 읽는 실습부터 확인하세요. 가상 세 행을 두 점포로 집계한 뒤 합계 10과 저장값을 대조합니다.

CSV를 읽어 차트를 만드는 전체 코드

아래는 범주 이름을 수식이 아닌 텍스트로 저장하도록 보완한 build_chart.py입니다. 다운로드 ZIP에는 본문의 보완 코드와 5개 회귀 테스트가 포함되어 있으므로 파일을 따로 교체할 필요가 없습니다. 기본 1~4월 입력과 합계 540, 차트 참조 범위는 그대로입니다.

CSV의 월 칸에 =1+1 같은 문자열이 들어오면 일반적인 셀 쓰기에서 Excel 수식 자료형으로 저장될 수 있습니다. 이전 코드는 셀을 다시 읽은 문자열만 비교해 이 경우도 통과했습니다. 보완 코드의 data_type = "s"는 범주를 문자 그대로 보존하며, 매출 열은 계속 정수로 검사합니다. 원본 CSV의 라벨을 계산식으로 해석하지 않는다는 입력 계약입니다.

분리된 Python 시험에서 이전 코드의 A2는 값 =1+1·자료형 f였고, 보완 후에는 같은 값·자료형 s였습니다. 기존 4개 테스트와 이 반례 1개, 총 5개가 통과했습니다. Excel 앱에서 수식을 실행하거나 차트 화면을 확인한 결과는 아닙니다. 다운로드 ZIP의 테스트 파일에는 이 반례를 포함했습니다. 아래 별도 확인 코드도 check_literal.py로 제공하며 Linux의 Python에서 검사했습니다.

import tempfile
from pathlib import Path
from openpyxl import load_workbook
from build_chart import build

with tempfile.TemporaryDirectory() as temporary:
    folder = Path(temporary)
    source = folder / "labels.csv"
    output = folder / "labels.xlsx"
    source.write_text("월,매출\n=1+1,120\n", encoding="utf-8")
    build(source, output)
    book = load_workbook(output, data_only=False)
    cell = book["월별매출"]["A2"]
    if cell.value != "=1+1" or cell.data_type != "s":
        raise AssertionError("범주가 문자 그대로 저장되지 않았습니다")
    print("literal_category=OK")
    book.close()

ZIP에 포함한 build_chart.py 전체 코드

import argparse
import csv
from pathlib import Path
from zipfile import ZipFile
from xml.etree import ElementTree as ET
from openpyxl import Workbook, load_workbook
from openpyxl.chart import BarChart, Reference
from openpyxl.styles import Font, PatternFill


def build(csv_file, output):
    output = Path(output)
    if output.exists():
        raise FileExistsError("기존 결과 보존: 새 출력 이름을 지정하세요")
    with Path(csv_file).open(encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream)
        if reader.fieldnames != ["월", "매출"]:
            raise ValueError("열은 월,매출이어야 합니다")
        rows = list(reader)
    if not rows or any(set(row) != {"월", "매출"} or not row["월"]
                       or not str(row["매출"]).isascii()
                       or not str(row["매출"]).isdigit() for row in rows):
        raise ValueError("빈 행/누락 열/0 이상 정수 매출을 확인하세요")
    wb = Workbook()
    ws = wb.active
    ws.title = "월별매출"
    ws.append(["월", "매출"])
    for row in rows:
        ws.append([row["월"], int(row["매출"])])
        ws.cell(ws.max_row, 1).data_type = "s"
    ws.column_dimensions["A"].width = 16
    ws.column_dimensions["B"].width = 18
    for cell in ws[1]:
        cell.font = Font(name="Arial", bold=True, color="FFFFFF")
        cell.fill = PatternFill("solid", fgColor="334155")
    for row in ws.iter_rows(min_row=2, max_col=2):
        row[1].number_format = '#,##0'
    chart = BarChart()
    chart.type = "col"
    chart.title = "월별 매출 (가상 단위)"
    chart.y_axis.title = "매출 (가상 단위)"
    chart.y_axis.scaling.min = 0
    chart.x_axis.title = "월"
    chart.legend = None
    chart.width, chart.height = 18, 10
    chart.add_data(Reference(ws, min_col=2, min_row=1, max_row=len(rows)+1), titles_from_data=True)
    chart.set_categories(Reference(ws, min_col=1, min_row=2, max_row=len(rows)+1))
    ws.add_chart(chart, "D2")
    with output.open("xb") as stream:
        wb.save(stream)
    saved = load_workbook(output, data_only=False)
    assert list(saved["월별매출"].values) == [("월", "매출")] + [(r["월"], int(r["매출"])) for r in rows]
    with ZipFile(output) as archive:
        names = [n for n in archive.namelist() if n.startswith("xl/charts/chart") and n.endswith(".xml")]
        assert len(names) == 1
        root = ET.fromstring(archive.read(names[0]))
        namespace = root.tag.split("}", 1)[0].lstrip("{")
        refs = [node.text for node in root.findall(".//{" + namespace + "}f")]
    return {"rows": len(rows), "sum": sum(int(r["매출"]) for r in rows), "charts": 1, "references": refs}

if __name__ == "__main__":
    p = argparse.ArgumentParser()
    p.add_argument("--input", default="data.csv")
    p.add_argument("--output", default="bar-chart.xlsx")
    a = p.parse_args()
    print(build(a.input, a.output))

예상 결과와 입력 변경 검산

120+150+90+180=540입니다. 생성된 파일의 B2:B5를 재읽은 값도 이 네 숫자여야 합니다. 차트 XML에서 계열 이름 '월별매출'!B1, 범주 '월별매출'!$A$2:$A$5, 값 '월별매출'!$B$2:$B$5를 확인합니다. 이 가상 예제 파일에서 확인한 D2 배치는 파일 내부 좌표로 열 3·행 1이며 이는 0부터 세는 좌표입니다.

실습 사본에서 3월만 90에서 100으로 바꿔 새 파일을 만들면 기대 합계는 550입니다. 저장 파일의 B4도 100이어야 합니다. 이전 파일의 차트 그림을 재사용하지 않고 CSV에서 새로 생성했는지 이 값으로 확인할 수 있습니다. 셀 값을 바꾸어도 합계가 계속 540이라면 다른 폴더의 CSV를 읽은 것인지 먼저 점검합니다.

3월 값을 90에서 100으로 바꾸는 합계 검산과 수식처럼 보이는 월 라벨의 f·s 자료형 비교
설명용 도해: 3월 90을 100으로 바꾸면 B4=100, 합계는 540에서 550이 됩니다. =1+1 라벨은 구 코드에서 f 자료형, 본문 보완 코드에서 s 텍스트로 저장됩니다. Excel에서 수식을 실행한 결과가 아닙니다. 잘못된 매출은 저장 전 중단하고 기존 출력은 새 이름으로 보존합니다.

오류와 복구

월,매출 헤더가 다르거나 매출이 비었거나 음수·소수·문자이면 저장 전에 중단합니다. 누락 매출을 자동으로 0으로 대체하지 않습니다. 빈칸이 정말 0인지 원본에서 확인한 뒤 CSV 사본을 수정하세요. 기존 출력 오류는 python build_chart.py --output bar-chart-2.xlsx로 새 이름을 지정해 해결할 수 있습니다.

ZIP의 python -m unittest -v test_example.py는 5개 테스트로 정상 범위·앵커·합계, 입력 변경, 잘못된 금액, 기존 파일 보존, 수식 모양 범주의 문자열 저장을 확인합니다. 테스트는 임시 폴더에서 파일을 생성하므로 배포된 결과 파일을 변경하지 않습니다.

이 예제가 보증하지 않는 것

입력의 기간 중복이나 실제 매출의 정당성을 판정하지 않습니다. 하나의 가상 월별 계열을 그리며 보조축·혼합 차트·선 차트·여러 시트의 기존 차트 보존은 범위 밖입니다. 저장 파일 구조가 맞아도 대상 앱의 글꼴과 인쇄 배치는 달라질 수 있으므로 최종 보고서에서는 실제 화면과 인쇄 미리 보기를 확인해야 합니다.

Python 3.12.14 문서를 참고 자료로 사용했습니다. ZIP에 실행 기록을 함께 넣었습니다.

공식 자료

Bar and Column Charts — openpyxl 3.1.3 documentation

pathlib — Object-oriented filesystem paths

출처 원문 수집일: S1 2026-09-23; S2 2026-09-23.

작성자 소개