Post

Python dict() 변환의 함정 - 금융 데이터 집계 버그

Python dict() 변환의 함정 - 금융 데이터 집계 버그

문제 발견

대출 심사 시스템에서 건강보험 납부 데이터를 외부 평가 엔진에 전달하는 로직에 버그가 있었다. 같은 신청 건에 대해 심사 경로(가심사/자동심사)에 따라 다른 값이 전달되고 있었다.

1
2
3
4
5
-- 자동심사 (공공 마이데이터)
"HEALTH_INSURANCE": "0,0,0,0,0,0,0,0,-56460,163070,163070,0,0,0,..."

-- 가심사 (제휴 페이 스크래핑)
"HEALTH_INSURANCE": "0,154470,154470,154470,154470,...,163070,163070,..."

DB에 저장된 원본 데이터는 동일했다. 전달 로직에서 데이터가 달라지고 있었다.


원인 분석

버그 1: dict() 변환 시 값 덮어쓰기

1
2
3
4
5
payments = dict(
    self.health_insurance_payments.values_list(
        "payment_month", "employee_health_notice_amount",
    )
)

이 코드의 문제는 dict()에 있다.

flowchart TD
    A["QuerySet.values_list() 결과"] --> B["[('2025-01', 150000),<br>('2025-01', 163070),<br>('2025-02', 154470)]"]
    B --> C["dict() 변환"]
    C --> D["{'2025-01': 163070,<br>'2025-02': 154470}"]
    C --> E["⚠️ '2025-01'의 150000이<br>163070으로 덮어써짐"]
    style E fill:#ff6b6b,stroke:#333,color:#fff

dict()에 동일 키의 튜플을 전달하면 나중에 나온 값이 이전 값을 덮어쓴다. 건강보험 데이터에서는 같은 월(payment_month)에 여러 데이터가 존재할 수 있다:

  • 겸직 (두 직장에서 동시 납부)
  • 이직 (전 직장 환급 + 현 직장 납부)
  • 가심사/자동심사별 별도 데이터
1
2
3
4
5
# dict()의 동작
dict([("a", 1), ("a", 2)])  # → {"a": 2}  (1이 사라짐)

# 의도한 동작
# 같은 월에 여러 값이 있으면 큰 값을 선택해야 함

버그 2: 심사 프로세스 무시

1
2
3
4
5
6
@property
def health_insurance_payments(self):
    return Payment.objects.filter(
        cert__submission__deal_application=self._deal_application,
        payment_month__gte=self.start_year_month,
    )

이 쿼리는 현재 진행 중인 심사 프로세스와 무관하게 모든 제출 데이터를 가져온다.

flowchart TD
    subgraph da["하나의 대출 신청"]
        A["가심사 제출<br>(제휴 페이 스크래핑)"]
        B["자동심사 제출<br>(공공 마이데이터)"]
    end
    subgraph payments["Payment 테이블"]
        C["가심사 Payment 데이터"]
        D["자동심사 Payment 데이터"]
    end
    A --> C
    B --> D
    
    E["기존 쿼리: 전부 다 가져옴"] --> C
    E --> D
    F["올바른 쿼리: 현재 심사의<br>최신 제출 데이터만"] --> D
    style E fill:#ff6b6b,stroke:#333,color:#fff
    style F fill:#51cf66,stroke:#333,color:#fff

가심사와 자동심사의 데이터가 섞여서 QuerySet에 들어오고, dict() 변환 시 순서에 따라 어떤 값이 살아남는지가 달라졌다.


해결

수정 1: 최신 제출 데이터만 조회

1
2
3
@property
def health_insurance_submission(self):
    return self.health_insurance_submissions.latest("created")

현재 심사 경로에 해당하는 가장 최신 제출 데이터에 연결된 Payment만 가져오도록 필터링했다.

수정 2: 같은 월 데이터는 큰 값 선택

dict() 대신 명시적인 집계 로직을 구현했다.

1
2
3
4
5
6
7
8
# AS-IS: dict() - 나중 값이 덮어씀 (비결정적)
payments = dict(queryset.values_list("payment_month", "amount"))

# TO-BE: 같은 월에 여러 값이면 큰 값 선택 (결정적)
result = {}
for month, amount in queryset.values_list("payment_month", "amount"):
    if month not in result or amount > result[month]:
        result[month] = amount
flowchart LR
    subgraph input["입력 데이터 (같은 월 중복)"]
        A["2025-02: 0 (전 직장 환급)"]
        B["2025-02: 163070 (현 직장 납부)"]
    end
    subgraph as_is["AS-IS: dict()"]
        C["순서에 따라 하나만 남음<br>(비결정적)"]
    end
    subgraph to_be["TO-BE: 큰 값 선택"]
        D["163070 선택<br>(결정적)"]
    end
    input --> as_is
    input --> to_be

왜 큰 값? 겸직(두 직장 동시 납부)의 경우 소득이 높은 직장 기준으로 보는 것이 보수적이며, 환급(-금액)보다 실제 납부 금액이 더 의미 있는 데이터이기 때문이다.


교훈

1. dict()로 QuerySet을 변환할 때 키 중복을 확인하라

1
2
3
4
5
6
7
8
9
10
11
# 안전하지 않음 - 키가 유니크하다는 보장이 없으면
dict(queryset.values_list("key", "value"))

# 대안 1: 키 유니크성 확인
assert queryset.values("key").distinct().count() == queryset.count()

# 대안 2: 명시적 집계
from collections import defaultdict
grouped = defaultdict(list)
for key, value in queryset.values_list("key", "value"):
    grouped[key].append(value)

2. ORM 쿼리의 필터링 범위를 의심하라

“이 쿼리가 가져오는 데이터가 정확히 필요한 범위인가?” 이 질문을 항상 하자. 특히 하나의 엔티티(대출 신청)에 여러 컨텍스트(가심사/자동심사)의 데이터가 연결될 수 있는 구조에서는, 현재 컨텍스트에 맞는 필터링이 필요하다.

3. 금융 데이터에서 “비결정적”은 버그다

dict() 변환의 결과가 QuerySet 순서에 의존한다는 것은, 같은 입력에 대해 다른 결과가 나올 수 있다는 뜻이다. 일반적인 앱에서는 큰 문제가 아닐 수 있지만, 대출 심사처럼 결과가 승인/거절에 직결되는 시스템에서 비결정적 동작은 심각한 버그다.

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