Machine Learning · Architecture

두 번째 사기 신호:
이력을 읽는 게 아니라 점수로 매긴다

RefundReasonClassifier는 고객이 뭐라고 말했는지를 읽는다. RefundFraudRiskScorer는 고객이 실제로 뭘 했는지 — 환불 횟수, 거절 비율, 금액 비율, 결제 후 경과 시간 — 를 본다. 그리고 classifier와 마찬가지로, 여전히 최종 결정권은 없다.

정정 — 2026.07.26

여기서 다루는 RefundFraudRiskScorer도 이후 제거되었다 — 사기꾼을 그대로 믿은 사기 탐지 신호에서 다룬 결함을 공유해서가 아니라(요청자 본인의 이력은 요청 시점에 마음대로 다시 쓸 수 있는 값이 아니다), 같은 라운드에서 내려진 별개의 단순화 결정 때문이다. RefundEligibilityService에는 이제 어떤 형태의 사기 판단도 남아있지 않다. 이 글 하단의 소스 링크는 이제 이 파일이 마지막으로 존재했던 커밋을 가리킨다.

이제 두 개의 Technical Service가 RefundEligibilityService에 신호를 공급하는데, 둘은 의도적으로 서로 다른 형태의 "머신러닝"이다. RefundReasonClassifier는 자유 텍스트를 읽는 LLM이다. RefundFraudRiskScorer는 구조화된 숫자를 읽는, 직접 만든 로지스틱 회귀다 — 기본값으로는 LLM도, 외부 API도 없이 네 개의 feature와 시그모이드만 있다.

인터페이스, 그리고 그 뒤의 두 구현체

interface RefundFraudRiskScorer {
    fun score(features: RefundRiskFeatures): Double
}

두 개의 클래스가 이 인터페이스를 구현하며, 선택은 호출자가 아니라 설정(config)이 한다 — RequestRefundService는 오직 인터페이스에만 의존하고, 어느 쪽이 실제로 동작하고 있는지 전혀 알지 못한다.

Feature 벡터

모델이 보는 모든 값은 요청자 본인의 이력에서 나오며, Application 계층이 Payment와 Refund Aggregate, 그리고 repository 요약 쿼리로부터 조립한다:

val mlFraudRiskScore =
    refundFraudRiskScorer.score(
        RefundRiskFeatures(
            refundCountLast30Days = refundSummary.count.toInt(),
            rejectedRefundCountLast30Days = rejectedRefundSummary.count.toInt(),
            refundToPaymentAmountRatio = refund.amount.toDouble() / payment.amount.toDouble(),
            minutesSincePayment =
                Duration.between(payment.createdAt, LocalDateTime.now())
                    .toMinutes()
                    .coerceAtLeast(0)
                    .toDouble(),
        ),
    )

의도적으로 placeholder 위에서 학습시킨다

이 예제 저장소 뒤에는 실제 사용자 기반이 없으므로, 학습시킬 실제 사기 심사 이력 데이터도 없다. native 구현체는 생성 시점에 한 번, 시드가 고정된 합성 데이터셋과 의도적으로 단순한 ground-truth 규칙으로 스스로 학습한다:

private fun generateTrainingData(): List<TrainingExample> {
    val random = Random(TRAINING_SEED)
    return (0 until TRAINING_EXAMPLE_COUNT).map {
        val refundCountLast30Days = random.nextInt(8)
        val rejectedRefundCountLast30Days = random.nextInt(4)
        val refundToPaymentAmountRatio = random.nextDouble()
        val minutesSincePayment = random.nextDouble() * 43200
        val riskScore =
            refundCountLast30Days * 0.15 +
                rejectedRefundCountLast30Days * 0.3 +
                refundToPaymentAmountRatio * 0.4 +
                maxOf(0.0, 1 - minutesSincePayment / 1440) * 0.3
        val label = if (riskScore > 1.1) 1.0 else 0.0
        TrainingExample(/* ... */ label = label)
    }
}

평범한 배치 경사하강법, 가중치 네 개와 bias 하나, ML 라이브러리는 없다:

private fun trainLogisticRegression(examples: List<TrainingExample>): LogisticModel {
    val weights = DoubleArray(FEATURE_COUNT)
    var bias = 0.0
    repeat(EPOCHS) {
        val weightGradients = DoubleArray(FEATURE_COUNT)
        var biasGradient = 0.0
        for (example in examples) {
            val vector = toVector(example.features)
            var z = bias
            for (i in vector.indices) z += vector[i] * weights[i]
            val error = sigmoid(z) - example.label
            for (i in vector.indices) weightGradients[i] += error * vector[i]
            biasGradient += error
        }
        for (i in weights.indices) weights[i] -= (LEARNING_RATE * weightGradients[i]) / examples.size
        bias -= (LEARNING_RATE * biasGradient) / examples.size
    }
    return LogisticModel(weights, bias)
}

여기서 고정된 랜덤 시드가 중요하다: 생성되는 데이터셋과 거기서 학습되는 가중치는 실행할 때마다 동일하다. 이건 명시적으로 대역(stand-in)일 뿐이며, 중요한 건 모델의 실제 예측 성능이 아니라 인터페이스 그 자체다.

다시 쓰지 않고, 설정으로 교체 가능하게

LLM classifier에 이미 쓰인 것과 같은 native/HTTP 토글이 여기서도 등장한다 — 설정 프로퍼티 하나가 프로세스 내부 계산과 공유 services/fraud-risk-scorer 마이크로서비스 호출 중 하나를 고른다:

@ConfigurationProperties(prefix = "fraud-scorer")
data class FraudScorerProperties(
    val mode: String = "native",
    val baseUrl: String = "http://localhost:8000",
) {
    val isHttpMode: Boolean get() = mode == "http"
}

HTTP 구현체는 fail open이다 — 네트워크 오류, 2xx가 아닌 응답, 형식이 잘못된 응답이 오면 환불을 막는 대신 점수 0.0을 반환한다:

override fun score(features: RefundRiskFeatures): Double =
    try {
        val response = httpClient.send(buildRequest(features), HttpResponse.BodyHandlers.ofString())
        if (response.statusCode() !in 200..299) FALLBACK_SCORE else parseScore(response.body()) ?: FALLBACK_SCORE
    } catch (e: Exception) {
        // A scoring failure is a technical-infrastructure concern, not a domain error — it must
        // never block a refund request. Swallow it here at the boundary and fall back.
        FALLBACK_SCORE
    }

두 개의 임계값, 하나의 결정

RefundEligibilityService는 두 신호를 서로 독립적인 값으로 받아들이며, 각각 자기만의 임계값을 가지고, 어느 Technical Service도 다른 하나의 존재를 알지 못한다:

companion object {
    private const val FRAUD_RISK_REJECTION_THRESHOLD = 0.7      // from RefundReasonClassifier (LLM)
    private const val ML_FRAUD_RISK_REJECTION_THRESHOLD = 0.8   // from RefundFraudRiskScorer (history model)
}

fun evaluate(payment: Payment, refund: Refund, classification: RefundReasonClassification, mlFraudRiskScore: Double): RefundDecision {
    // ...
    if (mlFraudRiskScore >= ML_FRAUD_RISK_REJECTION_THRESHOLD) {
        return RefundDecision(approved = false, reason = "This refund pattern was flagged as high risk by the fraud-risk model and requires manual review.")
    }
    return RefundDecision(approved = true)
}

이 두 숫자가 만나는 유일한 곳이 Domain Service이며, 그 둘이 무엇을 의미하는지 결정하는 곳도 여전히 여기뿐이다.

테스트 소유자를 공유해서 생긴 버그

공유 테스트 fixture를 쓰는 E2E 스위트에 이력 기반 scorer를 추가하자, 이 저장소의 다른 곳에서 실제로 결정론적인 실패가 발생했다 — 우연히 가끔 실패하는 flaky 테스트가 아니었다. Testcontainers Postgres 인스턴스에 대해 여러 테스트 메서드가 같은 owner ID를 재사용했고(테스트별 리셋이 없었다), 그 결과 나중 테스트들이 앞선 테스트들의 거절된 환불 이력을 그대로 물려받아 native 점수가 0.8 임계값을 넘겨버렸고, 정상적으로 유효한 환불을 고위험으로 잘못 분류했다.

이 문제에 부딪힌 두 포트는 서로 다른 두 가지 방식으로 고쳤는데, 하나의 공통 기법이었다고 뭉뚱그리기보다 정확히 이름을 붙일 가치가 있다. java-springboot 포트는 전체 E2E 스위트를 도달 불가능한 주소를 향한 HTTP 모드로 강제해서, 모든 테스트에서 점수가 결정론적으로 0으로 폴백되게 만들었다. nestjs 포트는 대신 스위트의 나머지 부분에는 native scoring을 그대로 살려두고, 영향을 받은 그 하나의 테스트에만 전용 owner ID를 부여했다 — 더 좁은 범위의 수정이지만, 근본 원인은 동일하다.

Flaky가 아니라 결정론적

이 차이는 짚고 넘어갈 가치가 있다: flaky 테스트는 테스트 대상 코드와 무관한 이유로 예측 불가능하게 실패한다. 이 실패는 매번, 같은 순서로, 같은 이유로 일어났다 — 앞선 테스트들에서 누적된 상태가 뒤따르는 테스트의 입력을 바꿔버린 것이다. 이건 "flaky 테스트"라는 옷을 입은 테스트 격리(test-isolation) 버그이며, retry-on-failure로 땜질하기 전에 격리 문제부터 다시 한번 살펴볼 가치가 있다.

저장소 내 추가 자료

docs/architecture/domain-service.md — Technical Service 패턴(이 예시는 이후 교체됐다, 위 정정 참고) · RefundFraudRiskScorerNativeImpl.kt — 제거 직전 마지막 커밋에 고정된, 존재했던 학습/스코어링 코드