현재까지 완료 코드 리뷰
moneybook/
├── .venv/
│
├── accounts/
│ ├── migrations/
│ ├── admin.py
│ ├── apps.py
│ ├── constants.py
│ ├── forms.py
│ ├── models.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
│
├── analysis/
│ ├── migrations/
│ ├── admin.py
│ ├── analyzers.py
│ ├── apps.py
│ ├── models.py
│ ├── serializers.py
│ ├── tasks.py
│ ├── tests.py
│ ├── urls.py
│ ├── utils.py
│ ├── views.py
│ └── views_html.py
│
├── core/
│ ├── __init__.py
│ ├── asgi.py
│ ├── celery.py
│ ├── settings.py
│ ├── urls.py
│ ├── views.py
│ └── wsgi.py
│
├── docker/
├── media/
├── static/
├── templates/
│
├── transactions/
│ ├── migrations/
│ ├── admin.py
│ ├── apps.py
│ ├── filters.py
│ ├── forms.py
│ ├── models.py
│ ├── serializers.py
│ ├── tests.py
│ ├── urls.py
│ ├── views.py
│ └── views_html.py
│
├── users/
│ ├── migrations/
│ │ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── forms.py
│ ├── models.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
│
├── .gitignore
├── __init__.py
├── celerybeat-schedule
├── db.sqlite3
├── manage.py
├── poetry.lock
├── pyproject.toml
└── README.md
├── accounts/
│ ├── migrations/ - DB 테이블 변경 내역을 기록하는 폴더(절대 직접 수정X)
│ ├── admin.py - Django Admin 페이지에서 모델을 어떻게 표시할지 설정하는 곳
│ ├── apps.py - Django가 이 앱을 인식하도록 하는 메타데이터 설정 파일
│ ├── constants.py - 앱 내부에서 사용하는 상수(코드, 선택지, ENUM 등) 정리하는 파일
│ ├── forms.py - Django Form / ModelForm을 정의하는 파일
│ ├── models.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py - 실제 기능(비즈니스 로직)을 수행하는 코드가 들어가는 곳
class Account(models.Model):
user = models.ForeignKey(
User,on_delete=models.CASCADE,related_name="accounts",
)
bank_code = models.CharField(
max_length=3,choices=BANK_CODES, verbose_name="은행",
)
account_number = models.CharField(
max_length=30,verbose_name="계좌번호",
)
account_type = models.CharField(
max_length=30,choices=ACCOUNT_TYPE,verbose_name="계좌 종류",
)
name = models.CharField(
max_length=50,verbose_name="계좌/지갑 이름",
)
created_at = models.DateTimeField(
auto_now_add=True,verbose_name="생성일",
)
def __str__(self):
return f"[{self.name}]{self.get_bank_code_display()} -
{self.get_account_type_display()}"
related_name="accounts"verbose_name="은행"from django import forms
from .models import Account
class AccountForm(forms.ModelForm):
class Meta:
model = Account
fields = ["bank_code", "account_number", "account_type", "name"]
widgets = {
"bank_code": forms.Select(),
"account_type": forms.Select(),
"account_number": forms.TextInput(attrs={"placeholder": "계좌번호"}),
"name": forms.TextInput(attrs={"placeholder": "계좌별명"})
}
forms.ModelFormclass Meta:model = Accountfields = []widgets = { ... }<select name="bank_code">
<option value="KB">국민은행</option>
<option value="NH">농협</option>
</select>
<input type="text"> 로 렌더링됨<input type="text" name="account_number" placeholder="계좌번호">
<input type="text" name="name" placeholder="계좌별명">
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-dark">등록</button>
</form>
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, get_object_or_404
from .models import Account
from .forms import AccountForm
@login_required
def account_list_view(request):
accounts = Account.objects.filter(user=request.user).order_by("created_at")
return render(request, "accounts/account_list.html", {
"accounts": accounts,
})
@login_required
def account_create_view(request):
if request.method == "POST":
form = AccountForm(request.POST)
if form.is_valid():
account = form.save(commit=False)
account.user = request.user
account.save()
return redirect("accounts:account_list")
else:
form = AccountForm()
return render(request, "accounts/account_form.html", {
"form": form,
})
@login_required
def account_delete_view(request, pk):
account = get_object_or_404(Account, pk=pk, user=request.user)
if request.method == "POST":
account.delete()
return redirect("accounts:account_list")
return redirect("accounts:account_list")
{% for a in accounts %}
<p>{{ a.name }}</p>
{% endfor %}
from django.urls import path
from . import views # views 모듈 전체를 불러오기
app_name = "accounts"
urlpatterns = [
path("", views.account_list_view, name="account_list"),
path("create/", views.account_create_view, name="account_create"),
path("<int:pk>/delete/", views.account_delete_view, name="account_delete"),
]
<a href="/accounts/create/">계좌 생성</a> 이렇게 사용시 <a href="{% url 'accounts:account_create' %}">계좌 생성</a> <a href="/accounts/1/delete/">삭제</a><a href="{% url 'accounts:account_delete' pk=account.id %}">삭제</a>├── analysis/
│ ├── migrations/
│ ├── admin.py
│ ├── analyzers.py - 분석 그래프 생성 / Pandas·Matplotlib 로직 담당
│ ├── apps.py
│ ├── models.py
│ ├── serializers.py - DRF용 직렬화 파일 → API로 반환할 데이터를 정의
│ ├── tasks.py - Celery 비동기 작업(Task) 정의
│ ├── tests.py
│ ├── urls.py
│ ├── utils.py - 날짜 범위 계산 등의 재사용되는 유틸 함수 모음
│ ├── views.py - DRF APIView / GenericAPIView 기반의 분석 API (JSON 응답)
│ └── views_html.py - HTML 화면을 렌더링하는 View (render 사용)
views.py / views_html.py 분리하는 이유
API 뷰와 HTML 뷰는 성격이 완전히 다르기 때문
| 구분 | HTML View | API View |
|---|---|---|
| 반환 | HTML 템플릿 | JSON |
| 목적 | 사용자 브라우저 UI | 프론트/모바일/JS 통신 |
| 특징 | render 사용 | Response(JSON) 사용 |
| 권한 | 로그인 기반 많음 | JWT·Token 기반 많음 |
| URL 패턴 | UI 중심 | REST API 규칙 중심 |
class AnalysisListView(ListAPIView):
serializer_class = AnalysisSerializer
- 1. 응답을 직렬화할 때 사용
- 1-1. Analysis 모델 데이터를 JSON 형태로 변환할 때 사용할 serializer
permission_classes = [IsAuthenticated]
- 2. 로그인한 사용자만 조회가능 -> API 토큰/세션 인증 필요
def get_queryset(self):
- 3. 어떤 데이터를 보여줄지 결정
- 3-1. ListAPIView는 내부에서 자동으로 get_queryset()을 호출해서 데이터를 가져온다.
user = self.request.user
qs = Analysis.objects.filter(user=user).order_by("-created_at")
- 4. Analysis = Django 모델(Model)
- 4-1. .objects.filter(user=user)
- 로그인한 사용자(= request.user)가 생성한 Analysis 데이터만 가져와
period = self.request.query_params.get("period")
- 5. query_params는 Django REST Framework(DRF)에서
- GET 요청의 쿼리스트링(parameters)을 가져올 때 사용하는 객체
if period == "DAILY":
start, end = get_daily_range()
qs = qs.filter(period_start=start, period_end=end)
elif period == "LAST_DAY":
start, end = get_last_day_range()
qs = qs.filter(period_start=start, period_end=end)
.
.
.
return qs
class AnalysisCreateView(APIView):
permission_classes = [IsAuthenticated]
- 1. 생성 요청 처리
def post(self, request):
- 2. 클라이언트가 보낸 정보 읽기.
user = request.user
analysis_type = request.data.get("type")
about = request.data.get("about", "TOTAL_SPENDING")
description = request.data.get("description", "")
# 기간 계산
if analysis_type == "DAILY":
start_date, end_date = get_daily_range()
elif analysis_type == "LAST_DAY":
start_date, end_date = get_last_day_range()
elif analysis_type == "WEEKLY":
start_date, end_date = get_week_range()
elif analysis_type == "LAST_WEEK":
start_date, end_date = get_last_week_range()
elif analysis_type == "MONTHLY":
start_date, end_date = get_month_range()
elif analysis_type == "LAST_MONTH":
start_date, end_date = get_last_month_range()
elif analysis_type == "YEARLY":
start_date, end_date = get_year_range()
elif analysis_type == "LAST_YEAR":
start_date, end_date = get_last_year_range()
else:
return Response({"error": "Invalid type"}, status=400)
- 3. Analyzer 객체 생성 -> 분석기 생성
analyzer = Analyzer(
user=user,
about=about,
period_type=analysis_type,
start_date=start_date,
end_date=end_date,
description=description,
)
- 4. 분석 실행
- run() 결과는 DB에 저장된 Analysis 객체
result = analyzer.run()
- 5. 생성된 분석 결과를 JSON으로 반환
return Response({
"id": result.id,
"type": result.type,
"about": result.about,
"period_start": result.period_start,
"period_end": result.period_end,
"image_url": result.result_image.url if result.result_image else None,
})
import matplotlib
matplotlib.use("Agg")
import os
import uuid
import pandas as pd
import matplotlib.pyplot as plt
from django.conf import settings
class Analyzer:
- 1. init : 초기 설정 = 분석 요청 시 전달되는 메타정보 저장 ⭐️
def __init__(self, user, about, period_type, start_date, end_date, description=""):
self.user = user
self.about = about
self.period_type = period_type
self.start_date = start_date
self.end_date = end_date
self.description = description
- 2. MEDIA_ROOT/analysis/유저ID/ 경로에 저장.
self.output_dir = os.path.join(settings.MEDIA_ROOT, "analysis", str(user.id))
- 2-1. 각 사용자별 폴더를 자동 생성
os.makedirs(self.output_dir, exist_ok=True)
- 3. DB에서 해당 기간 데이터 조회 ⭐️
def get_transactions(self):
from transactions.models import Transaction
qs = Transaction.objects.filter(
user=self.user,
transacted_at__date__gte=self.start_date,
transacted_at__date__lte=self.end_date,
).values("transacted_at", "amount", "type", "category")
- 4. .values() 를 사용해 QuerySet → 단순 dict 리스트로 가져옴
- 4-1. pandas가 바로 DataFrame으로 변환 가능하게 하기 위함
return pd.DataFrame(qs)
- 5. DataFrame 형태로 바로 분석할 수 있게 반환
- 6. 날짜 변환 처리 ⭐️
def build_dataframe(self):
df = self.get_transactions()
if df.empty:
return df
- 7. 날짜 비교 & 그래프 등을 위해 datetime 타입으로 변환.
df["transacted_at"] = pd.to_datetime(df["transacted_at"])
return df
- 7. 수입/지출/차액 계산 ⭐️
def calculate_totals(self, df):
- 8. type이 “deposit”(입금)인 row들의 amount 합
total_income = df[df["type"] == "deposit"]["amount"].sum()
- 9. type이 “withdraw”(출금)인 row들의 amount 합
total_spending = df[df["type"] == "withdraw"]["amount"].sum()
- 10. 차액 계산 = 수입 - 지출
difference = total_income - total_spending
return total_income, total_spending, difference
- 11. 새 그래프: 수입 vs 지출 비교 ⭐️⭐️
def build_plot(self, total_income, total_spending):
- 12. 그래프 크기 설정
plt.figure(figsize=(6, 4))
labels = ["Income", "Spending"]
values = [total_income, total_spending]
- 13. 단순 바(bar) 그래프 형태 / 수입은 초록, 지출은 빨강
plt.bar(labels, values, color=["green", "red"])
- 14. 제목, y축 표시, 여백 자동 조정.
plt.title("Income vs Spending")
plt.ylabel("Amount")
plt.tight_layout()
15. 그래프 이미지로 저장 ⭐️
def save_plot_as_image(self):
- 16. uuid4()를 사용하여 충돌 없는 파일 이름 생성
filename = f"{uuid.uuid4()}.png"
filepath = os.path.join(self.output_dir, filename)
- 17. 이미지 파일로 저장 / 메모리에서 그래프 객체 제거
plt.savefig(filepath)
plt.close()
return f"analysis/{self.user.id}/{filename}"
- 18. Analysis 모델 생성(DB 저장)
def save_analysis_model(self, image_path, total_income, total_spending, difference):
from .models import Analysis
analysis = Analysis.objects.create(
user=self.user,
about=self.about,
type=self.period_type,
period_start=self.start_date,
period_end=self.end_date,
description=self.description,
result_image=image_path,
total_income=total_income,
total_spending=total_spending,
difference=difference,
)
return analysis
- 19. 전체 프로세스를 순서대로 진행 ⭐️⭐️⭐️
def run(self):
df = self.build_dataframe()
if df.empty:
raise ValueError("해당 기간에 분석 가능한 거래가 없습니다.")
# 수입/지출/차액 계산
total_income, total_spending, difference = self.calculate_totals(df)
# 그래프 생성
self.build_plot(total_income, total_spending)
# 그래프 저장
image_path = self.save_plot_as_image()
# 모델 저장 (수치 포함)
return self.save_analysis_model(image_path, total_income, total_spending, difference)
run()
└─ build_dataframe()
└─ get_transactions() → DataFrame
└─ calculate_totals() → income/spending/difference
└─ build_plot() → 그래프 생성
└─ save_plot_as_image() → 이미지 저장, 경로 리턴
└─ save_analysis_model() → DB 저장
└─ 최종 Analysis 객체 반환
from django.urls import path
from .views import AnalysisListView, AnalysisCreateView
from .views_html import analysis_list_html, analysis_create_html
app_name = "analysis"
urlpatterns = [
# API
path("api/", AnalysisListView.as_view(), name="analysis_list"),
path("api/create/", AnalysisCreateView.as_view(), name="analysis_create"),
# HTML
path("html/", analysis_list_html, name="analysis_html"),
path("html/create/", analysis_create_html, name="analysis_create_html"),
]
| 코드 | 의미 |
|---|---|
AnalysisListView.as_view() | 클래스 기반 뷰(CBV)를 URL에 연결하는 방식 |
analysis_list_html | 함수 기반 뷰(FBV)를 URL에 연결하는 방식 |
def analysis_list_html(request):
return render(request, "analysis/list.html")
name = models.CharField(max_length=50)from rest_framework import serializers
from .models import Analysis
class AnalysisSerializer(serializers.ModelSerializer):
class Meta:
model = Analysis
fields = "__all__"
class AnalysisSerializer(serializers.ModelSerializer):ModelSerializer: DRF에서 모델을 기반으로 자동으로 Serializer를 생성해주는 클래스.class Meta:model = Analysisfields = "__all__"read_only_fields = ["id", "created_at"] 이런식으로 수정불가 처리# 모델 필드
id
user
about
period_type
period_start
period_end
result_image
created_at
# JSON 응답
{
"id": 1,
"user": 3,
"about": "TOTAL_SPENDING",
"period_type": "MONTHLY",
"period_start": "2025-11-01",
"period_end": "2025-11-30",
"result_image": "/media/analysis/3/graph_2025_11.png",
"created_at": "2025-11-27T13:45:00"
}