04. 오늘 무엇을 "핫이슈"로 고를지 정하는 규칙

2026. 8. 25. 12:55카테고리 없음

화면 뒤의 데이터 판단 · 핫이슈 브리핑

오늘 무엇을 "핫이슈"로 고를지 정하는 규칙

종목 네 개를 고르는 일에 들어간 결정들

홈 두 번째 줄에 종목 네 개가 나옵니다. 로고와 이름, 등락률, 뉴스가 있으면 초록색 N 배지.

화면은 단순하지만 그 뒤에는 질문이 여럿 있습니다. 무엇을 기준으로 "핫"이라 할 것인가. 로그인한 사람에게도 같은 걸 보여줄 것인가. 그리고 같은 화면 다른 칸과 숫자가 어긋나지 않게 하려면 어떻게 해야 하는가.

기준 — 등락률이 아니라 거래대금

"오늘 뜨거운 종목"의 후보는 보통 셋입니다.

기준고르면 생기는 일

등락률 시가총액 작은 종목이 상위를 독식한다. 100억짜리가 30% 오르는 건 흔하다
시가총액 매일 같은 목록이 나온다. "오늘"이라는 말이 무의미해진다
거래대금 규모와 관심을 함께 반영한다. 큰 종목이되 오늘 유독 많이 거래된 

거래대금 = 가격 × 거래량이라, 규모가 큰 종목 쪽으로 기울면서도 오늘의 쏠림을 반영합니다. 등락률처럼 소형주 잡음에 흔들리지도, 시가총액처럼 고정되지도 않습니다.

# 거래대금 상위 12개를 후보로 받는다
for r in (tv_rankings_cached(rg, "value") or [])[:12]:

네 개만 보여주는데 12개를 받는 이유는 아래에서 걸러 낼 것이 있기 때문입니다.

시가총액 랭킹은 실제로 걷어냈다 — 예전에는 매일 S&P500 전 종목의 시가총액을 20스레드로 훑어 후보 풀을 다시 만들었습니다. 콜드 상태에서는 수십 초로 늘어나 첫 사용자의 요청과 자원을 다퉜습니다. 정적 대형주 풀로 대체했고, 남아 있던 옛 함수도 나중에 지웠습니다.

이름이 같으면 하나만

거래대금 랭킹에는 같은 이름이 두 번 들어오는 경우가 있습니다. 그대로 두면 네 칸 중 두 칸이 같은 회사가 됩니다.

if not tk or nm in seen:   # 이름 중복 제거
    continue

티커가 아니라 이름으로 거릅니다. 우선주처럼 이름이 다르면 별개 종목으로 남습니다 — 실제로 화면에 삼성전자와 삼성전자우가 함께 뜨는 이유입니다. 같은 회사이지만 가격도 등락도 다른 별개 종목이라 합치지 않았습니다.

로그인하면 기준이 바뀐다

로그인 사용자에게는 거래대금 상위가 아니라 본인 관심종목을 보여줍니다. 같은 자리, 다른 기준입니다.

여기서 실제로 사고가 하나 있었습니다. "관심종목이 있으면 관심종목 경로"로 분기했는데, ETF는 브리핑 대상이 아니라 뒤에서 걸러집니다. 그래서 관심종목이 전부 ETF인 사용자는 빈 목록을 받았습니다.

화면에는 "브리핑할 종목이 없어요"만 뜨고, 핫이슈로 떨어지지도 않았습니다. 분기 조건이 거르기 전에 있었기 때문입니다.

wl_items = _carousel_items(rows)   # ETF 등을 걸러낸 뒤
if wl_items:                       # 남은 게 있을 때만 관심종목 경로
    ...
# 남은 게 없으면 그대로 아래 핫이슈 폴백으로 이어진다

판단을 거른 뒤로 옮긴 것이 수정의 전부입니다. "데이터가 있다"와 "쓸 수 있는 데이터가 있다"는 다르고, 필터가 있는 파이프라인에서는 후자로 분기해야 합니다.

같은 종목이 화면에서 두 값을 갖지 않게

홈에는 브리핑 말고 거래대금 섹션도 있고, 같은 종목이 양쪽에 나올 수 있습니다.

그런데 등락률이 서로 달랐습니다. 브리핑은 랭킹 원본 값을 그대로 실었고, 거래대금 섹션은 그 위에 실시간 시세를 덧씌우고 있었습니다. 같은 화면에서 같은 종목이 −8.35%와 −8.51%로 동시에 보이는 상태였습니다.

고친 방식이 중요합니다. 캐시에는 지연된 정규 값을 그대로 두고, 실시간 덮어쓰기는 응답을 만들 때마다 적용합니다.

out = [dict(it) for it in items]   # 캐시 원본은 건드리지 않는다 — 복사해서 덮어쓴다

캐시에 실시간 값을 넣어 버리면 60초 동안 그 순간의 시세가 굳습니다. 원본은 안정된 값으로 두고 표시 직전에 최신 값을 얹는 편이, 캐시의 목적(외부 호출 절감)과 화면의 목적(최신 값)을 둘 다 지킵니다.

추가 비용은 없습니다. 덮어쓰기에 쓰는 시세 맵은 랭킹 엔드포인트가 이미 60초 캐시로 들고 있던 것이라, 여기서 새로 나가는 외부 호출은 0입니다.

기다리지 않고 "아직"이라고 말한다

프리장·넥스트장 구간에는 세션 시세가 따로 있습니다. 그런데 그 시세 맵이 아직 안 데워졌을 수 있습니다.

여기서 3초를 기다리면 캐시로 줄여 놓은 응답 시간이 도로 늘어납니다. 특히 관심종목은 사용자마다 티커가 달라 커버리지 미스가 잦습니다.

그래서 기다리지 않고 session_pending 이라는 신호를 함께 보냅니다. 브리핑 카루셀에는 폴링이 없으므로, 프론트가 이 신호를 보고 한 번 더 물어봅니다.

"없으면 없다고 즉시 답하고, 다시 물어볼 근거를 준다" — 기다리는 것보다 정직하고 빠릅니다.

뉴스 배지는 N+1을 만들지 않는다

종목마다 붙는 N 배지는 "이 종목에 오늘 뉴스가 있다"는 표시입니다. 종목별 AI 브리프 캐시에 들어 있는 플래그를 읽습니다.

순진하게 짜면 종목 수만큼 조회가 나갑니다. 12개면 왕복 12번입니다.

# 종목별 개별 조회 대신 1쿼리 일괄(왕복 N→1)
brief_map = fetch_app_cache_many([_ck(rg, tk) for tk, _nm, _r in picks])

목록이 짧으니 괜찮아 보이지만, 홈 첫 화면에 걸리는 왕복은 사용자가 기다리는 시간에 그대로 더해집니다.

캐시는 두 겹, 그리고 미리 데운다

층범위효과

L1 메모리 같은 인스턴스 재요청 0ms
L2 app_cache 다른 인스턴스까지 외부 호출·DB 왕복 회피
prewarm startup·스케줄러 첫 사용자가 콜드(~0.7초)를 안 겪음

TTL은 60초인데, 이 숫자는 임의로 고른 게 아니라 거래대금 랭킹의 갱신 주기와 맞춘 것입니다. 더 짧게 잡으면 같은 값을 다시 만들고, 더 길게 잡으면 랭킹이 바뀌었는데 브리핑만 옛 목록을 들고 있게 됩니다.

prewarm은 목록만 데우지 않습니다. 그 종목들의 로고 맵(24시간 캐시)까지 같이 데웁니다 — 로그인 사용자 첫 요청에서 로고 fetch가 1.2초쯤 걸렸기 때문입니다.

정리

  • 거래대금 — 등락률은 소형주에 흔들리고, 시가총액은 매일 같다
  • 이름으로 중복 제거 — 단, 우선주는 별개 종목으로 남긴다
  • 필터가 있으면 거른 뒤에 분기한다 — "있다"와 "쓸 수 있다"는 다르다
  • 캐시엔 안정된 값, 표시 직전에 최신 값 — 같은 종목이 두 값을 갖지 않게
  • 기다리는 대신 "아직"이라고 알린다
  • 목록이 짧아도 N+1은 만들지 않는다
  • 캐시 TTL은 생산자 주기에 맞춘다

종목 네 개를 고르는 일이었지만, 정작 손이 많이 간 곳은 고른 다음에 그 값이 화면에서 일관되게 보이도록 만드는 부분이었습니다.

화면 뒤의 데이터 판단 · 홈 — 핫이슈 브리핑