한 줄 요약: 사기 탐지는 속도와 정확도의 트레이드오프가 아니라, 룰 엔진으로 명백한 사기를 0ms에 차단하고, ML 스코어링으로 모호한 거래를 판정하며, 피드백 루프로 날마다 더 영리해지는 레이어드 방어선 구조다.


실제 사고: 사기 탐지가 무너지면 어떤 일이 벌어지나

2021년 국내 한 핀테크 플랫폼에서 크리덴셜 스터핑 공격이 발생했습니다. 공격자들은 다크웹에서 수집한 1,200만 쌍의 아이디·비밀번호 조합을 봇으로 자동 입력해 64,000개의 계정을 탈취했습니다. 탈취한 계정에서 소액 이체를 반복해 48시간 안에 총 17억 원을 빼냈습니다. 탐지 시스템은 단건 이체 금액이 한도 이하였기 때문에 어떤 경보도 발령하지 않았습니다. “소액이라 괜찮다”는 룰 하나가 조직적 공격 앞에서 완전히 무력화된 사례입니다.

2023년 한 이커머스 플랫폼에서는 프로모션 어뷰징 공격이 있었습니다. 신규 가입 쿠폰 지급 이벤트를 노린 공격자들이 가상번호로 휴대폰 인증을 통과하는 봇을 이용해 1주일 동안 32만 개의 계정을 생성했습니다. 쿠폰 발행액만 9억 6,000만 원이었습니다. 탐지 시스템은 개별 가입 시도가 모두 정상으로 보였기 때문에 탐지를 실패했습니다. 기기 핑거프린트, IP 서브넷, 가입 속도 패턴을 아무것도 보지 않았습니다.

2022년에는 카드 번호 생성 공격(BIN Attack)이 터진 PG사 사례도 있었습니다. 공격자가 특정 BIN(카드 발급사 식별 번호) 뒤 6자리를 무작위로 생성해 소액 결제로 유효한 카드 번호를 찾아내는 방식이었습니다. 10분 만에 200만 번 시도가 들어왔는데, 각각 100원짜리 결제였기 때문에 금액 기반 룰을 전부 통과했습니다. 결제 시도 빈도를 보는 룰이 없었습니다.

이 세 사고의 공통 교훈은 명확합니다. 단건 판정만으로는 조직적 사기를 막을 수 없다. 시간 창(time window) 안에서 행동 패턴을 집계하고, 기기·IP·계정을 연결하는 그래프 신호를 보며, 피드백을 통해 새로운 공격 패턴을 학습해야 합니다.


1. 설계 의사결정 로드맵

사기 탐지 시스템을 설계할 때 가장 먼저 내려야 할 다섯 가지 결정입니다. 각 결정은 이후 레이턴시, 비용, 정확도의 전체 균형을 규정합니다.

결정 1: 탐지 방식 — 룰 기반 vs ML vs 하이브리드

후보 장점 단점 언제 적합한가
룰 기반 (Rule Engine) 해석 가능, 즉각 배포, 초저지연 새로운 공격 패턴에 수동 대응, False Negative 높음 알려진 사기 패턴이 단순하고 명확할 때
ML 모델 복잡한 패턴 자동 탐지, 적응형 해석 어려움, 학습 데이터 필요, 추론 지연 10~50ms 라벨 데이터가 충분하고 복잡한 이상 탐지가 필요할 때
하이브리드 (룰 → ML) 명백한 사기는 룰로 즉시 차단, 모호한 건은 ML이 정밀 판정 두 시스템 관리 비용, 불일치 처리 필요 대규모 결제 플랫폼, 다양한 사기 유형 동시 대응

우리의 선택: 하이브리드 — 룰 엔진 1차 필터 + ML 2차 정밀 판정

하이브리드 방식은 공항 보안과 같습니다. X-ray(룰)로 명백한 위험물을 1초에 탐지하고, 의심스러운 짐은 2차 검색대(ML)로 정밀 검사합니다. 모든 짐을 2차 검색대로 보내면 줄이 막히고, X-ray만 쓰면 정교한 위장을 놓칩니다.

룰 엔진은 “동일 IP에서 10분 내 20회 이상 결제 시도”처럼 명확한 조건을 0~5ms에 판정합니다. 이 필터를 통과한 거래 중 모호한 케이스만 ML 스코어링에 올립니다. 트래픽의 95%를 룰 엔진에서 처리하고 5%만 ML로 보내는 구조가 현실적인 성능 목표를 달성하는 방법입니다.


결정 2: 판정 시점 — 결제 전 동기 vs 결제 후 비동기

후보 장점 단점 언제 적합한가
결제 전 동기 (Inline) 사기 거래 자체를 차단, 환불 비용 없음 판정 지연이 결제 레이턴시에 직접 추가됨 결제·이체·신규 가입 등 돌이키기 어려운 액션
결제 후 비동기 (Async) 결제 레이턴시 영향 없음, 복잡한 ML 사용 가능 이미 처리된 거래 사후 취소 필요, 환불 비용 발생 낮은 금액의 콘텐츠 구매, 광고 클릭 사기
병렬 판정 (Shadow + Enforce) 비동기 판정을 실시간 모니터링과 병행 인프라 복잡, 결과 동기화 필요 새 모델 검증, 정책 변경 테스트

우리의 선택: 결제 전 동기 판정, 200ms 타임아웃

결제는 돌이키기 어렵습니다. 사기 판정을 비동기로 돌리면 “일단 결제를 받고 나중에 취소”해야 하는데, 실제 사기범은 취소 전에 상품을 수령하거나 이체를 완료합니다. 동기 판정이 결제 레이턴시를 늘리는 것은 맞지만, 200ms 예산 안에서 룰 엔진(5ms) + ML 스코어링(50ms) + 네트워크 오버헤드를 충분히 맞출 수 있습니다. 타임아웃이 발생하면 fail-open(통과) 또는 fail-closed(차단)를 비즈니스 리스크에 따라 설정합니다.


결정 3: 룰 엔진 — 하드코딩 vs DSL vs 외부 엔진

후보 장점 단점 언제 적합한가
하드코딩 (if/else) 빠른 구현, 최고 성능 룰 추가마다 배포 필요, 리뷰 없는 긴급 배포 위험 룰이 5개 이하이고 변경이 거의 없을 때
내부 DSL (JSON/YAML 룰) 배포 없이 실시간 룰 추가·수정, 버전 관리 가능 인터프리터 직접 구현 필요 리스크팀이 직접 룰을 관리하는 중대형 플랫폼
외부 룰 엔진 (Drools, OPA) 복잡한 조건 표현 가능, 비개발자 친화 운영 비용, 성능 오버헤드, 학습 곡선 보험·금융에서 수백 개의 복잡한 규정 처리

우리의 선택: 내부 DSL + DB 저장 룰

하드코딩된 룰은 철로에 고정된 기차와 같습니다. 새로운 공격이 나타났을 때 방향을 바꾸려면 선로 전체를 교체해야 합니다. DSL 기반 룰은 핸들로 방향을 바꿀 수 있는 버스입니다.

사기 공격은 빠르게 진화합니다. 새로운 공격 패턴을 발견했을 때 코드 리뷰 → 배포 → 검증 사이클(보통 수 시간)을 기다리면 그 사이 피해가 누적됩니다. JSON으로 정의된 룰을 DB에 저장하고, 리스크 팀이 관리 콘솔에서 직접 추가·비활성화할 수 있어야 합니다. 모든 룰 변경은 버전으로 관리되어 롤백이 가능해야 합니다.


결정 4: 피처 스토어 — 실시간 vs 배치 vs 혼합

후보 장점 단점 언제 적합한가
실시간 전용 (Redis 집계) 최신 데이터, 낮은 지연 과거 30일 이상의 장기 패턴 집계 어려움, 비용 높음 속도 위주 판정, 단기 행동만 필요할 때
배치 전용 (Spark/BigQuery) 장기 패턴 분석, 비용 효율 수 시간 지연, 현재 진행 공격 탐지 불가 사후 분석, 모델 학습
혼합 (실시간 + 배치 프로파일) 단기 속도 신호 + 장기 행동 프로파일 동시 활용 두 레이어 동기화 관리 대규모 결제 플랫폼

우리의 선택: 혼합 — Redis 실시간 집계 + Spark 배치 프로파일

결제 사기에는 두 종류의 신호가 있습니다. “지금 이 IP에서 5분 안에 몇 번 시도했는가”는 실시간 신호이고, “이 계정의 평소 결제 시간대와 금액대가 어떤가”는 장기 행동 프로파일입니다. Redis에 슬라이딩 윈도우로 단기 집계를 저장하고, 매일 Spark 배치가 30일치 거래를 분석해 사용자별 프로파일을 생성해 Feature Store에 적재합니다. ML 모델은 두 레이어의 피처를 동시에 사용합니다.


결정 5: 리뷰 큐 — 자동 에스컬레이션 vs 수동 분류

후보 장점 단점 언제 적합한가
자동 에스컬레이션 스코어 기반 자동 우선순위, 고위험 건 즉시 처리 에스컬레이션 룰 오류 시 정상 거래 지연 하루 수천 건 이상의 리뷰 큐가 발생할 때
수동 분류 분석가 판단 우선, 맥락 파악 유리 처리 속도 느림, 분석가 번아웃 고가 거래, 기업 계정 등 케이스 단위 처리

우리의 선택: 스코어 기반 자동 에스컬레이션 + 분석가 케이스 관리

ML 스코어와 룰 매칭 수를 결합해 HIGH/MEDIUM/LOW 티어로 자동 분류합니다. HIGH 티어는 결제를 일시 보류하고 분석가에게 즉시 배정합니다. MEDIUM은 결제를 허용하되 리뷰 큐에 쌓습니다. LOW는 로그만 남깁니다. 분석가 판정 결과는 라벨 데이터로 피드백 루프에 진입해 모델을 개선합니다.


2. 요구사항 분석 및 규모 추정

기능 요구사항

기능 상세 면접에서 확인할 것
결제 사기 탐지 카드 번호 생성 공격, 도용 카드 탐지 판정 실패 시 fail-open/closed 정책은?
계정 탈취 탐지 크리덴셜 스터핑, 비정상 로그인 위치 탐지 2차 인증 연동 방식은?
프로모션 어뷰징 탐지 다중 계정 생성, 쿠폰 반복 사용 탐지 기기 핑거프린트를 어디서 수집하나?
실시간 룰 엔진 속도/금액/패턴 룰, 배포 없이 추가 가능 룰 우선순위 충돌 처리 방식은?
ML 스코어링 거래별 0~1 사기 스코어 산출 모델 버전 교체 시 카나리 배포 방식은?
리뷰 큐 분석가 케이스 관리, SLA 추적 SLA 초과 시 자동 승인/차단 정책은?
피드백 루프 분석가 판정 → 라벨 → 모델 재학습 재학습 주기는 일별/주별 중 무엇인가?
설명 가능성 차단 이유 제공, 고객 이의 제기 대응 어떤 피처가 판정에 기여했는지 제공해야 하나?

비기능 요구사항

항목 목표 근거
판정 레이턴시 p99 < 200ms 결제 UX 기준, 200ms 초과 시 전환율 하락
처리량 50,000 TPS 국내 피크 결제 트래픽 (블프·설날)
가용성 99.99% (연 52분 다운타임) 결제 인프라 수준
False Positive Rate < 0.1% 정상 결제 차단은 매출 손실 직결
False Negative Rate < 1% 사기 탐지 실패는 직접 손실
감사 보관 5년 금융 규제 요건

규모 추정

항목 수치
일 결제 트랜잭션 4,320만 건 (50,000 TPS × 86,400초 × 1%)
피크 TPS 50,000
ML 스코어링 대상 (전체의 5%) 2,500 TPS
일 리뷰 큐 발생 건수 약 4만 3,000건 (전체의 0.1%)
피처 스토어 실시간 조회 50,000 QPS
피처 스토어 데이터 보관 30일 슬라이딩 윈도우
배치 프로파일 갱신 일 1회, 사용자 5,000만 명 기준
감사 로그 저장 약 43GB/일 (건당 1KB 기준)

3. 고수준 아키텍처

사기 탐지 시스템을 공항 보안 게이트에 비유하면 이렇습니다. 탑승객(결제 요청)이 게이트에 도착하면 금속 탐지기(룰 엔진)가 1초에 판정하고, 이상 신호가 있으면 전신 스캐너(ML 스코어링)가 정밀 검사합니다. 의심되면 2차 검색대(리뷰 큐)로 이동하고, 판정 결과는 다음 탑승객 검사에 반영됩니다(피드백 루프).

graph LR
A["결제 요청"] --> B["룰 엔진"]
B -->|"명백 사기"| C["즉시 차단"]
B -->|"모호한 거래"| D["ML 스코어링"]
D -->|"고위험"| E["리뷰 큐"]
D -->|"저위험"| F["결제 승인"]
E --> G["피드백 루프"]
G --> D

컴포넌트 역할

컴포넌트 역할 기술 선택
API Gateway 결제 요청 수신, 인증, 타임아웃 관리 Spring Cloud Gateway
룰 엔진 DSL 기반 룰 평가, Redis 카운터 조회 내부 Java 인터프리터 + Redis
ML 스코어링 피처 추출, 모델 추론, 스코어 반환 Python FastAPI + XGBoost/LightGBM
피처 스토어 실시간 슬라이딩 윈도우 집계 + 배치 프로파일 Redis Cluster + BigQuery
리뷰 큐 케이스 생성, 분석가 배정, SLA 추적 Kafka + PostgreSQL
피드백 루프 판정 결과 수집, 라벨 생성, 재학습 트리거 Kafka → Spark → MLflow
감사 로그 모든 판정 근거 불변 보관 Kafka → S3 (Parquet)

4. 핵심 컴포넌트 상세 설계

4-1. 실시간 룰 엔진

룰 엔진은 형사의 직관을 코드로 정리한 것입니다. “동일 카드로 5분 안에 다른 도시에서 두 번 긁혔다”는 룰은 형사가 수천 건의 사례에서 발견한 패턴입니다.

룰 엔진의 핵심은 슬라이딩 윈도우 카운터입니다. Redis의 Sorted Set을 이용해 시간 기반 윈도우 안의 이벤트 수를 O(log N)으로 계산합니다.

public class SlidingWindowCounter {
    private final RedisTemplate<String, String> redis;

    // 주어진 윈도우(초 단위) 안의 이벤트 수를 반환
    public long count(String key, int windowSecs) {
        long now = System.currentTimeMillis();
        long windowStart = now - (windowSecs * 1000L);

        // 윈도우 밖의 오래된 이벤트를 제거하고 현재 이벤트를 추가
        redis.opsForZSet().removeRangeByScore(key, 0, windowStart);
        redis.opsForZSet().add(key, UUID.randomUUID().toString(), now);
        redis.expire(key, windowSecs + 10, TimeUnit.SECONDS);

        return redis.opsForZSet().size(key);
    }
}

룰은 JSON으로 DB에 저장되며 런타임에 로드됩니다. 이 구조 덕분에 코드 변경 없이 새 룰을 즉시 활성화할 수 있습니다.

// DB에 저장된 룰 예시 (JSON)
// {
//   "ruleId": "SPEED_LIMIT_IP",
//   "description": "IP당 5분 이내 20회 초과 시도",
//   "condition": { "type": "SLIDING_WINDOW", "key": "ip:{ip}", "window": 300, "threshold": 20 },
//   "action": "BLOCK",
//   "priority": 10,
//   "enabled": true
// }

@Service
public class RuleEngine {
    private final SlidingWindowCounter counter;
    private final RuleRepository ruleRepo;

    public RuleResult evaluate(PaymentContext ctx) {
        List<Rule> rules = ruleRepo.findActiveRulesSortedByPriority();

        for (Rule rule : rules) {
            if (matches(rule, ctx)) {
                return RuleResult.of(rule.getAction(), rule.getRuleId());
            }
        }
        return RuleResult.PASS;
    }

    private boolean matches(Rule rule, PaymentContext ctx) {
        return switch (rule.getConditionType()) {
            case SLIDING_WINDOW -> {
                String key = resolveKey(rule.getKeyTemplate(), ctx);
                long count = counter.count(key, rule.getWindowSecs());
                yield count > rule.getThreshold();
            }
            case AMOUNT_THRESHOLD -> ctx.getAmount().compareTo(rule.getAmountLimit()) > 0;
            case VELOCITY_PATTERN -> checkVelocityPattern(rule, ctx);
            default -> false;
        };
    }
}

설명이 중요합니다. 룰 엔진이 BLOCK을 반환할 때 반드시 어떤 룰이 발동됐는지를 기록해야 합니다. 차단된 고객이 이의를 제기할 때 “귀하의 IP에서 5분 안에 20회 시도가 있었습니다”라고 설명할 수 있어야 합니다.


4-2. ML 스코어링

ML 스코어링 파이프라인은 배우가 오디션을 보기 전에 이력서를 정리하는 것과 같습니다. 모델이 판정을 내리기 전, 피처 추출 단계가 거래의 “이력서”를 만들어 제출합니다.

ML 스코어링은 세 단계로 동작합니다. 피처 추출(Feature Extraction), 모델 추론(Inference), 스코어 해석(Score Interpretation)입니다.

@Service
public class MlScoringService {
    private final FeatureExtractor featureExtractor;
    private final ModelClient modelClient;      // FastAPI 모델 서버 HTTP 클라이언트

    public MlScore score(PaymentContext ctx) {
        // 1단계: 피처 추출 — 실시간 집계 + 배치 프로파일 조합
        FeatureVector features = featureExtractor.extract(ctx);

        // 2단계: 모델 추론 — 타임아웃 50ms 설정
        try {
            return modelClient.infer(features);
        } catch (TimeoutException e) {
            // 타임아웃 시 룰 엔진 결과만으로 판정 (fail-open)
            return MlScore.unavailable();
        }
    }
}

피처 추출기는 실시간과 배치 레이어 두 곳에서 피처를 수집합니다.

@Component
public class FeatureExtractor {
    private final RedisTemplate<String, String> redis;
    private final FeatureStoreClient featureStore; // 배치 프로파일 조회

    public FeatureVector extract(PaymentContext ctx) {
        String userId = ctx.getUserId();
        String ip = ctx.getIp();

        // 실시간 피처: Redis 슬라이딩 윈도우
        double txCount1h = getWindowCount("user:" + userId + ":tx", 3600);
        double txAmount1h = getWindowSum("user:" + userId + ":amount", 3600);
        double ipCount10m = getWindowCount("ip:" + ip + ":tx", 600);

        // 배치 피처: 사용자 행동 프로파일 (Spark가 매일 생성)
        UserProfile profile = featureStore.getProfile(userId);
        double amountDeviation = computeDeviation(ctx.getAmount(), profile.getAvgAmount());
        double hourAnomalyScore = profile.getHourlyPattern()[ctx.getHour()];
        boolean newDevice = !profile.getKnownDevices().contains(ctx.getDeviceId());

        return FeatureVector.builder()
            .txCount1h(txCount1h)
            .txAmount1h(txAmount1h)
            .ipCount10m(ipCount10m)
            .amountDeviation(amountDeviation)
            .hourAnomalyScore(hourAnomalyScore)
            .newDevice(newDevice ? 1.0 : 0.0)
            .countryMismatch(isCountryMismatch(ctx, profile) ? 1.0 : 0.0)
            .build();
    }
}

모델은 0~1 사이의 사기 스코어를 반환합니다. 0.8 이상은 HIGH 리스크, 0.5~0.8은 MEDIUM, 0.5 미만은 LOW로 분류해 각기 다른 액션을 취합니다.


4-3. 피처 스토어

피처 스토어는 실시간 레이어와 배치 레이어로 나뉩니다.

실시간 레이어 (Redis Cluster): 슬라이딩 윈도우 카운터와 합산 값을 저장합니다. user:{id}:tx 키에 Sorted Set으로 최근 1시간의 거래 타임스탬프를 저장합니다. 윈도우 밖의 데이터는 자동으로 제거되어 메모리를 효율적으로 사용합니다.

배치 레이어 (Spark + BigQuery): 매일 새벽 2시에 Spark 잡이 전날 거래 데이터를 처리해 사용자별 30일 행동 프로파일을 생성합니다.

# Spark 배치 — 사용자별 행동 프로파일 생성
from pyspark.sql import functions as F

def build_user_profiles(df_transactions):
    return df_transactions \
        .groupBy("user_id") \
        .agg(
            F.avg("amount").alias("avg_amount"),
            F.stddev("amount").alias("stddev_amount"),
            F.collect_set("device_id").alias("known_devices"),
            F.collect_set("country").alias("known_countries"),
            # 시간대별 거래 비율 (0~23시)
            *[F.avg(F.when(F.hour("created_at") == h, 1).otherwise(0))
              .alias(f"hour_{h}_ratio") for h in range(24)]
        )

배치 프로파일은 BigQuery에 저장되고, 피처 서빙 API가 조회 시 Redis에 캐시합니다. TTL은 25시간으로 설정해 배치 갱신 전까지 유효하게 유지합니다.


4-4. 리뷰 큐 + 케이스 관리

리뷰 큐는 응급실 트리아지(triage)와 같습니다. 위급한 환자(HIGH 리스크)가 먼저 처리되고, 경증 환자(LOW 리스크)는 대기합니다. 모든 케이스에는 SLA가 있어 방치되지 않습니다.

HIGH 리스크 거래는 Kafka 토픽에 즉시 발행되어 분석가에게 배정됩니다. MEDIUM 리스크는 결제를 허용하되 케이스로 등록합니다. 분석가가 케이스를 판정하면 그 결과가 피드백 루프로 이어집니다.

@Entity
public class FraudCase {
    @Id
    private String caseId;
    private String transactionId;
    private String userId;
    private double mlScore;
    private String triggeredRules;   // 발동된 룰 목록 (JSON 배열)
    private CaseStatus status;        // OPEN, IN_REVIEW, CONFIRMED_FRAUD, FALSE_POSITIVE
    private String analystId;
    private String analystNote;
    private LocalDateTime slaDeadline; // SLA 초과 시 자동 에스컬레이션
    private LocalDateTime resolvedAt;
    private FraudLabel label;          // FRAUD, LEGITIMATE (피드백 루프 입력)
}

SLA 초과 케이스는 스케줄러가 감지해 상위 분석가에게 자동 에스컬레이션합니다.


4-5. 피드백 루프

피드백 루프는 사기 탐지 시스템이 시간이 지날수록 더 정확해지게 만드는 핵심 메커니즘입니다.

graph LR
A["분석가 판정"] --> B["라벨 생성"]
B --> C["학습 데이터셋"]
C --> D["모델 재학습"]
D --> E["카나리 배포"]
E --> A

분석가가 케이스를 CONFIRMED_FRAUD 또는 FALSE_POSITIVE로 마킹하면, 해당 거래의 피처 벡터와 라벨이 학습 데이터셋에 추가됩니다. 주 1회 MLflow가 새 모델을 학습하고, 카나리 배포(5% 트래픽)로 성능을 검증한 뒤 전체 배포합니다.

클래스 불균형(전체 거래의 0.1%만 사기)은 오버샘플링(SMOTE)이나 가중치 조정으로 처리합니다. False Positive와 False Negative의 비용이 다르기 때문에 임계값(threshold) 선택이 중요하며, 비즈니스 팀과 리스크 팀이 함께 검토합니다.


5. 극한 시나리오 3가지

시나리오 1: BIN Attack — 10분 만에 200만 건 소액 결제 시도

상황: 공격자 그룹이 2,000개의 봇을 이용해 특정 카드사 BIN(6자리) 뒤 10자리를 순열로 생성하면서 건당 100원짜리 결제를 초당 3,500건씩 보냅니다. 목적은 유효한 카드 번호를 찾아 이후 대형 결제를 시도하는 것입니다.

메커니즘: 각 결제 요청은 금액이 100원에 불과해 금액 기반 룰을 모두 통과합니다. 카드 번호는 매번 다르게 생성되므로 카드 번호 기반 슬라이딩 윈도우도 걸리지 않습니다. 그러나 공격 트래픽이 특정 IP 서브넷(2,000개 봇의 ASN)에서 집중됩니다.

1차 방어 — 룰 엔진 BIN 속도 제한:

// BIN(카드 앞 6자리) 기준 분당 시도 수 제한
// DB에 저장된 룰: { "key": "bin:{bin}", "window": 60, "threshold": 100 }
long binCount = counter.count("bin:" + ctx.getCardBin(), 60);
if (binCount > 100) {
    return RuleResult.block("BIN_VELOCITY_EXCEEDED");
}

공격이 시작되고 20초 이내에 BIN당 분당 100건 룰이 발동해 해당 BIN의 모든 거래를 차단합니다.

2차 방어 — ASN/서브넷 차단: 차단된 요청의 IP를 분석하면 동일 AS(자율 시스템) 번호에서 집중됩니다. 리스크 팀이 관리 콘솔에서 해당 ASN을 블랙리스트에 추가합니다. 코드 배포 없이 30초 이내에 적용됩니다.

3차 방어 — 카드사 실시간 알림: PG사 연동 모듈이 동일 BIN에서 비정상적인 승인 요청이 집중됨을 카드사에 통보합니다. 카드사가 해당 BIN 번호 대역의 거래를 일시 보류합니다.

결과: 공격 탐지까지 20~45초, BIN + ASN 차단 완료까지 2분. 200만 건 시도 중 실제로 유효 카드 번호를 확인한 건수는 500건 미만. 피해액은 0원(100원짜리 소액 결제이므로 차단 전 성공한 거래도 손실 미미). 카드사 협조로 해당 번호 대역 전체 차단.

이 시나리오는 댐 누수 탐지와 같습니다. 각각의 작은 구멍(100원 결제)은 무해해 보이지만, 전체 댐 표면에서 동시에 새는 패턴을 보면 구조적 공격임을 알 수 있습니다. BIN 레벨 집계가 이 “전체 패턴”을 보는 눈입니다.


시나리오 2: Flash Mob 어뷰징 — 1시간 안에 50만 계정 생성

상황: 블랙프라이데이 당일 오전 9시, 신규 가입 시 1만 원 쿠폰 이벤트가 시작되자마자 어뷰저들이 텔레그램으로 공유한 자동화 스크립트를 이용해 가상번호 서비스로 휴대폰 인증을 통과하며 계정을 생성하기 시작합니다. 시간당 50만 건의 가입 요청이 들어옵니다.

메커니즘: 개별 가입은 휴대폰 인증을 통과했으므로 정상처럼 보입니다. 단 하나의 시그널로는 탐지가 불가능합니다. 그러나 여러 시그널을 연결하면 패턴이 드러납니다.

첫 번째 시그널은 기기 핑거프린트입니다. 2,000개의 봇이 각각 다른 계정을 만들지만 같은 기기 환경(User-Agent, 화면 해상도, 플러그인 목록)을 사용합니다.

두 번째 시그널은 가입 직후 행동 패턴입니다. 정상 신규 사용자는 가입 후 상품을 탐색합니다. 어뷰저는 가입 즉시 쿠폰 화면으로 이동해 쿠폰을 사용합니다. 가입부터 쿠폰 사용까지의 시간이 30초 미만이면 이상 징후입니다.

세 번째 시그널은 IP 서브넷입니다. 2,000개의 봇은 수백 개의 VPN IP를 재사용합니다.

// 가입 시점 어뷰징 탐지 룰
public AccountCreateResult evaluateNewAccount(AccountCreateContext ctx) {
    // 1) 기기 핑거프린트 기반 같은 기기에서 24시간 내 계정 수
    long deviceAccountCount = counter.count(
        "device:" + ctx.getDeviceFingerprint() + ":accounts", 86400);
    if (deviceAccountCount > 3) {
        return AccountCreateResult.challenge("DEVICE_MULTI_ACCOUNT");
    }

    // 2) IP 서브넷(/24) 기준 10분 내 가입 수
    String subnet = getSubnet24(ctx.getIp());
    long subnetCount = counter.count("subnet:" + subnet + ":signup", 600);
    if (subnetCount > 50) {
        return AccountCreateResult.challenge("SUBNET_SIGNUP_BURST");
    }

    // 3) 가상번호 통신사 패턴 (특정 통신사 번호 비율 급증)
    if (isVirtualNumberCarrier(ctx.getPhoneNumber())) {
        return AccountCreateResult.review("VIRTUAL_NUMBER_CARRIER");
    }

    return AccountCreateResult.allow();
}

2차 방어 — 행동 기반 쿠폰 발행 지연: 쿠폰은 가입 즉시 발행하지 않고 24시간 유예 후 발행합니다. 유예 기간 동안 배치가 가입 패턴을 분석해 어뷰징으로 판정된 계정의 쿠폰 발행을 취소합니다.

3차 방어 — 쿠폰 사용 시점 ML 검증: 쿠폰 사용 시점에 ML 스코어링을 한 번 더 실행합니다. 신규 가입 후 30초 이내 쿠폰 사용 + 기기 핑거프린트 재사용 + VPN IP 조합이면 스코어가 0.9 이상으로 HIGH 리스크로 분류됩니다.

결과: 1차 룰로 50만 건 중 44만 건(88%)을 실시간 차단 또는 CAPTCHA 챌린지로 처리합니다. 남은 6만 건은 24시간 쿠폰 유예로 배치 분석을 거칩니다. 배치 분석에서 추가 5만 2,000건이 어뷰징으로 판정돼 쿠폰이 취소됩니다. 실제 쿠폰 피해는 약 8,000건으로 최초 50만 건 대비 98.4% 감소합니다.

이 시나리오는 은행 강도 무리가 동시에 같은 은행 지점 문을 두드리는 상황입니다. 한 명씩 보면 고객이지만, 전체 지점 네트워크 뷰에서 보면 동시 다발적 패턴이 명확합니다. “기기·IP·행동을 연결하는 그래프 뷰”가 어뷰징을 드러냅니다.


시나리오 3: 내부자 공격 — 분석가가 본인 케이스를 FALSE_POSITIVE로 처리

상황: 분석가 A가 본인 배우자의 계정으로 사기 거래를 시도하고, 해당 거래가 리뷰 큐에 올라오면 본인이 FALSE_POSITIVE로 처리해 차단을 해제합니다. 이후 배우자 계정으로 정상 거래로 위장한 상품 환급 사기(Refund Fraud)를 수행합니다.

메커니즘: 이 공격의 위험성은 외부 사기와 달리 시스템 내부에서 발동됩니다. 분석가는 케이스를 담당할 권한이 있기 때문에 기술적으로 아무것도 이상하지 않습니다.

방어 1 — 이해관계 분리(Conflict of Interest Check):

@Service
public class CaseAssignmentService {
    public void assignCase(FraudCase fraudCase, String analystId) {
        // 담당자가 피의심 계정과 연결된 계정을 가지고 있는지 확인
        boolean hasRelation = conflictChecker.check(analystId, fraudCase.getUserId());
        if (hasRelation) {
            throw new ConflictOfInterestException(
                "Analyst " + analystId + " has a relation with user " + fraudCase.getUserId()
            );
        }

        // 특정 금액 이상 또는 반복 어뷰징 의심 건은 2인 승인 필요
        if (fraudCase.getMlScore() > 0.85 || fraudCase.isRepeatOffender()) {
            fraudCase.requireDualApproval();
        }

        // 배정 이력 기록 (감사 추적)
        auditLog.record(analystId, fraudCase.getCaseId(), "ASSIGNED");
    }
}

방어 2 — 분석가 판정 이상 탐지: 분석가의 판정 패턴을 모니터링합니다. 특정 분석가의 FALSE_POSITIVE 비율이 팀 평균보다 3 표준편차 이상 높으면 알림을 발생시킵니다.

@Scheduled(cron = "0 0 * * * *") // 매시 정각
public void monitorAnalystBehavior() {
    double teamFpRate = analystMetrics.getTeamFalsePositiveRate();

    for (Analyst analyst : analystService.getAllActive()) {
        double fpRate = analystMetrics.getFalsePositiveRate(analyst.getId(), Duration.ofDays(30));
        double zscore = (fpRate - teamFpRate) / analystMetrics.getTeamStdDev();

        if (zscore > 3.0) {
            securityAlert.raise(
                "ANALYST_ANOMALY",
                analyst.getId() + " FP rate " + fpRate + " is " + zscore + " sigma above team avg"
            );
        }
    }
}

방어 3 — 불변 감사 로그: 모든 케이스 조회·판정·수정은 Kafka를 통해 S3에 불변 로그로 기록됩니다. 이 로그는 담당자 포함 누구도 삭제하거나 수정할 수 없습니다. 사건 발생 후 포렌식 분석의 기반이 됩니다.

결과: 방어 1(이해관계 분리)이 활성화된 경우, 분석가가 연결된 계정의 케이스를 배정받을 수 없으므로 공격 자체가 불가능합니다. 방어 1이 우회된 경우(관계가 시스템에 등록되지 않은 경우)에도 방어 2의 통계적 이상 탐지가 30일 내에 패턴을 감지합니다. 방어 3은 사후 조사에서 전체 공격 경로를 재현할 수 있게 합니다.

이 시나리오는 은행 금고 열쇠를 가진 직원이 공범인 상황과 같습니다. 기술적 잠금장치만으로는 부족하고, 이해관계 분리(4-eyes principle), 통계적 행동 감시, 불변 기록이 3중으로 작동해야 내부자 공격도 방어할 수 있습니다.


6. 이 설계의 한계와 대안 — 시니어 리드 비판적 시선

아키텍처는 설계할 때보다 운영 6개월 후가 더 솔직합니다. 이 섹션은 위에서 설명한 설계가 실패하는 지점과 그때 무엇을 해야 하는지를 다룹니다.

6-1. FP vs FN 비용 정량화 — “어느 오류가 더 비싼가”

ML 모델이 오판하는 데는 두 방향이 있습니다. 정상 고객을 막는 FP(False Positive)와 사기를 통과시키는 FN(False Negative)입니다. 교과서는 “둘 다 나쁘다”고 말하지만, 실제 운영에서는 어느 쪽 오류가 더 비싼지를 숫자로 계산해야 임계값을 정할 수 있습니다.

오류 유형 즉각 비용 간접 비용 측정 방법
FP (정상 차단) 해당 거래 매출 손실 고객 이탈률 × LTV 차단된 정상 고객 중 30일 내 재방문율
FN (사기 통과) 환불 + 차지백 수수료(거래액의 1~3%) 규제 패널티, 카드사 고위험 등급 지정 월간 사기 손실액 / 총 GMV

실제 계산 예시를 들면 이렇습니다. 월 GMV 1,000억 원, 평균 결제 건당 5만 원, FPR 0.1% 기준으로 월 2,000건의 정상 거래가 차단됩니다. 고객 1인의 LTV가 50만 원이고 차단 경험 후 이탈률이 20%라면 FP 1건의 기대 비용은 10만 원입니다. 반면 FNR 1%에서 사기 손실이 거래액의 평균 2%라면 FN 1건의 기대 비용은 1,000원입니다.

이 숫자가 나오면 결론은 명확합니다. FP가 FN보다 100배 비싼 구조입니다. 임계값을 0.5에서 0.7로 올려 FPR을 낮추는 것이 사업적으로 옳습니다. 비즈니스팀과 리스크팀이 이 계산 없이 임계값을 협의하면 직관에 의존하는 회의가 됩니다.

graph LR
A["임계값 낮춤"] --> B["FNR 감소 (사기 더 잡음)"]
A --> C["FPR 증가 (정상 고객 더 차단)"]
B --> D["사기 손실 감소"]
C --> E["고객 이탈 + 매출 손실"]
D --> F["비용 트레이드오프"]
E --> F

6-2. 피처 스토어 장애 시 Fallback 전략

피처 스토어가 다운되면 ML 스코어링 파이프라인 전체가 멈춥니다. 이때 선택지는 셋입니다.

옵션 A: ML 스코어링 건너뛰고 룰 엔진만 사용

가장 단순한 fallback입니다. 성능이 떨어지지만 시스템은 계속 동작합니다. 허용 범위는 “피처 스토어 없이 룰 엔진만으로 사기 탐지율이 몇 %인가”를 사전에 측정해두어야 정할 수 있습니다. 일반적으로 룰 엔진이 명백한 사기의 60~70%를 잡는다면, 피처 스토어 장애 중 추가 손실 허용 범위는 장애 지속 시간 × (FNR 증가분 × 시간당 평균 사기 금액)으로 계산합니다.

옵션 B: Stale 프로파일로 ML 계속 실행

Redis에 마지막으로 유효했던 배치 프로파일이 캐시되어 있으면, TTL이 만료되기 전까지는 오래된 프로파일로 스코어링을 계속합니다. 다만 피처 생성 시각을 ML 입력으로 포함시켜 모델이 “이 프로파일은 48시간 전 것”임을 감안해 스코어링할 수 있어야 합니다.

옵션 C: 임계값을 낮춰 더 보수적으로 운영

피처 스토어 장애 중에는 ML 판정 신뢰도가 낮아지므로 HIGH 리스크 임계값을 0.8에서 0.6으로 낮춥니다. FPR이 일시적으로 올라가더라도 안전 우선 운영입니다. 이 정책을 코드에 반영해두면 장애 감지 후 자동 전환이 가능합니다.

세 옵션 중 어느 것도 “완벽한 fallback”은 없습니다. 중요한 것은 사전에 결정하고 코드로 구현해두는 것입니다. 장애 중 회의로 결정하면 대응이 15분 늦어지고, 그 15분에 BIN Attack 1만 건이 통과합니다.


6-3. 200ms 타임아웃 초과 시 정책 결정

“타임아웃 발생 시 통과(fail-open) vs 차단(fail-closed)”은 면접의 단골 질문이기도 하지만, 실제 운영에서 가장 많이 잘못 설정되는 부분이기도 합니다.

단순히 fail-open/closed로 나누면 안 됩니다. 거래 유형별로 다르게 설정해야 합니다.

거래 유형 타임아웃 정책 근거
소액 결제 (< 5만 원) fail-open + 비동기 재검토 큐 사기 기대손실 < 판정 지연 UX 비용
고액 이체 (> 100만 원) fail-closed + 보류 상태 돌이킬 수 없는 손실, 지연 허용
신규 계정 첫 결제 fail-closed + 추가 인증 요청 Cold Start 위험 + 프로파일 없음
기존 우량 고객 fail-open 행동 프로파일 신뢰 가능, FP 비용 큼

타임아웃 정책을 거래 컨텍스트 없이 전역으로 설정하면, 고액 이체에 fail-open을 적용해 사기가 통과하거나, 소액 결제에 fail-closed를 적용해 UX를 불필요하게 망가뜨리는 사태가 발생합니다.


6-4. Concept Drift — 공격 패턴이 진화할 때

모델 드리프트에는 두 종류가 있습니다. 데이터 드리프트는 입력 피처의 분포가 변하는 것이고, Concept Drift는 같은 피처값이라도 “사기/정상” 레이블 자체의 의미가 변하는 것입니다.

공격자는 탐지 모델을 역으로 학습합니다. “높은 거래 빈도 + 새 기기 = 높은 사기 스코어”임을 파악하면, 공격 전 수 주 동안 정상적인 소액 거래를 반복해 행동 프로파일을 오염시킵니다. 이후 대형 사기 거래를 실행할 때 모델은 “이 계정은 30일 정상 거래 이력이 있다”고 판단해 낮은 스코어를 줍니다.

탐지 방법은 세 가지입니다.

첫 번째는 Population Stability Index(PSI)입니다. 학습 시점과 현재의 피처 분포 차이를 측정합니다. PSI > 0.2이면 해당 피처를 사용하는 모델의 신뢰도가 의심됩니다.

두 번째는 Challenger 모델 병행 운영입니다. 현재 Champion 모델과 최근 2주 데이터로 재학습한 Challenger 모델을 동시에 실행해 스코어 분포를 비교합니다. 두 모델의 판정이 일주일간 10% 이상 벌어지면 Concept Drift 신호입니다.

세 번째는 Delayed Label 수집 강화입니다. 분석가 판정 외에도, 결제 후 30일이 지나 차지백이 발생한 거래를 자동으로 FRAUD 라벨로 수집합니다. 이 “후속 라벨”이 모델의 편향을 수정하는 핵심 데이터입니다.


6-5. Adversarial Attack — 탐지 패턴을 학습해 우회하는 사기꾼

사기 탐지 ML의 가장 불편한 진실은 모델이 공개적으로 알려질수록 우회가 쉬워진다는 점입니다. 내부자가 임계값을 외부에 유출하거나, 공격자가 수천 번의 탐침 거래를 통해 모델의 결정 경계를 역공학하면, 스코어를 0.79에 정확히 맞추는 거래를 생성할 수 있습니다.

대응 원칙은 세 가지입니다.

원칙 1: 모델을 주기적으로 교체합니다. 같은 모델 구조가 6개월 이상 노출되면 적대적 학습의 대상이 됩니다. 알고리즘, 피처 조합, 임계값을 주기적으로 변경합니다.

원칙 2: 다중 모델 앙상블을 사용합니다. 단일 모델의 결정 경계를 파악해도 나머지 모델들이 다른 각도에서 판정합니다. XGBoost + Neural Network + 이상치 탐지(Isolation Forest)를 동시에 운영합니다.

원칙 3: 룰과 ML의 결합을 유지합니다. ML만으로 방어하면 adversarial attack에 취약합니다. 탐지할 수 없는 ML 우회 거래도 BIN 속도, IP 서브넷, 기기 핑거프린트 룰에 걸릴 수 있습니다. 레이어드 방어선의 이유가 바로 이것입니다.


7. 동시성 함정 — “동시에 두 곳에서 긁으면 어떻게 되나”

7-1. 동일 카드 동시 다건 결제 — Redis Lua 원자성

슬라이딩 윈도우 카운터 구현의 가장 흔한 실수는 ZREMRANGEBYSCORE → ZADD → ZCARD를 개별 명령으로 보내는 것입니다. 동시에 두 요청이 들어오면 두 요청 모두 count = 19를 읽고 임계값 20을 통과합니다. 실제로는 20번째와 21번째 요청인데도 불구하고 둘 다 허용됩니다.

해결책은 Lua 스크립트로 원자적 실행을 보장하는 것입니다.

-- Redis Lua 스크립트: 슬라이딩 윈도우 원자적 카운트 + 추가
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local threshold = tonumber(ARGV[3])
local window_start = now - window_ms

-- 윈도우 밖 제거
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)

-- 현재 카운트 조회
local count = redis.call('ZCARD', key)

-- 임계값 초과이면 추가 없이 현재 카운트만 반환
if count >= threshold then
    return count
end

-- 임계값 이하이면 현재 이벤트 추가 후 새 카운트 반환
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('EXPIRE', key, math.ceil(window_ms / 1000) + 10)
return count + 1

이 Lua 스크립트는 Redis 내에서 단일 트랜잭션으로 실행되어 경쟁 조건을 원천 차단합니다. Java 클라이언트에서는 RedisTemplate.execute(RedisScript<Long> script, ...) 로 호출합니다.


7-2. 피처 집계 동시성 — INCR vs Sorted Set 정합성

Redis INCR은 원자적이지만 Sorted Set ZADD는 복합 작업입니다. 속도 집계에 두 자료구조를 혼용하면 정합성 문제가 생깁니다.

예를 들어 “1시간 내 총 결제 금액” 집계를 INCR(카운터)와 ZADD(타임스탬프 + 금액)로 따로 관리하면, 프로세스 재시작 시 두 값이 달라질 수 있습니다. 단일 Sorted Set에 score=timestamp, member=금액:UUID 형식으로 저장하고 ZRANGEBYSCORE로 합산하는 방식이 정합성 면에서 더 안전합니다. 성능이 우선이라면 INCR을 쓰되, 윈도우 기간이 지나면 Sorted Set으로 재계산해 보정하는 하이브리드 방식을 씁니다.


7-3. 룰 캐시와 실시간 룰 업데이트 사이 일관성

룰을 DB에서 로드해 메모리에 캐시하면 30초~1분의 반영 지연이 생깁니다. 이 지연 동안 비활성화된 룰이 계속 실행되거나, 새로 추가된 긴급 룰이 아직 반영되지 않을 수 있습니다.

graph LR
A["리스크 팀: 룰 비활성화"] --> B["DB 업데이트"]
B --> C["캐시 TTL 30초 대기"]
C --> D["서버 A: 새 룰 적용"]
C --> E["서버 B: 아직 구 룰 실행"]
D --> F["판정 불일치 30초"]
E --> F

해결책은 두 가지입니다. 첫 번째는 Redis Pub/Sub으로 룰 변경 이벤트를 즉시 브로드캐스트해 모든 서버의 캐시를 동시에 무효화합니다. 두 번째는 룰 버전 번호를 요청에 포함시켜, 서버 간 다른 버전이 실행 중임을 탐지하면 알림을 발생시킵니다. 긴급 룰은 캐시 TTL을 0으로 설정해 항상 DB를 직접 조회하도록 플래그를 둡니다.


8. 오버엔지니어링 경고 — “이걸 지금 만들 필요가 있나”

사기 탐지 시스템을 잘못 설계하는 방법은 두 가지입니다. 너무 단순하게 만들거나, 너무 복잡하게 너무 빨리 만드는 것입니다. 후자가 더 흔하고 더 비쌉니다.

거래 규모별 적정 아키텍처

월 거래 건수 적합한 방식 투자 규모 주의사항
~ 1만 건 수동 리뷰 인건비만 ML 구축 비용이 사기 피해액보다 크다
~ 10만 건 룰 기반 탐지 5~10개 월 $100~300 룰 5개가 ML보다 더 잘 작동하는 구간
~ 100만 건 룰 + 간단한 통계 모델 월 $500~2,000 XGBoost 정도면 충분, 피처 스토어 불필요
100만 건 이상 룰 + ML + 피처 스토어 월 $5,000+ 이 포스트에서 설명한 풀스택 아키텍처

“룰 5개가 ML보다 나은 경우”

ML 모델이 우월하려면 세 가지 조건이 필요합니다. 라벨이 충분해야 하고(최소 수천 건의 확인된 사기 사례), 피처가 신호를 가져야 하며, 공격 패턴이 규칙으로 표현하기 어려울 만큼 복잡해야 합니다.

초기 서비스에서 월 사기 건수가 50건 미만이면 ML 학습 데이터가 절대적으로 부족합니다. 이때 ML 모델은 “훈련 데이터에 과적합된 노이즈 필터”가 됩니다. “동일 IP 10분 내 5번 이상 실패 시 차단”이라는 룰 하나가 XGBoost 모델보다 더 정확하고 해석 가능한 경우가 많습니다. 데이터가 적을 때 ML은 오히려 위험합니다.

“사기 탐지 시스템 없이도 되는 경우”

소규모 서비스라면 직접 구축 대신 PG사 자체 탐지에 의존하는 전략이 현실적입니다. 카카오페이, 토스페이먼츠, NICE페이 같은 PG사는 자체 ML 사기 탐지를 운영하며 이상 거래 시 알림을 제공합니다. 초기 서비스에서는 PG사 탐지 + 간단한 속도 제한 룰 2~3개만으로도 90%의 방어가 가능합니다.

자체 사기 탐지 시스템을 구축해야 하는 시점은 PG사가 탐지하지 못하는 도메인 특화 사기(쿠폰 어뷰징, 내부 포인트 악용 등)가 월 손실 기준으로 시스템 구축 비용을 초과할 때입니다.


9. Kafka 심층 — 결제 스트림에서 피드백 루프까지

9-1. Kafka Streams로 실시간 속도 집계

현재 설계는 Redis Sorted Set으로 슬라이딩 윈도우를 구현합니다. 규모가 커지면 Redis 메모리 비용이 선형으로 증가합니다. Kafka Streams는 상태 저장 집계를 토픽 기반으로 처리해 Redis 의존도를 낮출 수 있는 대안입니다.

// Kafka Streams: 카드별 5분 윈도우 결제 횟수 집계
StreamsBuilder builder = new StreamsBuilder();
KStream<String, PaymentEvent> payments = builder.stream("payment-events");

KTable<Windowed<String>, Long> cardVelocity = payments
    .groupBy((key, event) -> event.getCardBin())  // BIN 기준 그룹화
    .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
    .count(Materialized.as("card-velocity-store"));

cardVelocity.toStream()
    .filter((windowedBin, count) -> count > 100)
    .map((windowedBin, count) -> KeyValue.pair(
        windowedBin.key(),
        new VelocityAlert(windowedBin.key(), count)
    ))
    .to("velocity-alerts");

이 구조는 BIN Attack이 시작되면 Kafka Streams가 5분 윈도우 집계를 실시간으로 계산하고, 임계값 초과 시 velocity-alerts 토픽에 이벤트를 발행합니다. 룰 엔진이 이 토픽을 구독해 해당 BIN의 거래를 즉시 차단합니다.


9-2. 피드백 루프 파이프라인 — 판정에서 재학습까지

graph LR
A["분석가 판정"] --> B["Kafka: fraud-labels"]
B --> C["Spark Streaming: 피처 재계산"]
C --> D["MLflow: 학습 데이터셋 업데이트"]
D --> E["모델 재학습 트리거"]
E --> F["카나리 배포 5%"]
F --> A

피드백 루프의 숨겨진 위험은 Label Lag입니다. 분석가가 케이스를 판정하기까지 평균 4시간이 걸린다면, 그 4시간 동안 같은 패턴의 사기가 계속 통과합니다. 이를 줄이는 방법이 두 가지 있습니다.

첫 번째는 자동 라벨링입니다. 차지백(Chargeback)이 발생한 거래는 자동으로 FRAUD 라벨로 수집합니다. PG사 API 연동으로 차지백 이벤트를 실시간으로 받아 즉시 라벨 파이프라인에 투입합니다.

두 번째는 온라인 학습(Online Learning)입니다. 주 1회 배치 재학습 대신, 새 라벨이 100건 쌓일 때마다 모델 가중치를 점진적으로 업데이트합니다. XGBoost보다 River(Python 온라인 ML 라이브러리)나 Vowpal Wabbit이 이에 적합합니다. 단, 온라인 학습은 adversarial 라벨 오염에 취약하므로 라벨 품질 검증이 선행되어야 합니다.


9-3. Consumer Lag → 판정 지연 → 사기 통과 위험

Kafka Consumer가 메시지를 처리하지 못해 lag가 쌓이면 피드백 루프가 지연됩니다. 더 심각한 경우는 판정 결과를 Kafka를 통해 비동기로 처리하는 아키텍처에서 Consumer lag가 직접적인 사기 통과 위험으로 이어지는 경우입니다.

예를 들어 리뷰 큐가 Kafka 토픽으로 구현되어 있고, HIGH 리스크 거래의 차단 명령이 Kafka 메시지로 전달된다면, Consumer lag 10분 동안 해당 거래가 처리됩니다.

graph LR
A["ML: HIGH 리스크 판정"] --> B["Kafka: block-commands"]
B --> C["Consumer Lag 10분"]
C --> D["차단 명령 지연 처리"]
A --> E["결제 서버: 10분간 거래 허용"]
E --> F["사기 통과"]

대응 원칙은 차단 경로는 동기, 분석 경로는 비동기입니다. 거래 차단 명령은 Kafka를 거치지 않고 Redis 블랙리스트에 직접 씁니다. Kafka는 감사 로그, 피드백 루프, 분석 파이프라인에만 사용합니다. Consumer lag 모니터링은 Prometheus + Kafka Exporter로 토픽별 lag를 추적하고, lag > 1,000 또는 lag 증가율 > 100/분이면 즉시 알림을 발생시킵니다.


10. 실무 실수 Top 5

순위 실수 증상 올바른 해결
1 레이턴시 예산 없이 동기 판정 ML 타임아웃이 결제 레이턴시를 500ms 이상으로 만들어 전환율 하락 판정 단계별 타임아웃 예산 설정 (룰 5ms, ML 50ms), 타임아웃 시 fail-open
2 클래스 불균형 무시 모델이 “전부 정상”을 예측해 Accuracy 99.9%지만 사기를 하나도 못 잡음 SMOTE 오버샘플링, 클래스 가중치 조정, Precision-Recall AUC로 평가
3 룰을 하드코딩 새 공격 패턴에 대응하는 데 수 시간 소요, 긴급 배포 중 장애 발생 DSL 기반 룰 + 관리 콘솔, 배포 없이 30초 내 룰 활성화
4 피드백 루프 미구현 새로운 공격 패턴을 탐지 못하고 같은 수법에 반복 피해 분석가 판정 → 라벨 → 재학습 파이프라인 구축, 주 1회 모델 갱신
5 False Positive 비용 무시 정상 고객 차단 → 민원 폭발 → 고객 이탈 → 매출 손실 FP/FN 비용을 금액으로 계산해 임계값 최적화, FP 0.1% 목표

11. Phase 1 → 4 진화

Phase 1: MVP 룰 기반 (월 $500)

초기 서비스에서 ML 없이 룰 엔진만 운영합니다. Redis 단일 인스턴스로 슬라이딩 윈도우 카운터를 구현하고, 10개의 핵심 룰(속도 제한, 금액 한도, 국가 불일치 등)을 하드코딩합니다. 하루 결제 건수 10만 건 이하에서 충분히 동작합니다.

비용 내역: Redis EC2 t3.medium $30, 룰 엔진 서버 t3.large $60, RDS t3.medium $50, 기타 네트워크 $100. 총 월 $240.

한계: 새로운 공격 패턴에 수동 대응 필요, 모호한 거래 판정 능력 없음.

Phase 2: ML 스코어링 추가 (월 $2,500)

XGBoost 모델을 추가하고 Python FastAPI 서버로 서빙합니다. Redis Cluster(3 샤드)로 확장하고, 피처 스토어를 별도 서비스로 분리합니다. 하루 결제 건수 100만 건 수준을 처리합니다.

비용 추가: ML 서버 c5.xlarge × 2 $300, Redis Cluster $200, 배치 Spark EC2 $150, Kafka $200. 총 월 $1,850 추가.

Phase 3: 피처 스토어 + 피드백 루프 (월 $8,000)

BigQuery 기반 배치 프로파일, MLflow 기반 재학습 파이프라인, 분석가 케이스 관리 시스템을 추가합니다. 다중 모델(계정 탈취, 결제 사기, 프로모션 어뷰징 각각 전용 모델)을 운영합니다.

비용 추가: BigQuery 스토리지+쿼리 $500, MLflow 서버 $200, 케이스 관리 DB RDS r5.large $400, 분석가 도구 개발 인건비 환산 $2,000. 총 월 $5,500 추가.

Phase 4: 실시간 그래프 분석 (월 $20,000)

계정-기기-IP-카드를 연결하는 그래프 DB(Neo4j 또는 Neptune)를 도입합니다. 공범 네트워크를 발견하고, 새로운 사기 계정이 생성될 때 기존 사기 계정과의 연결고리를 자동으로 탐지합니다. 전체 트래픽을 처리하는 스트리밍 파이프라인(Kafka Streams)도 추가됩니다.

비용 추가: Graph DB Neptune $2,000, 스트리밍 처리 클러스터 $3,000, 증가한 Redis 용량 $1,000. 총 월 $12,000 추가.


12. 핵심 메트릭

메트릭 정의 목표 경보 임계값
False Positive Rate (FPR) 정상 거래 중 차단된 비율 < 0.1% > 0.3%
False Negative Rate (FNR) 사기 거래 중 통과된 비율 < 1% > 2%
판정 레이턴시 p99 요청부터 판정 완료까지 < 200ms > 350ms
ML 추론 레이턴시 p99 피처 추출 + 모델 추론 시간 < 50ms > 100ms
룰 엔진 처리량 초당 처리 가능한 판정 수 > 50,000 TPS < 40,000 TPS
리뷰 큐 SLA 준수율 HIGH 케이스 1시간 내 처리 비율 > 95% < 90%
모델 드리프트 지수 주간 Precision-Recall AUC 변화 < 5% 감소/주 > 10% 감소/주
피드백 루프 지연 분석가 판정 → 학습 데이터 반영까지 < 24시간 > 72시간

13. 실제 장애 사례

장애 1: 모델 드리프트로 FNR 8배 증가

2022년 한 PG사에서 XGBoost 모델을 6개월간 재학습 없이 운영했습니다. 그 사이 공격자들이 모델의 약점을 탐색하며 탐지를 피하는 피처 조합을 찾아냈습니다. FNR이 0.8%에서 6.4%로 증가하기까지 아무도 눈치채지 못했습니다. 모델 성능 메트릭을 실시간으로 모니터링하지 않았고, 재학습 파이프라인도 없었습니다.

해결책: 피드백 루프를 통해 매주 신규 라벨 데이터로 재학습, Precision-Recall AUC를 주간 리포트에 포함, AUC 5% 감소 시 자동 재학습 트리거.

장애 2: Redis 장애로 룰 엔진 전면 fail-open

Redis Cluster 업그레이드 중 마스터 노드 3개가 동시에 재시작되면서 15분간 슬라이딩 윈도우 카운터 조회가 전부 실패했습니다. 룰 엔진은 Redis 오류 시 룰을 통과시키는 fail-open 정책이었기 때문에, 이 15분 동안 속도 제한 룰이 완전히 비활성화됐습니다. BIN Attack이 탐지 없이 진행됐습니다.

해결책: Redis 장애 시 로컬 메모리 카운터로 fallback(정확도는 낮지만 기본 방어 유지), fail-open vs fail-closed를 룰별로 설정 가능하도록 변경, 고위험 룰은 fail-closed 기본 설정.

장애 3: 피처 스토어 배치 지연으로 프로파일 24시간 stale

Spark 배치가 인프라 문제로 18시간 지연되면서 피처 스토어의 사용자 프로파일이 하루 반 전 데이터로 유지됐습니다. 이 기간 동안 평소 새벽에만 결제하던 계정이 탈취당해 낮에 결제를 시도했는데, stale 프로파일로는 시간대 이상 점수가 낮게 계산돼 ML 스코어가 LOW로 산출됐습니다.

해결책: 배치 지연 모니터링 알림 추가, 프로파일 생성 시각을 피처에 포함해 stale 정도를 ML 입력으로 사용, stale 임계값(48시간) 초과 시 해당 피처 가중치 자동 감소.


14. 확장 포인트

그래프 분석 추가: 현재 구조는 개별 거래와 사용자를 독립적으로 봅니다. 실제 사기 조직은 계정-기기-IP-카드 번호를 네트워크로 공유합니다. Neo4j 또는 Amazon Neptune을 도입해 “이 신규 계정이 이미 차단된 사기 계정과 동일한 기기를 사용하는가”를 탐지하면 조직적 공격 탐지 능력이 대폭 향상됩니다.

설명 가능한 AI (XAI): 현재 ML 스코어는 숫자 하나입니다. SHAP(SHapley Additive exPlanations) 값을 계산해 “이 거래의 스코어가 0.85인 이유는 새로운 기기(+0.3), 비정상 시간대(+0.25), 평소보다 10배 큰 금액(+0.3) 때문입니다”처럼 설명을 제공하면 분석가의 판정 속도가 올라가고 고객 이의 제기에도 대응할 수 있습니다.

실시간 스트리밍 피처: 현재는 Redis 슬라이딩 윈도우와 일배치 프로파일을 사용합니다. Kafka Streams 또는 Apache Flink로 전환하면 수 분 단위의 중간 시간창(5분, 15분, 1시간) 집계를 실시간으로 계산하고, 더 세밀한 피처를 ML에 제공할 수 있습니다.

다국가 대응: 해외 결제가 추가되면 국가별 규제(PCI DSS, GDPR, 국내 개인정보보호법)를 고려한 데이터 거주지(data residency) 정책이 필요합니다. 피처 스토어를 리전별로 분리하고, 개인 식별 피처는 해당 리전을 벗어나지 않도록 설계합니다.


15. 면접 포인트

Q1. False Positive와 False Negative 중 어느 것이 더 나쁜가요? 둘 다 비용이 발생하지만, 비즈니스 컨텍스트에 따라 다릅니다. False Positive(정상 거래 차단)는 매출 손실과 고객 이탈로 이어집니다. 아마존 사례 연구에 따르면 FPR 1% 증가가 연간 수억 달러의 GMV 손실로 이어질 수 있습니다. False Negative(사기 거래 통과)는 직접 금전 손실 + 카드사 차지백 + 규제 패널티로 이어집니다. 실제로 많은 플랫폼은 FPR 0.1% 이하를 우선 목표로 삼고, 그 제약 안에서 FNR을 최소화하는 방향으로 임계값을 튜닝합니다. "정상 고객을 한 명이라도 차단하지 않는다"는 원칙이 장기 사업에서 중요하기 때문입니다.
Q2. 사기 탐지 시스템에서 타임아웃 정책은 어떻게 설계하나요? 타임아웃 정책은 fail-open(통과)과 fail-closed(차단) 두 가지 방향이 있습니다. 결제 서비스처럼 가용성이 중요하고 사기 확률이 낮은 환경에서는 fail-open이 일반적입니다. 탐지 서버 장애로 정상 고객까지 차단하는 것이 더 큰 비즈니스 리스크이기 때문입니다. 단, fail-open 발동 시 해당 거래를 비동기 검토 큐에 올려야 합니다. 반대로 고위험 환경(대형 이체, 계정 생성 후 즉시 출금)에서는 fail-closed가 맞습니다. 타임아웃이 발생한 거래를 보류 상태로 두고 분석가가 수동 검토합니다. 실제 구현에서는 룰별로 타임아웃 정책을 다르게 설정합니다. 속도 제한 룰은 Redis 장애 시 로컬 카운터 fallback을 사용하고, ML 스코어링 타임아웃 시 룰 엔진 결과만으로 판정합니다.
Q3. 모델 드리프트를 어떻게 탐지하고 대응하나요? 모델 드리프트는 두 가지 방식으로 탐지합니다. 첫 번째는 라벨 기반 모니터링입니다. 분석가 판정 결과(라벨)와 모델 예측의 불일치 비율을 추적합니다. 일주일간 Precision이 5% 이상 하락하면 드리프트 경보를 발생시킵니다. 두 번째는 피처 분포 모니터링입니다. 입력 피처의 분포(평균, 표준편차, 분위수)를 학습 시점 분포와 비교합니다. 피처 분포가 크게 달라지면 모델이 보지 못한 새로운 패턴이 등장했다는 신호입니다. 대응은 재학습 파이프라인 자동 트리거입니다. 신규 라벨 5,000건이 쌓이거나 드리프트 경보가 발생하면 자동으로 재학습이 시작됩니다. 새 모델은 카나리 배포(5% 트래픽)로 검증 후 전체 배포합니다.
Q4. 사기 탐지 시스템의 Cold Start 문제를 어떻게 해결하나요? 신규 사용자는 행동 프로파일이 없어 ML 피처의 상당 부분이 null입니다. 이것이 Cold Start 문제입니다. 세 가지 접근이 있습니다. 첫 번째는 글로벌 평균 대체입니다. 사용자 프로파일이 없으면 전체 사용자의 평균 값으로 대체합니다. 단, 신규 계정이라는 사실 자체를 피처(is_new_user: 1)로 포함해 모델이 이를 학습하게 합니다. 두 번째는 규칙 기반 강화입니다. 신규 계정의 첫 결제에는 ML 스코어 임계값을 낮게(더 엄격하게) 설정합니다. 3회 이상 정상 결제가 확인된 후 정상 임계값으로 완화합니다. 세 번째는 기기 기반 프로파일 이전입니다. 동일 기기로 이전에 정상 결제한 계정이 있다면 그 프로파일을 일부 참조합니다.
Q5. 사기 탐지 결과를 고객에게 어떻게 설명하나요? 고객 설명은 두 가지 상황이 있습니다. 첫 번째는 실시간 차단 메시지입니다. "보안 정책에 따라 결제가 차단됐습니다. 고객센터로 문의해주세요"처럼 막연한 메시지는 불신을 키웁니다. 가능하면 구체적인 이유("새로운 기기에서 처음 결제하시는 경우 추가 인증이 필요합니다")를 제공하되, 공격자에게 탐지 룰을 노출하지 않는 선에서 표현합니다. 두 번째는 이의 제기 대응입니다. 고객이 "왜 내 결제가 차단됐나"고 물으면, 내부 시스템에서는 SHAP 값 기반의 상세 설명이 가능해야 합니다. 단, 고객에게는 탐지 룰의 정확한 기준을 공개하면 공격자 학습 효과가 있으므로, 분석가가 검토 후 "비정상적인 패턴이 감지됐다"는 수준의 설명을 제공하고 추가 인증 후 해제합니다. GDPR 등 규제 대응을 위해 모든 차단 결정의 근거는 감사 로그에 5년 보관해야 합니다.

함께 읽으면 좋은 글

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →