2025/11/27 Django - Project 5

김기훈·2025년 11월 27일

TIL

목록 보기
71/194

오늘 학습 내용 ✅


현재까지 완료 코드 리뷰


현재까지의 구조

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

전체 구조의 특징 요약

  • Django 앱 4개

    • accounts / transactions / analysis / users
  • core 프로젝트 설정

    • settings.py / urls.py / celery.py

accounts

├── 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 - 실제 기능(비즈니스 로직)을 수행하는 코드가 들어가는 곳

models.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()}"

    • ForeignKey에서 역참조 이름을 지정하는 옵션
      • User → Account 관계에서 user.accounts.all() 로 해당 유저 계좌 목록을 조회 가능해짐
      • 미사용시, 기본값이 account_set 이라서, user.account_set.all()
    • 즉, 유저 객체에서 user.accounts 로 Account 목록 조회 가능하도록 설정
    • 나중에 모델 이름이 바뀌어도 직관적인 이름 그대로 사용 가능
    • 하나의 모델에서 같은 모델을 2개 이상 참조할 때 필수(안쓰면 오류 발생)
  • verbose_name="은행"

    • 관리자(admin) 페이지나 폼(Form) 출력 시 표시되는 필드의 한글 라벨을 지정하는 역할
    • admin 페이지에서 이렇게 보임: | 은행 | (입력란) |

forms.py

  • HTML Form을 자동으로 만들어주는 Django ModelForm
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.ModelForm
    • Django의 ModelForm을 상속한 폼 클래스.
    • ModelForm은 특정 모델을 기반으로 자동으로 HTML Form을 생성해줌
    • 즉, Account 모델을 등록/수정할 때 사용하는 입력폼을 만들기 위한 클래스
  • class Meta:
    • ModelForm의 내부 설정값을 정의하는 부분.
  • model = Account
    • 이 Form이 어떤 모델을 기반으로 만들어질지 결정하는 설정.
  • fields = []
    • 이 Form에서 어떤 필드를 입력받을지 지정
  • widgets = { ... }
    • 각 필드가 HTML에서 어떤 위젯으로 표시될지 정의하는 부분.

  • "bank_code": forms.Select()
<select name="bank_code">
    <option value="KB">국민은행</option>
    <option value="NH">농협</option>
</select>
  • "account_number": forms.TextInput(attrs={"placeholder": "계좌번호"})
    • <input type="text"> 로 렌더링됨
<input type="text" name="account_number" placeholder="계좌번호">
  • "name": forms.TextInput(attrs={"placeholder": "계좌별명"})
<input type="text" name="name" placeholder="계좌별명">

  • 실제 렌더링 예시
    • 렌더링 결과
      • 은행 선택 Select 박스 / 계좌번호 입력 / 계좌 타입 Select 박스 / 계좌 별명 입력
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit" class="btn btn-dark">등록</button>
</form>

views.py

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")

  • render(request, template_name, context=None)
    • Django에서 HTML 템플릿을 불러오고,
    • 데이터(context)를 전달해서 최종 HTML을 만들어 반환하는 함수
      • 즉, 백엔드에서 데이터를 가져와 HTML로 변환해 사용자 브라우저로 보내는 역할
  • request
    • 요청을 보낸 사용자 정보 / 로그인 여부 / 요청 방식(GET/POST) / form 데이터 / 세션 정보
    • 등이 담겨있음
  • "accounts/account_list.html"
    • 두 번째 인자 → 렌더링할 HTML 템플릿 경로
    • Django는 templates/ 폴더에서 찾아 들어감 -> accounts/account_list.html 파일을 렌더링
  • { "accounts": accounts }
    • 템플릿에 전달할 데이터(Context)
    • 템플릿 파일에서 accounts 라는 이름으로 이 데이터를 사용 가능.(아래처럼 사용 가능)
{% for a in accounts %}
  <p>{{ a.name }}</p>
{% endfor %}

  • render가 하는일 (요약)
    • accounts/account_list.html 파일을 찾음
    • "accounts"라는 이름으로 accounts QuerySet을 템플릿에 전달
    • 템플릿에서 데이터가 삽입된 최종 HTML 생성
    • 그 HTML을 브라우저로 응답(Response) 형태로 반환
      • “템플릿 + 데이터 → 완성된 HTML → 사용자에게 보여줌”

urls.py

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"),
]

  • name를 붙이는 이유?
      1. URL을 직접 쓰지 않고 이름으로 역참조(reverse)할 수 있음
      • <a href="/accounts/create/">계좌 생성</a> 이렇게 사용시
        • 나중에 URL을 변경하면 모든 템플릿을 고쳐야 함
      • <a href="{% url 'accounts:account_create' %}">계좌 생성</a>
        • URL이 바뀌어도 name은 그대로이기 때문에 아무것도 고칠 필요 없음.
      1. reverse() / redirect() 에서 사용 가능
      • return redirect("/accounts/") -> 직접 작성 -> 불편
      • return redirect("accounts:account_list") -> name로 역참조
      1. 대규모 프로젝트에서 URL 충돌을 방지 (namespace 사용)
      • app_name = "accounts" 이렇게 네임스페이스를 붙여두면 다른 앱과 이름이 겹쳐도 충돌X
      • accounts:account_list / transactions:account_list = 충돌X
      1. HTML에서 URL 하드코딩이 필요 없어짐
      • <a href="/accounts/1/delete/">삭제</a>
      • <a href="{% url 'accounts:account_delete' pk=account.id %}">삭제</a>

analysis

구조

├── 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

  • views.py / views_html.py 분리하는 이유

    • 작은 프로젝트인 경우 views.py 하나에 전부 넣어도 문제 없음
    • HTML 화면(View)과 API(View)를 분리하면 유지보수·가독성·확장성에서 압도적으로 유리
    1. API 뷰와 HTML 뷰는 성격이 완전히 다르기 때문

      구분HTML ViewAPI View
      반환HTML 템플릿JSON
      목적사용자 브라우저 UI프론트/모바일/JS 통신
      특징render 사용Response(JSON) 사용
      권한로그인 기반 많음JWT·Token 기반 많음
      URL 패턴UI 중심REST API 규칙 중심
    1. FE/BE 분리 개발에 유리
    • 나중에 Nuxt/React/Vue 같은 프런트엔드 앱을 붙이게 되면
      • views.py = API / views_html.py = 웹 브라우저 화면
    1. DRF(API)와 Django Template(HTML)은 아예 다른 스타일
    • HTML 방식: return render(request, "analysis/create.html", ctx)
    • API 방식: return Response(serializer.data)

view.py

  • 조회 (ListAPIView)

    • ListAPIView(DRF의 제네릭 뷰)
      • 자동으로 serializer 적용 / 목록 조회 전용 / 자동으로 GET 요청 처리
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
  • 생성 (APIView)

    • create 과정이 단순 serializer.save()가 아니라 분석 로직(Analyzer.run()) 이 필요하기 때문
      • 단순 CRUD가 아니므로 GenericAPIView보다 APIView가 적합
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,
        })

analyzers.py

import matplotlib
matplotlib.use("Agg")
  • Agg

    • 서버 환경에서 그래프를 화면에 띄우지 않고 이미지 파일로 생성만 하기 위해 사용하는 필수 설정
    • 웹 서버(Uvicorn, Gunicorn 등)에서는 GUI가 없어서 Agg 백엔드를 지정해야 오류가 안 남

  • import / from

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 객체 반환

urls.py

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() / analysis_list_html

    • Django에서 CBV 와 FBV 를 URL에 연결하는 방식 차이를 보여주는 코드
코드의미
AnalysisListView.as_view()클래스 기반 뷰(CBV)를 URL에 연결하는 방식
analysis_list_html함수 기반 뷰(FBV)를 URL에 연결하는 방식
  • AnalysisListView.as_view() — 클래스 기반 뷰(CBV)

    • CBV: 뷰를 클래스로 작성하는 방식 (REST APIView, ListAPIView 등)
    • class AnalysisListView(ListAPIView):
      • 클래스이기 때문에 그대로 URL에 연결하면 동작하지 않는다
      • 아래처럼 클래스를 "뷰 함수"로 바꿔주는 메서드가 필요
    • AnalysisListView.as_view()
  • .as_view()

    • 클래스를 실행 가능한 함수(view 함수)로 변환
    • HTTP 메서드(get/post/put/delete)를 자동으로 매핑
    • DRF/제네릭뷰 기능을 모두 초기화
  • analysis_list_html — 함수 기반 뷰(FBV)

def analysis_list_html(request):
    return render(request, "analysis/list.html")
  • 그냥 함수이기 때문에 url에 그대로 넣으면 바로 동작

serializers.py

  • DateTimeField → DateTimeFieldSerializer
    • ForeignKey → PrimaryKeyRelatedField
      • ImageField → URL 형태로 출력 -> 직접 하나하나 serializer 필드를 만들 필요 없음
  • 유효성 검사 자동 지원
    • name = models.CharField(max_length=50)
    • DRF가 자동으로: 비어있는지 체크 / max_length 체크 / 타입 검사
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를 생성해주는 클래스.
      • 즉, Django 모델을 JSON 형태로 변환
      • 클라이언트로부터 받은 데이터를 모델 객체로 변환해주는 역할을 함.
  • class Meta:
    • "이 Serializer가 어떤 모델을 변환할지", "어떤 필드를 노출시킬지" 설정하는 부분
  • model = Analysis
    • 이 Serializer가 처리할 대상 모델은 Analysis 모델이라는 뜻
    • Analysis 객체 → JSON 변환 가능 / JSON 입력 → Analysis 객체 생성 가능
  • fields = "__all__"
    • Analysis 모델의 모든 필드를 JSON으로 변환하겠다는 뜻
    • 실무에서는 보통 read_only_fields = ["id", "created_at"] 이런식으로 수정불가 처리
      • id / created_at (auto_now_add=True) / updated_at (auto_now=True)
      • user (로그인된 유저로 강제 설정) / balance_after (거래 등록 시 자동 계산)
      • period_start / period_end (분석 생성 시 서버에서 계산) 이런 필드 보통 넣음
# 모델 필드
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"
}

transactions

새롭게 알게된 내용 ✅

어려운 내용(추가 학습 필요) ✅

오늘 발생한 문제(발생 했다면) ✅

profile
안녕하세요.

0개의 댓글