방송 채팅 데이터로 하이라이트를 찾아내는 도구를 만들면서 부딪힌 문제들과 해결 과정, 그리고 한 번의 틀린 가설에 대한 기록이다.
뭘 만들었나
치지직(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에 몰린 값들"은 그 시간대 채팅 리듬과 초 단위 절단의 경계 효과였을 뿐이다.
교훈 세 가지:
- 패턴이 그럴듯할수록 위험하다. "값이 20 근처에 몰린다"는 관찰에서 "20 제한이 있다"로 건너뛰는 건 상관관계→인과 비약의 전형이다.
- 반증은 사용자에게서 온다. 화면을 실제로 보고 있는 사람의 "내 눈엔 다르게 보이는데?"는 어떤 로그보다 강한 신호였다.
- 검증 실험은 가설이 틀렸을 때도 수확을 남긴다. 다중 연결 실험은 가설을 죽였지만 "모든 클라이언트가 동일 스트림을 받는다"는 유용한 사실을 확정해줬다.
마치며 — 실시간 파이프라인에서 배운 것
- 실시간 경로에 디스크를 두지 마라. 내구성(저장)과 반응성(표시)은 다른 경로여야 한다. 큐 하나로 분리된다.
- 매초 전체 재계산은 시한폭탄이다. 데이터가 쌓이는 시스템의 주기 작업은 반드시 증분으로.
- 이벤트당 부수 작업(로그·브로드캐스트)은 폭주 시 흉기가 된다. 스로틀과 dedupe는 사치가 아니라 기본.
- 지표의 분자·분모는 같은 기간을 봐야 한다.
- 핫 리로드는 인메모리 데이터의 적이다. 라이브 데이터 도구는 재시작 복원(디스크 리플레이)을 처음부터 고려할 것.
- 가설은 실험으로만 승격된다. 그리고 실험 표본이 부족하면 실험 결과도 틀린다. 두 번 틀리고 배웠다.
수집기는 지금도 잘 돌고 있다. 다음 과제는 서버 재시작 시 인메모리 분석을 세션 파일에서 복원하는 것, 그리고 채팅 폭발 구간과 실제 경기 이벤트(킬/오브젝트)의 상관을 자동으로 붙이는 것이다.