FDS 차단을 승인 경로에 바로 연결하지 않은 이유
목차
이상거래 탐지(FDS) 판정 엔진을 만들고 등급을 넷으로 나눴습니다. 통과·추가 인증·검토·차단입니다. 그중 BLOCK이 나오면 결제를 세워야 하는데 승인 경로를 따라가 보니 판정을 부르는 자리가 없었습니다.
결제 승인 요청 ──▶ 승인 (판정은 여기 없다) │ 사후 리스너 ──▶ 판정 엔진 ──▶ BLOCK모듈 주석에는 판정 엔진을 “동기 인라인 판정과 비동기 사후 탐지 두 결에서 재사용한다”고 적혀 있었습니다. 그중 동기 인라인 쪽은 아예 연결돼 있지 않았고 판정을 부르는 곳은 결제 완료 이벤트를 받는 사후 리스너(FraudPostHocListener) 하나뿐이었습니다. 그래서 BLOCK 판정은 결제를 세우지 못하고 심사 큐에 쌓여 사람이 나중에 처리합니다. 주석은 지금 이 사실대로 고쳐 두었습니다.
결론부터
- FDS 판정을 승인 경로에 연결하지 않은 첫 이유는 지연이었지만, 실측 결과 p99 3~5ms만으로는 충분한 설명이 아니었습니다.
- 동기 판정은 데이터 최신성·오탐 비용·결제 실패 책임까지 함께 가져옵니다. 지연을 줄여도 이 세 조건을 검증하지 못하면 기본 경로에 연결하지 않습니다.
- 현재는 승인 후 탐지와 사람 검토 큐를 유지하고, 인라인 차단은 실제 라벨과 운영 정책을 확보한 뒤 별도 결정으로 남겼습니다.
이 글에서 중요한 결론은 FDS를 붙이지 말자는 것이 아니라, 판정 시간이 빠르다는 사실만으로 승인 경로에 연결할 수는 없다는 것입니다.
다른 회사는 어디에 두나
Stripe Radar는 엔지니어링 블로그에서 결제마다 사기 여부를 100ms 안에 판정한다고 밝힙니다. 사기는 결제 1,000건에 1건꼴이고 정상 결제를 잘못 막는 비율은 0.1%라고 적었습니다. 좋은 도구는 거래마다 정확하고 빠르며 싸게 돌아야 한다는 조건도 함께 겁니다. 결제 순간에 판정하니 지연 예산이 설계의 중심에 있습니다.
카카오뱅크는 FDS에 AI를 적용한 글에서 모델 서빙 응답 요건을 200ms 미만으로 두고 실제로는 10ms 안팎이 나왔다고 적었습니다. 자주 바뀌는 피처는 “고속 저장소가 적합한 Redis를 선정”해 온라인 피처 스토어로 씁니다.
1. 안 붙인 이유로 적어 둔 것은 지연이었습니다
승인 경로에 안 붙인 이유는 “판정 시간이 결제 응답에 얹히기 때문”이라고 적어 두었습니다. 판정 한 번이 카드·기기·IP별 속도 카운터를 보러 Redis를 세 번 왕복하니 공짜는 아닙니다. 그런데 같은 검토 문서에 “지연 예산을 한 번도 재본 적이 없다”고도 적혀 있었습니다. 재본 적이 없는 것을 근거로 설계를 정하고 있었습니다.
2. 그래서 재 봤습니다
먼저 따로따로 쟀습니다.
| 값 | |
|---|---|
| 판정 한 번 (실 Redis, 2,000회) | p50 1.19 / p95 1.53 / p99 1.92ms |
| 승인 경로 종단 (k6 50VU, 4,007요청) | med 26.2 / p95 56.8 / p99 105.4ms, 실패 0% |
두 p99를 더하면 약 107ms이고 판정 몫은 종단의 1.8%입니다. 처음에는 이 숫자로 결론을 냈습니다.
그런데 p99는 더하는 값이 아닙니다. 그래서 실 MySQL과 실 Redis 위에 애플리케이션을 띄우고 요청마다 판정과 승인을 연달아 재서 합을 모았습니다(표본 2,000회, 세 번).
| 1회차 | 2회차 | 3회차 | |
|---|---|---|---|
| 판정 p99 | 3.72ms | 5.38ms | 3.19ms |
| 합 p99 (실측) | 78.37ms | 87.57ms | 64.43ms |
| 판정 몫 (p50 기준) | 4.52% | 4.56% | 4.62% |
바뀐 것은 판정 쪽이었습니다. 혼자 재면 p99가 1.9ms인데 승인과 같이 돌리면 3.2~5.4ms로 올라갔습니다. 같은 기계에서 같은 Redis를 두고 경합하기 때문입니다. 판정 몫도 1.8%에서 p50 기준 4.5%, p99 기준 5~6%로 올라갔습니다. 따로 재면 꼬리를 작게 봅니다.
처음 500회로 돌렸을 때는 덧셈과 실측의 차이가 한 번은 음수, 한 번은 양수로 나왔습니다. 500회에서 p99는 다섯 번째로 느린 값이라 요청 하나가 튀면 결과가 뒤집힙니다. 2,000회로 늘린 뒤에야 세 번 모두 같은 방향이 나왔습니다.
숫자가 커졌어도 결론은 같았습니다. Stripe가 판정에 쓰는 100ms와 비교해도 판정 몫은 한 자릿수 ms입니다. 지연은 안 붙일 이유가 되지 못했습니다.
3. 지연을 지웠는데도 켜지 못한 세 가지
그런데도 승인 경로에 붙이지 않았습니다. 붙이기 전에 정할 것이 셋 남았습니다.
- Redis가 죽으면 결제를 어떻게 할지 정하지 않았습니다. 지금 속도 카운터는 Redis 실패를 fail-open으로 흡수합니다(이번 시도만 셉니다). 사후 탐지에서는 괜찮지만 승인 경로에서는 부정거래를 통과시키는 결정이 됩니다. fail-close로 두면 Redis 장애가 곧 결제 장애입니다.
- 막기 시작하면 오탐이 곧 매출 손실입니다. 일부러 통과시켜 오탐률을 재는 홀드아웃이 필요해집니다. 지금은 아무것도 막지 않아서 필요 없었던 장치입니다.
- 추가 인증 등급을 받을 화면이 없습니다. 3DS2 같은 추가 인증 흐름이 없어서
CHALLENGE등급은 이름만 있습니다.
첫 항목은 잘못 짚었습니다
다시 보니 첫 항목은 fail-open과 fail-close 중 하나를 고르는 문제로 적은 것부터 틀렸습니다. 속도 카운터를 TTL을 붙여 캐시에 두는 것 자체는 흔한 방식입니다. 판정에 쓸 수 있는 시간이 수십 ms라 이 구간에 관계형 DB를 넣기 어렵고 카카오뱅크도 같은 이유로 Redis를 씁니다.
차이는 층을 나눴는지입니다. 우리 속도 집계는 캐시에만 있어서 Redis가 죽으면 그 시간 창의 집계가 그대로 사라집니다. 막으려 해도 기준이 없으니 fail-open 말고 고를 것이 없습니다. 그래서 첫 항목의 진짜 질문은 “속도 집계를 어디에 한 벌 더 둘 것인가”입니다. 저장소를 바꾸는 이야기와는 다릅니다.
이번에 닫은 것은 “지연 때문에 못 한다”는 근거 없는 이유 하나입니다. 남은 셋은 판정 속도보다 큰 일이라 지금도 BLOCK은 사후 리스너에서만 나옵니다.
참고
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.