설정

블로그로

크레딧 차감 순서를 설계하는 법

하나로 보이는 잔액 뒤에서 여러 credit lot을 어떤 순서로 쓰고, 만료된 lot을 왜 조용히 건너뛰면 안 되는지 실제 Rust 코드로 정리했습니다.

  • Software Engineering
  • Payments

사용자 화면에는 크레딧 10,000원이 하나의 숫자로 보인다. 그런데 결제 시스템 안에서도 정말 하나의 잔액으로만 들고 있으면 금방 곤란해진다. 가입 보상 3,000원은 이번 달에 만료되고, 충전한 5,000원은 환불할 수 있고, 프로모션으로 받은 2,000원은 특정 상품에만 쓸 수 있기 때문이다.

그래서 ZDP Money Platform에서는 잔액을 credit lot 단위로 보존한다. 사용자가 보는 숫자는 lot들의 합계지만, 차감할 때는 각 lot의 출처, 만료 시각, 환불 가능 여부, 사용 가능한 상품 범위를 다시 본다.

잔액 하나를 여러 조각으로 보존하는 이유

크레딧을 한 줄짜리 balance로만 관리하면 차감 자체는 쉽다. balance에서 amount를 빼면 된다. 문제는 그다음이다. 환불 요청이 들어오면 얼마가 실제 충전분이었는지 알기 어렵고, 프로모션 만료 시점에는 어떤 금액을 없애야 하는지 추적하기 어렵다.

lot 모델에서는 같은 10,000원도 다음처럼 보인다.

lot성격잔액만료환불
signup_free가입 보상3,0007월 31일불가
paid_bonus충전 보너스2,0008월 31일불가
paid_base실제 충전금5,000없음가능

이 구조에서는 차감 순서가 제품 정책이 된다. 보통은 먼저 사라질 금액을 먼저 쓰고, 마지막까지 환불 가능한 원금을 보존하는 편이 사용자에게 유리하다. 우리 구현도 우선순위, 만료 시각, 생성 시각, lot ID 순서로 후보를 정렬한다.

만료된 lot을 건너뛰면 왜 위험한가

처음 구현을 보면 이렇게 생각하기 쉽다. 만료된 lot은 후보 목록에서 빼고 다음 lot을 쓰면 되지 않을까.

그런데 이 방식은 데이터 이상을 사용자 비용으로 덮어 버릴 수 있다. 상태는 Active인데 이미 만료된 무료 lot이 남아 있다면, 만료 처리 작업이나 원장 보정이 늦었다는 뜻이다. 이 lot을 조용히 건너뛰면 시스템은 뒤에 있는 유료 충전금을 대신 차감한다. 결제는 성공하지만 사용자는 원래 사라졌어야 할 무료 크레딧 대신 자기 돈을 쓴 셈이 된다.

그래서 현재 구현은 Active이고 잔액이 남아 있으며 해당 상품에 사용할 수 있는 lot이 만료됐다면 명시적으로 실패한다.

if lot.lot_status == CreditLotStatus::Active
    && lot.remaining_amount_credit_unit > 0
    && lot_allows_product_group(lot, &request.product_group)
    && lot.expires_at.as_deref().is_some_and(|expires_at| {
        credit_lot_expires_at_or_before(expires_at, &request.occurred_at)
    })
{
    return Err(CreditLotSpendError::ExpiredLot {
        credit_lot_id: lot.credit_lot_id.clone(),
    });
}

여기서 실패는 불편을 만들기 위한 방어가 아니다. 만료 처리와 원장 상태가 어긋났다는 신호를 숨기지 않고, 유료 잔액으로 자동 우회하지 않기 위한 장치다. 이 오류를 받은 상위 계층은 만료 보정이나 재시도를 선택할 수 있다.

계획 단계에서 검사했는데 저장 직전에 또 보는 이유

차감 로직은 먼저 어떤 lot에서 얼마를 쓸지 계획을 만든다. 하지만 계획을 만들었다는 사실만으로 저장 시점의 데이터가 같다고 보장할 수는 없다. 다른 요청이 같은 lot을 먼저 소비했을 수도 있고, 계획과 함께 전달된 만료 스냅샷이 잘못됐을 수도 있다.

그래서 저장 계획을 만드는 경계에서도 만료 스냅샷을 다시 검사한다.

if consumption
    .expires_at_at_consumption
    .as_deref()
    .is_some_and(|expires_at| expires_at <= input.plan.occurred_at.as_str())
{
    return Err(CreditLotSpendStorageError::PlanMismatch(
        "expires_at_at_consumption",
    ));
}

두 검사는 같은 코드를 중복한 게 아니다. 앞의 검사는 어떤 lot을 소비 후보로 삼을지 결정하는 도메인 규칙이고, 뒤의 검사는 저장하려는 계획이 자기 스냅샷과 모순되지 않는지 확인하는 저장 경계 규칙이다. 하나는 잘못된 결정을 막고, 다른 하나는 잘못된 기록을 막는다.

경계값은 만료 시각부터 제외한다

만료 시간이 00:00:00이라면 정확히 00:00:00에 발생한 사용은 허용하면 안 된다. 구현은 expires_at이 occurred_at보다 작거나 같은 경우를 만료로 본다. 이 경계는 테스트에도 박아 뒀다.

assert_eq!(
    plan_credit_lot_spend(sample_credit_spend_request(1), &[expires_at_usage]),
    Err(CreditLotSpendError::ExpiredLot {
        credit_lot_id: "lot_expires_at_usage".to_owned()
    })
);

현재 비교는 문자열 순서에 의존한다. 이게 안전하려면 모든 시각이 같은 canonical UTC 형식으로 들어온다는 계약이 필요하다. 서로 다른 오프셋이나 정규화되지 않은 소수점 표기를 허용한다면 문자열 비교는 깨진다. 입력 계약을 강하게 유지하거나, 경계에서 실제 시간 타입으로 파싱하고 비교해야 한다.

이 수정으로 끝나지 않는 부분

만료 검사를 넣었다고 동시성 문제가 해결되는 건 아니다. 두 요청이 같은 스냅샷을 읽고 동시에 같은 lot을 차감하려는 상황은 데이터베이스의 원자적 조건부 갱신이나 잠금으로 막아야 한다. 계획과 저장 사이의 검사는 모순을 줄여 주지만, 단독으로 경쟁 상태를 없애지는 못한다.

또 하나는 운영 복구다. ExpiredLot 오류가 늘어나면 단순한 사용자 오류로 묻을 일이 아니다. 만료 작업 지연, 이벤트 유실, 잘못된 상태 전이 중 무엇이 원인인지 추적할 수 있어야 한다. 실패를 명시적으로 만든 이유도 결국 관측하고 고치기 위해서다.

크레딧 차감은 숫자를 빼는 함수처럼 보이지만, 실제로는 어떤 돈을 먼저 쓰고 어떤 돈을 끝까지 지킬지 결정하는 정책이다. 만료된 무료 lot을 조용히 건너뛰는 한 줄이 사용자 유료 잔액을 태울 수 있다. 그래서 우리는 결제를 억지로 성공시키는 것보다, 상태가 이상할 때 정확히 멈추는 쪽을 택했다.

enzhesfrhiko