LLM 컨텍스트가 터지기 전에: 보안 로그 샘플링 전쟁기

2025. 10. 21. 13:03

현재 경진대회에 참가하여 보안 장비에서 발생한 로그들을 LLM API를 사용하여 분석하는 애플리케이션을 개발중에 있다. 대회 참여긴 하지만, 실제 업무 환경에서 사용될 애플리케이션을 개발하는 과정이기에 꽤나 심혈을 기울이고 있는 상황이다. 이 과정에서 겪은 문제에 대해 기술해보려고 한다.

 

보안 로그를 AI로 분석하는 시스템을 만들면서 가장 먼저 부딪힌 문제는 의외로 단순했다. Claude 4.5 Sonnet의 컨텍스트 윈도우가 200,000 토큰인데, 실제 보안 로그를 넣으면 금방 터진다는 것이었다. WAF 로그 몇천 개, IPS 로그 몇천 개를 합치면 순식간에 한계를 넘어버렸다.

 

처음에는 "그냥 샘플링하면 되지 않나"라고 생각했다. 통계학 시간에 배운 것처럼 전체 데이터의 일부만 뽑아서 분석하면 될 것 같았다. 그런데 막상 구현하려고 보니 이게 생각보다 훨씬 복잡한 문제였다. 여론조사용 샘플링과 보안 로그 샘플링은 완전히 다른 게임이었다.

1. 토큰 계산부터 잘못 시작했다

[ 같은 로그인데 왜 이렇게 다를까 ]

초기 시스템 설계 단계에서 WAF와 IPS 로그를 대충 비슷하게 처리하면 되겠다고 생각했다. 둘 다 보안 장비에서 나오는 로그니까 형식도 비슷할 거라고 가정했다. 그래서 "10,000개 로그면 2,500개 정도 샘플링하자"는 단순한 목표를 세웠다.

 

그런데 실제로 각 로그의 토큰을 계산해보니 충격적인 차이가 있었다. WAF 로그는 행당 평균 58 토큰이었다. 2,500개를 넣으면 약 145,000 토큰이 나온다. 시스템 프롬프트 5,000 토큰을 더해도 150,000 토큰으로 전체 컨텍스트의 75%만 쓴다. 안전한 수치였다.

 

문제는 IPS 로그였다. 똑같이 계산했더니 행당 평균 338 토큰이 나왔다. WAF의 거의 6배다. 2,500개를 넣으면 845,000 토큰이 된다. 컨텍스트 윈도우를 4배 이상 초과한다. 처음 설계가 완전히 잘못됐다는 걸 깨달았다.

 

왜 이런 차이가 생겼을까. 답은 데이터 구조에 있었다. WAF 로그는 CSV 형식으로 깔끔하게 정리되어 있었다. 타임스탬프, 출발지 IP, 목적지 IP, 공격 타입, 액션 같은 필드가 각각 컬럼으로 분리되어 있다. 이런 구조화된 데이터는 토큰 효율이 좋다. 같은 정보를 반복할 필요 없이 헤더에 한 번만 쓰면 되니까.

 

반면 IPS 로그는 "원본로그"라는 컬럼에 모든 정보가 텍스트로 때려박혀 있었다. 한 줄이 이런 식이었다.

date=2025-01-15 time=03:24:17 devname="FortiGate-01" srcip=80.66.76.120 srcport=44928 dstip=192.168.1.100 dstport=22 attack="SSH.Connection.Brute.Force" severity=critical action=block protocol=tcp msg="SSH brute force attempt detected from known malicious IP" count=147 ...

이런 key=value 형식이 한 줄에 다 들어가니까 토큰이 폭발한다. 똑같은 정보를 담고 있어도 표현 방식에 따라 토큰 소비가 6배 차이 난다는 걸 배웠다. AI에게 데이터를 줄 때는 구조화가 생명이다.

 

[ 75%만 쓰는 이유 ]

컨텍스트 윈도우가 200,000 토큰인데 왜 150,000만 쓰기로 했냐는 질문을 받았다. 25%나 남겨두는 게 아까워 보일 수 있다. 하지만 이건 필수적인 안전 마진이다.

 

첫 번째 이유는 AI의 출력 공간이다. Claude는 입력과 출력을 합쳐서 200,000 토큰 제한이 있다. 입력에 190,000 토큰을 다 쓰면 AI가 답변할 공간이 10,000 토큰밖에 안 남는다. 보안 분석 리포트는 생각보다 길다. JSON 메타데이터에 분석 날짜, 로그 타입, 통계 정보가 들어가고, 요약 섹션에 전체 공격 트렌드가 들어가고, 상세 분석에 각 시그니처별 분석이 들어가고, 권고사항에 차단 정책 제안이 들어간다. 이걸 다 합치면 20,000~30,000 토큰은 쉽게 나온다.

 

두 번째는 데이터의 변동성이다. 평균 행당 토큰을 측정했지만, 실제 로그는 매일 다르다. 어떤 날은 SQL Injection 공격이 많아서 URL이 짧다. 다른 날은 XSS 공격이 많아서 페이로드가 길다. Base64 인코딩된 데이터가 포함되면 토큰이 급증한다. 한글 에러 메시지가 많이 섞이면 토큰 효율이 떨어진다. 25% 마진은 이런 일상적인 변동을 흡수하는 완충 장치다.

 

세 번째는 미래의 개선 여지다. 시스템을 운영하다 보면 프롬프트에 지시사항을 추가하고 싶어진다. "이번 주에 발생한 신종 공격 유형도 참고해서 분석해줘" 같은 컨텍스트를 넣고 싶을 수 있다. 분석 결과에 과거 분석 이력을 참조하게 하고 싶을 수도 있다. 여유 공간이 없으면 이런 실험을 할 수 없다. 항상 한계선에서 아슬아슬하게 돌리면 개선이 불가능하다.

 

실제로 초기 테스트를 할 때 90% 사용률로 돌린 적이 있었다. 대부분은 괜찮았는데, 한 번은 특이하게 긴 로그가 들어왔고 컨텍스트 초과 에러로 분석이 실패했다. 그 이후로 75% 원칙을 철저하게 지키고 있다.

2. 통계학이 보안에 안 통하는 이유

[ 평균 게임 vs 극단값 게임 ]

샘플링 전략을 설계하면서 가장 많이 고민한 부분이 "통계학 교과서대로 하면 안 되나"였다. 여론조사에서는 5천만 명 중에서 1,000명만 뽑아도 전체 의견을 추정할 수 있다. 엄청난 효율이다. 보안 로그도 똑같이 하면 되지 않을까.

 

그런데 보안 로그와 여론조사는 근본적으로 다른 문제다. 여론조사의 목표는 평균을 구하는 것이다. "지지율이 52%인가 48%인가"를 알고 싶은 거다. 응답자 한 명 한 명은 크게 중요하지 않다. 누가 A를 찍든 B를 찍든 전체 비율만 맞으면 된다. 극단적으로 특이한 의견을 가진 사람이 있어도 전체 통계에는 거의 영향이 없다.

 

보안은 정반대다. 평균이 아니라 극단값을 찾아야 한다. 전체 공격 중에 SQL Injection이 70%고 XSS가 20%라는 건 참고 정보일 뿐이다. 정말 중요한 건 "딱 한 번 발생한 제로데이 익스플로잇"일 수 있다. 그 한 건을 놓치면 해킹당한다.

 

통계학 용어로 설명하면, 여론조사는 중심 경향을 측정하는 문제고, 보안은 이상치를 탐지하는 문제다. 샘플링은 중심 경향 측정에는 완벽하지만, 이상치 탐지에는 취약하다.

 

구체적인 예를 들어보자. 10,000개 로그가 있다고 치자. 이 중 9,500개는 Masscan 스캐너의 단순한 포트 스캔이다. 자동화된 봇이 무작위로 IP를 스캔하는 거라 별로 위협적이지 않다. 300개는 SQL Injection 시도다. 이것도 알려진 공격이라 WAF가 잘 막는다. 195개는 XSS 공격이다. 역시 흔한 패턴이다.

 

그런데 딱 5개의 로그가 있다. 알 수 없는 헤더 조작 시도다. 기존 시그니처 어디에도 매칭되지 않는다. 분석해보니 최신 버전 웹 프레임워크의 제로데이 취약점을 노리는 APT 그룹의 정찰 활동이었다.

 

이 상황에서 25% 샘플링을 하면 어떻게 될까. Masscan은 2,375개가 샘플에 들어간다. SQL Injection은 75개, XSS는 49개가 들어간다. 그런데 제로데이 5개는 확률적으로 1~2개만 선택되거나 아예 누락될 수 있다. 설령 1개가 들어간다 해도, AI는 "이게 공격인지 오류인지" 판단하기 어렵다. 전후 맥락이 없으니까.

 

[ 시간의 연속성이 끊어진다 ]

샘플링의 또 다른 문제는 시간적 연관성을 파괴한다는 점이다. 무작위 샘플링은 각 로그를 독립적인 사건으로 취급한다. 그런데 실제 공격은 연속된 단계로 이루어진다.

 

전형적인 침투 시나리오를 생각해보자. 공격자는 먼저 포트 스캔으로 열린 포트를 찾는다. 그다음 해당 포트에서 돌아가는 서비스 버전을 확인한다. 알려진 취약점이 있는지 검색한다. 익스플로잇 코드를 실행한다. 성공하면 쉘을 얻는다. 백도어를 설치한다. 내부망을 스캔한다. 중요 데이터를 찾는다. 외부로 유출한다.

 

이 과정이 몇 시간에 걸쳐 진행되고, 각 단계마다 로그가 남는다. 포트 스캔 로그 50개, 서비스 확인 로그 10개, 익스플로잇 시도 로그 5개, 백도어 통신 로그 20개, 데이터 유출 로그 3개. 총 88개의 로그가 하나의 공격 캠페인을 구성한다.

 

25% 샘플링을 하면 이 중에서 22개 정도가 선택된다. 포트 스캔 13개, 서비스 확인 2개, 익스플로잇 시도 1개, 백도어 통신 5개, 데이터 유출 1개. 각 단계의 일부만 보게 된다. 게다가 무작위로 선택되니까 시간 순서도 뒤죽박죽이다.

 

AI는 이 22개 로그를 보고 뭘 판단할 수 있을까. 포트 스캔 13개를 보고 "흠, 스캐너가 돌아간 것 같네요"라고 할 것이다. 백도어 통신 5개는 "정상적인 HTTPS 트래픽으로 보입니다"라고 할 수도 있다. 전체 시나리오를 재구성하기는 거의 불가능하다. 각 조각이 하나의 공격 체인임을 알 수 없다.

 

이게 샘플링의 근본적 한계다. 개별 로그의 통계는 유지되지만, 로그 간의 관계는 사라진다. 보안에서는 관계가 중요한데 말이다.

3. 세 가지 전략으로 나눈 이유

이런 문제들을 고민하다가 깨달은 게 있다. 하나의 샘플링 전략으로 모든 걸 해결할 수 없다는 것이다. WAF와 IPS는 로그 특성이 다르고, signature 분석과 log_analysis는 목적이 다르다. 각각에 맞는 전략이 필요했다.

 

[ 전략 1: Stratified Sampling - 분포를 지켜라 ]

WAF signature 분석과 모든 log_analysis에는 층화 추출을 적용했다. 이건 통계학 교과서에 나오는 정석적인 방법이다. 전체 데이터를 여러 층으로 나누고, 각 층에서 비율에 맞게 샘플링한다.

 

구현은 간단하다. 먼저 각 시그니처가 전체에서 차지하는 비율을 계산한다. SQL Injection이 7,000개면 70%, XSS가 2,000개면 20%, 기타가 1,000개면 10%다. 목표 샘플 크기가 2,500개라면, SQL Injection에서 1,750개, XSS에서 500개, 기타에서 250개를 뽑는다.

def _stratified_sample(self, df, target_rows):
    sig_counts = df['sig_id'].value_counts()
    sampled_dfs = []
    
    for sig_id, count in sig_counts.items():
        sig_df = df[df['sig_id'] == sig_id]
        ratio = count / len(df)
        n_samples = max(1, int(target_rows * ratio))
        
        if len(sig_df) <= n_samples:
            sampled = sig_df
        else:
            sampled = sig_df.sample(n=n_samples, random_state=42)
        
        sampled_dfs.append(sampled)
    
    return pd.concat(sampled_dfs, ignore_index=True)

 

여기서 중요한 부분이 몇 가지 있다. max(1, int(target_rows * ratio))에서 최소 1개는 보장한다. 아무리 희귀한 시그니처도 완전히 누락되지 않는다. 0.1% 비율이어도 최소 1개는 샘플에 들어간다.

 

random_state=42는 재현성을 위한 것이다. 같은 로그 파일로 분석하면 항상 똑같은 샘플이 선택된다. 이게 왜 중요한지는 나중에 자세히 설명하겠다.

 

Stratified Sampling의 장점은 명확하다. 샘플을 보고 전체를 정확하게 추론할 수 있다. "전체 공격 중에 SQL Injection이 70%구나"라고 자신 있게 말할 수 있다. 통계적 신뢰도가 높다. AI에게 "이 샘플은 전체 로그를 대표합니다"라고 말할 수 있다.

 

목표 행 수는 분석 타입에 따라 다르게 설정했다. WAF signature 분석은 2,500개, WAF log_analysis는 1,000개다. signature 분석은 시그니처별 통계를 내야 해서 샘플이 많이 필요하다. log_analysis는 전체적인 흐름을 보는 거라 1,000개면 충분하다. 게다가 log_analysis 프롬프트가 더 길어서 토큰 예약을 8,000으로 잡았다.

 

2,500개면 약 145,000 토큰이 나온다. 프롬프트 5,000을 더하면 150,000 토큰으로 75% 사용률이다. 안전하다. 1,000개면 58,000 토큰에 프롬프트 8,000을 더해 66,000 토큰이다. 33% 사용률로 매우 여유롭다.

 

[ 전략 2: Context Builder - 약자를 보호하라 ]

IPS signature 분석에는 완전히 다른 접근을 했다. Stratified Sampling 대신 "각 시그니처당 동등하게 50개씩"이라는 전략을 택했다. 이걸 Context Builder라고 부른다.

 

왜 이렇게 했을까. IPS signature 분석의 목표는 각 시그니처의 특성을 파악하는 것이다. CVE 번호를 매핑하고, 위협 등급을 평가하고, 영향받는 시스템을 찾고, 대응 방안을 제시한다. 이 작업은 시그니처가 전체에서 차지하는 비중과는 무관하다. 희귀한 공격이든 흔한 공격이든, 각 시그니처 자체를 정확히 분석해야 한다.

 

만약 Stratified Sampling을 썼다면 어떻게 됐을까. 아까 예시로 든 상황을 다시 보자. Masscan이 9,500개, SQL Injection이 300개, XSS가 195개, 제로데이가 5개 있다. 2,500개를 샘플링하면 Masscan에서 2,375개, SQL Injection에서 75개, XSS에서 49개, 제로데이에서 1~2개가 선택된다.

 

Masscan 분석에는 2,375개나 쓴다. 근데 Masscan은 단순한 포트 스캔이라 50개만 봐도 충분하다. 나머지 2,325개는 사실상 낭비다. 반면 제로데이는 1~2개밖에 못 본다. 이걸로 "이게 공격인지 오류인지" 판단하기 어렵다.

 

Context Builder는 이 문제를 해결한다. 각 시그니처당 50개씩 똑같이 샘플링한다. Masscan에서 50개, SQL Injection에서 50개, XSS에서 50개, 제로데이에서 5개 전부를 가져온다. 총 샘플은 155개로 훨씬 작지만, 제로데이 분석 품질은 오히려 더 좋다. 5개 전부를 보니까 패턴을 파악할 수 있다.

def build(self, df, samples_per_signature=50):
    sig_analysis = []
    
    for attack_id in df['attack_id'].unique():
        sig_df = df[df['attack_id'] == attack_id]
        n_samples = min(samples_per_signature, len(sig_df))
        
        if len(sig_df) <= samples_per_signature:
            samples = sig_df.to_dict('records')
        else:
            samples = sig_df.sample(n=n_samples, random_state=42).to_dict('records')
        
        sig_analysis.append({
            'attack_id': attack_id,
            'attack_name': sig_df['attack'].iloc[0],
            'total_count': len(sig_df),
            'sample_count': len(samples),
            'sample_logs': samples
        })
    
    return json.dumps({'signature_analysis': sig_analysis}, ensure_ascii=False)

실제 운영해보니 시그니처가 보통 10~15개 나온다. 15개 시그니처에 각각 50개씩이면 750개 샘플이다. JSON으로 구조화하면 약 15,000 토큰밖에 안 나온다. 전체 컨텍스트의 10%도 안 쓴다. 매우 효율적이다.

 

이 전략의 철학은 평등주의다. 모든 시그니처가 동등한 분석 기회를 받는다. 희귀하다고 차별받지 않는다. 보안에서는 희귀한 게 오히려 더 위험할 수 있으니까.

 

[ 전략 3: Smart Sampling - 상황에 맞춰라 ]

WAF와 IPS 상관분석에는 또 다른 전략이 필요했다. 이 분석의 목적은 "양쪽 방어선에서 동시에 탐지된 IP"를 찾는 것이다. WAF에서도 잡히고 IPS에서도 잡힌 IP는 뭔가 수상하다. 두 장비가 독립적으로 이상 신호를 감지한 거니까.

 

문제는 공통 IP의 비율이 매번 다르다는 점이다. 어떤 날은 전체의 1%만 공통 IP다. 대부분의 공격이 단일 레이어에서만 감지된다. 다른 날은 90%가 공통 IP다. 거의 모든 공격이 양쪽에서 다 잡힌다.

 

처음에는 단순하게 "공통 IP에 60%, 기타 IP에 40%"로 고정했다. 공통 IP가 중요하니까 더 많이 보자는 의도였다. 그런데 실제로 돌려보니 문제가 생겼다.

 

공통 IP가 1%밖에 안 되는 케이스를 생각해보자. 전체 10,000개 중에 공통 IP 로그가 100개, 기타 IP 로그가 9,900개다. 400개를 샘플링하는데 60%를 공통 IP에 할당하면 240개를 뽑으려고 시도한다. 그런데 실제로는 100개밖에 없다. 결국 100개 전부를 쓰고, 나머지 300개 자리가 비거나 기타 IP로 채워진다. 공통 IP를 우선한다는 본래 의도가 흐려진다.

 

반대 케이스도 문제다. 공통 IP가 90%를 차지하는 상황이다. 9,000개가 공통 IP고 1,000개가 기타 IP다. 60%를 할당하면 공통 IP에서 240개만 뽑는다. 전체 9,000개 중에서 2.7%만 보는 셈이다. 대표성이 떨어진다. 게다가 AI는 "공통 IP가 60%네요"라고 분석할 텐데, 실제로는 90%였다. 완전히 잘못된 결론이다.

 

그래서 적응형 비율을 도입했다. 먼저 공통 IP의 실제 비율을 계산한다. 그 비율에 따라 샘플링 전략을 바꾼다.

def _smart_sampling(self, waf_df, ips_df, common_ips, target_rows_per_log):
    # 공통 IP 로그 추출
    waf_common = waf_df[waf_df['src_ip'].isin(common_ips)] if 'src_ip' in waf_df.columns else pd.DataFrame()
    waf_others = waf_df[~waf_df.index.isin(waf_common.index)]
    
    # 실제 비율 계산
    if len(waf_df) > 0:
        common_ratio = len(waf_common) / len(waf_df)
    else:
        common_ratio = 0.0
    
    # 적응형 비율 결정
    if common_ratio < 0.1:
        priority_ratio = 0.8  # 희귀하므로 80% 할당
        logger.info(f"공통 IP 비율 낮음 ({common_ratio:.1%}) → 80% 우선 할당")
    elif common_ratio < 0.5:
        priority_ratio = 0.6  # 중간, 60% 할당
        logger.info(f"공통 IP 비율 중간 ({common_ratio:.1%}) → 60% 우선 할당")
    else:
        priority_ratio = common_ratio  # 다수, 원본 비율 유지
        logger.info(f"공통 IP 비율 높음 ({common_ratio:.1%}) → 원본 비율 유지")
    
    # 샘플링
    n_common = min(int(target_rows_per_log * priority_ratio), len(waf_common))
    n_others = target_rows_per_log - n_common
    
    sampled_common = waf_common.sample(n=n_common, random_state=42) if len(waf_common) > 0 else pd.DataFrame()
    sampled_others = waf_others.sample(n=min(n_others, len(waf_others)), random_state=42) if len(waf_others) > 0 else pd.DataFrame()
    
    return pd.concat([sampled_common, sampled_others], ignore_index=True)

공통 IP가 1%면 80%를 할당한다. 100개 전부를 포함시키고 나머지 자리는 기타 IP로 채운다. 공통 IP가 희귀하다는 건 그만큼 중요한 신호라는 뜻이니까 최대한 보호한다.

 

공통 IP가 50% 이상이면 원본 비율을 그대로 유지한다. 90%면 샘플에서도 90%를 유지한다. AI가 정확한 상황 파악을 할 수 있다.

 

10~50% 사이면 60%를 할당한다. 적당히 중요하니까 약간 과대 표집한다.

 

목표 행 수는 WAF 400개, IPS 400개로 총 800개다. WAF 400개는 23,200 토큰, IPS 400개는 135,200 토큰, 프롬프트 5,000 토큰을 더하면 163,400 토큰이다. 82% 사용률이다. 좀 빡빡하지만 허용 범위 안이다.

4. head()가 만든 재앙

시스템을 처음 구현할 때 Context Builder에 치명적인 버그가 있었다. 코드를 짜면서 무심코 head() 메서드를 썼다.

# 버그 버전
samples = sig_df.head(n_samples).to_dict('records')

로직은 간단했다. 각 시그니처에서 50개씩 뽑는데, head(50)을 쓰면 맨 앞에서 50개를 가져온다. 쉽고 빠르다. 뭐가 문제일까.

 

[ 시간대 편향의 발견 ]

문제를 발견한 건 실제 IPS 로그로 테스트하면서였다. Masscan 공격이 하루 종일 발생한 케이스였다. 오전 8시부터 다음 날 오전 8시까지 24시간 동안 꾸준히 로그가 쌓였다. 총 2,400개 로그였다.

 

분석 결과를 보니 뭔가 이상했다. AI가 "이 공격은 오전 시간대에만 집중되어 있습니다"라고 분석했다. 그런데 실제로는 하루 종일 발생했다. 뭔가 잘못됐다.

 

원인을 찾기 위해 샘플링된 로그의 타임스탬프를 확인했다. 50개가 전부 08:00~09:00 사이였다. 맨 앞 50개만 선택되니까 당연한 결과였다. 나머지 23시간 50분 동안의 로그는 아예 보지도 못했다.

 

더 심각한 문제가 있었다. IP 주소도 편향됐다. 오전 시간대에는 IP A가 공격했고, 오후에는 IP B, 밤에는 IP C가 공격했다. 그런데 샘플에는 IP A만 들어갔다. AI는 "이 공격은 단일 IP에서 발생했습니다"라고 분석했다. 완전히 틀린 결론이었다.

 

실제 공격자는 시간대별로 IP를 바꿔가며 공격하는 경우가 많다. 하나의 IP가 차단되면 다른 IP로 넘어간다. 또는 봇넷을 이용해서 여러 IP를 동시에 쓴다. head()는 이런 패턴을 전혀 포착하지 못한다.

 

[ sample()로의 전환 ]

수정은 간단했다.

# 수정 버전
if len(sig_df) <= samples_per_signature:
    samples = sig_df.to_dict('records')
else:
    samples = sig_df.sample(n=n_samples, random_state=42).to_dict('records')

sample()은 전체 데이터프레임에서 무작위로 n개를 뽑는다. 2,400개 로그에서 50개를 뽑으면 24시간 전체에서 골고루 선택된다. 오전에서 약 8개, 오후에서 약 17개, 밤에서 약 25개 이런 식으로 분산된다.

 

같은 Masscan 로그로 다시 테스트했다. 이번에는 AI가 "이 공격은 24시간 내내 지속되었으며, 3개의 서로 다른 IP에서 발생했습니다"라고 정확하게 분석했다. IP A, B, C가 모두 샘플에 포함됐고, 시간대별 분포도 제대로 파악됐다.

 

상관분석에서도 같은 수정을 했다. 처음에는 공통 IP 로그를 head()로 뽑고 있었다. 이것도 sample()로 바꿨다. 공통 IP 중에서도 골고루 선택되니까 더 대표성 있는 샘플이 됐다.

 

[ random_state=42의 의미 ]

sample() 메서드를 쓸 때마다 random_state=42를 붙였다. 이게 왜 중요한지 설명하겠다.

 

sample()은 무작위로 선택한다. 같은 데이터에서 sample(50)을 두 번 실행하면 다른 50개가 나온다. 이게 통계적으로는 맞다. 어떤 50개를 뽑든 대표성은 비슷하니까.

 

그런데 실무에서는 재현성이 중요하다. 분석가가 AI 리포트를 보고 "이 IP 주소가 위험하네요. 차단해야겠습니다"라고 판단했다고 치자. 상급자가 "정말 확실한가? 다시 한번 확인해보세요"라고 요청한다. 분석가가 같은 로그 파일로 분석을 다시 실행했는데, 이번에는 완전히 다른 샘플이 선택되어 그 IP 주소가 샘플에 포함되지 않았다. AI는 그 IP를 언급조차 하지 않는다.

 

이런 상황이 발생하면 시스템을 신뢰할 수 없다. "AI가 매번 다른 결과를 내놓는데 어떻게 믿을 수 있나"라는 의문이 생긴다. 보안 의사결정은 일관성이 생명이다.

 

random_state=42는 이 문제를 해결한다. 시드값을 고정하면 같은 데이터에서 항상 같은 샘플이 나온다. 오늘 분석하든 내일 분석하든, 개발 서버에서 하든 운영 서버에서 하든 결과가 똑같다.

 

42라는 숫자에는 특별한 의미가 없다. 123을 써도 되고 999를 써도 된다. 중요한 건 고정된 값을 쓴다는 것이다. 다만 42는 데이터 과학 커뮤니티의 관습이다. 더글러스 애덤스의 소설 "은하수를 여행하는 히치하이커를 위한 안내서"에서 나온 "생명, 우주, 그리고 모든 것에 대한 궁극적 질문의 답"이 42다. 프로그래머들이 이걸 재미있어해서 샘플링할 때 42를 자주 쓴다.

5. IPS가 토큰을 6배 먹는 이유

IPS log_analysis에서 목표 행 수를 1,000에서 400으로 대폭 줄였다. WAF는 1,000개를 유지하는데 왜 IPS만 400일까. 이건 토큰 밀도 차이 때문이다.

 

[ 구조화 vs 비구조화 ]

같은 정보를 담고 있어도 표현 방식에 따라 토큰 소비가 극적으로 달라진다. WAF 로그를 먼저 보자.

timestamp,src_ip,dst_ip,attack_type,severity,action
2025-01-15 10:23:45,80.66.76.120,192.168.1.100,SQL Injection,high,block

이건 CSV 형식으로 깔끔하게 정리되어 있다. 헤더에 컬럼명이 한 번만 나오고, 각 행에는 값만 들어간다. "src_ip"라는 단어를 매번 반복하지 않는다. 토큰 효율이 좋다.

 

IPS 로그는 다르다.

date=2025-01-15 time=10:23:45 devname="FortiGate-01" srcip=80.66.76.120 srcport=44928 srcintf="port1" dstip=192.168.1.100 dstport=22 dstintf="port2" sessionid=125847 attack="SSH.Connection.Brute.Force" attackid=18157 severity=critical action=block protocol=tcp msg="SSH brute force attempt detected from known malicious IP" count=147 attackcontext="Multiple failed login attempts within short timeframe" ref="https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-1234"

모든 정보가 한 줄에 key=value 형식으로 들어있다. "srcip=", "dstip=", "severity=" 같은 키워드가 매번 반복된다. 같은 정보를 담고 있지만 표현이 장황하다.

 

토큰 계산을 직접 해보자. WAF 한 행은 대략 이렇다.

2025-01-15 10:23:45,80.66.76.120,192.168.1.100,SQL Injection,high,block

약 70바이트다. 영어와 숫자 위주니까 바이트를 4로 나누면 약 18 토큰이다. 실제로 측정하면 평균 58 토큰 정도 나온다. CSV 구조 정보까지 포함한 값이다.

 

IPS 한 행은 위에 보인 것처럼 약 350바이트다. 한글은 없지만 특수문자와 URL이 많아서 토큰 효율이 떨어진다. 바이트를 3으로 나누면 약 117 토큰이다. 실제로는 338 토큰이 나온다. CSV가 아니라 JSON 형식으로 변환하면서 구조 정보가 더 추가되기 때문이다.

 

결과적으로 IPS 한 행이 WAF 한 행의 약 6배 토큰을 먹는다.

 

[ 400개로 줄인 근거 ]

1,000개를 고집하면 어떻게 될까.

IPS 1,000개 × 338 토큰 = 338,000 토큰
프롬프트 4,000 토큰
총 342,000 토큰 → 200,000 토큰 초과

컨텍스트가 터진다. API 호출이 실패한다.

 

400개로 줄이면 이렇다.

IPS 400개 × 338 토큰 = 135,200 토큰
프롬프트 4,000 토큰
총 139,200 토큰 (70% 사용률)

안전하다. 25% 마진도 확보했다.

 

"400개로 충분한가"라는 질문을 받을 수 있다. 충분하다. Stratified Sampling을 쓰니까 각 시그니처의 비율은 유지된다. 원본에 SQL Injection이 40%, SSH Brute Force가 30%, Port Scan이 20%면, 샘플에서도 그 비율이 유지된다.

 

AI는 패턴 인식이 목적이다. "SQL Injection이 정확히 몇 개인가"보다 "어떤 종류의 공격이 주로 발생하는가"를 파악하는 게 중요하다. 400개면 각 시그니처마다 충분한 샘플을 확보할 수 있다. 시그니처가 10개라면 평균 40개씩 샘플에 들어간다. 40개면 패턴을 파악하기에 충분하다.

 

실제로 400개로 테스트해보니 분석 품질이 1,000개일 때와 거의 차이가 없었다. AI는 "전체적으로 SSH Brute Force가 가장 많았고, 브라질과 러시아에서 주로 들어왔습니다"라고 정확하게 분석했다.

6. 앞으로 할 일들

샘플링 전략을 개선하면서 몇 가지 아이디어가 더 떠올랐다. 지금 당장 구현하지는 않았지만, 나중에 시도해볼 만한 것들이다.

 

[ 동적 샘플링 ]

지금은 목표 행 수가 고정되어 있다. WAF는 2,500개, IPS는 400개. 이건 평균적인 케이스를 기준으로 잡은 값이다. 그런데 실제 로그는 케이스마다 토큰 밀도가 다르다.

 

어떤 날은 공격이 단순해서 로그가 짧다. SQL Injection이 많으면 URL이 짧다. "?id=1' OR '1'='1" 정도만 붙으니까. 이런 날은 2,500개를 넣어도 120,000 토큰밖에 안 나올 수 있다.

 

다른 날은 공격이 복잡해서 로그가 길다. XSS가 많으면 페이로드가 길다. "<script>alert(document.cookie)</script>" 같은 게 수십 글자씩 붙는다. Base64 인코딩된 데이터가 포함되면 더 길어진다. 이런 날은 2,000개만 넣어도 150,000 토큰이 나온다.

고정된 행 수를 쓰면 전자는 토큰을 낭비하고, 후자는 컨텍스트 초과 위험이 있다. 동적 샘플링을 구현하면 이 문제를 해결할 수 있다.

def dynamic_sampling(df, max_tokens=150000, prompt_tokens=5000):
    # 처음 100개를 무작위로 샘플링
    if len(df) > 100:
        sample_100 = df.sample(n=100, random_state=42)
    else:
        sample_100 = df
    
    # 평균 토큰 추정
    csv_str = sample_100.to_csv(index=False)
    bytes_count = len(csv_str.encode('utf-8'))
    tokens_per_byte = 0.33  # 보수적 추정
    avg_tokens = int(bytes_count * tokens_per_byte)
    avg_tokens_per_row = avg_tokens / len(sample_100)
    
    # 최적 행 수 계산
    available_tokens = max_tokens - prompt_tokens
    max_rows = int(available_tokens / avg_tokens_per_row)
    
    # 최소/최대 제한
    max_rows = max(500, min(max_rows, 3000))
    
    return stratified_sample(df, max_rows)

처음 100개로 평균 토큰을 추정한다. 그 값으로 최적 행 수를 역산한다. 이렇게 하면 토큰 예산을 항상 최대한 활용할 수 있다.

주의할 점은 처음 100개가 전체를 대표한다고 가정한다는 것이다. 만약 로그가 시간 순으로 정렬되어 있고 처음 100개가 유독 짧거나 길다면 추정이 틀릴 수 있다. 그래서 무작위로 뽑는 게 중요하다.

 

또 다른 개선은 여러 구간에서 샘플링하는 것이다. 처음 50개, 중간 25개, 끝 25개 이런 식으로 전체에서 골고루 뽑으면 더 정확한 추정이 가능하다.

 

[ 이상치 우선 샘플링 ]

샘플링의 근본적 한계는 희귀한 이벤트를 놓치기 쉽다는 것이다. 무작위로 뽑으면 드문 공격이 샘플에서 빠질 확률이 높다. 이걸 완화하는 방법이 가중 샘플링이다.

 

샘플링하기 전에 각 로그에 이상치 점수를 매긴다. 간단한 휴리스틱으로 계산할 수 있다.

def calculate_anomaly_score(row):
    score = 0
    
    # 희귀한 국가에서 온 공격
    rare_countries = ['North Korea', 'Iran', 'Syria']
    if row['src_country'] in rare_countries:
        score += 3
    
    # 비정상적인 시간대
    hour = pd.to_datetime(row['timestamp']).hour
    if hour < 6 or hour > 22:  # 새벽이나 밤
        score += 2
    
    # 비표준 포트
    if row['dst_port'] not in [80, 443, 22, 3389]:
        score += 2
    
    # 긴 페이로드
    if 'payload' in row and len(str(row['payload'])) > 500:
        score += 2
    
    # 알 수 없는 User-Agent
    if 'user_agent' in row and 'unknown' in str(row['user_agent']).lower():
        score += 1
    
    return score

이상치 점수가 높은 로그는 뭔가 수상하다는 뜻이다. 샘플링할 때 이 점수에 비례해서 선택 확률을 높인다.

def weighted_sampling(df, target_rows):
    df['anomaly_score'] = df.apply(calculate_anomaly_score, axis=1)
    
    # 점수가 높을수록 선택 확률 높음
    weights = df['anomaly_score'] + 1  # 0점도 최소 확률 보장
    
    sampled = df.sample(
        n=target_rows,
        weights=weights,
        random_state=42
    )
    
    return sampled.drop('anomaly_score', axis=1)

이렇게 하면 일반적인 공격은 적게, 의심스러운 공격은 많이 샘플에 들어간다. 희귀한 공격을 놓칠 확률이 줄어든다.

 

단점은 휴리스틱이 잘못되면 편향이 생긴다는 것이다. 예를 들어 "새벽 시간대는 수상하다"는 규칙을 넣었는데, 실제로는 정상적인 배치 작업이 새벽에 돌아서 오탐이 많을 수 있다. 휴리스틱을 신중하게 설계해야 한다.

 

[ 계층적 분석 ]

샘플링의 또 다른 한계는 전체를 한 번밖에 못 본다는 것이다. 25% 샘플링을 하면 나머지 75%는 영원히 분석되지 않는다. 그 안에 중요한 공격이 숨어있을 수 있다.

 

계층적 분석은 이 문제를 완화한다. 먼저 샘플로 1차 분석을 한다. 그 결과를 보고, AI가 "더 조사가 필요하다"고 판단한 부분만 추가로 분석한다.

def hierarchical_analysis(df):
    # 1차: 샘플링해서 전체 개요 파악
    sampled = stratified_sample(df, 2500)
    initial_result = analyze_with_ai(sampled)
    
    # AI 결과에서 의심스러운 시그니처 추출
    suspicious_sigs = initial_result['suspicious_signatures']
    
    # 2차: 의심스러운 시그니처만 전수 분석
    for sig_id in suspicious_sigs:
        sig_df = df[df['sig_id'] == sig_id]
        detailed_result = analyze_with_ai(sig_df)
        
        # 1차 결과에 2차 분석 추가
        initial_result['detailed_analysis'][sig_id] = detailed_result
    
    return initial_result

대부분의 경우 1차 분석으로 끝난다. API 호출 1번. 비용이 저렴하다. 하지만 AI가 "이 시그니처는 뭔가 이상하다"고 판단하면 해당 부분만 추가로 분석한다. API 호출이 2~3번으로 늘지만, 여전히 전체를 여러 번 분석하는 것보다는 훨씬 싸다.

이 방식의 장점은 유연성이다. 일반적인 케이스는 빠르고 저렴하게 처리한다. 복잡한 케이스는 더 많은 리소스를 투입한다. 자동으로 적응한다.

 

단점은 구현이 복잡하다는 것이다. AI가 뭘 의심스럽다고 판단할지 미리 알 수 없다. 결과 파싱도 까다롭다. 또한 AI의 판단이 틀릴 수 있다. 실제로는 중요하지 않은 걸 의심스럽다고 할 수도 있고, 반대로 중요한 걸 놓칠 수도 있다.

7. 재현성이 신뢰를 만든다

random_state=42를 모든 샘플링에서 쓴다고 했는데, 이게 왜 그렇게 중요한지 실제 사례로 설명하겠다.

 

[ 매번 다른 결과가 나온다면 ]

초기 버전에서는 random_state를 안 썼다. 그냥 df.sample(n=2500)만 했다. 테스트할 때는 문제가 없어 보였다. 샘플링도 잘 되고, AI 분석도 잘 나왔다.

 

그런데 베타 테스트 중에 이상한 리포트가 들어왔다. 한 사자가 같은 로그 파일로 분석을 두 번 실행했는데 결과가 달랐다고 했다. 첫 번째 분석에서는 "80.66.76.120 IP가 가장 위험합니다"라고 나왔는데, 두 번째 분석에서는 그 IP가 아예 언급되지 않았다.

로그를 확인해보니 그 IP의 공격이 전체의 0.5%밖에 안 됐다. 10,000개 중에 50개. 2,500개를 샘플링하면 확률적으로 12~13개 정도가 선택된다. 그런데 무작위니까 어떤 때는 20개가 선택되고, 어떤 때는 5개만 선택된다.

 

첫 번째 분석에서는 운 좋게 20개가 선택됐다. AI가 충분한 샘플을 보고 "이 IP는 패턴이 일관되고 공격이 정교합니다"라고 판단했다. 두 번째 분석에서는 5개만 선택됐다. AI가 "샘플이 적어서 확실하지 않습니다"라고 판단해서 리포트에서 제외했다.

이건 분석가 입장에서는 매우 혼란스럽다. "첫 번째가 맞나, 두 번째가 맞나?" 차단 결정을 내릴 수 없다. 상급자에게 보고하기도 애매하다.

 

[ random_state가 해결하는 것 ]

random_state=42를 추가하니까 이 문제가 완전히 사라졌다. 같은 로그 파일로 몇 번을 분석해도 항상 똑같은 결과가 나온다. 분석가가 "이 결과가 확실합니다"라고 자신 있게 말할 수 있다.

 

재현성은 디버깅에도 필수다. 버그 리포트가 들어왔을 때 "같은 입력으로 재현해서 원인을 찾는다"는 게 소프트웨어 개발의 기본이다. 매번 다른 결과가 나오면 버그인지 정상인지 구분조차 못한다.

 

테스트 자동화도 마찬가지다. CI/CD 파이프라인에서 샘플링 로직을 테스트할 때, 예상 결과를 고정할 수 있다. "이 로그 파일을 입력하면 이 샘플이 나와야 한다"는 assertion을 쓸 수 있다. random_state 없이는 이런 테스트를 작성할 수 없다.

 

감사 로그에도 도움이 된다. 나중에 "왜 이런 분석 결과가 나왔지?"를 추적할 때, 정확히 어떤 샘플이 사용됐는지 재현할 수 있다. 문제가 샘플링 때문인지, AI 프롬프트 때문인지, 아니면 실제 로그가 이상했는지 명확하게 구분할 수 있다.

 

[ 42가 아니어도 된다 ]

42라는 숫자 자체에는 의미가 없다. 123을 써도 되고, 2025를 써도 되고, 99999를 써도 된다. 중요한 건 고정된 값을 쓴다는 것이다.

 

다만 42는 데이터 과학 커뮤니티의 관습이다. 더글러스 애덤스의 소설 "은하수를 여행하는 히치하이커를 위한 안내서"에서 슈퍼컴퓨터가 "생명, 우주, 그리고 모든 것에 대한 궁극적 질문의 답"을 750만 년 동안 계산해서 내놓은 게 42다. 프로그래머들이 이걸 재미있어해서 샘플링이나 난수 생성할 때 42를 자주 쓴다.

 

실제로 pandas, scikit-learn, NumPy 공식 문서의 예제 코드를 보면 random_state=42가 많이 나온다. 일종의 밈이 됐다. 우리도 이 관습을 따랐다.

 

만약 여러 샘플링을 한 프로젝트에서 쓴다면 각각 다른 시드값을 쓰는 게 좋을까? 꼭 그럴 필요는 없다. 같은 42를 써도 데이터프레임이 다르면 결과는 다르다. random_state는 "같은 데이터에서 같은 결과"를 보장할 뿐이다. 다른 데이터에서는 당연히 다른 샘플이 나온다.

8. 교훈

보안 로그 샘플링을 설계하면서 배운 가장 큰 교훈은 "하나의 전략으로 모든 걸 해결할 수 없다"는 것이었다. WAF와 IPS는 로그 구조가 다르고, signature 분석과 log_analysis는 목적이 다르고, 상관분석은 또 다른 요구사항이 있었다. 각각에 맞는 전략을 설계해야 했다.

 

통계학 교과서에 나오는 샘플링은 평균을 구하는 데최적화되어 있다. 여론조사나 품질 관리에는 완벽하다. 하지만 보안은 평균이 아니라 극단값이 중요한 게임이다. 딱 한 번 발생한 공격이 가장 위험할 수 있다. 그래서 Stratified Sampling만으로는 부족했고, Context Builder 같은 "약자 보호" 전략이 필요했다.

 

토큰 계산도 생각보다 복잡했다. 같은 정보를 담고 있어도 표현 방식에 따라 토큰 소비가 6배 차이 난다는 걸 배웠다. 구조화된 CSV와 비구조화된 텍스트의 차이였다. AI에게 데이터를 줄 때는 구조가 생명이다.

 

안전 마진의 중요성도 깨달았다. 컨텍스트 윈도우를 100% 다 쓰려고 하면 시스템이 불안정해진다. 75%만 쓰고 25%는 비워두니까 예상치 못한 변동도 흡수하고, 미래의 개선도 가능하고, 무엇보다 마음이 편했다. 엔지니어링에서 여유는 사치가 아니라 필수다.

 

재현성은 신뢰의 기반이었다. random_state=42 하나 추가한 게 전부였지만, 이게 있고 없고가 시스템을 믿을 수 있느냐 없느냐를 결정했다. 같은 입력에 같은 출력이 나온다는 확신이 없으면 누구도 그 시스템으로 중요한 의사결정을 하지 않는다.

 

[ 완벽하지 않다는 걸 인정하기 ]

샘플링은 완벽한 해법이 아니다. 희귀한 공격을 놓칠 위험이 항상 있다. 시간적 연속성이 끊어진다. 전체의 일부만 보니까 맥락을 놓칠 수 있다.

 

하지만 비용 대비 효과를 생각하면 충분히 합리적인 선택이다. 전수 분석을 하려면 API 호출이 10배 늘어나고 비용도 10배가 된다. 그런데 분석 품질이 10배 좋아지는 건 아니다. 아마 2배 정도 좋아질까. 샘플링으로 80점을 받을 수 있는데 10배 비용을 들여서 90점을 받을 필요가 있을까. 대부분의 경우 80점이면 충분하다.

 

중요한 건 한계를 인정하고 명시하는 것이다. 분석 리포트에 "본 분석은 전체 10,000개 로그 중 2,500개(25%)를 샘플링하여 수행되었습니다. 희귀한 공격 패턴은 누락되었을 수 있습니다"라고 적는다. AI에게도 프롬프트에서 "이 데이터는 샘플이므로 과도한 확신은 자제하세요"라고 지시한다.

 

투명성이 신뢰를 만든다. 시스템의 한계를 숨기면 나중에 문제가 생겼을 때 더 큰 불신을 산다. 차라리 처음부터 "이건 샘플이고, 이 정도 정확도입니다"라고 솔직하게 말하는 게 낫다.

 

[ 데이터 특성을 이해하라 ]

가장 중요한 교훈은 데이터를 이해해야 한다는 것이다. WAF와 IPS 로그가 똑같을 거라고 가정했다가 크게 당했다. 실제로 뜯어보니 구조도 다르고, 토큰 밀도도 다르고, 분석 목적도 달랐다.

 

signature 분석과 log_analysis도 다르다. 전자는 "각 공격 타입의 특성 파악"이 목표고, 후자는 "전체적인 흐름과 패턴 파악"이 목표다. 같은 샘플링 전략을 쓸 수 없다.

 

상관분석은 또 다르다. 공통 IP의 비율이 케이스마다 천차만별이다. 고정된 비율을 쓰면 어떤 케이스에서는 과대 표집이 되고, 다른 케이스에서는 과소 표집이 된다. 적응형 전략이 필요했다.

 

코드를 짜기 전에 데이터를 먼저 들여다봐야 한다. 최소 100개, 가능하면 1,000개를 직접 읽어본다. 패턴이 보인다. 이상한 점이 보인다. 그때 비로소 제대로 된 전략을 세울 수 있다.

 

[ 측정하고 모니터링하라 ]

토큰 사용률을 실시간으로 로깅한다.

logger.info(f"원본 로그: {len(df):,}개")
logger.info(f"샘플링: {len(sampled_df):,}개 ({len(sampled_df)/len(df)*100:.1f}%)")
logger.info(f"예상 토큰: {estimated_tokens:,} ({estimated_tokens/200000*100:.1f}%)")

이 정보가 쌓이면 시스템이 실제로 어떻게 동작하는지 알 수 있다. "평균 사용률은 70%구나", "가끔 85%까지 올라가네", "이 시그니처가 특히 토큰을 많이 먹는구나" 같은 인사이트를 얻는다.

 

문제가 생기면 빠르게 발견한다. 어느 날 갑자기 사용률이 95%까지 올라가면 즉시 알람이 온다. 로그를 확인해서 원인을 파악한다. 특정 공격 유형이 급증했는지, 로그 형식이 바뀌었는지, 아니면 코드에 버그가 생겼는지 추적한다.

 

개선 효과도 측정할 수 있다. 동적 샘플링을 도입했을 때 "평균 사용률이 70%에서 73%로 올라갔네. 토큰을 3% 더 활용하게 됐구나"라고 정량화할 수 있다.

 

측정하지 않으면 개선할 수 없다. 감으로 "이게 더 나을 것 같아"라고 하는 게 아니라, 데이터로 "이게 3% 더 효율적이다"라고 말할 수 있어야 한다.

9. 실무에 적용하려면

이 글을 읽고 "우리 프로젝트에도 비슷한 샘플링이 필요한데"라고 생각하는 사람이 있을 것이다. 몇 가지 조언을 하겠다.

 

[ 첫째, 토큰 계산부터 정확하게 ]

샘플링 전략을 세우기 전에 토큰 소비를 정확하게 측정해야 한다. 대충 어림잡으면 안 된다. 실제 데이터로 테스트한다.

100개 로그를 뽑는다. CSV나 JSON으로 변환한다. 바이트 수를 센다. 실제로 API에 보내서 토큰 수를 확인한다. 평균을 계산한다. 표준편차도 계산한다. 변동성이 크면 안전 마진을 더 크게 잡아야 한다.

 

영어와 한글의 토큰 비율이 다르다는 것도 기억한다. 영어는 바이트를 4로 나누면 대략 맞다. 한글은 3으로 나눈다. 특수문자나 URL이 많으면 더 보수적으로 2.5로 나눈다.

 

프롬프트 토큰도 잊지 말아야 한다. 시스템 지시사항, 예시, 포맷 설명이 다 토큰을 먹는다. 프롬프트가 길면 5,000~10,000 토큰을 예약해야 한다.

 

출력 공간도 확보한다. AI의 응답이 얼마나 길지 예측해서 그만큼 비워둔다. JSON 응답이면 구조 정보까지 포함해서 계산한다.

 

[ 둘째, 데이터 특성을 파악하라 ]

샘플링은 데이터마다 다르다. 로그 데이터와 텍스트 데이터는 다르고, 시계열 데이터와 정적 데이터는 다르다. 하나의 전략으로 모든 걸 해결하려고 하지 마라.

 

먼저 데이터를 직접 본다. 최소 100개, 가능하면 1,000개를 읽는다. 스프레드시트로 열어서 스크롤하며 훑어본다. 패턴이 보이기 시작한다.

 

질문을 던진다. "이 데이터에서 뭐가 중요한가?", "평균을 구하는 게 목표인가, 극단값을 찾는 게 목표인가?", "희귀한 케이스를 놓치면 안 되는가?", "시간 순서가 중요한가?"

 

답에 따라 전략을 선택한다. 평균이 중요하면 Stratified Sampling, 희귀 케이스가 중요하면 Context Builder나 가중 샘플링, 시간이 중요하면 시간 윈도우 분할을 고려한다.

 

[ 셋째, 안전 마진을 확보하라 ]

컨텍스트 윈도우를 100% 다 쓰려고 하지 마라. 최소 20%, 가능하면 25%는 비워둔다. 이게 사치처럼 보일 수 있지만 필수다.

AI의 출력 공간이 필요하다. 데이터의 변동성을 흡수해야 한다. 미래의 개선을 위한 여지를 남겨야 한다. 예상치 못한 에러가 생겼을 때 버퍼가 있어야 한다.

 

초기에는 더 보수적으로 잡는다. 70% 정도만 쓴다. 운영하면서 안정적이면 서서히 늘린다. 75%, 80%까지 올릴 수 있다. 하지만 절대 90%를 넘기지 않는다. 90%를 넘어가면 언젠가는 터진다.

 

[ 넷째, 재현성을 보장하라 ]

모든 무작위 샘플링에 random_state를 쓴다. 이건 선택이 아니라 필수다. 프로덕션 시스템에서 매번 다른 결과가 나오면 안 된다.

 

시드값은 뭘 써도 상관없다. 42가 관습이지만, 프로젝트 시작 연도(2025)나 팀 번호(123) 같은 걸 써도 된다. 중요한 건 고정된 값을 쓴다는 것이다.

 

코드 리뷰에서 확인한다. sample(), choice(), shuffle() 같은 함수를 쓸 때 random_state나 seed 파라미터가 있는지 체크한다. 없으면 추가하라고 요청한다.

 

테스트 코드를 작성한다. 같은 입력으로 여러 번 실행해서 항상 같은 결과가 나오는지 확인한다. 재현성이 깨지면 테스트가 실패한다.

 

[ 다섯째, 투명하게 공개하라 ]

샘플링을 사용한다는 사실을 숨기지 마라. 사용자에게 명확하게 알린다. UI에 표시한다. "본 분석은 전체 10,000개 중 2,500개(25%)를 샘플링하여 수행되었습니다"

 

AI에게도 알린다. 프롬프트에 "주의: 이 데이터는 샘플입니다. 전체를 대표하지 않을 수 있습니다. 분석 시 이 점을 고려하세요"라고 쓴다. AI가 더 신중하게 결론을 내린다.

 

한계를 명시한다. "희귀한 패턴은 누락될 수 있습니다", "시간적 연속성이 손실될 수 있습니다" 같은 경고를 리포트에 포함시킨다.

 

투명성이 신뢰를 만든다. 한계를 솔직하게 인정하는 시스템을 사람들은 더 믿는다. 완벽한 척하다가 문제가 생기면 신뢰가 무너진다.

 

[ 여섯째, 측정하고 개선하라 ]

처음부터 완벽한 샘플링 전략을 세울 수 없다. 운영하면서 데이터를 모으고, 문제를 발견하고, 개선한다.

 

토큰 사용률을 로깅한다. 샘플 크기를 기록한다. 분석 품질 피드백을 받는다. 이 데이터가 쌓이면 패턴이 보인다.

 

정기적으로 리뷰한다. 한 달에 한 번, 분기에 한 번 샘플링 전략을 점검한다. "평균 사용률은 적절한가?", "놓친 중요한 케이스가 있었나?", "사용자 불만은 없는가?"

 

작은 개선을 계속한다. 목표 행 수를 10% 올린다. 새로운 휴리스틱을 추가한다. 적응형 로직을 개선한다. 큰 변화보다 작은 개선을 자주 하는 게 안전하다.

 

A/B 테스트를 한다. 새로운 전략을 일부 사용자에게만 적용한다. 기존 전략과 비교한다. 더 나으면 전체에 적용한다. 데이터로 의사결정한다.

 

10. 마치며

샘플링 전략을 설계하고 구현하면서 가장 많이 배운 건 "완벽한 해법은 없다"는 사실이었다. 여론조사처럼 깔끔하게 통계학 공식을 적용할 수 있는 문제가 아니었다. 보안 로그는 평균이 아니라 극단값이 중요하고, 희귀한 공격 하나가 전체 시스템을 위협할 수 있다.

 

그래서 세 가지 서로 다른 전략을 만들었다. WAF에는 Stratified Sampling으로 전체 분포를 유지했고, IPS signature 분석에는 Context Builder로 희귀한 공격도 충분히 볼 수 있게 했고, 상관분석에는 Smart Sampling으로 데이터 상황에 맞춰 유연하게 대응했다. 각각의 목적이 달랐고, 각각에 맞는 전략이 필요했다.

 

head() 대신 sample()을 쓴 것, random_state=42로 재현성을 보장한 것, 75% 사용률로 안전 마진을 확보한 것. 이런 작은 결정들이 모여서 안정적으로 돌아가는 시스템을 만들었다.

 

지금도 개선할 부분이 많다. 동적 샘플링을 도입하면 토큰 예산을 더 효율적으로 쓸 수 있을 것 같다. 이상치 우선 샘플링을 추가하면 희귀한 공격을 놓칠 확률을 더 줄일 수 있다. 계층적 분석을 구현하면 중요한 케이스만 깊게 파볼 수 있다.

 

하지만 일단은 지금 전략으로 충분히 돌아간다. 비용도 통제되고, 분석 품질도 괜찮다. 더 개선할 수는 있지만, 지금 당장 급한 건 아니다. 실무에서는 80점짜리 해법을 빨리 만들어서 검증하는 게, 100점짜리를 몇 달 고민하는 것보다 낫다.

 

당신이 비슷한 문제를 만난다면 이 글이 참고가 되길 바란다. 정답을 알려주는 건 아니다. 데이터마다, 상황마다 다르니까. 하지만 어떤 질문을 던져야 하는지, 어떤 부분을 측정해야 하는지, 어떤 트레이드오프가 있는지는 보여줄 수 있다. 나머지는 직접 데이터를 들여다보고, 테스트하고, 개선하면서 찾아가는 수밖에 없다.

BELATED ARTICLES

more