최종 프로젝트: 게임 전투 로그 분석 및 밸런스 리포트 도구
24강. 최종 프로젝트: 게임 전투 로그 분석 및 밸런스 리포트 도구
1. 이번 강의에서 해결할 문제
앞에서 만든 데이터 모델, reader, 통계, 테스트와 CLI를 하나의 실행 흐름으로 연결합니다. 입력 형식과 출력 형식이 달라도 같은 지표를 안정적으로 계산해야 합니다.
2. 학습 목표
3. 핵심 개념
최종 구조는 입력 → 검증 모델 → 분석 → 리포트 직렬화 → CLI로 의존 방향을 한쪽으로 유지합니다. 각 계층은 Python 객체를 주고받고 파일 형식과 argparse는 경계에만 둡니다.
최종 프로젝트에서 새 문법을 한꺼번에 외우는 것이 목표는 아닙니다. 앞 강의에서 만든 작은 책임을 연결하고, 각 단계의 입력과 출력을 눈으로 확인하는 것이 목표입니다.
| 단계 | 받는 값 | 돌려주거나 만드는 값 | 이 단계가 몰라도 되는 것 |
|---|---|---|---|
| reader | JSON·CSV 파일 경로 | list[BattleRecord] | 평균 계산과 출력 형식 |
| model | 한 전투의 원시 값 | 검증된 BattleRecord | 파일이 JSON인지 CSV인지 |
| analysis | BattleRecord 목록 | 중첩된 리포트 dict | 디스크 경로와 argparse |
| writer | 리포트 dict, 출력 경로 | JSON 또는 CSV 파일 | 전투 통계 계산 방법 |
| CLI | 사용자가 입력한 옵션 | 전체 흐름과 종료 코드 | 각 함수 내부 계산 상세 |
직렬화는 Python의 딕셔너리·객체를 JSON이나 CSV처럼 파일에 저장할 수 있는 표현으로 바꾸는 과정입니다. 경계는 파일 읽기, 명령줄 입력처럼 프로그램 밖과 만나는 부분입니다. 계산 함수를 경계에서 분리하면 파일을 만들지 않고도 작은 목록만으로 통계를 시험할 수 있습니다.
game-log-analyzer/
├─ battle_analyzer/
│ ├─ __init__.py
│ ├─ models.py
│ ├─ readers.py
│ ├─ analysis.py
│ ├─ writers.py
│ └─ cli.py
├─ data/battles.json
├─ tests/test_analyzer.py
├─ main.py
└─ requirements.txt
4. 구현 순서와 코드 예시
코드를 위에서 아래로 한 번에 복사하지 마세요. analysis.py를 먼저 실행 가능한 상태로 만들고 테스트한 뒤 writer와 CLI를 연결합니다. 시작 전에는 18강의 BattleRecord, 19강의 read_json·read_csv가 동작해야 합니다.
파일 경로: battle_analyzer/analysis.py
from collections import defaultdict
from .models import BattleRecord
def metrics(records: list[BattleRecord]) -> dict[str, float | int]:
count = len(records)
wins = sum(record.result == "win" for record in records)
return {
"battle_count": count,
"average_damage": round(
sum(record.damage for record in records) / count, 2
) if count else 0.0,
"win_rate": round(wins / count, 4) if count else 0.0,
}
def group_metrics(records: list[BattleRecord], field: str) -> dict[str, dict]:
groups = defaultdict(list)
for record in records:
groups[getattr(record, field)].append(record)
return {name: metrics(group) for name, group in sorted(groups.items())}
def build_report(records: list[BattleRecord]) -> dict:
return {
"overall": metrics(records),
"players": group_metrics(records, "player"),
"monsters": group_metrics(records, "monster"),
"skills": group_metrics(records, "skill"),
}
분석 코드 한 줄씩 추적하기
count = len(records)는 0으로 나누지 않기 위해 먼저 전투 수를 구합니다.sum(record.result == "win" for record in records)에서 비교 결과True는 합계에서 1,False는 0으로 계산됩니다.if count else 0.0은 빈 입력에서 나눗셈 오류 대신 명시적인 0을 반환합니다.defaultdict(list)는 처음 보는 플레이어·몬스터·스킬 이름에도 빈 목록을 자동으로 준비합니다.getattr(record, field)는player,monster,skill중 전달한 필드의 값을 읽습니다. 허용하지 않은 필드를 외부 입력에서 그대로 받지 않고build_report안의 정해진 세 이름만 사용합니다.
예를 들어 Knight의 전투가 피해 80 승리, 피해 120 패배 두 건이라면 다음 순서로 계산됩니다.
| 계산 | 값 |
|---|---|
count | 2 |
wins | 1 |
| 피해 합계 | 200 |
average_damage | 100.0 |
win_rate | 0.5 |
파일 경로: battle_analyzer/writers.py
import csv
import json
from pathlib import Path
def write_report(report: dict, path: Path, output_format: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if output_format == "json":
with path.open("w", encoding="utf-8") as file:
json.dump(report, file, ensure_ascii=False, indent=2)
return
rows = []
for category, groups in report.items():
if category == "overall":
groups = {"all": groups}
for name, values in groups.items():
rows.append({"category": category, "name": name, **values})
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.DictWriter(
file,
fieldnames=["category", "name", "battle_count", "average_damage", "win_rate"],
)
writer.writeheader()
writer.writerows(rows)
writer는 계산하지 않고 표현만 바꿉니다. JSON은 overall, players처럼 중첩 구조를 그대로 보존하지만 CSV는 한 행에 한 그룹만 담을 수 있으므로 category와 name 열을 붙여 평평하게 만듭니다. newline=""은 Windows에서 CSV 행 사이에 빈 줄이 생기는 문제를 피하고, ensure_ascii=False는 한글을 \u... 형태로 강제 변환하지 않습니다.
파일 경로: battle_analyzer/cli.py
import argparse
import logging
from pathlib import Path
from .analysis import build_report
from .readers import read_csv, read_json
from .writers import write_report
def build_parser():
parser = argparse.ArgumentParser(description="게임 전투 로그 분석기")
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--format", choices=("json", "csv"), default="json")
parser.add_argument("--verbose", action="store_true")
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
try:
reader = {".json": read_json, ".csv": read_csv}[args.input.suffix.lower()]
records = reader(args.input)
report = build_report(records)
write_report(report, args.output, args.format)
except (FileNotFoundError, KeyError, TypeError, ValueError) as error:
logging.error("분석 실패: %s", error)
return 1
logging.info("%d건 분석 완료: %s", len(records), args.output)
return 0
CLI 흐름은 다음과 같습니다.
parse_args가 필수 경로와 출력 형식을 검사합니다.- 입력 파일 확장자로 reader 하나를 선택합니다.
- reader가 만든 공통 모델을
build_report에 전달합니다. - writer가 선택한 형식으로 결과를 저장합니다.
- 예상 가능한 입력 오류는 로그를 남기고 종료 코드 1을 반환합니다.
main(argv=None)처럼 인자를 받을 수 있게 하면 테스트가 실제 PowerShell 프로세스를 띄우지 않고 문자열 목록으로 CLI를 검증할 수 있습니다.
파일 경로: tests/test_analyzer.py
import json
import pytest
from battle_analyzer.analysis import build_report, metrics
from battle_analyzer.models import BattleRecord
from battle_analyzer.readers import read_json
from battle_analyzer.writers import write_report
def record(player="Knight", monster="Slime", skill="Slash", damage=100, result="win"):
return BattleRecord(player, monster, skill, damage, result)
def test_metrics_calculates_average_and_win_rate():
result = metrics([record(damage=80), record(damage=120, result="loss")])
assert result["battle_count"] == 2
assert result["average_damage"] == pytest.approx(100.0)
assert result["win_rate"] == pytest.approx(0.5)
def test_report_groups_player_monster_and_skill():
report = build_report([record(), record(player="Mage", skill="Fireball")])
assert set(report["players"]) == {"Knight", "Mage"}
assert report["monsters"]["Slime"]["battle_count"] == 2
assert report["skills"]["Fireball"]["average_damage"] == 100
def test_json_input_and_output(tmp_path):
source = tmp_path / "battles.json"
output = tmp_path / "report.json"
source.write_text(json.dumps([{
"player": "Knight", "monster": "Slime", "skill": "Slash",
"damage": 80, "result": "win"
}]), encoding="utf-8")
report = build_report(read_json(source))
write_report(report, output, "json")
assert json.loads(output.read_text(encoding="utf-8"))["overall"]["battle_count"] == 1
파일 경로: main.py
from battle_analyzer.cli import main
if __name__ == "__main__":
raise SystemExit(main())
Set-Location C:\dev\game-log-analyzer
.\.venv\Scripts\python.exe -m pytest -q
.\.venv\Scripts\python.exe .\main.py --input .\data\battles.json --output .\reports\balance.json --format json
.\.venv\Scripts\python.exe .\main.py --input .\data\battles.csv --output .\reports\balance.csv --format csv --verbose
Get-Content .\reports\balance.json
실행 결과와 동작 흐름
테스트가 모두 통과하면 첫 명령은 3 passed와 같은 요약을 보여 줍니다. 실제 개수는 추가한 테스트에 따라 달라질 수 있습니다. 분석 명령이 성공하면 로그에 분석 건수와 출력 경로가 나타나고 reports\balance.json이 생성됩니다.
두 전투 예시의 핵심 출력은 다음 형태입니다.
{
"overall": {
"battle_count": 2,
"average_damage": 100.0,
"win_rate": 0.5
}
}
파일이 생겼다는 사실만 확인하지 말고 입력 두 건을 손으로 계산해 세 값이 일치하는지 비교해야 합니다. 지원하지 않는 .txt 파일을 넣었을 때는 오류 로그와 함께 프로세스 종료 코드가 1인지도 확인합니다. PowerShell에서는 실행 직후 $LASTEXITCODE로 볼 수 있습니다.
5. 코드가 동작하는 이유
reader는 두 입력 형식을 BattleRecord로 통일하고 analysis는 파일을 모른 채 통계를 계산합니다. getattr은 지정 필드별 그룹화를 공통화합니다. writer는 동일 리포트를 중첩 JSON 또는 평면 CSV 행으로 직렬화하며 CLI가 전체 흐름과 종료 코드를 조정합니다.
6. 자주 하는 실수와 해결법
7. 직접 실습
8. 이해 점검 질문 3개
9. 핵심 요약
10. 과정 마무리와 다음 적용
이 강의는 새 문법을 더하는 단계가 아니라 변수·조건·함수·컬렉션·파일·클래스·테스트·CLI를 하나의 검증 가능한 흐름으로 연결하는 단계입니다. 완성 뒤에는 README에 문제, 입력 형식, 설계 선택, 실행 명령, 실제 결과, 알려진 한계를 기록하고 포트폴리오 실습에서 개선 이력을 남겨 보세요.
최종 프로젝트: 게임 전투 로그 분석 및 밸런스 리포트 도구 미니 퀴즈
선택 즉시 정답과 해설을 확인할 수 있습니다. 결과는 이 브라우저에만 저장됩니다.
학습을 마쳤나요?
직접 실습과 점검 질문까지 확인한 뒤 완료로 표시하세요.