방송 채팅 데이터로 하이라이트를 찾아내는 도구를 만들면서 부딪힌 문제들과 해결 과정, 그리고 한 번의 틀린 가설에 대한 기록이다.

뭘 만들었나

치지직(CHZZK)과 SOOP의 라이브 채팅을 동시에 수집해서 실시간으로 분석하는 대시보드다. 구성은 단순하다:

  • 수집: 각 플랫폼의 채팅 웹소켓에 붙는 어댑터 (치지직 비공식/공식, SOOP)
  • 서버: Fastify + socket.io — 수집한 채팅을 집계하고 대시보드에 푸시
  • 대시보드: React — 초 단위 윈도우 막대그래프, 참여율, 하이라이트 후보 감지, 키워드 추적
  • 부가: OBS용 채팅 오버레이, 세션 녹화(JSONL), CSV/JSON 내보내기

목적은 "채팅이 폭발한 순간 = 하이라이트"라는 가설로 방송 다시보기 편집 지점을 자동으로 찾는 것. e스포츠 중계(이 글의 테스트 대상은 T1 경기 시청 방송)처럼 채팅 리듬이 뚜렷한 방송에서 특히 잘 맞는다.


문제 1. 참여율을 계산할 수 없다

채팅 수는 세는데 "시청자 대비 몇 %가 채팅을 치는가"를 알 수 없었다. 채팅 웹소켓은 시청자 수를 알려주지 않기 때문이다.

해결: 각 플랫폼의 라이브 상태 REST API를 주기적으로 폴링해서 시청자 수 샘플을 쌓았다. 채팅은 웹소켓 푸시, 시청자 수는 폴링 — 두 데이터의 성격이 다르다는 걸 받아들이는 게 시작이었다. 폴링 주기는 처음 30초로 잡았다가 10초로 줄였다. 1초로 줄이지 않은 이유는 명확하다. 채팅과 달리 시청자 수는 플랫폼이 "요청당 응답"으로 주는 데이터라, 초당 폴링은 차단당할 위험만 키우고 얻는 건 없다.

문제 2. 막대그래프가 뚱뚱하다

수집 초반에는 데이터가 적어서 막대 몇 개가 차트 전체 폭을 나눠 가졌다. 막대 하나가 화면의 1/3을 차지하는, 그래프라 부르기 민망한 상태.

해결: 막대 폭을 고정(7px 슬롯)하고, 채팅이 없던 시간 구간도 빈 윈도우로 채워서 시간축을 균일하게 만들었다. 차트는 가로 스크롤 + 가상 렌더링(보이는 구간 ± 버퍼만 그림)으로 수만 개 슬롯도 감당한다. "데이터 개수에 맞춰 폭을 늘리는" 기본 동작을 버리고 "시간에 맞춰 폭을 고정"하는 것 — 시계열 차트의 기본기였다.

문제 3. 매초 20만 건을 다시 계산하고 있었다

서버는 1초마다 대시보드에 요약을 푸시하는데, 그 요약을 만들 때마다 전체 레코드를 복사 → 정렬 → 윈도우 재구성 → 모든 메시지 재토큰화하고 있었다. 방송 3시간이면 수만 건, 상한은 20만 건. 매초 O(전체)를 태우는 구조라 방송이 길어질수록 서버가 뜨거워진다.

해결: 증분 집계로 다시 썼다.

  • 메시지가 들어올 때 윈도우 버킷·단어 카운터·채팅러 카운터를 그 자리에서 갱신
  • 요약 생성 시에는 "변경된(dirty) 윈도우"만 다시 물질화(materialize)하고 나머지는 캐시 재사용
  • 윈도우 크기(1/3/5/10초)별 버킷을 각각 유지해서 크기 전환 시에도 재계산 1회로 끝
  • 상위 단어/채팅러 랭킹처럼 "큰 Map 전체 정렬"이 필요한 것만 1초 캐시

효과는 매초 O(전체) → O(신규 메시지). 이 리라이트 덕분에 나중에 갱신 주기를 1초에서 0.1초로 줄일 때도 서버 부하 걱정이 없었다. 기존 배치 함수는 그대로 두고 증분 클래스가 같은 결과를 내는지 동등성 테스트로 검증했다 — 리팩토링의 안전망으로 이만한 게 없다.

문제 4. 같은 데이터를 두 번 받고 있었다

대시보드가 소켓으로 실시간 요약을 받으면서, 동시에 1초마다 REST로 같은 요약을 폴링하고 있었다. 서버는 캐시를 5초 들고 있는 엔드포인트를 클라이언트가 1초마다 두드리는 낭비도 있었다.

해결: 소켓을 주 채널로 정리했다. 기본 상태(5초 윈도우)에서는 폴링 0회. 소켓 페이로드도 전체 윈도우 대신 최근 24개만 + partial 플래그로 줄이고, 클라이언트가 기존 차트 데이터에 병합하게 했다. 접속·리셋 시에만 전체 페이로드를 보낸다. "실시간 = 소켓, 무결성 = 최초 1회 전체 로드"로 역할을 나누는 패턴.

문제 5. "동시 연결하면 채팅이 안 들어와요" — 그런데 로그가 없다

치지직과 SOOP을 동시에 연결하면 뭔가 이상하다는 제보. 그런데 진단 로그를 열어보니 100개 버퍼 전체가 "치지직 연결되었습니다" success 로그로 도배돼 있었다. SOOP이 언제 왜 끊겼는지 알 수 있는 기록이 전부 밀려나 사라진 상태.

원인은 어댑터가 채팅 메시지 1건마다 상태 브로드캐스트를 쏘고 있던 것. lastEventAt 갱신용이었는데, 이게 메시지당 소켓 emit 2개(상태 2종) + 진단 로그 1개를 만들었다. 채팅이 초당 20건이면 로그 버퍼 100개가 5초 만에 증발한다.

해결은 두 겹으로:

  • 어댑터의 이벤트성 상태 브로드캐스트를 5초에 1번으로 스로틀
  • 로그는 상태·메시지가 실제로 바뀔 때만 기록 (dedupe) — 연결→끊김→재연결 같은 진짜 전환은 다 남는다

그리고 타임라인 데이터로 검증해보니 정작 동시 수신 자체는 멀쩡했다 — 7/4 수정 이후 치지직·SOOP을 1ms 차이로 동시에 연결한 세션에서 첫 90초간 치지직 432건 / SOOP 380건이 나란히 들어왔다. "안 들어온다"는 체감의 진짜 원인 후보는 따로 있었다. 아래로.

문제 6. 채팅이 폭발하면 그래프가 안 움직인다

한타가 터져서 채팅창이 뒤집어지는데 대시보드 그래프는 조용하다 — 사용자가 직접 목격한 현상.

경로를 추적해보니 이랬다:

웹소켓 수신 → await 디스크 저장(appendFile) → 분석 반영 → 소켓 푸시
                    ↑ 여기가 관문

메시지 1건마다 mkdir + appendFile을 await한 뒤에야 분석에 반영되는 구조. 평소엔 티가 안 나지만 폭주 시에는 디스크 쓰기 수백 건이 fs 스레드풀(기본 4개)에 몰리면서 수 초씩 밀리고, 분석 반영이 통째로 그 뒤에 줄을 선다. 덤으로 동시 실행되는 쓰기들이 시퀀스 번호를 경합하는 문제까지 있었다.

해결: 실시간 경로와 내구성 경로를 분리했다.

  • 인메모리 상태(시퀀스, 세션 카운트, 분석)는 동기로 즉시 갱신
  • 디스크 쓰기는 프로미스 체인 큐로 직렬화해서 백그라운드로
  • 읽기 API는 읽기 전에 큐를 flush해서 일관성 유지

폭주가 와도 그래프는 0.1초 안에 반영되고, 디스크는 자기 속도대로 따라온다. 참고로 "아예 반영이 안 됐다"는 체감에는 공범이 하나 더 있었다 — 개발 중 tsx watch가 파일 저장마다 서버를 재시작하면서 인메모리 분석 데이터를 통째로 날린 것. 라이브 데이터를 만지는 도구를 개발할 때는 핫 리로드가 데이터 소실 버튼이 된다는 걸 뼈저리게 배웠다.

문제 7. 참여율 수식이 이상하다

사용자가 수식 자체를 의심했고, 까보니 맞는 지적이었다.

  • (기존) 참여율 = 최근 10분간 채팅 친 유니크 닉네임 ÷ 현재 순간 시청자 수
  • 분자는 10분 누적인데 분모는 순간값 — 기간이 어긋난다. 시청자가 빠지면 참여율이 뻥튀기되고, 급증하면 꺼져 보이는 왜곡.

해결: 분모를 같은 기간의 평균 시청자 수로 교체하고, 창을 5분으로 줄였다.

참여율(5분) = 최근 5분 유니크 채팅러 ÷ 같은 5분의 평균 시청자

시청자 샘플이 10초마다 쌓이니 5분이면 샘플 30개 — 평균으로 쓰기에 충분하다. 지표를 만들 때 분자와 분모의 관측 기간을 일치시키는 것은 기본인데, 처음 구현할 땐 놓치기 쉽다.

문제 8. "초당 20개 제한"이라는 틀린 가설

이 프로젝트에서 가장 기록할 가치가 있는 부분은 성공이 아니라 틀린 가설의 궤적이다.

폭주 구간 데이터를 보다가 치지직 수신량이 초당 14~19개 근처에 몰려 있는 걸 발견했다. "치지직이 대형 채널의 공개 연결에 채팅을 샘플링해서 초당 ~20개로 잘라 보낸다"는 가설을 세웠고, 꽤 그럴듯해서 사실처럼 말하기 시작했다.

사용자가 반박했다. "실제 채팅창에서는 초당 20개 넘게 보이는데?"

그래서 검증 실험을 했다.

실험 1 — 샘플링이면 연결마다 다른 표본을 받을 것이다. 같은 채널에 웹소켓 3개를 열고 60초간 각 연결이 받은 메시지 ID를 비교했다. 결과: 3개 연결이 100% 동일한 메시지를 받았다. 샘플링이 아니다. 덤으로 "다중 연결로 수신률을 올린다"는 우회책도 무의미하다는 게 확정됐다.

실험 2 — 그럼 진짜 상한이 있는가. 20분간 초당 수신량을 기록하며 한타를 기다렸다. peak 40이 찍혔다. 나는 이걸 "초당 20으로 페이싱하다가 밀린 걸 40개 배치로 방출한다"고 또 그럴듯하게 해석했다.

사용자가 또 반박했다. "80개 찍히는 구간도 있는데?"

실서버 데이터를 다시 조회했다. 1초 윈도우 최대치: 치지직 30, SOOP 61. 상한 같은 건 없었다. "정확히 20에 몰린 값들"은 그 시간대 채팅 리듬과 초 단위 절단의 경계 효과였을 뿐이다.

교훈 세 가지:

  1. 패턴이 그럴듯할수록 위험하다. "값이 20 근처에 몰린다"는 관찰에서 "20 제한이 있다"로 건너뛰는 건 상관관계→인과 비약의 전형이다.
  2. 반증은 사용자에게서 온다. 화면을 실제로 보고 있는 사람의 "내 눈엔 다르게 보이는데?"는 어떤 로그보다 강한 신호였다.
  3. 검증 실험은 가설이 틀렸을 때도 수확을 남긴다. 다중 연결 실험은 가설을 죽였지만 "모든 클라이언트가 동일 스트림을 받는다"는 유용한 사실을 확정해줬다.

마치며 — 실시간 파이프라인에서 배운 것

  1. 실시간 경로에 디스크를 두지 마라. 내구성(저장)과 반응성(표시)은 다른 경로여야 한다. 큐 하나로 분리된다.
  2. 매초 전체 재계산은 시한폭탄이다. 데이터가 쌓이는 시스템의 주기 작업은 반드시 증분으로.
  3. 이벤트당 부수 작업(로그·브로드캐스트)은 폭주 시 흉기가 된다. 스로틀과 dedupe는 사치가 아니라 기본.
  4. 지표의 분자·분모는 같은 기간을 봐야 한다.
  5. 핫 리로드는 인메모리 데이터의 적이다. 라이브 데이터 도구는 재시작 복원(디스크 리플레이)을 처음부터 고려할 것.
  6. 가설은 실험으로만 승격된다. 그리고 실험 표본이 부족하면 실험 결과도 틀린다. 두 번 틀리고 배웠다.

수집기는 지금도 잘 돌고 있다. 다음 과제는 서버 재시작 시 인메모리 분석을 세션 파일에서 복원하는 것, 그리고 채팅 폭발 구간과 실제 경기 이벤트(킬/오브젝트)의 상관을 자동으로 붙이는 것이다.