실시간 거래 사기 탐지 시스템 설계

1. 개요

고객이 카드를 긁을 때마다 결제 시스템은 수십 밀리초 안에 이 거래가 믿을 만한가라는 질문에 답해야 합니다. 답이 느리면 결제 경험이 나빠집니다. 답이 틀리면 돈을 잃거나 정상 고객을 잘못 차단합니다.

이 글은 실시간 사기 탐지 시스템 설계를 다룹니다. 요구사항, 데이터 모델, API, 처리 흐름이 범위입니다. 또한 세 가지 어려운 점을 다룹니다: 핫 키에서의 경쟁, 재시도 시 일관성, 트래픽 급증. 이 글을 읽고 나면 Spring Boot 3, Redis, PostgreSQL, Kafka로 직접 구축할 수 있는 아키텍처 틀을 갖게 됩니다.

2. 요구사항

2.1. 기능 요구사항

  • 거래를 받아서 APPROVE, REVIEW, DECLINE 결정과 함께 위험도 점수와 사유를 반환합니다.
  • 규칙을 적용합니다: 비정상적인 금액, 높은 거래 빈도(velocity), 위험 국가.
  • 모든 결정을 저장하여 감사 추적과 향후 모델 학습을 지원합니다.
  • REVIEW 결정을 조사 부서(case management)로 전달합니다.

2.2. 규모와 제약

지표 값
평균 부하 3,000 TPS
피크 부하(검은금요일, 11/11) 30,000 TPS
API 레이턴시 p99 < 50ms
결정 저장 피크 시 ~2억6천만 레코드/일, 2년 유지

세 가지 중요한 제약이 있습니다:

  1. 멱등성: 결제 게이트웨이가 같은 거래를 재시도할 수 있습니다. 매번 재시도할 때마다 이전과 동일한 결정을 받아야 합니다.
  2. 이벤트 손실 없음: 반환한 결정은 반드시 기록되고 발행되어야 합니다.
  3. 설명 가능성: 모든 DECLINE에는 구체적인 사유 목록이 있어야 합니다.

3. 아키텍처와 처리 흐름

흐름은 두 부분으로 나뉩니다. 동기 부분은 결제 경로 위에 있고 빨라야 합니다. 비동기 부분은 나머지 작업을 합니다.

Payment Gateway
      │ POST /api/v1/fraud/score
      ▼
┌──────────────────────┐   velocity, profile   ┌─────────┐
│  Fraud Scoring API   │ ────────────────────▶ │  Redis  │
│  (stateless, N pod)  │                       └─────────┘
└──────────┬───────────┘
           │ 1 transaction: decision + outbox
           ▼
     ┌────────────┐   outbox relay   ┌──────────────────────┐
     │ PostgreSQL │ ───────────────▶ │ Kafka: fraud.decisions│
     └────────────┘                  └──────────┬───────────┘
                                                ▼
                         Case management, analytics, ML feature store

구체적인 단계:

  1. API가 요청을 받고 transactionId에 대한 결정이 이미 있는지 확인합니다.
  2. 규칙을 실행합니다. 규칙이 상태(velocity)가 필요하면 원자적 명령 하나로 Redis를 읽고 쓰기 합니다.
  3. 점수를 합산하고 임계값에 따라 결정합니다.
  4. 결정과 outbox 레코드를 동일한 PostgreSQL 트랜잭션에 기록합니다.
  5. 릴레이가 outbox를 읽고 Kafka에 발행합니다. 다음 consumer가 느려도 API에 영향을 주지 않습니다.

4. 데이터 모델

다음 의존성이 필요합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

PostgreSQL 스키마:

CREATE TABLE fraud_decision (
    transaction_id  VARCHAR(64)  PRIMARY KEY,
    card_id         VARCHAR(64)  NOT NULL,
    amount          NUMERIC(18,2) NOT NULL,
    decision        VARCHAR(10)  NOT NULL,
    score           INT          NOT NULL,
    reasons         TEXT[]       NOT NULL,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);

CREATE TABLE fraud_outbox (
    id          BIGSERIAL PRIMARY KEY,
    aggregate_key VARCHAR(64) NOT NULL,   -- card_id, Kafka key로 사용
    payload     JSONB       NOT NULL,
    published   BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_outbox_unpublished ON fraud_outbox (id) WHERE NOT published;

테이블은 날짜별로 파티셔닝됩니다. 덕분에 오래된 데이터를 삭제할 때 수억 행을 DELETE하지 않고 그냥 파티션을 DROP하면 됩니다.

참고: 파티셔닝된 테이블에서 primary key는 파티션 컬럼을 포함해야 합니다. 실제로는 PRIMARY KEY (transaction_id, created_at)을 사용합니다. 단순히 transaction_id로만 멱등성을 빠르게 확인하려면 TTL이 있는 작은 idempotency_key 테이블을 별도로 유지하세요. 여기서는 읽기 쉽게 단순화했습니다.

핫한 상태는 Redis에 있습니다:

velocity:card:{cardId}     ZSET  member=transactionId, score=epochMillis   TTL 10분
profile:card:{cardId}      HASH  avgAmount, homeCountry                     TTL 30일

5. API

요청, 응답, 공유 데이터 타입:

public record TransactionRequest(
        String transactionId, String cardId, String merchantId,
        BigDecimal amount, String currency, String country, Instant occurredAt) {}

public enum Decision { APPROVE, REVIEW, DECLINE }

public record RuleHit(String code, int score) {
    public static RuleHit none() { return new RuleHit(null, 0); }
    public boolean matched() { return code != null; }
}

public record FraudResult(String transactionId, Decision decision, int score, List<String> reasons) {}

Controller는 단순히 전달만 합니다:

@RestController
@RequestMapping("/api/v1/fraud")
public class FraudController {

    private final FraudScoringService scoringService;

    public FraudController(FraudScoringService scoringService) {
        this.scoringService = scoringService;
    }

    @PostMapping("/score")
    public FraudResult score(@RequestBody TransactionRequest request) {
        return scoringService.score(request);
    }
}

샘플 호출과 결과:

curl -X POST localhost:8080/api/v1/fraud/score -H 'Content-Type: application/json' -d '{
  "transactionId":"tx-1001","cardId":"card-42","merchantId":"m-7",
  "amount":15000000,"currency":"VND","country":"NG","occurredAt":"2026-10-12T08:00:00Z"}'
{"transactionId":"tx-1001","decision":"REVIEW","score":55,"reasons":["HIGH_RISK_COUNTRY","AMOUNT_SPIKE"]}

6. 규칙 엔진

각 규칙은 독립적인 bean입니다. 새 규칙을 추가해도 기존 코드에 영향을 주지 않습니다:

public interface FraudRule {
    RuleHit evaluate(TransactionRequest tx);
}

@Component
public class HighRiskCountryRule implements FraudRule {
    private static final Set<String> RISKY = Set.of("NG", "KP", "IR");

    @Override
    public RuleHit evaluate(TransactionRequest tx) {
        return RISKY.contains(tx.country()) ? new RuleHit("HIGH_RISK_COUNTRY", 30) : RuleHit.none();
    }
}

Service는 점수를 합산하고 결정하고 결과를 저장합니다:

@Service
public class FraudScoringService {

    private final List<FraudRule> rules;
    private final DecisionRepository repository;

    public FraudScoringService(List<FraudRule> rules, DecisionRepository repository) {
        this.rules = rules;
        this.repository = repository;
    }

    public FraudResult score(TransactionRequest tx) {
        Optional<FraudResult> existing = repository.find(tx.transactionId());
        if (existing.isPresent()) {
            return existing.get();               // 재시도 → 이전 결정 반환
        }
        List<RuleHit> hits = rules.stream().map(r -> r.evaluate(tx)).filter(RuleHit::matched).toList();
        int score = Math.min(100, hits.stream().mapToInt(RuleHit::score).sum());
        Decision decision = score >= 80 ? Decision.DECLINE : score >= 40 ? Decision.REVIEW : Decision.APPROVE;
        FraudResult result = new FraudResult(tx.transactionId(), decision, score,
                hits.stream().map(RuleHit::code).toList());
        return repository.saveOrGetExisting(tx, result);
    }
}

Spring이 모든 FraudRule bean을 List<FraudRule>에 자동 주입합니다. 점수는 100에서 캡핑됩니다. 임계값 40과 80은 설정으로 빼서 위험팀이 재배포 없이 조정할 수 있게 하세요.

7. 어려운 점과 해결책

7.1. 경쟁: 핫 키에서의 velocity check

“카드당 60초에 5건 이상의 거래”라는 규칙은 간단해 보입니다. 하지만 사기꾼은 요청을 병렬로 쏟아냅니다. 숫자를 읽고 쓰면 두 요청이 같은 4를 읽고 둘 다 통과합니다. 이것이 클래식한 race condition입니다.

해결책은 “오래된 거래 제거, 새 거래 추가, 개수 세기”를 Lua 스크립트에 묶는 것입니다. Redis가 이 스크립트를 원자적으로 실행합니다:

@Component
public class VelocityRule implements FraudRule {

    private static final RedisScript<Long> SCRIPT = RedisScript.of("""
            redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2]))
            redis.call('ZADD', KEYS[1], ARGV[1], ARGV[3])
            redis.call('PEXPIRE', KEYS[1], ARGV[2])
            return redis.call('ZCARD', KEYS[1])
            """, Long.class);

    private final StringRedisTemplate redis;

    public VelocityRule(StringRedisTemplate redis) { this.redis = redis; }

    @Override
    public RuleHit evaluate(TransactionRequest tx) {
        try {
            Long count = redis.execute(SCRIPT, List.of("velocity:card:" + tx.cardId()),
                    String.valueOf(tx.occurredAt().toEpochMilli()), "60000", tx.transactionId());
            return count != null && count > 5 ? new RuleHit("VELOCITY_EXCEEDED", 50) : RuleHit.none();
        } catch (DataAccessException e) {
            return new RuleHit("VELOCITY_UNAVAILABLE", 20);  // degrade, API 다운 방지
        }
    }
}

세 가지 세부사항이 중요합니다:

  • Member가 transactionId입니다. 게이트웨이가 재시도하면 ZADD는 기존 member의 score만 업데이트하고 개수를 늘리지 않습니다. 규칙이 자동으로 멱등성을 가집니다.
  • occurredAt을 사용합니다. 서버 시간 대신 사용하면 pod 사이의 시계 편차로 인한 차이가 없습니다.
  • Redis Cluster는 키로 파티셔닝합니다. 각 카드는 별도의 키이므로 부하가 자동으로 분산됩니다. 정말 핫한 키는 보통 merchant (velocity:merchant:*)인데, 한 merchant의 부하가 매우 많습니다. 이 경우 시간 버킷으로 키를 분할하거나 로컬 메모리에서 개수를 센 후 정기적으로 동기화하세요.

7.2. 일관성: 멱등성과 outbox

두 가지 흔한 일관성 버그:

  1. 같은 transactionId를 가진 두 요청이 동시에 옵니다. 둘 다 이전 결정을 찾지 못합니다.
  2. DB 쓰기는 성공하지만 Kafka 발행이 실패합니다, 또는 그 반대입니다.

버그 (1)은 unique 제약과 ON CONFLICT로 해결합니다. 버그 (2)는 같은 트랜잭션 내 outbox로 해결합니다:

@Repository
public class JdbcDecisionRepository implements DecisionRepository {

    private final JdbcTemplate jdbc;
    private final ObjectMapper mapper;

    public JdbcDecisionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
        this.jdbc = jdbc;
        this.mapper = mapper;
    }

    @Override
    @Transactional
    public FraudResult saveOrGetExisting(TransactionRequest tx, FraudResult result) {
        int inserted = jdbc.update("""
                INSERT INTO fraud_decision (transaction_id, card_id, amount, decision, score, reasons)
                VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (transaction_id) DO NOTHING
                """, tx.transactionId(), tx.cardId(), tx.amount(), result.decision().name(),
                result.score(), result.reasons().toArray(String[]::new));
        if (inserted == 0) {
            return find(tx.transactionId()).orElseThrow();   // 다른 요청이 이겼음
        }
        jdbc.update("INSERT INTO fraud_outbox (aggregate_key, payload) VALUES (?, ?::jsonb)",
                tx.cardId(), toJson(result));
        return result;
    }

    // find(...) 와 toJson(...) 은 간결함을 위해 생략
}

inserted == 0은 병렬 요청이 먼저 썼다는 의미입니다. 그러면 그 요청의 결정을 반환합니다. 덕분에 두 번 호출해도 항상 같은 답을 얻습니다.

릴레이가 outbox를 배치로 읽고 cardId를 키로 해서 발행합니다. 한 카드의 모든 이벤트가 같은 파티션에 들어가므로 순서가 유지됩니다:

@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
    List<Map<String, Object>> rows = jdbc.queryForList("""
            SELECT id, aggregate_key, payload::text AS payload FROM fraud_outbox
            WHERE NOT published ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED""");
    for (Map<String, Object> row : rows) {
        kafka.send("fraud.decisions", (String) row.get("aggregate_key"), (String) row.get("payload")).join();
        jdbc.update("UPDATE fraud_outbox SET published = true WHERE id = ?", row.get("id"));
    }
}

FOR UPDATE SKIP LOCKED는 여러 인스턴스가 릴레이를 동시에 실행해도 발을 밟지 않도록 합니다. 이 메커니즘은 at-least-once를 보장합니다. 따라서 다음 consumer도 멱등성을 가져야 하는데, 예를 들어 케이스를 만들 때 transactionId를 키로 사용합니다.

7.3. 트래픽 급증

피크 부하가 평균의 10배입니다. 세 가지 기법이 도움이 됩니다:

타이트한 타임 버짓. Redis와 DB에 짧은 타임아웃을 설정해서 한 컴포넌트가 느려도 전체 API가 50ms를 초과하지 않도록 합니다:

spring.data.redis.timeout=15ms
spring.datasource.hikari.connection-timeout=20
spring.datasource.hikari.maximum-pool-size=30
spring.threads.virtual.enabled=true

Java 21의 virtual thread는 I/O를 기다리는 수천 개의 요청을 Tomcat의 thread pool을 고갈시키지 않으면서 처리합니다.

명확한 degrade 정책. Redis가 실패하면 VelocityRule은 20점인 VELOCITY_UNAVAILABLE을 반환합니다. 이것은 기술 결정이 아니라 비즈니스 선택입니다. Fail-open(통과)은 수익을 지키고, fail-closed(차단)는 안전을 지킵니다. 이 정책을 위험팀과 미리 정하고 degrade할 때마다 메트릭을 기록하세요.

무거운 작업을 핫 경로 밖으로. 복잡한 ML 모델, 제3자 서비스 호출, 일일 feature 계산은 모두 Kafka consumer에서 실행합니다. Kafka는 트래픽 급증을 흡수합니다: consumer의 lag이 몇 분 늘어도 결제에 영향이 없습니다. 그 결과는 나중에 profile:card:*에 다시 써서 다음 거래가 사용하게 합니다.

8. 핵심 로직 테스트

다음 테스트는 결정 로직과 멱등성을 확인합니다. 테스트는 가짜 규칙과 메모리 내 repository를 사용하므로 Redis나 DB가 필요 없습니다:

class FraudScoringServiceTest {

    static class InMemoryRepo implements DecisionRepository {
        final Map<String, FraudResult> store = new ConcurrentHashMap<>();
        public Optional<FraudResult> find(String id) { return Optional.ofNullable(store.get(id)); }
        public FraudResult saveOrGetExisting(TransactionRequest tx, FraudResult r) {
            FraudResult prev = store.putIfAbsent(tx.transactionId(), r);
            return prev != null ? prev : r;
        }
    }

    private TransactionRequest tx(String id) {
        return new TransactionRequest(id, "card-42", "m-7", new BigDecimal("15000000"),
                "VND", "NG", Instant.parse("2026-10-12T08:00:00Z"));
    }

    @Test
    void declinesWhenScoreReachesThreshold() {
        List<FraudRule> rules = List.of(t -> new RuleHit("HIGH_RISK_COUNTRY", 30),
                                        t -> new RuleHit("VELOCITY_EXCEEDED", 50));
        var service = new FraudScoringService(rules, new InMemoryRepo());

        FraudResult result = service.score(tx("tx-1"));

        assertThat(result.decision()).isEqualTo(Decision.DECLINE);
        assertThat(result.score()).isEqualTo(80);
        assertThat(result.reasons()).containsExactly("HIGH_RISK_COUNTRY", "VELOCITY_EXCEEDED");
    }

    @Test
    void retryReturnsSameDecisionEvenIfRulesChange() {
        AtomicInteger calls = new AtomicInteger();
        FraudRule flaky = t -> calls.incrementAndGet() == 1 ? RuleHit.none() : new RuleHit("X", 90);
        var service = new FraudScoringService(List.of(flaky), new InMemoryRepo());

        FraudResult first = service.score(tx("tx-2"));
        FraudResult retry = service.score(tx("tx-2"));

        assertThat(first.decision()).isEqualTo(Decision.APPROVE);
        assertThat(retry).isEqualTo(first);
    }
}

두 번째 테스트가 매우 중요합니다. 두 호출 사이에 외부 상태가 바뀌어도 재시도할 때 APPROVE를 DECLINE으로 바꾸면 안 됩니다. Lua 스크립트는 Testcontainers를 사용한 integration 테스트를 추가로 작성하세요. 그 테스트는 한 카드에 20개의 요청을 병렬로 보내고 6번째부터의 요청만 VELOCITY_EXCEEDED가 붙는지 확인합니다.

9. 흔한 버그와 언제 이렇게 하면 안 되는가

  • 타임아웃과 fallback 없이 동기적으로 ML 모델을 호출합니다. 한 번 모델이 2초 늦으면 전체 결제 시스템이 늦습니다.
  • Velocity window에 서버 시간을 사용합니다. Pod 사이의 시계 편차가 몇 초만 되어도 개수가 틀립니다.
  • Service에서 직접 Kafka를 발행합니다(outbox 대신). 그러면 “유령” 결정이 생깁니다: 게이트웨이에는 반환했지만 downstream 아무도 모릅니다.
  • Velocity를 “읽고 쓰는” 방식으로 세십시오 (GET하고 INCR하기). 사기꾼이 병렬로 요청을 보내면 통과합니다.
  • Redis를 진실의 원천으로 봅니다. Redis는 핫 상태만 유지하고 손실될 수 있습니다. 진실의 원천은 PostgreSQL과 Kafka입니다. profile을 거기서 재구성할 수 있어야 합니다.

참고: 부하가 수십 TPS에 불과하고 레이턴시 약정이 없으면 이 아키텍처는 과설계입니다. 간단한 service가 PostgreSQL을 읽고 쓰면서 card_id, created_at으로 인덱싱된 COUNT(*)를 몇 개 실행하면 충분합니다. Redis, outbox, Kafka는 숫자로 정말 필요함이 증명되었을 때만 추가하세요.

10. 결론

이 글은 핫 경로와 콜드 경로를 분리하는 원칙에 따라 실시간 사기 탐지 시스템을 설계했습니다. 동기 API는 빠른 규칙만 실행하고 원자적 Lua 스크립트로 Redis를 읽고 쓰며 같은 트랜잭션에 결정과 outbox를 씁니다. 더 무거운 작업은 Kafka로 밀어냅니다.

세 가지 어려운 점에 대해 세 가지 구체적인 해결책이 있습니다. 경쟁은 원자적 연산과 카드별 키로 처리합니다. 일관성은 ON CONFLICT와 transactional outbox에 기반합니다. 트래픽 급증은 타이트한 타임아웃, 명확한 degrade 정책, 부하를 흡수하는 큐로 제어합니다. 이 틀에서 시작해 새 규칙을 추가하고, Kafka consumer 쪽에 ML 모델을 꽂고, 그 결과를 다시 Redis에 써서 다음 거래가 사용하게 합니다.

시스템-설계spring-bootrediskafka사기-탐지