Post

Django 리팩토링 기록: 뷰, 유스케이스, 그리고 비용 최적화

Life Diary 프로젝트를 리팩토링하며 쓸데없이 복잡해진 Django 뷰의 처리 흐름을 Use Case와 Repository로 옮기고, 통계 쿼리와 캐시, 세션 설정까지 함께 정리한 과정을 기록

Django 리팩토링 기록: 뷰, 유스케이스, 그리고 비용 최적화

들어가며

Django로 프로젝트를 시작할 때 처음부터 복잡한 설계를 고민하기는 어렵다.

처음에는 뷰에서 요청을 받고, 모델을 조회하고, 필요한 검증을 거쳐 응답을 반환하는 정도로도 충분하다. 오히려 빠르고 자연스럽다. Django의 장점도 여기에 있다. 적은 코드로 기능을 만들 수 있고, 화면과 데이터의 움직임을 한눈에 확인할 수 있다.

불편함은 프로젝트가 조금씩 커진 뒤에 드러난다.

처음에는 단순한 저장 로직이었던 코드에 권한 검증이 붙고, 중복 처리와 트랜잭션이 붙고, 통계 캐시 무효화가 붙는다. 특정 화면에서만 쓰이던 조회 로직은 다른 화면에서도 필요해지고, 하나의 앱에서 시작한 데이터는 어느새 다른 앱의 계산 결과와 연결된다.

뷰는 요청을 받아 응답하는 입구라기보다, 서비스의 여러 판단을 함께 떠안는 위치가 된다.

Life Diary도 그랬다.

Life Diary는 하루를 10분 단위로 기록하고, 그 기록을 태그와 통계로 다시 읽어보는 생활 로그 서비스다. 사용자는 하루 144개의 시간 슬롯 중 일부를 선택하고, 태그와 메모를 붙여 자신의 시간을 기록한다. 이후 기록 데이터는 목표, 통계, 피드백 화면으로 이어진다.

처음에는 가벼웠다.

하지만 기능이 붙으면서 기록은 더 이상 기록만으로 끝나지 않았다. 기록이 바뀌면 통계도 바뀌어야 했고, 통계는 목표 달성률과 연결되었고, 목표는 다시 마이페이지와 연결되었다. 여기에 캐시, 세션, DB 쿼리 수, 배포 비용 같은 운영상의 부담도 따라왔다.

새로운 아키텍처 이름을 붙이고 싶었던 것은 아니다. 이미 동작하는 기능을 더 잘 설명할 수 있는 형태로 옮기고 싶었다.

뷰에 섞여 있던 처리 순서를 덜어내고, 기능 단위의 로직은 Use Case로 옮기고, ORM 조회는 Repository에 모았다. 동시에 통계 계산과 인프라 사용량처럼 운영에서 드러나는 부담도 함께 줄이고자 했다.

리팩토링을 시작하면서 붙잡은 질문은 하나였다.

Django 프로젝트가 커질 때, 뷰는 어디까지 책임져야 하는가.


뷰가 무거워지기 시작한 순간

Django에서 뷰는 가장 자연스러운 출발점이다.

요청을 받는다.
파라미터를 읽는다.
모델을 조회한다.
검증한다.
저장한다.
응답을 반환한다.

처음에는 충분히 빠르고 직관적이다. 파일을 많이 나누지 않아도 되고, 기능의 흐름도 한눈에 보인다. 다만 기능이 늘어난 뒤에도 같은 형태를 유지하면 불편함이 쌓이기 시작한다.

Life Diary의 핵심 기능은 시간 기록이다. 사용자는 하루 144개의 10분 슬롯 중 일부를 선택하고, 태그와 메모를 저장한다. 겉으로 보면 단순한 저장 API처럼 보인다.

하지만 실제 처리 순서는 단순하지 않았다.

사용자가 보낸 슬롯 인덱스가 0부터 143 사이인지 확인해야 한다.
메모 길이가 너무 길지 않은지 확인해야 한다.
선택한 태그가 기본 태그이거나 본인 소유 태그인지 확인해야 한다.
이미 기록된 슬롯이면 수정해야 하고, 비어 있는 슬롯이면 새로 생성해야 한다.
저장이 끝나면 해당 날짜의 통계 캐시를 무효화해야 한다.

검증, 권한 확인, 저장 분기, 캐시 무효화가 모두 뷰에 있으면 뷰는 더 이상 단순한 HTTP 입구가 아니다. 사실상 하나의 기능 전체를 직접 처리하게 된다.

당장 동작하지 않는 것은 아니다.

오히려 잘 동작한다.
그래서 더 늦게 불편함이 드러난다.

코드가 길어지고, 검증 규칙이 흩어지고, 테스트가 HTTP 요청과 응답에 강하게 묶인다. 핵심 로직만 따로 검증하기 어렵고, 변경이 생겼을 때 어디를 고쳐야 하는지도 점점 흐려진다.

불편했던 것은 뷰의 길이 자체가 아니었다.

진짜 부담은 뷰가 너무 많은 판단 근거를 알고 있다는 데 있었다.


뷰에 남길 것과 옮길 것

“뷰를 얇게 만들자”는 말은 자주 듣는다.

하지만 조금 모호하다. 단순히 뷰의 코드 줄 수를 줄인다고 좋은 구조가 되지는 않는다. 핵심은 뷰가 어떤 일을 맡고, 어떤 일을 넘길지 정하는 데 있다.

이번 리팩토링에서 뷰에 남기고 싶었던 일은 많지 않았다.

요청을 파싱한다.
Command 객체를 만든다.
Use Case를 호출한다.
결과를 응답으로 바꾼다.

반대로 뷰에서 덜어내고 싶었던 일은 다음과 같았다.

태그 접근 권한 확인.
기존 데이터와 신규 데이터의 분기 처리.
트랜잭션 경계 설정.
통계 캐시 무효화.
여러 Repository를 조합하는 처리 순서.

태그 권한 확인, 저장 분기, 캐시 무효화는 HTTP 요청 자체보다 기능의 처리 순서에 더 가깝다. 사용자가 “시간을 기록한다”는 기능은 API 요청으로 들어오든, 나중에 다른 인터페이스를 통해 들어오든 본질적으로 크게 달라지지 않는다.

그래서 시간 기록 저장 과정을 뷰 밖으로 옮기기로 했다.

옮겨간 위치가 Use Case였다.


Use Case로 옮긴 처리 흐름

Use Case라는 이름은 다소 거창하게 들릴 수 있다.

하지만 Life Diary에서 Use Case는 특별한 아키텍처 장치라기보다, 뷰에 섞여 있던 처리 순서를 옮겨 놓는 용도에 가까웠다.

시간 기록 저장을 예로 들면, 기존 뷰에는 입력 검증, 태그 권한 확인, 기존 슬롯 조회, 신규 생성, 기존 수정, 캐시 무효화가 함께 들어 있었다. 입력 검증부터 캐시 무효화까지 이어지는 과정은 HTTP 요청 처리라기보다, “시간 기록 저장” 기능 자체의 처리 순서에 가까웠다.

그래서 입력 검증, 권한 확인, 저장 분기, 캐시 무효화를 Use Case로 옮겼다.

리팩토링 후 처리 경로는 대략 이렇게 바뀌었다.

1
2
3
4
5
6
HTTP Request
  -> views.py
  -> use_cases.py
  -> repositories.py / domain_services.py
  -> models.py
  -> DB

각 파일의 역할도 이전보다 분명해졌다.

views.py는 요청 파싱과 응답 반환을 담당한다. use_cases.py는 기능 하나의 처리 순서를 담당한다. repositories.py는 ORM 쿼리를 담당한다. domain_services.py는 정책 판단과 계산을 담당한다. models.py는 데이터 제약과 저장 규칙을 가진다.

목표는 완전한 클린 아키텍처가 아니었다. 그 정도의 층위를 감당해야 할 규모도 아니었다.

Django 프로젝트 안에서 각 코드가 맡을 일을 조금 더 분명하게 나누고 싶었다. 뷰가 모든 것을 직접 알지 않게 하고, 기능 처리 순서는 Use Case로 옮기고, DB 조회는 Repository에 모았다.

그 결과, 코드를 따라가는 길이 조금 달라졌다.

예전에는 뷰를 읽으면서 “여기서 무엇을 검증하고, 어디서 저장하고, 왜 이런 응답이 나가는가”를 한 번에 따라가야 했다. 이제는 뷰를 보면 어떤 Use Case로 넘어가는지 보이고, Use Case를 보면 기능 하나의 처리 순서가 보인다.

코드를 읽을 때 정말 중요한 것은 파일의 길이만이 아니다.

어디를 읽어야 무엇을 알 수 있는지가 분명해야 한다.


일부만 저장될 수 있는 위험 줄이기

리팩토링 초기에 먼저 본 부분은 트랜잭션이었다.

시간 기록 저장은 새 슬롯을 만들 수도 있고, 기존 슬롯을 수정할 수도 있다. 내부적으로는 bulk_createbulk_update가 함께 사용된다. 그런데 두 작업 중 하나만 성공하고 다른 하나가 실패하면 어떻게 될까.

사용자는 하나의 저장 요청을 보냈지만, DB에는 일부만 반영될 수 있다.

겉으로는 구현 방식의 차이처럼 보이지만, 실제로는 데이터가 반쯤 저장될 수 있는 위험이었다.

그래서 쓰기 Use Case에는 transaction.atomic을 적용했다. 사용자의 한 번의 기록 요청은 DB에서도 하나의 단위로 처리되어야 한다. 성공하면 모두 반영되고, 실패하면 모두 되돌아가야 한다.

고민은 트랜잭션을 어디에 둘 것인가였다.

뷰에 둘 수도 있다. Repository에 둘 수도 있다. 하지만 Life Diary에서는 Use Case에 두는 편이 가장 자연스러웠다.

Repository는 저장 방법을 안다. Use Case는 저장 과정 전체를 안다.

따라서 “처리 전체가 하나의 단위로 성공해야 한다”는 판단은 Use Case에 두는 편이 더 적절했다.

구조를 바꾸는 일은 추상적인 설계 문제가 아니었다. 실제로 데이터가 잘못 남을 수 있는 지점을 줄이는 작업이었다.


bulk_create가 놓치는 검증 지점

Django 모델에 clean()을 작성하면 모든 저장 경로에서 검증이 항상 실행될 것처럼 느껴진다.

하지만 실제로는 그렇지 않다.

bulk_create는 개별 모델 인스턴스의 full_clean()을 호출하지 않는다. 따라서 모델에 적어둔 검증 규칙이 있다고 해서 모든 저장 경로에서 자동으로 보장되지는 않는다.

Life Diary에서는 시간 블록이 반드시 0부터 143 사이의 슬롯 인덱스를 가져야 한다. 또한 태그는 기본 태그이거나 본인 소유 태그여야 한다. 모델에도 의미 있는 규칙이지만, 실제 저장 경로가 bulk_create라면 저장 이전 경계에서 명시적으로 검증하는 편이 안전하다.

그래서 Command DTO를 두었다.

UpsertTimeBlocksCommand는 사용자의 입력을 Use Case로 넘기기 전에 검증한다. 슬롯 목록은 비어 있으면 안 되고, 각 슬롯은 0부터 143 사이여야 하며, 메모는 정해진 길이를 넘으면 안 된다.

단순히 Pydantic을 쓰고 싶어서가 아니었다.

입력 검증의 위치를 분명히 하기 위한 선택이었다.

사용자의 요청은 불안정하다. DB에 들어가는 데이터는 안정적이어야 한다. 그 사이에는 검증 경계가 필요하다.

Command DTO는 그 경계를 담당한다.


조회 로직을 한곳에 모은 이유

Repository 패턴을 이야기하면 종종 이런 질문이 따라온다.

Django ORM을 굳이 감싸야 할까?

타당한 질문이다. Django ORM은 이미 충분히 강력하고, 모델 매니저도 잘 되어 있다. 모든 쿼리를 기계적으로 Repository로 감싸면 불필요한 코드만 늘어날 수 있다.

그럼에도 Life Diary에서 Repository를 둔 이유는 ORM을 완전히 숨기기 위해서가 아니었다.

조회 의도를 한곳에 모으기 위해서였다.

예를 들어 시간 기록은 여러 곳에서 조회된다. 대시보드에서는 특정 날짜의 기록이 필요하고, 통계에서는 기간별 기록이 필요하고, 태그 삭제에서는 해당 태그가 사용 중인지 확인해야 한다.

쿼리가 각 뷰와 서비스에 흩어져 있으면 같은 개념을 서로 다른 방식으로 조회하게 된다. 반대로 Repository에 모아두면 조회 방식에 이름이 생긴다.

find_by_date find_by_date_range find_by_slots is_tag_in_use

find_by_date, find_by_date_range 같은 이름은 조회 목적을 드러낸다. 그리고 쿼리 최적화를 적용할 위치도 분명해진다.

이번 리팩토링에서 주간 통계는 기존에 날짜별 조회를 7번 수행하던 구조에서, 기간 조회 한 번으로 가져온 뒤 Python에서 그룹핑하는 방식으로 바뀌었다. Repository에 조회 코드가 모여 있었기 때문에 비교적 안전하게 바꿀 수 있었다.

Repository는 ORM을 감추기 위한 장치라기보다, 여러 곳에서 쓰이는 조회 방식을 한곳에서 관리하기 위한 선택이었다.


통계 화면이 생각보다 복잡했던 이유

처음에는 통계 기능도 단순해 보였다.

하루 기록을 합산한다. 주간 기록을 합산한다. 월간 기록을 합산한다. 태그별 시간을 보여준다.

하지만 실제로는 통계가 가장 무거운 영역 중 하나였다.

통계는 dashboard의 시간 기록을 읽는다. users의 목표를 읽는다. 최근 메모도 읽는다. 목표 달성률을 계산한다. 미분류 시간을 포함한다. 마지막에는 규칙 기반 피드백까지 생성한다.

즉, stats는 단순 조회 화면이 아니라 여러 앱의 데이터를 다시 조립하는 영역에 가까웠다.

문제는 StatsCalculator가 점점 비대해졌다는 점이다. 날짜 범위 계산, 태그 정보 추출, 빈 슬롯 계산, 미분류 데이터 주입, 일간·주간·월간·분석 통계 처리가 한곳에 모이기 시작했다.

처음에는 편하다. 하지만 시간이 지나면 수정하기 어려워진다.

그래서 stats/aggregation 아래로 계산 흐름을 나누었다.

일간 통계는 daily. 주간 통계는 weekly. 월간 통계는 monthly. 태그 분석은 analysis.

daily, weekly, monthly, analysis로 나눈 것은 단순한 파일 분리가 아니었다. 통계라는 큰 기능을 시간 단위와 분석 목적에 따라 다시 나눈 것이다.

좋은 분리는 파일 수를 늘리는 것이 아니다.

나중에 수정할 때 어디를 봐야 할지 예측할 수 있게 만드는 것이다.


순환 참조가 없어도 의존성은 불편해질 수 있다

리팩토링 전 구조에 명확한 순환 참조가 있었던 것은 아니다.

하지만 그렇다고 의존성이 좋은 상태였던 것도 아니다.

대표적인 예가 usersstats의 관계였다. 마이페이지는 사용자의 목표와 최근 메모를 보여주면서, 목표 달성률도 함께 보여줘야 했다. 그러다 보니 users.viewsstats.logic을 직접 호출하는 구조가 생겼다.

겉으로 보기에는 동작한다.

하지만 의미상으로는 어색했다. 사용자 앱이 통계 앱의 내부 계산 로직을 직접 알고 있었기 때문이다.

users.viewsstats.logic을 직접 호출한다고 해서 당장 오류가 생기지는 않는다. 그러나 시간이 지나면 변경을 어렵게 만든다. stats.logic의 내부 구성을 바꾸려 할 때, 통계 화면뿐 아니라 마이페이지까지 함께 고려해야 한다.

그래서 Use Case를 도입하면서 마이페이지 처리도 함께 정리했다.

마이페이지는 GetMyPageUseCase를 통해 필요한 목표와 메모, 목표 달성률을 조립하도록 바꾸었다. 필요한 통계도 모든 기간을 무조건 계산하지 않고, 사용자의 목표 period에 따라 필요한 것만 계산하도록 했다.

마이페이지에서 필요한 통계만 계산하도록 바꾸자, 앱 간 의존도 줄고 불필요한 계산도 줄었다.

목표가 daily만 있으면 weekly와 monthly까지 계산할 필요가 없다. monthly 목표만 있으면 daily와 weekly 계산은 낭비다.

코드를 나누는 일이 성능 개선과 만나는 지점이었다.


코드뿐 아니라 쿼리와 세션도 정리했다

개인 프로젝트에서는 비용 문제를 뒤로 미루기 쉽다.

사용자가 많지 않으니 괜찮다고 생각한다. 트래픽이 작으니 큰 문제가 아니라고 생각한다. 하지만 무료 또는 저가 인프라 위에서 운영하는 프로젝트일수록 작은 비효율이 더 빨리 눈에 들어온다.

Life Diary는 Render와 Supabase를 사용한다. 이 환경에서는 DB 쿼리 수, 세션 write, 연결 재사용, 캐시 전략이 모두 운영 부담과 연결된다.

특히 통계 페이지는 비용이 큰 기능이었다. 같은 날짜의 같은 사용자가 통계를 다시 조회할 때마다 동일한 계산을 반복할 필요는 없다. 과거 날짜의 기록은 거의 변하지 않는다. 오늘 날짜의 기록은 자주 변할 수 있다.

그래서 캐시 TTL을 다르게 가져갔다.

과거 날짜는 24시간. 오늘 날짜는 5분.

과거 24시간, 오늘 5분이라는 기준은 기술적으로 복잡하지 않다. 하지만 서비스의 성격을 반영한다. 과거의 기록은 안정적이고, 오늘의 기록은 유동적이다.

또한 시간 기록이 저장되거나 삭제되면 해당 날짜의 통계 캐시를 무효화한다. 캐시 무효화는 Use Case 내부에서 처리했다. 시간 기록 변경이라는 처리를 가장 잘 아는 곳이 Use Case이기 때문이다.

세션 설정도 바꾸었다. 요청마다 세션을 저장하면 DB write가 불필요하게 늘어난다. 그래서 SESSION_SAVE_EVERY_REQUEST를 끄고, DB 연결은 CONN_MAX_AGE를 통해 재사용하도록 했다.

세션 write를 줄이고 DB 연결을 재사용하는 일은 화려하지 않다.

하지만 실제 서비스를 운영할 때는 이런 작은 선택들이 비용과 안정성에 영향을 준다.


개선 효과는 측정해야 알 수 있다

다만 조심해야 할 점이 있다.

쿼리 수를 줄이고, 캐시를 넣고, 세션 write를 줄였다고 해서 효과가 자동으로 증명되는 것은 아니다. 구조상 더 나아졌다고 말할 수는 있지만, 실제 운영에서 얼마나 좋아졌는지는 측정해야 한다.

그래서 다음 단계로 MetricsMiddleware를 도입하려고 했다.

요청 경로, HTTP 메서드, 응답 상태, 응답 시간, 사용자 ID 같은 정보를 구조화 로그로 남기면 Render 로그에서 /stats/ 응답 시간을 비교할 수 있다. Supabase 대시보드에서는 세션 write, DB egress, 쿼리 수 추이를 볼 수 있다.

성능과 비용을 이야기할 때 감각만으로는 부족하다.

“좋아졌을 것이다”와 “좋아졌다”는 다르다.

이번 리팩토링에서 비용 최적화는 아직 구조적 가설에 가깝다. 실제 효과는 로그와 대시보드로 확인해야 한다. 그래서 문서에도 측정이 남은 과제로 정리해 두었다.

좋은 리팩토링 기록은 “무엇을 했다”만 적지 않는다.

아직 무엇을 확인하지 못했는지도 함께 남겨야 한다.


일부러 하지 않은 것들

이번 리팩토링에서 중요했던 것은 무엇을 적용했는가만이 아니었다.

무엇을 적용하지 않았는지도 중요했다.

Redis를 도입하지 않았다. Celery를 도입하지 않았다. DRF로 전환하지 않았다. django-ninja를 붙이지 않았다. Domain Events를 만들지 않았다. CQRS나 Read Replica도 도입하지 않았다.

Redis나 Celery를 붙일 만큼의 복잡도는 아직 없었다.

리팩토링을 하다 보면 구성을 더 멋지게 만들고 싶은 욕심이 생긴다. Use Case를 만들었으니 이벤트도 만들고 싶고, Repository를 만들었으니 더 엄격한 포트와 어댑터 구조를 만들고 싶고, 캐시를 넣었으니 Redis도 붙이고 싶어진다.

하지만 좋은 구조는 많은 기술을 붙인 구조가 아니다.

현재 불편한 지점을 해결하는 만큼만 복잡해진 구조가 좋은 구조다.

Life Diary의 현재 문제는 대규모 트래픽이 아니었다. 백그라운드 작업이 많은 것도 아니었다. 여러 클라이언트를 위한 거대한 API 서버도 아니었다.

불편했던 지점은 뷰의 역할이 커지고, 통계 계산이 무거워지고, 앱 간 의존이 흐려지고, 비용 최적화 지점이 흩어지는 것이었다.

그러면 그 부분만 먼저 다루면 된다.


정리 후에 보인 다음 과제

리팩토링을 했다고 해서 코드가 완벽해지는 것은 아니다.

오히려 정리하고 나면 이전에는 보이지 않던 다음 작업이 더 잘 보인다.

운영 설정에서 확인해야 할 부분이 있다. DEBUG 설정은 배포 전 반드시 복구해야 한다. 캐시가 실제로 어느 정도 효과를 내는지도 측정해야 한다. 구조화 로그와 MetricsMiddleware를 통해 응답 시간과 캐시 히트율을 봐야 한다. AJAX 경로와 통계 캐시 TTL, 로그인 잠금 시나리오에 대한 테스트도 더 보강해야 한다. CSP, 세션 쿠키 플래그, 비밀번호 정책 같은 보안 후속 작업도 남아 있다.

남은 과제가 보인다고 해서 리팩토링이 실패했다는 뜻은 아니다.

오히려 좋은 신호에 가깝다. 남은 작업이 더 구체적인 이름을 갖기 시작했기 때문이다.

막연히 “코드가 복잡하다”가 아니라, “MetricsMiddleware로 실측이 필요하다”, “AJAX 목표 저장 경로의 회귀 테스트가 필요하다”, “통계 캐시 TTL 분기를 테스트해야 한다”처럼 말할 수 있게 되었다.

리팩토링의 효과는 단순히 코드가 정리되는 데 있지 않다.

다음에 무엇을 해야 하는지 더 분명해지는 데 있다.


좋은 리팩토링은 이유가 남는다

이번 작업을 하면서 다시 느낀 것은, 리팩토링은 단순히 코드의 모양을 바꾸는 일이 아니라는 점이다.

좋은 개선은 이유를 설명할 수 있어야 한다.

왜 뷰를 얇게 만들었는가. 왜 Use Case를 두었는가. 왜 Repository를 만들었는가. 왜 Command DTO를 사용했는가. 왜 트랜잭션은 Use Case에 두었는가. 왜 통계 캐시는 오늘과 과거의 TTL을 다르게 가져갔는가. 왜 Redis를 도입하지 않았는가. 왜 DRF로 전환하지 않았는가.

위 질문들에 답하기 어렵다면, 구조를 충분히 검토하지 못했을 가능성이 있다.

반대로 답할 수 있다면, 설령 완벽한 구조가 아니더라도 의미가 있다. 지금 프로젝트의 제약 안에서 어떤 선택을 했고, 무엇을 미뤘고, 어떤 위험을 먼저 줄였는지 설명할 수 있기 때문이다.

Life Diary의 리팩토링은 거대한 아키텍처 전환이 아니었다. Django 프로젝트 안에서 역할을 조금 더 나누고, 데이터 정합성과 운영 부담을 함께 다루고, 다음 개선 지점을 드러나게 만드는 과정이었다.

결국 이번 리팩토링을 한 문장으로 정리하면 이렇다.

기능은 이미 동작하고 있었지만, 그 기능이 왜 그렇게 동작해야 하는지 설명하기 어려워지고 있었다. 그래서 코드를 옮겼다.

This post is licensed under CC BY 4.0 by the author.