본문 바로가기
AI System/클린 아키텍처와 바이브 코딩을 활용한 AI Agent 시스템의 설계와 구

d02 - 8. 시각화와 출력 설계

by Toddler_AD 2026. 6. 23.

8교시 · 시각화 및 출력 설계

2일차 09:00–10:00 · 이론/실습 선행: 7교시(AnalysisResult), 4교시(ChartRenderer 포트)


🎯 학습 목표

  • 분석 결과를 차트 이미지로 그리는 어댑터를 구현한다.
  • "파일을 만든다" 같은 부수효과(side effect) 가 있는 코드를 테스트하는 법을 익힌다.
  • 서버 환경·한글 폰트 등 matplotlib의 현실적 함정을 처리한다.

📖 개념 1 — 차트 생성은 왜 "어댑터"인가

차트를 그리려면 matplotlib라는 외부 라이브러리가 필요하고, 결과로 이미지 파일이라는 외부 산출물을 만듭니다. 외부 도구를 쓰고 외부에 흔적을 남기는 일 → 3계층 어댑터의 책임입니다.

유스케이스(7교시)와 비교하면 성격이 정반대입니다.

  분석 유스케이스(7교시) 차트 어댑터(8교시)
외부 의존 없음(순수 계산) matplotlib
산출물 값(AnalysisResult) 파일(.png)
같은 입력 → 항상 같은 값 파일이 "생기는" 효과
테스트 초점 반환값이 맞는가 파일이 생겼는가

📖 개념 2 — 부수효과(side effect)란, 그리고 어떻게 테스트하나

부수효과란 함수가 값을 반환하는 것 외에 바깥 세계를 바꾸는 일입니다(파일 쓰기, 네트워크 전송, 화면 출력 등). 순수 계산 함수는 "입력→출력"만 보면 되지만, 부수효과가 있는 함수는 "효과가 실제로 일어났는가" 를 확인해야 합니다.

차트 어댑터의 테스트 전략은 이렇습니다.

  • 반환된 파일 경로가 실제로 존재하는가?
  • 파일이 비어 있지 않은가(크기 > 0)?
  • 반환 개수가 기대대로(상위 상품 차트 + 월별 추이 차트 = 2개)인가?

이때 실제 화면 창을 띄우면 안 되고, 임시 폴더에 그려야 합니다(프로젝트를 더럽히지 않음). 두 가지 모두 아래에서 처리합니다.


🔴 Red — 차트 어댑터 테스트

tests/test_chart_renderer.py:

from datetime import date
from pathlib import Path

from sales_agent.domain.models import AnalysisResult
from sales_agent.adapters.chart_renderer import MatplotlibChartRenderer


def make_result() -> AnalysisResult:
    """차트 입력으로 쓸 고정된 분석 결과를 만든다(테스트가 항상 같은 그림을 그리도록)."""
    return AnalysisResult(
        total_revenue=7_200_000,
        total_quantity=17,
        period_start=date(2024, 1, 5),
        period_end=date(2024, 2, 11),
        by_product={"노트북": 7_000_000, "마우스": 200_000},  # 막대 차트 입력
        by_region={"서울": 7_000_000, "부산": 200_000},
        by_month={"2024-01": 5_200_000, "2024-02": 2_000_000},  # 선 차트 입력
    )


def test_render_contains_top_products_and_monthly_png(tmp_path: Path):
    """렌더링 결과에 상위상품/월별추이 png가 포함되고 실제 파일도 생성되는지 검증한다."""
    renderer = MatplotlibChartRenderer()

    # tmp_path(임시 폴더)에 차트를 그리게 한다. 반환값은 생성된 파일 경로 목록.
    paths = renderer.render(make_result(), str(tmp_path))

    names = {Path(p).name for p in paths}
    expected = {"top_products.png", "monthly_trend.png"}
    assert expected.issubset(names)              # 필수 차트 파일명이 모두 포함됐는가

    for filename in expected:
        f = tmp_path / filename
        assert f.exists()                        # 부수효과 검증①: 파일이 실제로 존재
        assert f.suffix == ".png"                # 부수효과 검증②: 확장자가 png
        assert f.stat().st_size > 0              # 부수효과 검증③: 빈 파일이 아님(크기>0)

    for p in paths:
        f = Path(p)
        assert f.suffix == ".png"                # 렌더러가 반환한 경로는 모두 png여야 한다


def test_render_into_given_directory(tmp_path: Path):
    """차트는 '호출자가 지정한 폴더' 안에 생성된다(경로 규약 확인)."""
    renderer = MatplotlibChartRenderer()

    paths = renderer.render(make_result(), str(tmp_path))

    for p in paths:
        assert str(tmp_path) in p                # 반환 경로에 지정 폴더가 포함돼 있는가

 

🔍 관찰 포인트 — 픽셀 색을 검사하지 않습니다. "막대 높이가 정확한가, 색이 파란가"까지 테스트하면 너무 깨지기 쉽고 유지보수가 어렵습니다. 대신 "파일이 의도대로 생성됐는가(개수·존재·크기·위치)" 라는 관찰 가능한 결과만 검증합니다. 이것이 부수효과 테스트의 현실적인 균형점입니다.

uv run pytest tests/test_chart_renderer.py -v   # 🔴 실패 확인

🟢 Green — 차트 어댑터 구현

이제 위 테스트를 통과시키는 MatplotlibChartRenderer를 src/sales_agent/adapters/chart_renderer.py에 직접 작성합니다. 규칙은 ▲usecases/ports.py의 ChartRenderer 프로토콜을 만족할 것 ▲화면 없는 환경을 위해 Agg 백엔드를 쓸 것 ▲상위 상품 막대 차트와 월별 추이 선 차트, 두 개의 png를 만들어 경로 리스트를 반환할 것 ▲그린 뒤 figure를 닫아 메모리 누수를 막을 것입니다.

 

src/sales_agent/adapters/chart_renderer.py:

from __future__ import annotations

from pathlib import Path

import matplotlib

# ⚠️ 반드시 pyplot을 import 하기 '전에' 백엔드를 지정해야 효과가 있다.
# "Agg"는 화면 창 없이 파일로만 그리는 모드 → 디스플레이 없는 서버/CI/교육장에서도 동작.
matplotlib.use("Agg")

import matplotlib.pyplot as plt  # noqa: E402  (백엔드 설정 뒤 import 하려는 의도적 순서)

from sales_agent.domain.models import AnalysisResult


class MatplotlibChartRenderer:
    """분석 결과를 차트 이미지(png)로 렌더링하는 어댑터.

    usecases.ports.ChartRenderer 프로토콜(render 메서드)을 구현한다.
    값을 반환할 뿐 아니라 '파일을 생성'하는 부수효과를 가진 점이 유스케이스와 다르다.
    """

    def __init__(self) -> None:
        # rcParams: matplotlib 전역 설정. 한글 라벨이 깨지지 않도록 폰트 후보를 지정한다.
        # 앞에서부터 설치된 폰트를 사용(Windows=Malgun Gothic, mac=AppleGothic, 그 외=DejaVu).
        plt.rcParams["font.family"] = ["Malgun Gothic", "AppleGothic", "DejaVu Sans"]
        plt.rcParams["axes.unicode_minus"] = False  # 마이너스 기호(−)가 깨지는 현상 방지

    def render(self, result: AnalysisResult, out_dir: str) -> list[str]:
        """상위 상품 차트와 월별 추이 차트를 그려 생성된 파일 경로 목록을 반환한다."""
        out = Path(out_dir)
        out.mkdir(parents=True, exist_ok=True)  # 폴더가 없으면 만든다(parents: 중간 폴더까지)

        # 두 차트를 각각 그리고 경로를 모아 반환. 차트를 늘리려면 여기 항목만 추가하면 된다.
        return [
            self._bar_top_products(result, out),
            self._line_monthly(result, out),
        ]

    def _bar_top_products(self, result: AnalysisResult, out: Path) -> str:
        """상위 5개 상품의 매출을 막대 차트로 그려 파일 경로를 반환한다."""
        top = result.top_products[:5]                 # 매출 상위 5개 (상품명, 매출) 쌍
        names = [name for name, _ in top]             # x축 라벨: 상품명들
        values = [value for _, value in top]          # y축 값: 매출들

        fig, ax = plt.subplots()                      # 새 도화지(fig)와 좌표축(ax) 생성
        ax.bar(names, values)                         # 막대 그래프
        ax.set_title("상위 상품별 매출")
        ax.set_ylabel("매출(원)")
        path = out / "top_products.png"
        fig.savefig(path, bbox_inches="tight")        # 파일로 저장(여백 자동 정리)
        plt.close(fig)                                # ⚠️ 메모리 누수 방지: 그린 figure는 닫는다
        return str(path)

    def _line_monthly(self, result: AnalysisResult, out: Path) -> str:
        """월별 매출 추이를 선 차트로 그려 파일 경로를 반환한다."""
        months = sorted(result.by_month.keys())       # 월 키를 시간순으로 정렬("2024-01"…)
        values = [result.by_month[m] for m in months] # 각 월의 매출

        fig, ax = plt.subplots()
        ax.plot(months, values, marker="o")           # 선 그래프(각 점에 동그라미 표시)
        ax.set_title("월별 매출 추이")
        ax.set_ylabel("매출(원)")
        path = out / "monthly_trend.png"
        fig.savefig(path, bbox_inches="tight")
        plt.close(fig)                                # 여기서도 반드시 닫는다
        return str(path)

 

🔍 관찰 포인트 1 — matplotlib.use("Agg")를 import 직후에 둡니다. "Agg"는 화면 창을 띄우지 않고 파일로만 그림을 그리는 모드입니다. 교육장 PC, 서버, CI 환경에는 디스플레이가 없을 수 있어, 기본(화면) 모드면 오류가 납니다. 이 한 줄이 "어디서나 동작"을 보장합니다. 단, pyplot을 import 하기 전에 설정해야 효과가 있습니다.

 

🔍 관찰 포인트 2 — plt.close(fig)로 메모리를 정리합니다. matplotlib는 그린 figure를 내부에 계속 쌓아 둡니다. 차트를 많이 그리는 앱에서 이를 닫지 않으면 메모리가 새어 나갑니다. 그려서 저장한 뒤 즉시 닫는 습관이 중요합니다. 어댑터가 외부 자원(메모리·파일 핸들)을 책임지고 정리하는 모습입니다.

uv run pytest tests/test_chart_renderer.py -v   # 🟢 통과 확인

🔵 Refactor — 그리고 실제 데이터로 차트 확인

두 메서드의 중복(subplots → 그리기 → savefig → close)을 공통 헬퍼로 묶어도 좋습니다. 단, 테스트가 계속 통과해야 합니다.

실제 데이터로 차트가 만들어지는지 확인합니다(7교시에서 익힌 here-string 방식).

@'
from pathlib import Path
from sales_agent.adapters.csv_repository import CsvSalesRepository
from sales_agent.usecases.analyze_sales import AnalyzeSalesUseCase
from sales_agent.adapters.chart_renderer import MatplotlibChartRenderer

result = AnalyzeSalesUseCase(CsvSalesRepository("sample_data/sales_sample.csv")).execute()
paths = MatplotlibChartRenderer().render(result, "outputs/charts")

for p in paths:
    print("생성:", p, "크기:", Path(p).stat().st_size, "bytes")
'@ | uv run python -

 

outputs/charts 폴더에 top_products.png, monthly_trend.png가 생기면 성공입니다. VS Code 탐색기에서 열어 한글이 깨지지 않는지 눈으로 확인하세요.

uv run pytest -v   # 전체 회귀 확인

✏️ 미니 실습 — "지역별 매출" 차트 추가(선택)

원한다면 같은 패턴으로 by_region 파이/막대 차트를 추가해 보세요. 순서는 동일합니다.

  1. 테스트에 "차트가 3개 생성된다"로 기대값 변경(Red)
  2. _bar_by_region 메서드 추가, render가 3개 반환(Green)
  3. 재확인·정리(Refactor)

🔍 관찰 포인트: 차트를 늘려도 유스케이스·도메인은 한 줄도 바뀌지 않습니다. 시각화는 전적으로 어댑터의 책임이기 때문입니다.

🔒 진행 게이트

  • 제출물(기록, 선택): 지역별 차트 추가 시 render가 png 3개 경로 반환. (선택 과제이므로 건너뛰어도 진행 가능)
  • 정답·해설(별도 파일)solutions/미니실습정답_08.md

✅ 체크포인트

  • [ ] 두 개의 png가 실제로 생성되는 테스트가 통과한다
  • [ ] Agg 백엔드로 화면 없이 그린다
  • [ ] plt.close()로 figure를 닫는다
  • [ ] 한글 라벨이 깨지지 않는다
  • [ ] 실제 데이터로 차트가 생성된다

🛠️ 트러블슈팅

증상원인해결

증상 원인 해결
한글이 □□□로 깨짐 폰트 미설정 font.family에 Malgun Gothic(Win 기본) 지정
RuntimeError: main thread... GUI 백엔드 사용 matplotlib.use("Agg")를 pyplot import 전에
파일이 비어 있음 savefig 전에 close 순서: 그리기 → savefig → close
메모리 사용량 증가 figure 미반환 매번 plt.close(fig)

🔑 핵심 정리

  • 차트 생성은 외부 도구·파일을 다루므로 어댑터의 일이다.
  • 부수효과(파일 생성)는 "효과가 일어났는가(존재·크기·개수)"로 검증한다.
  • Agg 백엔드와 plt.close()는 어디서나·안정적으로 동작하기 위한 필수 처리다.

다음 교시: 확정된 숫자를 LLM이 자연어로 해석하게 하고, 차트·요약·지표를 하나의 리포트로 조립합니다.