방송에서 가장 많이 나온 단어는? : NLP를 활용한 채팅 워드클라우드
CEO & Fullstack Engineer
1. 이 글의 범위
SOOP·치지직·씨미의 방송을 찾고 채팅을 정규화해 PostgreSQL·Redis·ClickHouse로 나누는 과정은 멜로밍 랭킹 - SOOP, 치지직, 씨미 채팅 수집 및 처리 아키텍처에서 다뤘습니다. 그 글에서 워드클라우드는 한 문단이었습니다. 실제로는 그 한 문단이 별도 서비스 하나 분량이었습니다.
이 글은 수집된 채팅 원문에서 단어를 뽑아 화면에 그리기까지 네 가지 질문에 어떻게 답했는지 다룹니다.
- 채팅에서 무엇을 단어로 셀 것인가?
- 하루치 집계를 어떤 시간 창에서 계산할 것인가?
- 절반만 계산된 결과를 어떻게 감출 것인가?
- 빈도와 톤을 어떻게 그림으로 옮길 것인가?
수집과 저장, 스케일링은 다시 설명하지 않습니다. 이 글의 관심은 저장된 뒤부터입니다.
2. 채팅은 문장이 아닙니다
공백으로 자르는 방법을 먼저 시도했습니다. 결과는 쓸 수 없었습니다. 신청은, 신청을, 신청도가 서로 다른 단어로 세어졌고, 노래추천해주세요는 통째로 한 단어가 됐습니다. 한국어는 조사가 붙고 띄어쓰기가 지켜지지 않습니다.
그래서 형태소 분석기를 붙였습니다. MeCab에 한국어 사전을 얹고, 품사가 일반명사·고유명사·감탄사인 형태소만 남깁니다.
class Tokenizer:
TARGET_POS = {"NNG", "NNP", "IC"}
URL_PATTERN = re.compile(r"https?://\S+")
FORM_FEED = re.compile(r"\f")
def extract_words(self, text: str) -> list[str]:
if not text or not text.strip():
return []
text = self.URL_PATTERN.sub("", text)
text = self.FORM_FEED.sub("", text)
words = []
parsed = self.tagger.parse(text)
for line in parsed.strip().split("\n"):
if line == "EOS" or line == "":
continue
parts = line.split("\t")
if len(parts) < 2:
continue
surface = parts[0]
features = parts[1].split(",")
pos = features[0]
if pos in self.TARGET_POS and len(surface) >= 2:
words.append(surface)
return words동사와 형용사를 뺀 이유는 활용형 때문입니다. 재밌다, 재밌어, 재밌네, 재밌었음을 원형으로 되돌리지 않고 세면 같은 의미가 네 조각으로 흩어집니다. 워드클라우드가 답해야 하는 질문은 “무엇에 대해 이야기했는가”이고, 그 답은 대부분 명사에 있습니다.
한 글자 형태소도 뺐습니다. 것, 수, 때, 말이 상위권을 채우면 그림이 아무 말도 하지 않습니다. URL은 분석 전에 제거합니다. 폼 피드 문자도 마찬가지입니다. 플랫폼 원본 페이로드에서 넘어오는 제어 문자인데 그대로 두면 토큰 안에 섞여 들어갑니다.
실시간 트렌딩은 사정이 다릅니다. 최근 15분을 보여주는 화면이라 배치를 기다릴 수 없습니다. API 요청 경로에서 형태소 분석기 프로세스를 부를 수도 없습니다. 이쪽은 분석 저장소의 SQL 안에서 정규식으로 자릅니다.
SELECT channel_id,
arrayJoin(
splitByRegexp(
'[^0-9A-Za-z가-힣ぁ-んァ-ヶ一-龠]+',
lowerUTF8(message_text)
)
) AS word
FROM chat_messages
PREWHERE timestamp >= now() - INTERVAL 15 MINUTE
...
GROUP BY word
HAVING frequency >= 2
AND uniqExact(channel_id) >= 3품질을 조건으로 보충했습니다. 2자에서 20자, 숫자가 섞인 토큰 제외, 불용어 목록 제외, 2회 이상 등장. 마지막 조건이 가장 중요합니다. 서로 다른 채널 3개 이상에서 나와야 합니다. 한 방송에서만 도배된 단어는 트렌딩이 아니라 그 방송의 사정입니다.
3. 웃음을 셀 것인가
품사 필터에 감탄사를 넣은 것은 채팅에서 감탄사가 실제 반응이기 때문입니다. 그런데 사전이 ㅋㅋㅋ나 ㅠㅠ 같은 채팅 전용 표기를 감탄사로 일관되게 내주지 않았습니다. 어떤 길이는 잡히고 어떤 길이는 사라졌습니다.
그래서 형태소 분석 전에 정규식으로 먼저 회수했습니다.
CHAT_INTERJECTION_PATTERN = re.compile(r"[ㅋㅎㅠㅜ]{2,}")
words = self.CHAT_INTERJECTION_PATTERN.findall(text)
parsed = self.tagger.parse(text)증상: ㅋㅋㅋ의 빈도가 실제보다 부풀었습니다.
원인: 정규식으로 뽑기만 하고 원문은 그대로 넘겼습니다. 사전이 같은 자리를 감탄사로 잡아주는 경우 한 번 등장한 토큰이 두 번 세어졌습니다.
해결: 회수한 구간을 원문에서 공백으로 치환한 뒤 파싱합니다.
words = self.CHAT_INTERJECTION_PATTERN.findall(text)
text = self.CHAT_INTERJECTION_PATTERN.sub(" ", text)
parsed = self.tagger.parse(text)이중 계수는 사라졌는데 그림은 나아지지 않았습니다. 거의 모든 채널의 1위가 ㅋㅋㅋ, 2위가 ㅋㅋ, 3위가 ㅎㅎ였습니다. 크기가 빈도인 시각화에서 상위 세 자리를 웃음이 가져가면 나머지 단어는 전부 최소 크기로 눌립니다. 어느 방송을 열어도 같은 그림이 나왔습니다.
빈도가 가장 높은 토큰의 정보량이 가장 적었습니다. 정규식을 걷어내고 사전이 주는 것만 쓰기로 되돌렸습니다. 테스트도 뒤집었습니다.
def test_does_not_force_preserve_chat_interjections(self):
text = "ㅋㅋㅋ 진짜 웃기다 ㅎㅎ"
words = self.tokenizer.extract_words(text)
assert "ㅋㅋㅋ" not in words
assert "ㅎㅎ" not in words같은 판단이 다른 층에도 있습니다. 실시간 트렌딩 불용어 목록의 앞부분은 ㅋㅋ, ㅎㅎ, ㅠㅠ이고 뒷부분은 욕설입니다. 이미지 렌더러에는 자모로만 이루어진 토큰과 같은 글자 반복을 거르는 필터가 따로 있습니다.
여기서 얻은 것은 세는 방법보다 셀 대상을 정하는 일이 먼저라는 점입니다. 빈도 기반 시각화에서 최상위 토큰은 정보가 아니라 배경일 수 있습니다.
4. 이름은 토큰이 아니라 개체입니다
특정 경기 시간대의 채팅을 모아 이미지로 만드는 작업을 하면서 다른 문제를 만났습니다. 사람 이름이 조각났습니다.
사전에 없는 고유명사는 형태소 분석기가 아는 조각으로 쪼갭니다. 세 글자 이름 하나가 두 개의 토큰이 되고, 그중 하나가 다른 사람의 이름 조각과 같으면 서로 다른 두 사람이 한 단어로 합쳐집니다. 반대로 채팅에서 흔한 축약 표기는 정식 이름과 별개의 단어로 남습니다.
해결은 순서를 바꾸는 것입니다. 형태소 분석 전에 원문에서 개체를 먼저 회수합니다.
SURF2CANON = {}
for canon, surfs in NAME_ALIASES.items():
for s in surfs:
SURF2CANON[s] = canon
_surfs = sorted(SURF2CANON.keys(), key=len, reverse=True)
NAME_RE = re.compile("|".join(re.escape(s) for s in _surfs))names = NAME_RE.findall(text) if NAME_RE else []
if names:
text = NAME_RE.sub(" ", text)
nouns = [w for w in tok.extract_words(text) if keep(w)]
for m in names:
freq[SURF2CANON[m]] += 1두 가지가 핵심입니다. 표기를 긴 것부터 정렬해 정규식을 만듭니다. 짧은 표기가 먼저 매칭되면 긴 이름의 앞부분만 먹고 뒤가 남습니다. 그리고 매칭된 구간은 원문에서 지운 뒤 형태소 분석기에 넘깁니다. 감탄사에서 겪은 이중 계수를 여기서 똑같이 피하는 방법입니다.
축약 표기를 매핑할 때는 동명이인을 확인해야 합니다. 두 사람의 이름이 같은 조각을 공유하면 잘못된 정식명으로 묶여 한쪽의 빈도가 다른 쪽에 흡수됩니다. 이 사전은 자동으로 만들 수 없습니다. 대상 인물 목록을 확인해서 손으로 씁니다.
5. 색으로 톤 보여주기
이미지에서 크기는 빈도를 뜻합니다. 색은 비워두기 아까운 축이라 여론의 톤을 얹었습니다.
메시지 단위로 점수를 냅니다. 긍정 어휘 매칭 수에서 부정 어휘 매칭 수를 빼고, 절댓값 2로 자릅니다.
def msg_score(text):
s = len(POS_RE.findall(text)) - len(NEG_RE.findall(text))
return 2 if s > 2 else (-2 if s < -2 else s)자르는 이유는 한 사람의 도배가 전체 분포를 끌고 가지 못하게 하기 위해서입니다. 잘한다가 한 줄에 다섯 번 들어와도 2점입니다.
어휘 사전을 고른 것은 채팅의 특성 때문입니다. 채팅 한 줄은 짧고 앞뒤 문맥이 없습니다. 문장 단위 감성 모델을 붙여도 반어와 밈을 못 잡는 것은 마찬가지인데 비용은 훨씬 큽니다. 대신 어휘 목록을 이벤트 도메인에 맞게 갈아끼울 수 있게 두는 편이 실용적이었습니다. 어떤 경기에서는 침대축구가 강한 부정어이고 다른 주제에서는 등장하지 않습니다.
점수를 단어로 옮기는 방식은 이렇습니다. 한 메시지의 점수를 그 메시지에 등장한 모든 토큰에 더합니다. 단어의 톤은 그 단어가 등장한 메시지들의 평균 점수입니다.
POS_T, NEG_T, MIN_N = 0.05, -0.05, 20
def polarity(w):
if not ENABLE_SENTIMENT or sent_n[w] < MIN_N:
return "neu"
a = sent_sum[w] / sent_n[w]
return "pos" if a >= POS_T else ("neg" if a <= NEG_T else "neu")단어 자체의 극성이 아니라 그 단어가 놓인 자리의 분위기를 재는 셈입니다. 선수 이름은 어느 사전에도 긍정어나 부정어가 아니지만, 그 이름이 나온 메시지들이 어떤 분위기였는지는 색으로 드러납니다.
표본이 20개 미만인 단어는 무조건 중립으로 둡니다. 세 줄짜리 표본으로 톤을 단정하면 색이 요동칩니다. 평균 임계값도 0이 아니라 양쪽 0.05로 벌려 중립 구간을 만들었습니다.
한계는 분명합니다. 반어법은 긍정으로 셉니다. 어휘 목록에 없는 신조어는 0점입니다. 그래서 이 색은 판정이 아니라 분포를 보여주는 장치로만 씁니다. 이미지에는 긍정·부정·일반 세 가지 범례만 넣고 점수는 표시하지 않습니다.
6. 하루에 37일치를 다시 세는 배치
서빙하는 기간은 7일과 30일 두 가지입니다. 채널마다 상위 200단어를 저장합니다.
집계는 매일 두 기간을 각각 처음부터 다시 셉니다. 하루 갱신에 37일치 원문을 형태소 분석하는 셈입니다. 채팅량이 늘면 결과물의 크기는 그대로인데 계산량만 커집니다.
플랫폼별로 측정한 값입니다.
| 범위 | 채널 수 | 소요 시간 |
|---|---|---|
| 씨미 7일 + 30일 | 1,206 | 20분 46초 |
| SOOP 7일 | 12,811 | 1시간 53분 |
| SOOP 30일 | 19,582 | 7시간 36분 |
| 치지직 7일 | 24,245 | 3시간 02분 |
| 치지직 30일 | 35,691 | 12시간 동안 32,500개 처리 후 91.1%에서 중단 |
채널 수와 소요 시간이 비례하지 않습니다. 치지직 7일은 SOOP 30일보다 채널이 많은데 절반 이하 시간에 끝났습니다. 비용을 결정하는 것은 채널 수가 아니라 채널이 가진 메시지 양입니다.
샤딩은 이 비대칭을 반영하지 못했습니다.
shard_condition = (
" AND cityHash64(channel_id) % {shard_count:UInt32} "
"= {shard_index:UInt32}"
)해시는 채널을 개수 기준으로 고르게 나눕니다. 메시지 양 기준으로는 나누지 않습니다. 대형 방송이 걸린 샤드 하나가 나머지가 다 끝난 뒤에도 계속 돌고, 워크플로우 전체가 그 샤드를 기다립니다.
작업을 개수로 나눠도 시간으로 나뉘지 않습니다. 균등 분할의 기준은 항목 수가 아니라 항목의 비용이어야 하고, 그러려면 비용을 미리 추정할 수 있어야 합니다. 시간별 메시지 수 같은 값이 있으면 그것으로 범위를 자를 수 있습니다.
7. 창을 고정하지 않으면 집계가 흔들립니다
같은 뿌리에서 나온 문제를 세 번 만났습니다.
페이지네이션 도중 데이터가 들어옵니다
채널 하나의 메시지를 10,000행씩 끊어 읽습니다. 처음에는 각 페이지가 timestamp > now() - INTERVAL 7 DAY를 자기 시점에 다시 평가했습니다. 페이지마다 now()가 다르면 창이 조금씩 미래로 밀립니다. 새 메시지가 들어오면 정렬 위치가 밀려 어떤 행은 두 번 읽히고 어떤 행은 건너뜁니다.
첫 페이지를 읽기 전에 상한 시각을 한 번 구해 모든 페이지에 같은 값을 넘기도록 고쳤습니다.
ts_ref = before_timestamp or "now()"
...
f" AND timestamp > {ts_ref} - {interval_sql} "
f" AND timestamp <= {ts_ref} "
f"ORDER BY timestamp, message_id "
f"LIMIT {{limit:UInt32}} OFFSET {{offset:UInt32}}",샤드가 자정을 넘깁니다
각 파드가 자기 시작 시각에서 “어제”를 계산하면, 12시간 걸리는 샤드는 먼저 시작한 파드와 나중에 시작한 파드가 서로 다른 날을 집계합니다. 검증 단계가 원본 개수를 세는 창도 달라져서, 정상적으로 끝난 배치가 불일치로 판정됩니다.
그래서 워크플로우가 시작할 때 배치 시각을 하나 정해 모든 파드에 인자로 넘깁니다. cutoff는 그 값에서만 유도합니다.
def get_cutoff_for_batch(self, batch_timestamp: datetime) -> str:
"""Return a cutoff pinned to the workflow start date.
Long-running shards can cross UTC midnight. Deriving "yesterday" in
every pod would then make validation compare a different source window
from the one the shards processed.
"""
aware = batch_timestamp.replace(tzinfo=timezone.utc) if batch_timestamp.tzinfo is None else batch_timestamp
previous_day = aware.astimezone(timezone.utc).date() - timedelta(days=1)
cutoff = datetime.combine(previous_day, datetime.max.time(), tzinfo=timezone.utc).replace(microsecond=0)
return f"toDateTime('{cutoff.strftime('%Y-%m-%d %H:%M:%S')}')"늦게 도착한 메시지가 검증을 깹니다
수집 워커는 메시지를 버퍼에 모아 배치로 저장합니다. 그래서 발화 시각과 적재 시각이 다릅니다. 배치가 채널 목록을 뽑은 뒤에 새 채널의 메시지가 적재되면, 처리한 채널 수와 검증 시점의 원본 채널 수가 어긋납니다. 아무도 실패하지 않았는데 검증만 실패합니다.
모든 원본 조회에 적재 시각 상한을 걸어 배치가 보는 세계를 얼렸습니다.
if batch_timestamp is not None:
ingestion_condition = " AND ingested_at <= {batch_timestamp:DateTime64(3)}"
parameters["batch_timestamp"] = batch_timestamp세 문제의 원인은 하나입니다. 작업자가 자기 시계로 입력 범위를 정한 것입니다. 입력 창은 작업자가 정하지 않고 작업 지시서에 적혀서 와야 합니다. 그래야 같은 지시서가 언제 실행되든 같은 결과를 냅니다.
8. 53만 번의 조회를 530번으로
채널별 워드클라우드 외에 사용자별 워드클라우드도 만듭니다. 주간 리캡 메일에 들어가는 개인 요약입니다.
처음 구조는 단순했습니다. 기간 안에 5개 이상 발화한 사용자를 뽑고, 사용자마다 메시지를 조회해 토큰화합니다. 한 플랫폼에서 활동 사용자가 53만 명이었습니다.
문제는 정렬 키입니다. 원본 테이블은 시간 순으로 정렬돼 있어서 특정 사용자 하나로 거는 조회가 그 정렬을 쓰지 못합니다. 조회 한 번마다 기간 전체를 훑습니다. 53만 번의 전체 구간 스캔은 끝나지 않습니다.
사용자를 1,000명씩 묶어 한 번에 받고, 사용자별 그룹핑과 토큰화는 애플리케이션에서 합니다.
result = self.client.query(
f"SELECT user_id, message_text "
f"FROM chat_messages "
f"WHERE message_type = 'chat' "
f" AND platform = {{platform:String}} "
f" AND timestamp > {cutoff} - {interval_sql} "
f" AND timestamp <= {cutoff} "
f" AND user_id IN {{user_ids:Array(String)}}",
parameters={"platform": platform, "user_ids": user_ids},
)per_user: dict[str, list[str]] = {}
for user_id, text in pairs:
per_user.setdefault(user_id, []).append(text)
for user_id, texts in per_user.items():
counter: Counter = Counter()
for w_list in self.tokenizer.batch_extract(texts):
counter.update(w_list)
top = counter.most_common(self.config.MAX_WORDS_PER_USER)조회 횟수가 1/1,000로 줄었습니다. 스캔은 청크마다 한 번입니다. 그 한 번이 1,000명분을 함께 가져옵니다.
N+1 조회는 ORM만의 문제가 아닙니다. 분석 저장소에서는 정렬 키에 없는 컬럼으로 거는 반복 조회가 같은 모양의 문제이고, 데이터 크기가 크기 때문에 더 비쌉니다.
9. 절반만 계산된 결과를 감추는 법
배치가 도는 동안 결과 행은 서빙 테이블에 그대로 들어갑니다. 이 상태에서 API가 읽으면 사용자는 절반만 계산된 워드클라우드를 봅니다. 틀린 그림이지만 틀려 보이지 않습니다.
완료 표식으로 이 구간을 가립니다. 채널 하나를 끝낼 때마다 예약어를 단어로 하는 행을 빈도 0으로 씁니다.
top_words = word_counter.most_common(self.config.MAX_WORDS_PER_CHANNEL)
rows = [(platform, channel_id, period, word, freq, now) for word, freq in top_words]
rows.append((platform, channel_id, period, CHANNEL_BATCH_MARKER, 0, now))
self.ch.insert_word_frequencies(rows)메시지가 하나도 없어 저장할 단어가 없는 채널도 표식은 씁니다. 처리하지 않은 채널과 처리했는데 결과가 빈 채널을 구분하기 위해서입니다.
검증 단계는 원본 채널 수와 표식 수를 비교합니다. 일치할 때만 스코프 완료 표식을 씁니다.
if trending_rows == 0 or processed != expected:
raise RuntimeError(
"channel batch incomplete: "
f"{platform}/{period}={processed}/{expected}, "
f"trending_rows={trending_rows}"
)이 규칙은 테스트로 고정했습니다. 부분 배치가 완료 표식을 쓰면 테스트가 실패합니다.
def insert_word_frequencies(self, rows):
raise AssertionError("partial batches must not publish a completion marker")처음에는 이 완료 판정이 세 플랫폼을 하나로 묶고 있었습니다. SOOP 30일 샤드 하나가 실패하면 멀쩡하게 끝난 치지직과 씨미의 워드클라우드까지 함께 내려갔습니다. 실패의 범위가 원인의 범위보다 훨씬 넓었습니다. 그래서 발행 단위를 플랫폼 × 기간으로 쪼개, 각 스코프가 자기 검증만 통과하면 발행되도록 바꿨습니다.
다만 읽는 쪽 게이트는 아직 세 플랫폼을 함께 봅니다.
if(
count() = 3
AND uniqExact(platform) = 3
AND min(computed_at) = max(computed_at)
AND max(computed_at) >= now() - INTERVAL 36 HOUR,
1,
0
) AS healthy생산은 스코프별로 쪼갰는데 소비는 여전히 전역입니다. 한 플랫폼이 밀리면 나머지 두 플랫폼도 503을 받습니다. 발행 단위를 쪼갤 때 읽는 쪽을 같이 쪼개지 않으면 절반만 고친 것입니다.
그리고 이 게이트를 붙이기 전에는 더 나빴습니다. API가 데이터 없음과 데이터 만료를 구분하지 않고 둘 다 200과 빈 배열로 돌려줬습니다. 채팅이 없는 채널과 파이프라인이 죽은 채널의 응답이 같았습니다. 사람도 모니터링도 장애를 정상으로 읽었습니다.
빈 결과는 성공이 아닙니다. 데이터가 없는 것과 데이터를 만들지 못한 것은 다른 응답이어야 합니다.
10. TTL이 장애를 증폭시킨 방식
서빙 테이블의 정의입니다.
CREATE TABLE IF NOT EXISTS chat_word_frequencies (
platform Enum8('chzzk' = 1, 'soop' = 2, 'cime' = 3),
channel_id LowCardinality(String),
period Enum8('24h' = 1, '7d' = 2, '30d' = 3),
word LowCardinality(String),
frequency UInt64,
computed_at DateTime
) ENGINE = ReplacingMergeTree(computed_at)
PARTITION BY period
ORDER BY (period, platform, channel_id, word)
TTL computed_at + INTERVAL 7 DAY마지막 줄이 장애를 만들었습니다. 갱신이 며칠 연속 실패하면 마지막으로 성공한 데이터까지 만료됩니다.
실제로 이어진 순서는 이렇습니다. 하나의 일 워크플로우가 채널 서빙 집계와 사용자 리캡 집계를 같은 데드라인에 묶고 있었습니다. 연속으로 실패했고, 그중 한 번은 남은 파드가 종료된 뒤에도 워크플로우 객체가 실행 중으로 남았습니다. 동시 실행 금지 정책이 걸려 있어 이후 스케줄이 조용히 전부 건너뛰어졌습니다. 그동안 TTL이 모든 행을 만료시켰고, API는 200과 빈 배열을 돌려줬고, 두 프론트엔드는 그것을 평범한 무데이터로 렌더했습니다.
이 체인에 애플리케이션 로직 버그는 없습니다. 각 조각은 자기 역할을 했습니다. 실패한 것은 조각들의 조합입니다.
여기서 얻은 것은 TTL이 저장 비용 설정이 아니라 복구 가능 시간의 상한이라는 점입니다. 탐지에 걸리는 시간과 복구에 걸리는 시간을 더한 값보다 짧으면, 운영 문제 하나가 데이터 전손으로 번집니다.
이 테이블에는 더 근본적인 제약이 하나 더 있습니다. 엔진은 ReplacingMergeTree(computed_at)인데 정렬 키에 computed_at이 없습니다. 같은 (기간, 플랫폼, 채널, 단어) 키의 이전 세대가 새 배치에 덮입니다. 그래서 이 테이블 위에서는 “실패하면 마지막 정상본을 계속 보여준다”를 만들 수 없습니다. 보관 기간을 늘려도 지난 세대가 남지 않기 때문입니다.
마지막 정상본을 지키려면 세대를 불변으로 쌓은 뒤 검증이 끝난 세대만 가리키는 작은 포인터를 원자적으로 바꾸는 구조여야 합니다. 읽는 쪽은 최신 타임스탬프를 추론하지 않고 그 포인터가 준 세대만 읽습니다. 그러면 발행 직전에 프로세스가 죽어도 독자에게는 아무 일도 일어나지 않고, 남은 것은 아무도 가리키지 않는 행뿐입니다.
11. 크기는 빈도, 색은 톤
집계가 끝나면 그리는 일이 남습니다. 화면에서는 SVG로, 메일과 SNS 이미지는 PNG로 만듭니다.
폰트 크기는 로그 스케일로 매핑합니다.
const scale = scaleLog()
.domain([minFreq, maxFreq])
.range([minFontSize, maxFontSize])
.clamp(true);선형으로 매핑하면 그림이 죽습니다. 상위 단어의 빈도가 하위 단어의 수십 배라 1위만 크고 나머지는 전부 최소 크기에 붙습니다. 로그 스케일은 상위권의 격차를 압축하고 중하위권의 차이를 살립니다.
배치 계산에서는 글자가 겹치는 문제를 만났습니다. 원인은 측정과 렌더의 불일치였습니다. 레이아웃 계산은 캔버스의 텍스트 측정으로 폭을 재고, 실제 렌더는 그때 지정한 폰트로 그립니다. 측정할 때 굵기를 넘기지 않으면 기본값으로 재고, 그릴 때 굵게 그리면 실제 폭이 측정값보다 큽니다. 겹치지 않는다고 판단해 배치한 자리에서 글자가 겹칩니다.
측정과 렌더가 같은 함수로 굵기를 계산하게 맞췄습니다.
function weightForSize(size: number): number {
return size > 56 ? 700 : size > 32 ? 600 : 500;
}const layout = cloud<cloud.Word>()
.fontWeight((d) => weightForSize((d as cloud.Word).size ?? minFontSize))
.fontSize((d) => (d as cloud.Word).size ?? minFontSize)const weight = weightForSize(size);
ctx.font = `${weight} ${size}px "${FONT_FAMILY}"`;레이아웃 라이브러리에 측정 함수를 위임할 때는 그 측정이 실제 렌더와 같은 조건인지 확인해야 합니다. 굵기, 자간, 폰트 로딩 상태가 하나라도 다르면 계산은 맞고 결과만 틀립니다.
SNS용 이미지는 가독성 쪽으로 더 밀었습니다. 전부 가로쓰기로 두고, 최소 폰트를 22로 올리고, 단어 사이 여백을 넉넉히 주고, 단어 수를 80개 안팎으로 제한합니다. 회전을 섞으면 밀도는 올라가지만 타임라인에서 작게 볼 때 읽히지 않습니다. 워드클라우드는 밀도를 자랑하는 그림이 아니라 한눈에 읽히는 그림이어야 합니다.
12. 마무리
채팅 워드클라우드는 형태소 분석기를 붙이면 끝나는 기능처럼 보였습니다. 실제로 시간을 쓴 곳은 다른 데였습니다.
무엇을 셀지 정하는 일이 어떻게 셀지보다 어려웠습니다. 가장 많이 나온 토큰이 가장 쓸모없어서 지웠고, 사전에 없는 이름은 형태소 분석 전에 따로 회수해야 했습니다. 집계는 작업자가 자기 시계로 창을 정하는 순간 세 가지 방식으로 흔들렸고, 그래서 입력 창을 지시서에 적어 넘기게 바꿨습니다. 절반만 계산된 결과를 감추기 위해 완료 표식과 범위 검증을 넣었지만, 발행 단위를 스코프별로 쪼갠 뒤에도 읽는 쪽 게이트는 여전히 세 플랫폼을 함께 보고 있습니다. 이건 아직 절반만 고친 상태입니다.
가장 비싸게 배운 것은 TTL이었습니다. 며칠짜리 만료는 저장 비용 설정처럼 보이지만 실제로는 장애를 알아채고 고칠 수 있는 시간의 상한입니다. 그리고 마지막 정상본을 지키는 일은 보관 기간을 늘려서 되는 게 아니라, 세대를 불변으로 쌓고 검증된 세대만 가리키는 구조여야 가능합니다.
시리즈 · 채팅 수집과 처리
5 / 5SOOP, 치지직, 씨미의 라이브 채팅을 모아 랭킹과 알림으로 바꾸기까지의 기록.
- 1.멜로밍 랭킹 - SOOP, 치지직, 씨미 채팅 수집 및 처리 아키텍처
- 2.SOOP, 치지직, 씨미 채팅 수집 커넥터로 12,000개 방송 채팅 수집하기
- 3.라이브 스트리밍 채팅 수집 시스템을 KEDA와 Karpenter로 확장한 방법
- 4.채팅 키워드알림 서비스 - 멀티테넌트 키워드 알림
- 5.방송에서 가장 많이 나온 단어는? : NLP를 활용한 채팅 워드클라우드