관리자 대시보드 주문 카드 숫자 불일치 버그 수정
목차
관리자 대시보드에서 주문 카드에 표시되는 숫자가 주문 목록 화면의 실제 건수와 다른 현상이 있었음. 겉으로 보면 단순한 숫자 차이인데, 결제·주문 도메인에서 이런 불일치는 꽤 신경 쓰이는 문제다. 운영팀이 카드를 보고 "오늘 신규 주문 12건"이라고 파악했는데 실제 목록을 펼쳐보면 15건이 나오는 식이면, 숫자 자체보다 시스템 전체를 의심하게 된다.
변경 파일은 내부 클래스 하나, 뷰·스타일 파일 하나. 파일 수만 보면 아주 작은 수정이지만, 이런 버그가 보통 그렇듯 뚜껑을 열어보면 여러 레이어가 엮여 있었음.
원인: 엣지 케이스가 빠진 집계 로직
주문 카드 숫자는 보통 집계 쿼리나 집계 메서드를 거쳐 나온다. 문제는 카드에서 쓰는 집계 경로와 목록 화면에서 쓰는 필터·페이지네이션 경로가 달랐고, 특정 상태의 주문 처리가 두 경로 사이에서 서로 달랐던 것. 예를 들어 이런 패턴이 버그를 만든다.
# 카드용 집계 - 특정 상태 조건이 빠져 있음
def count_orders(status=None):
qs = Order.objects.all()
if status:
qs = qs.filter(status=status)
return qs.count()
# 목록용 쿼리 - 취소·소프트딜리트를 exclude
def list_orders(status=None):
qs = Order.objects.exclude(status='cancelled')
if status:
qs = qs.filter(status=status)
return qs
집계와 목록이 같은 베이스 쿼리셋을 공유하지 않으면 조건 드리프트가 생긴다. 취소된 주문, 소프트딜리트 상태, 부분 환불 처리 중인 주문 같은 엣지 케이스가 한 쪽에만 빠지면 숫자가 어긋남. 내부 클래스 로직 수정은 바로 이 지점이었음. 집계 경로에서 누락된 조건을 목록 경로와 맞추고, 비슷한 패턴이 다른 코드 경로에도 중복으로 존재하는지 같이 확인했음.
수정하면서 체크한 것들
숫자 하나를 고치는 데서 끝내지 않고, 비슷한 패턴이 다른 집계 카드에도 있는지 먼저 훑었음. 관리자 대시보드엔 주문 외에도 여러 지표를 보여주는 카드가 있을 수 있고, 같은 실수가 반복될 가능성이 있음.
| 체크 항목 | 이유 |
|---|---|
| 같은 필터 로직이 다른 경로에 중복되어 있는지 | 한 곳만 고치면 나머지가 또 달라짐 |
| 수정한 조건이 정상 케이스를 깨는지 | 고치다가 다른 걸 부수는 경우가 의외로 잦음 |
| 실제 화면에서 수치 재확인 | 코드 리뷰만으론 부족, 실측이 기준 |
| 연관 화면과 cross-check | 목록 건수, 페이지네이션 토탈, 카드 숫자가 모두 일치하는지 |
화면 렌더링 수정은 뷰 레이어에서 카드 컴포넌트가 집계 값을 어떻게 받아 출력하는지까지 확인한 것. 백엔드가 맞는 숫자를 내보내도 프론트에서 잘못된 키를 참조하거나 이전 상태를 그대로 쓰면 화면에는 여전히 틀린 숫자가 나올 수 있음. 프론트 스크립트도 같이 손봤던 이유가 거기 있음.
결제·주문 도메인에서 숫자 정합성이 예민한 이유
기능을 만들다 보면 SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 한 화면에 다 엮여 있다는 걸 계속 체감함. 주문 하나가 취소됐는지, 환불 중인지, 부분 취소인지에 따라 집계에 포함할지 말지가 달라지고, 그 기준이 어디선가 명시적으로 정의되지 않으면 개발자마다 다르게 구현하게 됨.
결제 도메인에서 "대충 맞는 것 같다"는 없다. 운영팀이 카드 숫자로 당일 처리 건수를 파악하고 있었다면, 그 숫자가 흔들리는 순간 시스템 전체 데이터를 의심하게 됨. 신뢰를 한 번 잃으면 회복이 생각보다 오래 걸림.
집계 로직은 가능하면 단일 경로로 관리하는 게 낫다. 목록용 쿼리셋을 베이스로 .count()를 호출하는 방식이 가장 안전함. 집계용 별도 메서드를 따로 두면 조건이 조금씩 달라지는 드리프트가 쌓이고, 그게 이런 버그로 나온다.
# 목록과 집계가 같은 베이스를 쓰는 방식
def get_order_queryset(filters):
qs = Order.objects.exclude(status='deleted')
if filters.get('status'):
qs = qs.filter(status=filters['status'])
return qs
def count_orders(filters):
return get_order_queryset(filters).count()
def list_orders(filters, page_slice):
return get_order_queryset(filters)[page_slice]
조건이 바뀌어도 get_order_queryset 한 곳만 고치면 집계와 목록이 같이 맞춰짐. 이게 처음부터 설계에 없었다면, 버그를 고치는 김에 구조를 맞춰두는 게 맞음.
사내 서비스를 만들다 보면 기능 하나가 단순히 화면에 버튼 하나 추가하는 것으로 끝나지 않는다는 걸 반복해서 느낌. 어느 하나만 빠뜨려도 숫자가 맞지 않거나 특정 상황에서 이상한 화면이 나타남. 그게 기능이 얽혀 있는 서비스의 기본 무게임.
수정 후 동작 확인은 실제 화면에서 직접 재현해서 했음. 코드만 보고 "됐겠지"로 넘어가면 프론트 레이어 문제가 남아있을 수 있음. 관련 숫자도 연관 화면과 비교해서 cross-check 했음.
커밋은 내부 클래스 수정과 뷰·스타일 수정을 논리 단위로 나눠서 올렸음. 파일 두 개지만 한 커밋에 다 묶으면 나중에 어느 변경이 문제였는지 추적할 때 곤란함. 커밋 메시지도 "무엇을"보다 "왜"를 담으려고 함. "주문 카드 숫자 수정"보다 "집계 경로와 목록 경로의 상태 필터 불일치로 카드 건수 오차 발생, 베이스 쿼리셋 통일로 수정" 쪽이 몇 달 뒤에도 컨텍스트를 복원하기 훨씬 쉬움.
엣지 케이스를 꼼꼼히 따지는 게 귀찮아 보여도, 같은 버그로 다시 오는 시간 비용이 훨씬 크다는 걸 이번에도 확인했음.
댓글 0
첫 댓글 달아줘.