1. 왜 행동 기반 탐지가 필요한가
초기의 매크로는
- AutoHotkey
- 단순 키보드/마우스 매크로
- 화면 좌표 클릭
같은 UI기반의 매크로가 대부분이었다.
때문에 매크로 차단은
- IP 차단
- 요청 빈도 제한
- CAPTCHA
기술들이 주요 방어 수단으로 사용되었다.
그러나 최근 매크로는
- Selenium / Playwright / Puppeteer(브라우저 자동화 프레임워크)
- Headless Chrome
- HTTP Client 직접 호출 (fetch 재현)
같이 UI와 함께 HTTP 코드를 가지고 자동화 클라이언트 형태로 존재한다.
기존에 있던 매크로 차단 기술은 여전히 유효하나 이 기술 만으로는 모든 자동화 공격을 판별할 수 없는 단계에 도달한 것이다.그래서 관점이 바뀌었다.
이 요청이 단순히 정상 형식을 갖추었는가가 아니라 이 요청이 어떤 사용자 행동의 연속에서 발생했는지를 판단해야 한다.
이것이 행동 기반 탐지의 출발점이다.
2. 행동 기반 탐지의 기본 철학
행동 기반 탐지는 다음 가정 위에 서 있다.
- 사람의 행동은 불규칙하다
- 자동화된 행동은 패턴을 가진다
- 패턴은 통계적으로 측정 가능하다
따라서 판단 대상은
- 단일 요청이 아니라 행동의 연속
- 속도가 아니라 분포와 전이
- 빠름/느림이 아니라 예측 가능성
3. 행동 탐지 모델의 종류
실무에서 사용되는 행동 탐지 모델은 크게 다음 범주로 나뉜다.
- 통계 기반 모델
- 엔트로피 기반 모델
- 시퀀스/확률 모델
- 분포 거리 기반 모델
- 이상치 탐지(Anomaly Detection)
- 머신러닝 기반 모델
이들은 서로 배타적이지 않으며, 대부분 조합해서 사용된다.
3.1. 통계 기반 행동 모델 (Statistical Behavioral Models)
행동 탐지 모델 중 가장 기본이 되는 모델이다.
학습 데이터가 불필요 하기 때문에 구현이 쉽고 계산 비용도 낮아서 실시간 판단에 적합하다
또한 상위 모델의 기초가 되는 모델이다.
3.1.1. 시간 간격 변동 모델 (Inter-arrival Variability)
요청에 대해서 각 신청에 대한 요청 시간 간격을 보고 그 요청 간격이 불규칙적인지 확인하는 모델이다.
서버가 실제로 보는 데이터는 요청시각과 그 요청시각 사이의 간격 뿐이다.
분석은 요청시각 사이의 간격만 보기 때문에 간단하게 이해할 수 있다.
기본적인 판단기준은 평균, 표준편차, 변동계수를 보고 판단한다.
- 평균
- 요청 간격이 전반적으로 빠른지/느린지 판단할 때 사용한다.
- 사람마다 속도가 다르기 때문에 평균은 판단 기준이 아니라 참고 지표로 사용할 수 있다.
- 표준편차
- 요청 간격이 얼마나 퍼져있는지 판단할 때 사용한다.
- 평균이 커지면 자연히 표춘편차도 커지기 때문에 단독으로 사용되지 않는다.
- 변동계수(CV = σ / μ)
- 시간 간격 변동 모델의 핵심이다.
- 변동계수는 펴준편차 / 평균 이며 이 값으로 평균 대비 얼마나 요청 간격이 퍼져있는지 판단한다.
- 변동계수 값이 작을수록 매크로일 확률이 올라간다.
요청 간격의 개수가 적으면 통계는 의미가 없기 때문에 보통 8개에서 15개 정도 모여 있어야 판단이 가능하다.
판단의 핵심은 변동계수로 판단 기준은 다음과 같다. 해당 통계는 봇/자동화 탐지 실무 관행을 가지고 작성했다.
| CV 범위 | 해석 |
| CV < 0.05 | 매우 규칙적 (강한 자동화 의심) |
| 0.05 ≤ CV < 0.15 | 의심 구간 |
| 0.15 ≤ CV < 0.30 | 정상 가능 |
| CV ≥ 0.30 | 매우 불규칙 (사람다움) |
다만 표춘편차는 큰데 평균도 마찬가지로 커서 변동계수가 낮은 경우 사람이 실행했지만 변동계수는 낮아져 오탐지 할 가능성이 있다.
테스트로 구현한 시간 간격 변동 모델이다.
package examples.behavior;
import java.util.*;
/**
* 시간 간격 변동 모델 (Inter-arrival Variability)
* 순수 분석 모듈 (판단 없음)
*/
public class InterArrivalVariability {
/**
* Inter-arrival 기반 탐지
*
* @param timestampsMs 요청 시각(ms) 오름차순
* @return Map<String,Object>
* - decision : ALLOW | SUSPICIOUS | BOT_LIKELY
* - reason : 판단 근거
* - score : 원 점수(0~1)
* - details : 하위 모델 결과 전체
*/
public static Map<String, Object> detectByInterArrival(List<Long> timestampsMs) {
Map<String, Object> result = new LinkedHashMap<>();
// ===== 파라미터(실무에선 설정값으로 분리) =====
long burstThresholdMs = 200; // 버스트 간격 기준(ms): 이 값 이하의 요청 간격을 기계적 연속 요청으로 판단
double minHumanCv = 0.15; // 인간 최소 변동계수(CV): 이 값보다 작으면 지나치게 규칙적인 행동으로 간주
double maxBurstRate = 0.6; // 최대 버스트 비율(비율): 이 비율을 기준으로 버스트 과다 여부를 정규화
// ===== 하위 분석 모델 호출 =====
Map<String, Object> analysis = analyzeInterArrivalVariability(
timestampsMs,
burstThresholdMs,
minHumanCv,
maxBurstRate
);
double score = (double) analysis.get("score");
double cv = (double) analysis.get("cv");
double burstRate = (double) analysis.get("burstRate");
// ===== 탐지 판단 =====
/**
* score는 튜닝 대상
* 얼마나 매크로 탐지를 강하게 줄지 사용하면서 조절해야 한다.
*/
String decision;
String reason;
if (score >= 0.65) {
decision = "BOT_LIKELY";
reason = "inter_arrival_highly_regular_and_bursty";
} else if (score >= 0.4) {
decision = "SUSPICIOUS";
reason = "inter_arrival_somewhat_regular";
} else {
decision = "ALLOW";
reason = "inter_arrival_within_human_range";
}
// ===== 결과 구성 =====
result.put("decision", decision);
result.put("reason", reason);
result.put("score", score);
// 디버깅/설명용으로 하위 결과 전체를 같이 보관
result.put("details", analysis);
// 판단에 직접 사용한 핵심 지표 요약
Map<String, Object> signals = new LinkedHashMap<>();
signals.put("cv", cv);
signals.put("burstRate", burstRate);
result.put("signals", signals);
return result;
}
public static Map<String, Object> analyzeInterArrivalVariability(
List<Long> timestampsMs,
long burstThresholdMs,
double minHumanCv,
double maxBurstRate
) {
/**
* 출력 순서를 고정하기 위해서 LinkedHashMap을 이용함
*/
Map<String, Object> out = new LinkedHashMap<>();
/**
* 시간값이 없거나 3개 미만이면 빈값을 넣어줌
* 값이 너무 없으면 계산해도 의미가 없기 때문
* */
if (timestampsMs == null || timestampsMs.size() < 3) {
out.put("score", 0.0);
out.put("cv", 0.0);
out.put("burstRate", 0.0);
out.put("deltasMs", Collections.emptyList());
return out;
}
/**
* 시간값의 각 차이를 가지고 타임스탬프를 만든고 넣어준다.
*/
List<Long> deltas = new ArrayList<>();
for (int i = 1; i < timestampsMs.size(); i++) {
long dt = timestampsMs.get(i) - timestampsMs.get(i - 1);
if (dt > 0) deltas.add(dt);
}
/**
* 타임스탬프를 저장하는 LinkedHashMap을 만든다.
* 만약 요청시간값이 다들 똑같다면 역시 계산에 의미가 없기 때문에 체크해준다.
*/
if (deltas.size() < 2) {
out.put("score", 0.0);
out.put("cv", 0.0);
out.put("burstRate", 0.0);
out.put("deltasMs", deltas);
return out;
}
double mean = mean(deltas); // 평균 요청 간격(평균)
double std = stddev(deltas, mean); // 평균 요청 간격과 각 요청 시간 사이의 흔들림(표준편차)
double cv = mean > 0 ? std / mean : 0.0; // 변동계수 계산(표준편차 / 평균)
/**
* 과도하게 빠른 요청이 있는지 확인
*/
long burstCount = deltas.stream().filter(d -> d <= burstThresholdMs).count();
double burstRate = (double) burstCount / deltas.size();
double cvScore = clamp01((minHumanCv - cv) / minHumanCv); // 평균 흔들림 계산
double burstScore = clamp01(burstRate / maxBurstRate); // 평균 요청 속도 계산
double score = 0.4 * cvScore + 0.6 * burstScore; // 최좀 점수 결합(각 결과값의 곱셈식은 튜닝 대상)
out.put("score", score);
out.put("cv", cv);
out.put("burstRate", burstRate);
out.put("deltasMs", deltas);
return out;
}
/**
* 평균 간격 계산 메서드
* @param v
* @return
*/
private static double mean(List<Long> v) {
long s = 0;
for (long x : v) s += x;
return (double) s / v.size();
}
/**
* 표준 편차 계산 메서드
* @param v
* @param mean
* @return
*/
private static double stddev(List<Long> v, double mean) {
double s = 0;
for (long x : v) {
double d = x - mean;
s += d * d;
}
return Math.sqrt(s / v.size());
}
/**
* 점수 안정화 메서드
* 어떤 값이든 0~1 범위로 만든다
* @param v
* @return
*/
private static double clamp01(double v) {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
}
실제 코드에 대한 설명은 주석으로 대신한다.
테스트용 메서드를 동작시켜서 확인이 가능하다.
package examples.behavior;
import java.util.*;
/**
* 행동 기반 탐지 모델 (Inter-arrival Variability 기반)
*
* 역할: 통계 기반 행동 점수(score)를 해석해서
* "정상 / 의심 / 매우 의심" 같은 탐지 판단을 만든다.
*
*/
public class Model {
public static void main(String[] args) {
// 인간 행동 예시
List<Long> human = Arrays.asList(
0L, 900L, 2300L, 3100L, 4800L, 6200L
);
// 매크로 행동 예시
List<Long> macro = Arrays.asList(
0L, 150L, 550L, 650L, 900L, 1200L
);
Map<String, Object> r1 = InterArrivalVariability.detectByInterArrival(human);
Map<String, Object> r2 = InterArrivalVariability.detectByInterArrival(macro);
System.out.println("=== HUMAN ===");
System.out.println(r1);
System.out.println("\n=== MACRO ===");
System.out.println(r2);
}
}
3.1.2. 반복 행동 비율 모델 (Repetition Ratio)
사용자의 행동이 과도하게 동일한 패턴을 반복하고 있는가에 대해서 판단한다.
여기 핵심은 반복 자체가 아니라 반복의 정도와 방식이다.
언제 요청했는지, 무엇을 선택했는지, 같은 행동을 얼마나 집요하게 반복했는지 판단하는지가 위 모델의 핵심이다.
이 모델은 랜덤 지연으로 우회하기 어렵고 사람 흉내 비용이 매우 높다.
그러나 사용자가 실제로 연속 클릭하는 경우나 서버 오류로 인해 같은 요청을 여러번 보낼 경우 오탐지할 가능성이 있다.
반복 행동 비율 모델은 다음 가정을 가지고 있다.
- 사람도 반복한다
- 하지만 사람의 반복에는 변형이 있다
- 타이밍이 다르다
- 중간에 멈춘다
- 실패 후 행동이 변한다
- 자동화의 반복은 정확히 동일하다
- 성공할 때 까지 반복
- 실패 후 행동이 변하지 않는다.
한마디로 사람은 불완전한 반복, 매크로는 완벽한 반복을 한다는 가정을 가지고 모델을 사용한다.
반복으로 보는 대상은 다음과 같다.
- 동일한 URL + HTTP Method
- 동일한 파라미터 조합
- 동일한 상태 전이 흐름
- 동일한 실패 원인 이후 동일 재시도
이 대상을 몇 번 반복했는가를 보는게 아니라
- 동일 행동이 연속으로 나타나는 비율(연속 반복 비율)
- 동일 행동이 전체 행동 중 차지하는 비율(동일 행동 점유율)
- 실패 이후 동일 행동 재시도 비율(실패 후 동일 재시도 비율)
이것들을 본다.
반복의 빈도와 집중도를 본다고 할 수 있다.
3.2. 엔트로피 기반 행동 모델 (Entropy-based Models)
엔트로피는 행동의 예측 불가능성을 수치로 표현하는 개념이다.
통계 기반 행동 모델과 엔트로피 기반 행동 모델은 모두 행동 데이터의 집합을 기반으로 판단을 수행한다.
다만 접근 방식에는 차이가 있다.
통계 기반 행동 모델은 평균, 표준편차, 변동계수 등을 통해 행동 패턴의 변동성과 규칙성의 강도를 요약해 판단한다.
반면 엔트로피 기반 행동 모델은 행동의 분포를 분석하여 다음 행동이 얼마나 쉽게 추측 가능한 상태인지를 평가한다.
즉, 엔트로피 기반 모델은 다음 행동을 예측하려는 것이 아니라 예측이 쉬운 상태인지 여부 자체를 판단하는 모델이다.
이러한 엔트로피 기반 모델은 통계 기반 모델이 평균과 분산을 인위적으로 조정한 자동화를 완전히 구분하지 못하는 한계를 보완하기 위해 등장했다.
3.2.1. 시간 엔트로피 (Time Entropy)
앞에서 배운 시간 간격 변동 모델은 요청 간의 시간 간격의 변동계수를 대상으로 매크로를 판별한다.
그렇다 보니 평균과 표준편차를 의도적으로 조정한 자동화에 대해서는 오탐지 또는 미탐지가 발생할 여지가 있다.
시간 엔트로피는 요청 간 시간 간격의 크기 자체가 아니라 그 시간 간격이 얼마나 예측 가능 한지를 기준으로 매크로를 판단하는 모델이다.
시간 엔트로피는 요청 간 시간 간격을 여러 구간으로 나눈 뒤,
각 구간에 요청이 얼마나 몰려 있는지를 통해 분포의 불균형을 측정한다.
- 특정 한두 구간에 요청이 집중될수록 엔트로피는 감소한다
- 여러 구간에 고르게 퍼질수록 엔트로피는 증가한다
즉, 불규칙성의 크기가 아니라 불규칙성의 정보량, 다시 말해 다음 요청 간격을 얼마나 쉽게 예측할 수 있는지를 본다.
시간 엔트로피 역시 시간 간격 변동 모델과 마찬가지로 요청 간 시간 간격 데이터를 기반으로 판단한다.
그러나 변동계수처럼 표준편차의 크기를 직접 비교하는 대신 표준편차로 표현되는 흔들림이 어떤 시간 구간에 집중되어 있는지에 주목한다.
예를 들어, 요청 간 간격이 다소 변동을 보이더라도 항상 비슷한 시간대의 구간 안에서만 움직인다면 다음 요청 간격은 충분히 예측 가능하며, 엔트로피는 낮게 측정된다.
반대로 요청 간 간격이 빠른 구간, 보통 구간, 느린 구간을 넘나들며 여러 시간대에 분산되어 나타난다면 다음 요청 간격을 예측하기 어려워지고 엔트로피는 높아진다.
이처럼 시간 엔트로피는 랜덤 지연을 섞어 변동계수 기준을 회피한 자동화에 대해서도 시간 분포의 집중도 자체를 통해 추가적인 판별 근거를 제공하는 역할을 한다.
시간 엔트로피 모델의 예시코드는 다음과 같다.
package examples.behavior;
import java.util.*;
/**
* 시간 엔트로피(Time Entropy) 기반 분석 모듈 (순수 계산기)
*
* 목적:
* - 요청 간 시간 간격(Δt)을 구간화(binning)하여 분포를 만들고
* - Shannon Entropy(log2, bits)로 "예측 가능성(단조로움)"을 수치화
*/
public class TimeEntropyModel {
/**
* 상위 탐지 모델(시간 엔트로피 해석기)
* - 하위 분석(TimeEntropyModel)을 호출
* - 점수를 해석해서 decision/reason을 만든다
*/
public static Map<String, Object> detectByTimeEntropy(List<Long> timestampsMs) {
Map<String, Object> result = new LinkedHashMap<>();
// ===== 설정값(실무에서는 외부 설정/튜닝 대상) =====
// 요청 간 시간간격을 0~200, 200~500, 500~1000, 1000+ 4개 범위로 나눔
long[] binEdgesMs = new long[] { 200, 500, 1000 }; //
// score = 1 - normalizedEntropy 이므로
// score가 높을수록 "분포가 한 구간에 몰림(예측 가능)" → 의심
Map<String, Object> analysis = analyzeTimeEntropy(timestampsMs, binEdgesMs);
double score = (double) analysis.get("score"); // 평분 시간 분포
double normalizedEntropy = (double) analysis.get("normalizedEntropy"); // 분포 예측 불가능성
double concentration = (double) analysis.get("concentration"); // 최대 집중도
String decision;
String reason;
// 정책 예시: score 기반
// 결과값 판단은 사용해보면서 튜닝이 필요함
if (score >= 0.75) {
decision = "BOT_LIKELY";
reason = "time_entropy_low_high_predictability";
} else if (score >= 0.55) {
decision = "SUSPICIOUS";
reason = "time_entropy_somewhat_predictable";
} else {
decision = "ALLOW";
reason = "time_entropy_within_human_range";
}
result.put("decision", decision);
result.put("reason", reason);
result.put("score", score);
Map<String, Object> signals = new LinkedHashMap<>();
signals.put("normalizedEntropy", normalizedEntropy);
signals.put("concentration", concentration);
result.put("signals", signals);
result.put("details", analysis);
return result;
}
/**
* @param timestampsMs 오름차순(과거->현재) 요청 시간(ms)
* @param binEdgesMs 요청 간 시간 경계(ms). 예: [200, 500, 1000]
* @return Map<String,Object>
* entropyBits 는 Shannon 엔트로피 값이며 로그 밑은 2이고 단위는 비트이다
* normalizedEntropy 는 엔트로피를 0부터 1 사이로 정규화한 값이다
* concentration 은 하나의 구간이 전체에서 차지하는 최대 비율이다
* 값이 1에 가까울수록 특정 구간에 집중되어 있음을 의미한다
* score 는 0부터 1 사이의 의심 점수이며 값이 클수록 매크로 가능성이 높다
* score 는 1에서 normalizedEntropy 를 뺀 값이다
* binCounts 는 각 시간 구간별 델타티 개수이다
* binProbs 는 각 시간 구간별 델타티 확률 분포이다
* deltasMs 는 계산에 사용된 요청 간 시간 차이 리스트로 디버깅 목적이다
* meta 는 구간 정보 개수 파라미터 등 부가 정보를 담는다
*/
public static Map<String, Object> analyzeTimeEntropy(List<Long> timestampsMs, long[] binEdgesMs) {
Map<String, Object> out = new LinkedHashMap<>();
// 값이 너무 없으면 계산해도 의미가 없기 때문
if (timestampsMs == null || timestampsMs.size() < 3) {
out.put("entropyBits", 0.0);
out.put("normalizedEntropy", 0.0);
out.put("concentration", 0.0);
out.put("score", 0.0);
out.put("binCounts", Collections.emptyList());
out.put("binProbs", Collections.emptyList());
out.put("deltasMs", Collections.emptyList());
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("reason", "need_at_least_3_timestamps");
meta.put("countTimestamps", timestampsMs == null ? 0 : timestampsMs.size());
out.put("meta", meta);
return out;
}
if (binEdgesMs == null || binEdgesMs.length == 0) {
// 구간이 없으면 엔트로피 자체가 성립이 애매하니 안전하게 종료
out.put("entropyBits", 0.0);
out.put("normalizedEntropy", 0.0);
out.put("concentration", 0.0);
out.put("score", 0.0);
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("reason", "binEdgesMs_required");
out.put("meta", meta);
return out;
}
// 1) 시간 분포 계산
List<Long> deltas = new ArrayList<>();
for (int i = 1; i < timestampsMs.size(); i++) {
long dt = timestampsMs.get(i) - timestampsMs.get(i - 1);
if (dt > 0) deltas.add(dt);
}
if (deltas.size() < 2) {
out.put("entropyBits", 0.0);
out.put("normalizedEntropy", 0.0);
out.put("concentration", 0.0);
out.put("score", 0.0);
out.put("binCounts", Collections.emptyList());
out.put("binProbs", Collections.emptyList());
out.put("deltasMs", deltas);
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("reason", "not_enough_positive_deltas");
meta.put("countTimestamps", timestampsMs.size());
meta.put("countDeltas", deltas.size());
out.put("meta", meta);
return out;
}
/**
* 2) bin 구성: edges K개면 bins는 K+1개
* edges는 체크할 시간간격 범위를 의미하며 여기서는 200,500,1000을 말한다.
* 그렇게 되면 체크 구간은 총 4개가 된다.
*/
int binCount = binEdgesMs.length + 1;
int[] counts = new int[binCount];
/**
* 3) 각 Δt를 bin에 누적
*/
for (long dt : deltas) {
int idx = binIndex(dt, binEdgesMs);
counts[idx]++;
}
/**
* 4) 확률 분포 p_i
* 요청 시간 간격이 어디 구간에 얼마나 자주 나타나는지 체크한다.
*/
int total = deltas.size();
double[] probs = new double[binCount];
for (int i = 0; i < binCount; i++) {
probs[i] = (double) counts[i] / (double) total;
}
/**
* 5) Shannon entropy (log2)
* 확률분포를 가지고 얼마나 예측하기 어려운지를 수치로 표현한다
* 각 구간의 비율을 사용해 로그 기반 계산으로 전체 분포의 복잡도를 구한다
*/
double entropyBits = 0.0;
double maxP = 0.0;
for (double p : probs) {
if (p > maxP) maxP = p;
if (p > 0) entropyBits += -p * log2(p);
}
/**
* 6) 정규화 엔트로피 (0~1)
* 요청의 엔트로피값을 최대 엔트로피 값(log2(binCount)으로 나눠서 정규화시킨다.
* 값이 작아질수로 특정 구간에 몰려있다고 판단, 매크로로 의심할 수 있다.
*/
double maxEntropyBits = log2(binCount);
double normalizedEntropy = (maxEntropyBits > 0) ? (entropyBits / maxEntropyBits) : 0.0;
/**
* 7) 의심 점수 (엔트로피 낮을수록 매크로로 의심)
*/
double score = clamp01(1.0 - normalizedEntropy);
out.put("entropyBits", entropyBits);
out.put("normalizedEntropy", normalizedEntropy);
out.put("concentration", maxP);
out.put("score", score);
out.put("binCounts", toList(counts));
out.put("binProbs", toList(probs));
out.put("deltasMs", deltas);
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("binEdgesMs", toList(binEdgesMs));
meta.put("binCount", binCount);
meta.put("countTimestamps", timestampsMs.size());
meta.put("countDeltas", deltas.size());
meta.put("maxEntropyBits", maxEntropyBits);
out.put("meta", meta);
return out;
}
/**
* 구간을 만들어주는 메서드
* bins:
* - idx 0: (0 ~ edge0]
* - idx 1: (edge0 ~ edge1]
* - ...
* - idx K: (edgeK-1 ~ INF)
* @param dt
* @param edges
* @return
*/
private static int binIndex(long dt, long[] edges) {
for (int i = 0; i < edges.length; i++) {
if (dt <= edges[i]) return i;
}
return edges.length;
}
private static double log2(double x) {
return Math.log(x) / Math.log(2.0);
}
/**
* 점수 안정화 메서드
* 어떤 값이든 0~1 범위로 만든다
* @param v
* @return
*/
private static double clamp01(double v) {
if (v < 0.0) return 0.0;
if (v > 1.0) return 1.0;
return v;
}
private static List<Integer> toList(int[] arr) {
List<Integer> list = new ArrayList<>(arr.length);
for (int v : arr) list.add(v);
return list;
}
private static List<Double> toList(double[] arr) {
List<Double> list = new ArrayList<>(arr.length);
for (double v : arr) list.add(v);
return list;
}
private static List<Long> toList(long[] arr) {
List<Long> list = new ArrayList<>(arr.length);
for (long v : arr) list.add(v);
return list;
}
}
해당 모델의 테스트 코드는 다음과 같다.
package examples.behavior;
import java.util.*;
public class ModelTimeEntropy {
// ===== 실행 예시 =====
public static void main(String[] args) {
List<Long> human = Arrays.asList(0L, 850L, 2100L, 3100L, 4300L,
5050L, 6400L, 7200L, 8600L, 9400L, 11000L,
11800L, 13200L, 14050L, 15600L);
List<Long> macro = Arrays.asList(0L, 120L, 340L, 380L, 480L,
600L, 700L, 840L, 970L, 1080L,
1200L, 1320L, 1500L, 1660L, 1780L);
System.out.println("=== HUMAN ===");
System.out.println(TimeEntropyModel.detectByTimeEntropy(human));
System.out.println("\n=== MACRO ===");
System.out.println(TimeEntropyModel.detectByTimeEntropy(macro));
}
}
결과는
=== HUMAN ===
{decision=ALLOW,
reason=time_entropy_within_human_range, score=0.5,
signals={normalizedEntropy=0.5, concentration=0.5},
details={entropyBits=1.0, normalizedEntropy=0.5, concentration=0.5,
score=0.5, binCounts=[0, 0, 7, 7], binProbs=[0.0, 0.0, 0.5, 0.5],
deltasMs=[850, 1250, 1000, 1200, 750, 1350, 800, 1400, 800, 1600, 800, 1400, 850, 1550],
meta={binEdgesMs=[200, 500, 1000], binCount=4, countTimestamps=15, countDeltas=14,maxEntropyBits=2.0}}}
=== MACRO ===
{decision=BOT_LIKELY,
reason=time_entropy_low_high_predictability, score=0.8143838366795622,
signals={normalizedEntropy=0.18561616332043782, concentration=0.9285714285714286},
details={entropyBits=0.37123232664087563, normalizedEntropy=0.18561616332043782, concentration=0.9285714285714286,
score=0.8143838366795622, binCounts=[13, 1, 0, 0], binProbs=[0.9285714285714286, 0.07142857142857142, 0.0, 0.0],
deltasMs=[120, 220, 40, 100, 120, 100, 140, 130, 110, 120, 120, 180, 160, 120],
meta={binEdgesMs=[200, 500, 1000], binCount=4, countTimestamps=15, countDeltas=14, maxEntropyBits=2.0}}}
로 정상적으로 탐지한 것을 확인할 수 있다.
3.2.2. 선택 엔트로피 (Choice / Input Entropy)
선택 엔트로피(Choice Entropy)는 사용자의 선택이 다음에 무엇을 고를지 예측되기 쉬운 상태인지를 판단하는 방법이다.
예를 들어 여러 선택지가 존재할 때
- 특정 선택이 반복적으로 압도적인 비율을 차지한다면 다음 선택은 쉽게 예측 가능해지고 선택 엔트로피는 낮아진다.
- 선택이 고르게 분산되어 있다면 다음 선택을 예측하기 어려워지고 선택 엔트로피는 높아진다.
이렇게 엔트로피 높낮이로 판단을 실행하는 모델이다.
중요한 점은 선택 엔트로피는 예측을 수행하는 모델이 아니라 예측이 쉬운 상태인지 여부를 판단하는 지표라는 것이다.
이 개념은 통계 기반 선택 모델과 닮아 있지만 관점이 다르다.
통계 기반 모델이 얼마나 쏠렸는가를 본다면 선택 엔트로피는 그 쏠림이 만들어내는 정보량의 부족, 즉 예측 가능성을 본다.
다만 선택 엔트로피 하나의 모델 만으로는 매크로 판단이 어렵다.
엔트로피는 본질적으로 “분포”를 다룬다.
하지만 선택이 1~2회로 끝난 경우에는 분포 자체가 성립하지 않는다.
따라서 선택 엔트로피는 짧은 세션이나 단발성 행동에 대해서 판단할 수 없다.
이는 통계 정보를 가지고 판단하는 모든 모델에 해당하지만 선택 엔트로피의 선택의 경우 그 특징이 두드러진다.
다른 경우로는 루틴형 인간과 매크로 사이의 판단이 어렵다.
사람이라고 해서 항상 다양한 선택을 하는 것은 아니다.
- 항상 같은 메뉴를 고르는 사람
- 늘 같은 시간대, 같은 시설만 이용하는 사람
- 특정 목적을 가진 반복 이용자
이런 사용자는 선택 엔트로피 관점에서 보면 엔트로피가 낮게 나온다.
그러나 이는 인간의 습관과 루틴일 뿐 자동화의 증거는 아니다.
선택 엔트로피를 단독 판정 기준으로 사용할 경우 이러한 정상 사용자를 매크로로 오탐할 위험이 매우 크다.
따라서 선택 엔트로피 모델의 경우 주 매크로 탐지 모델 보다는 보조 도구의 측면으로 사용하는 것이 바람직하다.
3.2.3. 시퀀스 엔트로피 (Sequence Entropy)
행동 기반 매크로 탐지에서 선택 엔트로피가 무엇을 고르는가의 분포를 본다면 시퀀스 엔트로피는 한 단계 더 나아가 행동의 ‘순서’ 자체가 얼마나 예측 가능한지를 본다.
즉, 사용자가 어떤 화면을 거쳐 어떤 순서로 행동했는지 그 순서가 얼마나 쉽게 예측 가능한 패턴인지를 수치화한다.
예를 들어,
- A → B → C → D
라는 흐름이 거의 모든 세션에서 동일하게 반복된다면 다음 행동은 쉽게 예측 가능해지고 시퀀스 엔트로피는 낮아진다.
반대로,
- A → B → C
- A → C → B
- A → B → D
처럼 흐름이 흔들린다면 다음 단계를 예측하기 어려워지고 시퀀스 엔트로피는 높아진다.
이는 자동화 탐지에서 매우 강력한 관점이지만 동시에 오해와 남용의 위험도 큰 지표다.
전체적인 흐름은 사람이나 매크로나 똑같이 움직일 것이다.
예를 들어 공연의 신청부터 결제하기까지의 과정을 신청한다고 한다면 좌석선택, 정보입력, 결제 까지의 과정은 동일할 것이다.
그러나, 사람의 경우 결제 화면에서 결제를 실패했을 때 새로고침, 뒤로가기 등의 새로운 시퀀스가 생기는 반면에 매크로의 경우 결제에 실패하면 즉시 재시도를 할 것이다.
시퀀스 엔트로피는 전체 플로우가 아니라 특정 전이 지점의 예측 가능성, 예를 들어 실패했을 때의 대처를 볼 때 의미가 커진다고 볼 수 있다.
3.3. 시퀀스·확률 모델 (Sequence & Probabilistic Models)
시퀀스 엔트로피와 시퀀스 확률 모델은 서로 매우 유사한 방식으로 매크로를 탐지하지만 그 판단에 대한 기준이 서로 다르다.
시퀀스 엔트로피는 순서의 예측 가능성을 판단하는 모델이다.
그러나 시퀀스 확률 모델은 더 나아가 행동 시퀀스를 확률적으로 생성·전이되는 구조로 가정하고 그 구조 자체를 학습하거나 정의하는 모델을 말한다.
쉽게 말해서
- 시퀀스 엔트로피: 행동의 분포 = 행동이 얼마나 다양한가?/얼마나 단조로운가?
- 시퀀스 확률 모델: 행동의 정당성 = 행동이 정상 세계에서 얼마나 그럴 듯 한가?
로 볼 수 있다.
3.3.1 n-gram 모델
n-gram 모델은 행동 시퀀스를 길이 n짜리 조각으로 잘라서 확률을 보는 방식이다.
예를 들어 행동 시퀀스가 다음과 같다고 하자.
A → B → C → D → E
이때,
- 1-gram: A, B, C, D, E
- 2-gram: (A,B), (B,C), (C,D), (D,E)
- 3-gram: (A,B,C), (B,C,D), (C,D,E)
이렇게 연속된 행동 묶음을 만든다.
그 다음 이 행동 묶음이 사용자에게 얼마나 많이, 자주 발생하는가를 보는 것이다.
정상 사용자는
(조회 > 스크롤 > 상세)
같이 조회와 상세 안에 새로고침이나 스크롤 같은 다른 행동이 들어갈 수 있는데 매크로의 경우
(조회 > 상세), (상세 > 신청)
처럼 연속된 행동을 일관되고 연속적으로 실행할 수 있다.
이러한 연속된 행동 묶음이 많이 발생하는 사용자를 매크로로 판단하는 방법이다.
다만 이 방법도 데이터가 적으면 확률이 불안정하다는 단점이 있다.
또한 n이 커질수록 경우의 수가 폭발적으로 늘어나 성능에 이슈가 생길 가능성이 있다.
3.3.2 마르코프 전이 모델 (Markov Transition Model)
마르코프 전이 모델은 행동의 흐름을 상태(state)와 상태 간 전이(transition)로 표현하고 각 전이가 어떤 확률로 발생하는지를 모델로 만든 것이다.
여기서 중요한 부분은 전이 모델은 과거를 보는게 아니라 현재 상태만을 보고 판단하는 것이다.
예를 들어 상태를 이렇게 잡아보자.
- A = 목록
- B = 상세
- C = 예약
- D = 결제
- E = 뒤로가기
정상 사용자 로그를 보면 이런 경향이 있다.
- 목록 → 상세 : 자주
- 상세 → 예약 : 가끔
- 상세 → 뒤로가기 : 꽤 자주
- 목록 → 결제 : 거의 없음
이 때 대다수의 사용자는 목록 > 상태로 가는데 목록 > 결제로 바로 넘어가는 사용자는 거의 없다.
거의 없음에 해당하는 정상 사용자는 거의 없지만 매크로는 아니다.
그러나 거의 없는 상태 전의를 보이는 사용자는 매크로 사용자 이거나 다른 특이한 사용자일 수 있으니 주의 깊게 볼 필요가 있고 주의 깊게 봤을 때 이러한 패턴이 자주 발생한다면 이상 사용자로 판단할 수 있다.
또는 상태 전환되는 모습이 매우 일정할 경우도 있다.
목록 > 상세 > 예약 > 결제의 상태 전환이 예외 없이 계속 진행된다면 이 경우도 이상 사용자로 판단할 근거가 된다.
왜냐하면 사람일 경우 새로고침이나 뒤로가기 같은 다른 상태 전환이 이뤄질 가능성이 크기 때문이다.
현재 상태에서 다음 상태에 대해서 판단하는건 전에 공부했던 맥락 바인딩, 순서 엔트로피랑 동일하게 순서를 보는 모델이다.
그러나 3가지 모델은 판단 기준, 강제력 등에서 각기 차이가 존재한다.
맥락 바인딩은 요청이 발생한 맥락(context)이 올바른지를 검증하는 방식이다.
여기서 맥락이란 다음과 같은 정보들을 말한다.
- 이전 단계에서 발급된 토큰
- 현재 단계(화면, 상태)
- 사용자 세션
- 요청 파라미터의 허용 범위
개발자가 미리 규정한 규칙을 기반으로 토근을 발급하기 때문에 결정론적으로 동작한다.
순서 엔트로피는 행동 실행 순서의 분포를 보고 그 순서가 얼마나 예측 가능한지를 수치로 평가한다.
중요한 점은
- 특정 순서가 정상인지 아닌지를 직접 판단하지 않는다
- 행동 순서의 다양성이 얼마나 줄어들었는지를 본다
이러한 점이다.
즉, 순서 엔트로피는 측정 지표(feature)로 볼 수 있다.
마르코프 모델은 행동을 상태(state)와 상태 간 전이(transition)로 표현하고 각 전이가 정상 사용자에게서 어떤 확률로 발생하는지를 모델링한다.
판단 방식은
- 정상 사용자 데이터를 기반으로 전이 확률 모델을 만든다
- 단일 전이가 아니라 시퀀스 전체의 가능도를 평가한다
- 확률이 낮은 전이가 누적될수록 비정상으로 판단한다
즉, 마르코프 모델은 확률적(probabilistic)판단을 한다.
마르코프 모델의 예시코드는 다음과 같다.
package examples.behavior;
import java.util.*;
/**
* Markov(1차) 시퀀스 확률 모델
*
* - 상태(state): 페이지/액션 등
* - 전이(transition): 이전 상태 -> 다음 상태
*
* 학습(Training):
* - 정상 사용자 세션 시퀀스를 여러 개 넣고
* - 전이 카운트를 세어서
* - P(next | current) 확률 분포를 만든다.
*
* 평가(Scoring):
* - logP(seq) = Σ log P(next | current)
* - avgLogLikelihood = logP / (전이 개수) (길이 다른 시퀀스 비교용)
*
* smoothing:
* - Laplace smoothing(alpha)로 0확률 방지
*/
public class PageMarkovModel {
// ===== 1) 상태 정의 (오타 방지용 상수) =====
public static final String MAIN = "MAIN";
public static final String LIST = "LIST";
public static final String DETAIL = "DETAIL";
public static final String APPLY_DONE = "APPLY_DONE";
public static final String LOGIN = "LOGIN";
public static final String MYPAGE = "MYPAGE";
public static final String SIGNUP = "SIGNUP";
/** 기본 상태 목록 (예시) */
public static List<String> defaultStates() {
return Arrays.asList(MAIN, LIST, DETAIL, APPLY_DONE, LOGIN, MYPAGE, SIGNUP);
}
/**
* (학습) 세션 시퀀스들로부터 전이 확률 분포 생성
* 정상적인 행동 패턴의 기준이 되는 모델을 만드는 것이다.
*
* @param sessions 정상 사용자 세션들의 상태 시퀀스 목록
* @param allStates 전체 상태 목록(스무딩에 필요)
* @param alpha Laplace smoothing 계수 (예: 1.0)
*
* Laplace Smoothing: 확률 모델에서 한 번도 관측되지 않은 사건에 확률 0이 부여되는 것을 방지하기 위해 모든 가능한 사건에 동일한 작은 가짜 관측값(pseudo-count)을 추가하는 기법이다.
*/
public static Map<String, Object> trainTransitions(
List<List<String>> sessions,
List<String> allStates,
double alpha
) {
Map<String, Object> out = new LinkedHashMap<>();
// 전이 카운트: from -> (to -> count)
Map<String, Map<String, Integer>> counts = new LinkedHashMap<>();
if (sessions == null) sessions = Collections.emptyList();
/**
* 1) 카운트 집계
* 페이지들의 순서(state sequence)를 입력으로 받아서 각 페이지 전이(from → to)의 출현 빈도 집계한다.
*/
for (List<String> seq : sessions) {
if (seq == null || seq.size() < 2) continue;
for (int i = 1; i < seq.size(); i++) {
String from = seq.get(i - 1);
String to = seq.get(i);
counts.computeIfAbsent(from, k -> new LinkedHashMap<>());
Map<String, Integer> inner = counts.get(from);
inner.put(to, inner.getOrDefault(to, 0) + 1);
}
}
/**
* 2) 확률로 변환 (Laplace smoothing 포함)(한번도 관측되지 않아도 실제로는 페이지가 존재할 수 있기 때문에 포함한다.)
* 출현 빈도 집계한 값으로 전이 확률 분포를 만든다.
*/
Map<String, Map<String, Double>> probs = new LinkedHashMap<>();
for (String from : allStates) {
Map<String, Integer> fromCounts = counts.getOrDefault(from, Collections.emptyMap());
/**
* 분모: (관측된 전이 총합 + alpha * 총 페이지 개수)
* 모든 가능한 상태에 alpha씩 추가된 가짜 카운트를 반영해 확률 합이 1이 되도록 보정한다.
*/
int sum = 0;
for (int c : fromCounts.values()) sum += c;
double denom = sum + alpha * allStates.size();
/**
* 분자: (관측된 전이 횟수 + alpha)
* 관측되지 않은 전이도 확률 0이 되지 않도록 최소 가짜 카운트(alpha)를 부여한다.
*/
Map<String, Double> pMap = new LinkedHashMap<>();
for (String to : allStates) {
int c = fromCounts.getOrDefault(to, 0);
double p = (c + alpha) / denom;
pMap.put(to, p);
}
/**
* 각 출발 페이지별 다음 페이지로 가는 페이지 확률
*/
probs.put(from, pMap);
}
out.put("transitionCounts", counts);
out.put("transitionProbs", probs);
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("alpha", alpha);
meta.put("stateCount", allStates.size());
meta.put("sessionCount", sessions.size());
out.put("meta", meta);
return out;
}
/**
* (평가) 특정 시퀀스의 log-likelihood 계산
*
* @param sequence 평가할 상태 시퀀스
* @param transitionProbs 학습된 전이 확률 분포
*/
public static Map<String, Object> scoreSequence(
List<String> sequence,
Map<String, Map<String, Double>> transitionProbs
) {
Map<String, Object> out = new LinkedHashMap<>();
// 페이지 이동이 없거나 거의 없을 경우는 모델을 사용하지 않는다.
if (sequence == null || sequence.size() < 2) {
out.put("logLikelihood", 0.0);
out.put("avgLogLikelihood", 0.0);
out.put("steps", Collections.emptyList());
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("reason", "need_at_least_2_states");
out.put("meta", meta);
return out;
}
double logL = 0.0;
List<Map<String, Object>> steps = new ArrayList<>();
int transitions = 0;
for (int i = 1; i < sequence.size(); i++) {
String from = sequence.get(i - 1);
String to = sequence.get(i);
Map<String, Double> pMap = transitionProbs.get(from);
// 모델이 학습하지 않은 페이지를 조회했을 경우
if (pMap == null) {
double p = 1e-12;
double lp = Math.log(p);
Map<String, Object> step = new LinkedHashMap<>();
step.put("from", from);
step.put("to", to);
step.put("prob", p);
step.put("logProb", lp);
step.put("note", "unknown_from_state_penalty");
steps.add(step);
logL += lp;
transitions++;
continue;
}
double p = pMap.getOrDefault(to, 1e-12); // 다음 페이지로 가는 전이 확률을 구한다. 전이 확률이 없으면 아주 작은 확률을 가져온다.(0 방지)
double lp = Math.log(p); // 전이 확률을 안정적으로 합치고 이상 전이에 강한 페널티를 주기 위해서 확률을 로그로 만든다.
Map<String, Object> step = new LinkedHashMap<>();
step.put("from", from);
step.put("to", to);
step.put("prob", round3(p));
step.put("logProb", round3(lp));
steps.add(step);
logL += lp; // 로그로 바꾼 이동한 페이지의 전이확률의 합
transitions++; // 페이지 이동횟수
}
/**
* 페이지 전이 로그확률의 합 / 페이지 이동횟수 ( 로그값은 무조건 음수 = 값은 무조건 음수)
* 모든 행동을 가지고 이상 행동이 있는지
*/
double avg = (transitions > 0) ? (logL / transitions) : 0.0;
out.put("logLikelihood", round3(logL));
out.put("avgLogLikelihood", round3(avg));
out.put("steps", steps);
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("transitionCount", transitions);
out.put("meta", meta);
return out;
}
private static double round3(double v) {
return Math.round(v * 1000.0) / 1000.0;
}
}
가독성을 위해서 소숫점 3째자리까지 나오도록 수정했다.
테스트는 밑의 코드로 테스트 가능하다.
package examples.behavior;
import java.util.*;
public class PageMarkovMain {
public static void main(String[] args) {
// 1) 상태 목록
List<String> states = PageMarkovModel.defaultStates();
/**
* 2) 정상 사용자 세션 시퀀스(학습 데이터) 예시
* 지금은 하드코딩으로 넣고 있지만 원래 정상 사용자의 사용 흐름을 배치, 또는 실시간으로 받아서 학습한다.
*/
List<List<String>> normalSessions = new ArrayList<>();
// 정상적인 탐색/신청 흐름
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.LIST, PageMarkovModel.DETAIL, PageMarkovModel.APPLY_DONE));
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.LIST, PageMarkovModel.DETAIL, PageMarkovModel.APPLY_DONE));
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.LIST, PageMarkovModel.DETAIL));
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.LIST));
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.LIST, PageMarkovModel.DETAIL, PageMarkovModel.LIST, PageMarkovModel.DETAIL, PageMarkovModel.APPLY_DONE));
// 로그인 후 마이페이지 확인
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.LOGIN, PageMarkovModel.MYPAGE));
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.LOGIN, PageMarkovModel.MYPAGE));
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.LOGIN));
// 회원가입 후 로그인 -> 마이페이지
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.SIGNUP, PageMarkovModel.LOGIN, PageMarkovModel.MYPAGE));
normalSessions.add(Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.SIGNUP, PageMarkovModel.LOGIN));
// 3) 학습 (전이 확률 분포 만들기)
Map<String, Object> model = PageMarkovModel.trainTransitions(normalSessions, states, 1.0);
@SuppressWarnings("unchecked")
Map<String, Map<String, Double>> probs =
(Map<String, Map<String, Double>>) model.get("transitionProbs");
// 4) 평가할 시퀀스 예시들
List<String> seqNormalLike = Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.LIST, PageMarkovModel.DETAIL, PageMarkovModel.APPLY_DONE);
List<String> seqWeird = Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.APPLY_DONE);
List<String> seqWeirder = Arrays.asList(PageMarkovModel.MAIN, PageMarkovModel.MYPAGE, PageMarkovModel.APPLY_DONE);
printScore("seqNormalLike", PageMarkovModel.scoreSequence(seqNormalLike, probs));
printScore("seqWeird", PageMarkovModel.scoreSequence(seqWeird, probs));
printScore("seqWeirder", PageMarkovModel.scoreSequence(seqWeirder, probs));
// 해석 팁:
// - avgLogLikelihood가 "더 낮을수록" 정상 모델에서 보기 드문 흐름(=의심)로 해석 가능
// - 절대값 기준 컷은 위험하니, (유저군/시간대/기기별) 상대 비교 + 다른 신호와 앙상블 추천
}
private static void printScore(String name, Map<String, Object> score) {
System.out.println("=== " + name + " ===");
System.out.println("avgLogLikelihood=" + score.get("avgLogLikelihood"));
System.out.println("steps=" + score.get("steps"));
System.out.println();
}
}
값은 전부 음수로 나오며 보통 -1.5 이상 나오면 이상이 있다고 보면 될 것 같다.
=== seqNormalLike ===
avgLogLikelihood=-0.915
steps=[{from=MAIN, to=LIST, prob=0.353, logProb=-1.041}, {from=LIST, to=DETAIL, prob=0.5, logProb=-0.693}, {from=DETAIL, to=APPLY_DONE, prob=0.364, logProb=-1.012}]
=== seqWeird ===
avgLogLikelihood=-2.833
steps=[{from=MAIN, to=APPLY_DONE, prob=0.059, logProb=-2.833}]
=== seqWeirder ===
avgLogLikelihood=-2.39
steps=[{from=MAIN, to=MYPAGE, prob=0.059, logProb=-2.833}, {from=MYPAGE, to=APPLY_DONE, prob=0.143, logProb=-1.946}]
3.4. 분포 거리 기반 모델 (Distribution Distance Models)
분포 거리 기반 모델은 “이 사용자의 행동 분포가 정상 사용자들의 행동 분포와 얼마나 다른지”를 거리(distance)로 계산하는 방식이다.
전체 정상 사용자의 패턴을 정상 패턴으로 두고 그 패턴과 얼마나 차이나는지를 비교하는 모델이라 볼 수 있다.
분포 거리 기반 모델의 장점 으로는
- 정상/비정상 비교가 직관적이다.
- 여러 행동 지표를 하나의 거리 값으로 통합 가능하다.
- 설명 가능한 이상치 탐지가 가능하다.
단점으로는 - 분포를 어떻게 만들지에 따라 성능이 크게 달라진다.
- 데이터가 적으면 분포 자체가 불안정하다.
등의 특징이 있다.
정상 패턴을 어떻게 만들 것인지, 정상 패턴과 사용자의 패턴을 어떻게 수치화 할것인지가 분포 거리 기반 모델의 핵심이다.
3.4.1 사용 지표
분포 거리 기반 모델에서 사용지표(distance metric)란 두 행동 분포가 얼마나 다른지를 하나의 숫자로 표현하는 방법이다.
KL Divergence
사용자의 패턴이 정상이라는 가정 하에서 그 이상함이 얼마나 누적되어 있는지를 수치로 표현한 것을 말한다.
개념적으로는 어렵지만 쉽게 말해서 정상 패턴에 비해 사용자의 패턴이 얼마나 비정상적인지를 누적해서 수치로 표현한 사용지표이다.
JS Divergence
KL Divergence 사용지표가 얼마나 이상한지 판단하는 거라면 Jensen–Shannon Divergence는 얼마나 정상적인지를 수치로 표현한 사용지표다.
정상 패턴과 사용자 패턴의 중간값을 구하고 그 차이가 얼마나 나는지, 정확하게 얼마나 공유하는지를 또는 얼마나 정상적인지를 측정하는 방법이다.
값이 대칭적이고 안정적이며 해석이 직관적이라는 장점이 있다.
정상지표가 정확하지 않을 때 쓰면 좋다.
예시 코드는 다음과 같다.
package examples.behavior;
import java.util.*;
public class DistributionDistanceJS {
/**
* JS Divergence 기반 거리 점수 계산
*
* @param normalCounts 정상 사용자 집단의 행동 카운트 분포 (비교 기준 분포)
* @param userCounts 현재 사용자(세션)의 행동 카운트 분포 (비교 대상 분포)
* @param epsilon 0 확률을 방지하기 위해 모든 항목에 더하는 최소 확률값 (수치 안정성용)(Laplace smoothing)
* @param scoreScale JS Divergence를 0~1 의심 점수로 변환할 때 사용하는 민감도 조절 파라미터
*
* @return Map<String,Object>
* - jsDivergenceBits : 정상 분포와 사용자 분포 간의 정보 이론적 차이 (log2 기준, bits 단위)
* - jsDistance : JS Divergence의 제곱근으로 분포 간 차이를 거리처럼 표현한 값
* - score : JS Divergence를 0~1 범위로 정규화한 의심 점수 (높을수록 정상과 다름)
* - alignedProbs : 정상(p), 사용자(q), 중간(m) 분포를 항목별로 정렬해 비교할 수 있는 디버깅 정보
* - meta : 계산에 사용된 파라미터와 내부 상태(ε, scale, KL 값 등)를 담은 메타데이터
*/
public static Map<String, Object> jsDistanceScore(
Map<String, Integer> normalCounts,
Map<String, Integer> userCounts,
double epsilon,
double scoreScale
) {
Map<String, Object> out = new LinkedHashMap<>();
if (normalCounts == null) normalCounts = Collections.emptyMap();
if (userCounts == null) userCounts = Collections.emptyMap();
/**
* 1) 지원집합 통합: 정상/사용자 중 등장한 모든 키(분포를 구성하는 행동항목)를 대상으로 비교
* 각 페이지의 분포는 하나로만 저장되어야 하기 때문에 중복이 있으면 안된다 = LinkedHashSet
*/
Set<String> keys = new LinkedHashSet<>();
keys.addAll(normalCounts.keySet());
keys.addAll(userCounts.keySet());
if (keys.isEmpty()) {
out.put("jsDivergenceBits", 0.0);
out.put("jsDistance", 0.0);
out.put("score", 0.0);
out.put("alignedProbs", Collections.emptyList());
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("reason", "empty_distributions");
out.put("meta", meta);
return out;
}
// 2) count -> 확률 분포로 변환 (normalize)
Map<String, Double> p = normalizeCounts(normalCounts, keys, epsilon); // normal
Map<String, Double> q = normalizeCounts(userCounts, keys, epsilon); // user
/**
* 3) JS Divergence는 두 분포(p, q)를 직접 비교하지 않고 중간 분포 m = (p + q) / 2 를 기준으로
* p가 m과 얼마나 다른지, q가 m과 얼마나 다른지를
* 각각 측정한 뒤 그 두 값을 평균낸 대칭적 분포 거리 지표다.
* 여기서는 KL 계산에 사용할 "중간 기준 분포 m"을 만든다.
*/
Map<String, Double> m = new LinkedHashMap<>();
for (String k : keys) {
double mk = 0.5 * (p.get(k) + q.get(k));
m.put(k, mk);
}
/**
* 4) 만든 확률 기준과 KL Divergence로 각각 계산한 측정값으로
* 최종 거리지표에 대한 평균을 만든다.
*/
double klPm = klDivergenceBits(p, m);
double klQm = klDivergenceBits(q, m);
double js = 0.5 * (klPm + klQm);
/**
* 5) JS Divergence 값을 제곱근으로 변환해
* 분포 차이를 거리(distance)처럼 해석하기 위한 값
*/
double jsDistance = Math.sqrt(js);
/**
* 6) 점수화(0~1): score = 1 - exp( -js / scale )
* 점수를 0~1 범위로 자연스럽게 압축하기 위해 exp를 사용한다.
*/
double score = 1.0 - Math.exp(-(js / Math.max(scoreScale, 1e-12)));
score = clamp01(score);
// 7) 디버깅용 정렬 정보(p,q,m)
List<Map<String, Object>> aligned = new ArrayList<>();
for (String k : keys) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("key", k);
row.put("p_normal", p.get(k));
row.put("q_user", q.get(k));
row.put("m_mid", m.get(k));
aligned.add(row);
}
out.put("jsDivergenceBits", js);
out.put("jsDistance", jsDistance);
out.put("score", score);
out.put("alignedProbs", aligned);
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("epsilon", epsilon);
meta.put("scoreScale", scoreScale);
meta.put("keyCount", keys.size());
meta.put("klPmBits", klPm);
meta.put("klQmBits", klQm);
out.put("meta", meta);
return out;
}
/**
* 주어진 counts를 keys 전체에 대해 확률 분포로 변환한다.
* 등장하지 않은 key는 0으로 보고 epsilon을 더해 0확률을 방지한다.
* 모든 값의 합이 1이 되도록 정규화(normalize)한다.
*
* @param counts: 행동별 발생 횟수 카운트 (예: 페이지 방문 횟수)
* @param keys: 분포를 구성할 전체 행동 차원 집합 (정상/사용자 분포의 합집합)
* @param epsilon: 0확률로 인한 로그 계산 오류를 방지하기 위한 미세 스무딩 값
* @return
*/
private static Map<String, Double> normalizeCounts(
Map<String, Integer> counts,
Set<String> keys,
double epsilon
) {
Map<String, Double> tmp = new LinkedHashMap<>();
double sum = 0.0;
for (String k : keys) {
double v = counts.getOrDefault(k, 0);
v = v + epsilon; // 0확률 방지
tmp.put(k, v);
sum += v;
}
Map<String, Double> probs = new LinkedHashMap<>();
for (String k : keys) {
probs.put(k, tmp.get(k) / sum);
}
return probs;
}
/**
* 분포 p를 기준으로 q가 얼마나 다른지를 정보량(bits)으로 계산한다.
* JS Divergence의 안정성을 위해 내부 계산용으로 KL Divergence 을 사용한다.
* @param p
* @param q
* @return
*/
private static double klDivergenceBits(Map<String, Double> p, Map<String, Double> q) {
double sum = 0.0;
for (String k : p.keySet()) {
double pk = p.get(k);
double qk = q.get(k);
sum += pk * log2(pk / qk);
}
return sum;
}
private static double log2(double x) {
return Math.log(x) / Math.log(2.0);
}
private static double clamp01(double v) {
if (v < 0.0) return 0.0;
if (v > 1.0) return 1.0;
return v;
}
}
테스트용 코드는 다음과 같다.
package examples.behavior;
import java.util.*;
public class Main {
private static double r3(Object v) {
if (v == null) return 0.0;
double d = (v instanceof Number) ? ((Number) v).doubleValue() : Double.parseDouble(v.toString());
return Math.round(d * 1000.0) / 1000.0;
}
private static String fmt3(Object v) {
return String.format("%.3f", r3(v));
}
public static void main(String[] args) {
// 페이지(상태) 기준 "방문 횟수" 분포를 비교한다고 가정
Map<String, Integer> normal = new LinkedHashMap<>();
normal.put("MAIN", 1200);
normal.put("LIST", 2200);
normal.put("DETAIL", 1800);
normal.put("APPLY_DONE", 300);
normal.put("LOGIN", 700);
normal.put("MYPAGE", 500);
normal.put("SIGNUP", 120);
// 사용자(세션) 예시 1: 정상에 가까운 분포
Map<String, Integer> userOk = new LinkedHashMap<>();
userOk.put("MAIN", 5);
userOk.put("LIST", 9);
userOk.put("DETAIL", 7);
userOk.put("APPLY_DONE", 1);
userOk.put("LOGIN", 1);
userOk.put("MYPAGE", 1);
// 사용자(세션) 예시 2: 이상한 분포(예: 신청완료만 과도)
Map<String, Integer> userWeird = new LinkedHashMap<>();
userWeird.put("APPLY_DONE", 12);
userWeird.put("DETAIL", 1);
userWeird.put("MAIN", 1);
double epsilon = 1e-12;
double scoreScale = 0.25;
Map<String, Object> r1 = DistributionDistanceJS.jsDistanceScore(normal, userOk, epsilon, scoreScale);
Map<String, Object> r2 = DistributionDistanceJS.jsDistanceScore(normal, userWeird, epsilon, scoreScale);
System.out.println("=== userOk ===");
System.out.println("jsBits=" + fmt3(r1.get("jsDivergenceBits"))
+ ", jsDist=" + fmt3(r1.get("jsDistance"))
+ ", score=" + fmt3(r1.get("score")));
printAligned3((List<Map<String, Object>>) r1.get("alignedProbs"));
System.out.println("\n=== userWeird ===");
System.out.println("jsBits=" + fmt3(r2.get("jsDivergenceBits"))
+ ", jsDist=" + fmt3(r2.get("jsDistance"))
+ ", score=" + fmt3(r2.get("score")));
printAligned3((List<Map<String, Object>>) r2.get("alignedProbs"));
}
private static void printAligned3(List<Map<String, Object>> aligned) {
// 보기 좋게 key별 p/q/m만 소수 3자리로 출력
//보통 0.2정도면 정상, 0.5정도면 의심할 단계지만 매크로 제한은 직접 사용해보고 설정해야 한다.
for (Map<String, Object> row : aligned) {
String key = String.valueOf(row.get("key"));
Object p = row.get("p_normal");
Object q = row.get("q_user");
Object m = row.get("m_mid");
System.out.println(" " + key
+ " | p=" + fmt3(p)
+ " q=" + fmt3(q)
+ " m=" + fmt3(m));
}
}
}
Wasserstein Distance
Wasserstein Distance는 두 행동 분포를 같은 형태로 만들기 위해 ‘얼마나 많이, 얼마나 멀리’ 옮겨야 하는지를 수치로 표현한 거리 지표다.
KL과 JS는 확률값 자체를 비교한다.
그러나 Wasserstein Distance 확률들의 집합을 보고 그 확률이 일치하려면 얼마나 이동해야 하는가, 이동량 × 이동거리의 총합을 본다.
분포 거리 기반 모델을 정리하자면 다음과 같다.
| 지표 | 무엇을 본다 | 강점 | 약점 |
| KL Divergence | 정상 가정 붕괴 | 강한 이상 신호 | 노이즈 민감 |
| JS Divergence | 행동 세계 유사성 | 안정적 | 미세 변화 둔감 |
| Wasserstein | 분포 이동 거리 | 형태 변화 감지 | 계산 비용 |
'IT정리 > 보안 기초' 카테고리의 다른 글
| 3. 트래픽/행동 레벨 보안 (1) | 2026.01.18 |
|---|---|
| 2-2. 비즈니스 로직 보안 - 매크로를 막기 위한 추가 로직 보안 (1) | 2026.01.18 |
| 2-1. 비즈니스 로직 보안 - 액션 토큰에 대해서 (0) | 2026.01.18 |
| 1. 웹 보안 기초: HTTP·세션·인증/인가·CSRF 개념 정리 (0) | 2026.01.18 |
| 0. 매크로란 (0) | 2026.01.18 |