실시간 거래 사기 탐지 시스템 설계
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년 유지 |
세 가지 중요한 제약이 있습니다:
- 멱등성: 결제 게이트웨이가 같은 거래를 재시도할 수 있습니다. 매번 재시도할 때마다 이전과 동일한 결정을 받아야 합니다.
- 이벤트 손실 없음: 반환한 결정은 반드시 기록되고 발행되어야 합니다.
- 설명 가능성: 모든
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
구체적인 단계:
- API가 요청을 받고
transactionId에 대한 결정이 이미 있는지 확인합니다. - 규칙을 실행합니다. 규칙이 상태(velocity)가 필요하면 원자적 명령 하나로 Redis를 읽고 쓰기 합니다.
- 점수를 합산하고 임계값에 따라 결정합니다.
- 결정과 outbox 레코드를 동일한 PostgreSQL 트랜잭션에 기록합니다.
- 릴레이가 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
두 가지 흔한 일관성 버그:
- 같은
transactionId를 가진 두 요청이 동시에 옵니다. 둘 다 이전 결정을 찾지 못합니다. - 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에 써서 다음 거래가 사용하게 합니다.