- Published on
아무도 안 묻는 질문에 답하던 화면 — 워드클라우드를 검색어 빈도로
- Authors

- Name
- Hyo814
아무도 안 묻는 질문에 답하던 화면 — 워드클라우드를 검색어 빈도로
워드클라우드 화면이 있었습니다. ASN.1 노드에서 단어를 뽑아 빈도를 세고, 서버에서 matplotlib + wordcloud로 PNG를 그려 내려주는 화면이었어요.
기술적으로는 멀쩡했습니다. 그런데 이 화면을 열어놓고 한참 봤는데, 답하고 있는 질문이 뭔지를 못 찾겠더라고요.
"우리 ASN.1 스펙 안에
Message라는 단어가 몇 번 나오는가?"
아무도 이걸 안 궁금해합니다. 게다가 PNG라 클릭도 안 돼요. 단어를 보고 뭔가 하고 싶어도 갈 데가 없습니다.
1. 화면의 성격을 바꾸는 게 먼저였다
고칠 방향을 정하는 데 시간을 제일 많이 썼습니다. 렌더링을 개선할지, 집계 대상을 바꿀지가 갈렸어요.
| 방향 | 답하는 질문 | 판단 |
|---|---|---|
| ① 그대로 두고 폰트·색만 개선 | "등록된 데이터에 어떤 단어가 많은가" | 질문 자체를 아무도 안 함. 예뻐져도 안 봄 |
| ② 클래스·속성 이름 빈도로 교체 | "모델에 어떤 용어가 많이 쓰이나" | 유용하지만 이미 클래스 목록 화면이 답함 |
| ③ 검색어 빈도로 교체 | "사용자가 뭘 찾고 있나" | 다른 화면이 못 답하는 질문. 채택 |
③을 고른 건 "이 화면이 없으면 알 수 없는 것"이 여기에만 있었기 때문입니다. 등록된 데이터는 목록·트리·관계도가 이미 보여주는데, 찾았지만 못 찾은 것은 어디에도 안 나와요.
방향을 정하니 나머지가 따라왔습니다. 단어를 클릭하면 그 검색어의 통합검색 결과로 갑니다. 워드클라우드가 장식이 아니라 진입점이 돼요. ②로 갔으면 클릭할 곳이 마땅치 않았을 겁니다.
시각화 화면을 고칠 땐 렌더링보다 "이 화면이 없으면 못 아는 게 뭔가" 를 먼저 묻습니다. 그게 없으면 예쁘게 만들어도 안 봅니다.
2. 검색어가 컬럼에 없다
집계 대상을 정하고 데이터를 찾으러 갔더니, 검색어를 저장하는 컬럼이 없었습니다. 방문 로그(PageVisit)의 url 쿼리스트링에만 남아 있어요.
/search/total/?search_keyword=교통정보&page=1
| 방법 | 얻는 것 | 포기하는 것 | 판단 |
|---|---|---|---|
① PageVisit에 search_keyword 컬럼 추가 + 백필 | 집계가 인덱스 한 방 | 백필이 결국 같은 URL 파싱. 컬럼을 늘리고도 파싱 코드는 남음 | 보류 |
| ② 검색 이벤트 전용 모델 신설 | 정석 | 지금 필요한 건 상위 10~50개 목록 하나. 모델 하나 값을 못 함 | 기각 |
| ③ 조회 시점에 URL 파싱 | 스키마 무변경, 과거 로그도 그대로 집계됨 | 로그가 커지면 느려짐 | 채택 |
③이 맞았던 이유는 규모였습니다. 이 집계는 화면 하나가 페이지 로드마다 한 번 부르고, 결과는 상위 50개예요. 여기에 스키마를 늘릴 근거가 약했습니다. 다만 이건 로그 건수에 선형이라, 로그가 커지면 ①로 가야 합니다. 지금은 그 시점이 아니라고 판단한 것뿐이에요.
@staticmethod
def get_search_keyword_stats(top_n=10, min_length=2, min_count=2):
"""방문 로그의 search_keyword 파라미터를 세어 상위 검색어를 돌려준다.
검색어는 URL 쿼리스트링에만 남고 별도 컬럼이 없어 집계가 곧 파싱이다.
1회성 오타가 목록을 채우지 않도록 min_count 로 걸러낸다.
"""
counter = Counter()
urls = (PageVisit.objects.filter(url__contains="search_keyword=")
.only("url").values_list("url", flat=True))
for url in urls.iterator():
try:
query = parse_qs(urlparse(url).query)
except ValueError:
continue
for raw in query.get("search_keyword", []):
keyword = raw.strip()
if len(keyword) >= min_length:
counter[keyword] += 1
ranked = sorted(((k, c) for k, c in counter.items() if c >= min_count),
key=lambda kv: (-kv[1], kv[0]))[:top_n]
세 가지가 의도적입니다.
.only("url")+.iterator()— 전 로그를 훑으니 모델 인스턴스를 다 만들 이유가 없습니다. 필요한 건 문자열 하나예요.min_count=2— 오타를 거릅니다. 누가교퉁정보라고 한 번 친 게 상위 목록에 뜨면 목록이 오염돼요. 한 번뿐인 검색어는 통계가 아니라 사고입니다.- 정렬 키가
(-count, keyword)— 동점일 때 가나다순으로 고정합니다. 안 그러면 새로고침마다 순위가 흔들려요.
3. 서버 PNG를 버리고 클라이언트로
렌더링도 같이 바꿨습니다.
서버 PNG (matplotlib+wordcloud) | 클라이언트 (d3-cloud) | |
|---|---|---|
| 클릭 | 불가 | 단어마다 <a href> |
| 폰트 | 서버에 한글 폰트 설치 필요 | 브라우저 폰트 |
| 배포 | 폰트 캐시 예열 코드 필요 | 없음 |
| 의존성 | 파이썬 패키지 2개 | JS 파일 2개 |
| 접근성 | 이미지 한 장 | 텍스트 노드, <title> 툴팁 |
클릭 가능성 하나로 결정이 끝났습니다. 1절에서 이 화면을 "진입점"으로 정의했으니 PNG는 애초에 후보가 아니었어요.
const DEFAULTS = {
height: 440,
minFont: 12,
maxFont: 56,
padding: 3,
// 세로로 눕히면 한글이 특히 읽기 어려워 가로로만 배치한다
rotations: [0],
fontFamily: "Pretendard, sans-serif",
defaultColor: "#006FF1", // primary — 캔버스 렌더라 Tailwind 클래스를 못 쓴다
};
두 줄에 이유를 적어놨는데, 둘 다 밟고 나서 적은 것입니다.
단어를 세로로 눕히지 않습니다. 워드클라우드 예제는 보통 90° 회전을 섞어요. 모양은 그게 예쁩니다. 그런데 한글은 세로로 누우면 확연히 안 읽혀요. 라틴 문자는 글자 폭이 좁아 눕혀도 눈이 따라가는데, 한글은 네모 글자라 회전하면 획 방향이 통째로 어긋납니다. 모양을 포기하고 가독성을 골랐습니다.
색을 Tailwind 클래스가 아니라 헥스로 박습니다. d3-cloud는 배치 계산에 캔버스를 쓰고, 우리는 그 결과를 SVG로 그려요. 클래스명으로 색을 주면 배치 단계에서 폭 계산이 어긋납니다. 팔레트 값을 상수로 복사해 두되, 어디서 온 값인지 주석에 남겼습니다.
4. 데이터가 없을 때 — 목업이라고 쓴다
운영 전이라 방문 로그가 거의 없습니다. 집계 결과가 빈 화면이 되죠.
using_sample = False
if not keyword_entries:
try:
with open(SAMPLE_DATA_PATH, encoding="utf-8") as f:
sample = json.load(f)
keyword_entries = _attach_weights(sample.get("keyword_entries", []))
using_sample = True
except (FileNotFoundError, json.JSONDecodeError):
pass
샘플 JSON으로 대체하되 배너로 "이건 샘플"이라고 씁니다. 데모용 화면에서 제일 위험한 게 목업을 실데이터처럼 보여주는 거예요. 회의에서 누가 저 숫자를 인용하면 그 자리에서 정정할 사람이 없습니다.
같은 이유로 섹션 제목 옆에 목업 태그 슬롯을 만들어서, 시드·샘플 데이터로 돌아가는 화면 세 개(검색어 빈도·페이지 통계·통합검색 랭킹)에 붙였습니다.
그런데 이 태그가 조건을 안 봅니다
context["section"]["badge"] = {
"id": "stats-mockup-badge",
"label": _("목업"),
"hidden": False, # ← 항상 켜짐
}
context["using_sample"] = using_sample
using_sample을 계산해놓고 배지는 그 값을 안 씁니다. 지금은 로그가 없어 항상 샘플이라 결과적으로 맞지만, 실제 검색 로그가 쌓이는 순간 진짜 데이터에 목업 태그가 붙어요. 배너와 배지가 서로 다른 조건으로 도는 상태입니다.
"hidden": using_sample is False 한 줄이면 끝나는 건데, 배지를 붙일 당시엔 "이 화면은 어차피 목업"이라는 전제가 머릿속에 있어서 조건을 안 걸었습니다. 전제가 코드에 안 적히면 전제가 바뀌는 날 아무도 모릅니다.
5. 같은 집계가 두 군데 있다
통합검색 화면에도 검색어 랭킹 표를 붙였습니다. 검색어 없이 들어왔을 때 빈 화면 대신 상위 10개를 보여주는 표예요.
이때 집계를 매니저에 뒀습니다. 커밋 메시지에 이유까지 적었어요.
집계는
PageVisitManager로 — 검색어가PageVisit.url쿼리스트링에만 남아 파싱이 필요한데 뷰에 둘 로직이 아니다.
그런데 워드클라우드 뷰에는 같은 파싱이 통째로 복사돼 있습니다.
# std_data/views/visualization/wordcloud.py
def _collect_search_keyword_stats():
base_qs = PageVisit.objects.filter(url__contains="search_keyword=")
urls = base_qs.only("url").values_list("url", flat=True)
counter = Counter()
for url in urls.iterator():
try:
qs_map = parse_qs(urlparse(url).query)
...
"뷰에 둘 로직이 아니다"라고 적어놓고 뷰에 뒀습니다. 매니저 쪽이 top_n=10 고정이라 50개가 필요한 워드클라우드에서 그대로 못 쓴 게 발단이었는데, 인자를 넘기면 되는 일이었어요.
그리고 이게 화면 두 개의 숫자를 어긋나게 만듭니다
두 구현 모두 비율을 이렇게 계산합니다.
total = sum(c for _, c in top) # 잘라낸 상위 N 의 합
ratio = c / total
분모가 "상위 N의 합"인데 N이 다릅니다.
| 화면 | 집계 N | 표에 보이는 행 | 비율 분모 |
|---|---|---|---|
| 통합검색 빈 화면 랭킹 | 10 | 10 | 상위 10개 합 |
| 데이터 통계 검색어 빈도 | 50 | 10 (keyword_entries[:10]) | 상위 50개 합 |
워드클라우드 쪽 분모에는 표에 보이지도 않는 11~50위 검색어의 건수가 들어가 있습니다. 그래서 같은 검색어가 두 화면에서 다른 숫자로 나오고, 워드클라우드 표의 10행을 다 더해도 100%가 안 됩니다. 둘 다 "틀린 값"은 아니에요 — 정의가 다를 뿐입니다. 그런데 화면에는 그냥 비율이라고만 적혀 있어서, 두 화면을 나란히 본 사람은 어느 쪽이 맞는지 알 수가 없습니다.
원래 의도는 "이 표 안에서의 점유율"이었고 통합검색 쪽은 표=집계라 일치합니다. 워드클라우드 쪽에서 구름은 50개, 표는 10개로 자르면서 정의가 깨졌어요. 표를 10개로 줄인 건 화면 길이 때문이었는데, 그 결정이 비율 정의까지 건드린다는 걸 그때는 못 봤습니다.
"전체 대비"가 아닌 비율을 화면에 낼 땐 분모를 라벨에 적어야 합니다.
비율이 아니라상위 10개 중 점유율이라고요. 분모가 코드에만 있으면 화면을 두 개 만드는 순간 갈립니다.
고치는 방향은 둘 중 하나입니다. 매니저 함수 하나로 합치고 top_n을 인자로 받거나, 비율을 전체 검색 횟수 대비로 통일하거나. 전자가 근본이지만 후자가 사용자에게 더 정직해서 아직 어느 쪽인지 못 정했습니다.
6. 기간 필터는 하루 만에 뺐다
처음엔 기간 필터(전체·1일·7일·30일)를 넣었습니다. 다음 날 화면을 데이터 통계로 개편하면서 뺐어요.
집계 대상이 시드 데이터인데 기간 축을 주면, 사용자가 "7일" 버튼을 눌러 나온 숫자를 최근 7일 실적으로 읽습니다. 목업 배너를 달아도 컨트롤이 있는 순간 그렇게 읽혀요. 실데이터가 쌓이면 그때 다시 넣는 게 맞습니다.
같은 개편에서 용어 사용 빈도 탭을 붙였습니다. 이쪽은 공개 클래스 구성(MetaClassMetadata)에서 속성이 포함된 횟수를 세는 거라 실데이터예요.
def _collect_term_usage_stats():
"""용어 사용 빈도 — 공개 클래스의 구성(MetaClassMetadata)에 속성 용어가 포함된 횟수.
"빈도만 보이고 어느 클래스에 연결된 건지 모르겠다"는 피드백에 따라
건수의 출처인 포함 클래스 목록을 함께 담는다(표 컬럼·구름 툴팁에 사용).
"""
links = MetaClassMetadata.objects.filter(
metaclass__public=True, metaclassproperty__isnull=False
).select_related("metaclass__namespace", "metaclassproperty__namespace")
피드백이 "숫자는 알겠는데 어디서 나온 건지 모르겠다" 였습니다. 빈도 화면은 대개 이 피드백을 받아요. 집계 결과만 주면 사용자가 검산을 못 하니까요. 그래서 건수와 함께 그 건수를 만든 클래스 목록을 표 컬럼과 구름 툴팁에 같이 담았습니다.
select_related 두 개는 그 목록을 만들면서 네임스페이스 접두사(dct:)를 붙이느라 필요했습니다. 없으면 링크 수만큼 쿼리가 나가요.
7. 검증과 남은 것
| 항목 | 결과 |
|---|---|
| 검색어 클릭 | 통합검색 결과로 이동, 검색어 그대로 전달 |
| 빈 집계 | 샘플 JSON 대체 + 배너 노출 |
| 용어 사용 빈도 | 실데이터 집계, 포함 클래스 병기 |
| 서브탭 | ?tab=term 진입 시 용어 탭 활성 |
| 삭제한 서버 렌더 | PNG 응답 경로 제거 |
남은 것 — 정직하게:
std_data/views/wordcloud_utils.py가 고아로 남아 있습니다. 서버 PNG 렌더를 걷어냈는데 이 파일을 안 지웠어요. 지금 이 파일을import하는 곳이 없습니다.matplotlib==3.10.0,wordcloud==1.9.4도requirements.txt에 그대로고,settings.py에는 matplotlib 로거 설정 4줄이 남아 있습니다. 화면을 바꾸는 데 집중하느라 걷어낸 쪽의 뒷정리를 안 한 것이고, 이건 다음 사람이 "아 서버에서도 그리는구나" 하고 오해할 자리입니다.- 5절의 비율 분모 불일치.
- 4절의
목업배지 조건 누락. - URL 파싱 집계는 로그 건수에 선형입니다. 지금은 문제없지만 임계는 정해두지 않았어요.
정리
- 시각화 화면은 렌더링보다 "이 화면이 없으면 못 아는 것"을 먼저 정한다. 그게 정해지면 클릭 가능 여부 같은 게 자동으로 따라온다.
- 한 번뿐인 값은 통계가 아니라 사고다. 빈도 집계에는 하한을 둔다.
- 목업은 목업이라고 화면에 쓴다. 단, 그 표시를 조건이 아니라 상수로 박으면 전제가 바뀌는 날 거짓말이 된다.
- 전체 대비가 아닌 비율은 분모를 라벨에 적는다. 안 적으면 화면이 둘로 늘어나는 순간 갈린다.
- 기능을 걷어냈으면 그쪽 의존성도 같이 걷는다. 남은 고아 파일은 다음 사람의 오해다.
- 그리고 "뷰에 둘 로직이 아니다"라고 적었으면 그다음 화면에서도 그걸 지켜야 한다. 안 지킬 거면 안 적는 게 낫다.
관련 글: Django N+1 문제와 해결