FireDrago

[치즈픽] 치지직 채팅 화력분석 알고리즘 개선기 본문

프로젝트

[치즈픽] 치지직 채팅 화력분석 알고리즘 개선기

화이용 2026. 3. 19. 18:55

https://cheesepick.me/

 

치즈픽 - Cheese-Pick

 

cheesepick.me

치지직의 상위 N개의 방송을 동적으로 인식하고, 자동으로 하이라이트 타임스탬프를 찍는 '치즈픽' 서비스를 배포했다.

스트리머들의 채팅화력을 통해 하이라이트를 추출하는 알고리즘을 고도화하는 과정은 쉽지 않았다.

실제 스트리머의 채팅화력과 노트북 lm을 통한 데이터 분석을 통해 알고리즘을 고도화한 과정을 정리했다.

 

절대값으로 하이라이트 판정 (V1)

스트리머 측정시간 (UTC) 오프셋 화력 (채팅 수) 판정결과
시라유키 히나 2026-02-15 16:40:21 24184334 17 PEAK
시라유키 히나 2026-02-15 16:40:24 24187334 32 PEAK
시라유키 히나 2026-02-15 16:40:27 24190334 16 PEAK
시라유키 히나 2026-02-15 16:40:30 24193334 53 PEAK
시라유키 히나 2026-02-15 16:40:33 24196334 45 PEAK
시라유키 히나 2026-02-15 16:40:36 24199334 42 PEAK
시라유키 히나 2026-02-15 16:40:39 24202334 37 PEAK
시라유키 히나 2026-02-15 16:40:42 24205334 44 PEAK
스트리머 측정시간 (UTC) 평균 화력 (채팅 수) 화력 (채팅 수) 판정결과
살구 2026-02-15 16:39:39 1 6 NORMAL
살구 2026-02-15 18:42:42 1 1 NORMAL
김재범1 2026-02-15 23:49:00 2 1 NORMAL
김재범1 2026-02-15 23:50:00 2 11 NORMAL

처음 알고리즘을 작성할때는 단순히 초당 15개가 넘으면 하이라이트로 인식하도록 단순하게 구현했다.

시험삼아 하루동안 분석엔진을 돌린결과,

  - 대형스트리머는 30초동안 8개가 넘는 하이라이트가 생성되어, 하이라이트가 중복 생성되고, 일상적인 소통 구간까지 오탐지되었다.

  - 소형스트리머는 평소 채팅이 1~2개인 방에서는 11개의 채팅이 발생했음에도 하이라이트가 생성되지 않았다.

오탐지중복생성 두가지 문제가 발생했다.

 

Z-Score의 도입 (V2)

이때 살짝 멘붕이 왔다. 생각보다 하이라이트 탐지를 위해 고려해야 할것이 많았기 때문이다.

문제를 해결하기 위해 절대값을 주지 않고 '평균대비 얼마나 비정상적으로 튀어 올랐는가?'를 측정하는 방법이 필요했다.

실측 데이터를 바탕으로 노트북 lm으로 분석을 맡기고, gemini를 통해 '표준편차'와 z-score를 도입하기로 결정했다.

Z-Score란

Z-Score는 현재 값이 평균대비 어느정도 튀는 값인지를 나타내는 지표이다.

공식은 (현재 값 - 평균) / 표준편차 로 계산한다.

2분동안의 평균표준편차를 계산하는 로직을 통해 z-score 3.0 이상 (상위 0.13%)의 화력을 하이라이트로 탐지하도록 설정했다.

z-score의 기준값은 노트북 lm의 분석을 통해 도출했다.

Z-Score의 맹점

Z-Score 3.0(상위 0.13%)이라는 통계적 기준을 도입하면 모든 문제가 해결될 줄 알았다. 하지만 실제 라이브 방송의 채팅 트래픽은 정규분포처럼 예쁘게 흘러가지 않았다. 하루 동안 V2 엔진을 모니터링한 결과, 전혀 예상치 못한 3가지 결함이 또 발견되었다.

 

1. 기준선 붕괴

Z-Score 공식은 (현재 값 - 평균) / 표준편차 이다. 여기서 치명적인 수학적 오류가 발생했다. 평소 채팅이 아예 없는 소규모 방송이나, 대형 스트리머의 시청자가 빠진 새벽 시간대에는 평균과 표준편차가 0에 한없이 수렴하게 된다. 결과적으로 분모가 0에 가까워지니, 누군가 소소하게 인사만 해서 채팅이 2~3개만 올라와도 Z-Score가 20.0, 30.0으로 폭발해 버리는 현상이 발생했다.

스트리머 측정시간 (UTC) 2분 평균 채팅량 화력 (채팅 수) 판정결과
쾅준 2026-03-01 19:35:00 1 2 PEAK (Z-Score :20)
살구 2026-03-01 18:50:42 1 3 PEAK (Z-Score: 30)

 

2. 평균의 함정

인터넷 방송의 레전드 장면은 갑자기 터지는 경우가 많다. 갑자기 게임에서 웃기게 죽는다거나, 예상치못한 웃긴 장면이 나온다. 그리고 채팅화력은 그때 급격하게 터지다가, 스트리머의 찰진 리액션에 의해 2차로 다시 터지는 경우가 많다. 문제는 앞서 발생한 하이라이트가 평균값을 극단적으로 높인다는 점이다. 평균이 이미 높아져 버리니, 진짜 클라이맥스 구간이나, 재밌는 리액션이 Z-Score 임계치 (3.0)을 넘지 못하고 누락되는 결과가 나왔다.

스트리머 측정시간 화력 (채팅 수) 방송 흐름 판정 결과
탬탬버린 2026-03-02 11:41:23 29 어이 없는 데스 발생 PEAK
탬탬버린 2026-03-02 11:41:35 37 스트리머 억울함 리액션 시작 PEAK
탬탬버린 2026-03-02 11:41:38 41 시청자들의 2차 반응 NORMAL
탬탬버린 2026-03-02 11:41:41 61 스트리머의 하이라이트 리액션 NORMAL

 

3. 파편화 현상

대기업 방송의 경우, 스트리머가 재밌는 리액션을 하면 시청자들의 웃음 여진이 30초에서 1분 가까이 이어진다. Z-Score만 적용하게 되면 이 1분간의 여진을 개별 하이라이트로 쪼개서 인식하게 된다. 이는 V1에서도 확인할 수 있는 문제였다.

 

통계의 맹점 해결하기 (V3)

Z-Score 일괄 적용만으로는 라이브 방송을 모두 제어할 수 없음을 알게되었다.

통계적 방법외에 추가적인 방법으로 하이라이트를 보다 정확하게 포착하도록 개선했다.

 

1. 동적 체급 분류

최근 60분 채팅 평균을 기준으로 방송의 체급을 실시간 분류하여 기준을 다르게 적용했다.

  • 대기업: 변화가 빠르므로 1분 윈도우 적용 / Z-Score 임계치 3.5 (덜 민감하게)
  • 중소규모: 모수가 적으므로 2분 윈도우 적용 / Z-Score 임계치 4.0 (기준선 붕괴 방어)

2. 절대 기준 설정및 스케줄러 설정

통계의 허점을 막기 위한 물리적 방어막을 추가했다.

  • 최소 화력 하한선: 아무리 트래픽이 없어도 '최소 5개 이상'일 때만 탐지하도록 커트라인 설정.
  • 배치(Batch) 스케줄러 정제: 새벽 시간대 노이즈 발생을 대비해 실시간 노출은 화력 순 Top 6로 제한하고, 24시간 후 스케줄러가 최고 화력 Top 10만 남기고 DB에서 일괄 Drop 처리하여 인프라 비용 절감.

3. 10초 갭 윈도우 도입

평균과 표준편차를 계산할 때 '직전 10초'의 데이터를 고의로 배제했다. 이를 통해 방금 터진 1차 리액션이 평균 기준선을 오염시켜 진짜 하이라이트를 누락시키는 현상을 해결했다.

 

4. 파편화 방지 쿨타임 도입

하나의 하이라이트가 여러개로 쪼개지는 문제를 해결했다.

  • 60초 쿨다운 세션 병합: 피크 감지 후 60초 내의 이벤트는 Caffeine Cache를 이용하여 하나의 '세션 ID'로 묶는다.
  • -20초 / +5초 버퍼링: 실제 채팅 딜레이를 계산하여, 채팅 피크 기준 -20초 전으로 시작점을 당겨 맥락을 알 수 있게하고, 피크 직후 +5초 만에 영상을 끊어내어 타임라인을 완성했다.

 

하이라이트 분석 핵심 도메인 코드

@Slf4j
public class ChatFirepowerDetector implements HighlightDetector {

    private static final int MIN_DATA_POINTS_FOR_ANALYSIS = 10;
    private static final double STD_DEV_THRESHOLD = 0.0001;

    @Override
    public DetectionResult detect(String streamId, List<Long> deltas, StreamTierInfo tierInfo) {
        if (isDataInsufficient(deltas)) {
            return DetectionResult.waiting();
        }

        long currentDelta = deltas.getLast();

        // 1차 필터링: 동적 1% 임계치 미만인 경우 분석 제외
        if (currentDelta < tierInfo.minFirepowerCutoff()) {
            return new DetectionResult(ChatFirepowerStatus.NORMAL, currentDelta);
        }

        return runZScoreAnalysis(streamId, deltas, tierInfo, currentDelta);
    }

    private boolean isDataInsufficient(List<Long> deltas) {
        return deltas == null || deltas.size() < MIN_DATA_POINTS_FOR_ANALYSIS;
    }

    private DetectionResult runZScoreAnalysis(String streamId, List<Long> deltas, StreamTierInfo tierInfo, long currentDelta) {
        // 현재 피크가 평균을 오염시키지 않도록 최근 틱들을 배제한 과거 데이터 추출
        int exclusionCount = tierInfo.maskingExclusionTicks() + 1;
        if (deltas.size() <= exclusionCount) {
            return new DetectionResult(ChatFirepowerStatus.NORMAL, currentDelta);
        }

        List<Long> history = deltas.subList(0, deltas.size() - exclusionCount);
        double mean = history.stream().mapToLong(Long::valueOf).average().orElse(0.0);
        double stdDev = calculateStandardDeviation(history, mean);

        // 표준편차가 0에 가까운 경우 단순 비교로 대체 (Divide by Zero 방지)
        if (stdDev < STD_DEV_THRESHOLD) {
            ChatFirepowerStatus status = (currentDelta > mean) ? ChatFirepowerStatus.PEAK : ChatFirepowerStatus.NORMAL;
            return new DetectionResult(status, currentDelta);
        }

        double zScore = (currentDelta - mean) / stdDev;
        ChatFirepowerStatus status = (zScore > tierInfo.zScoreThreshold()) ? ChatFirepowerStatus.PEAK : ChatFirepowerStatus.NORMAL;

        if (status == ChatFirepowerStatus.PEAK) {
            logPeakDetection(streamId, tierInfo, zScore, currentDelta, mean, stdDev);
        }

        return new DetectionResult(status, currentDelta);
    }

    private double calculateStandardDeviation(List<Long> data, double mean) {
        double variance = data.stream()
            .mapToDouble(v -> Math.pow(v - mean, 2))
            .average()
            .orElse(0.0);
        return Math.sqrt(variance);
    }

    private void logPeakDetection(String streamId, StreamTierInfo tierInfo, double zScore, long currentDelta, double mean, double stdDev) {
        log.info("[PEAK 감지] Stream: {}, 체급: {}, Z-Score: {} (허들: {}), 화력: {} (1%컷: {}), 평균: {}, 편차: {}",
            streamId, tierInfo.tier().name(), String.format("%.2f", zScore), tierInfo.zScoreThreshold(),
            currentDelta, tierInfo.minFirepowerCutoff(), String.format("%.2f", mean), String.format("%.2f", stdDev));
    }
}