개발 slecs

오발행 사고 수습부터 Admin 전면 리뉴얼까지

목차

kpopdex에서 변형판/가짜컴백 기사가 그냥 나가버렸다. 저녁 작업은 그 수습으로 시작했고, 끝날 때는 admin 전체 동선이 바뀌어 있었다. 사고 하나가 반나절치 리팩터링의 도화선이 된 셈이다.

사고 수습: 발행 게이트와 상시 모니터

오발행의 경위는 단순했다. 파이프라인이 뉴스가치가 없는 기사를 걸러낼 기준이 아예 없었던 것. 크롤링 - 가공 - 발행이 거의 일직선으로 흘렀고, 그 사이에 판단 레이어가 없었다. 팬캠이나 비공식 리믹스를 정식 컴백처럼 처리해버린 건 당연한 결과였다.

처음엔 빠른 핫픽스 하나로 넘어가려는 유혹이 있었다. is_newsworthy 체크 한 줄 박으면 당장은 막히니까. 그런데 그렇게 하면 엣지케이스가 또 터진다. 이번 사고가 "이 케이스를 안 막아서" 터진 게 아니라 "구조적으로 판단 레이어가 없어서" 터진 거라, 구조를 고치는 게 맞다고 판단했다.

수습은 두 단계로 나눴다. 첫째, 발행 직전에 차단하는 게이트. _fill_news_inline.pyseed_news.pyis_newsworthy 체크를 박아서, 판단 기준을 통과하지 못한 기사는 아예 큐에 안 올라가게 했다.

# news_guard.py (핵심 구조 요약)
def is_newsworthy(article: dict) -> bool:
    if article.get("variant"):        # 변형판 플래그
        return False
    if article.get("fake_comeback"):  # 가짜컴백 플래그
        return False
    # ... 추가 기준
    return True

둘째, 이미 발행된 것들을 상시 감시하는 news_guard 모니터. 게이트가 막아준다 해도 엣지케이스는 항상 생긴다. 크롤러 로직이 바뀌거나, 판단 기준에 애매하게 걸리는 기사가 나타날 수 있다. 모니터는 발행된 기사들을 주기적으로 훑으면서 사후에도 감지하는 역할이다. 발행 게이트 단독으로는 "통과시키지 않는 것"만 보장하지, "통과된 것이 맞다"는 보장이 없다. 두 레이어가 같이 있어야 실질적으로 방어가 된다.

같은 시간에 vtuberprofile 쪽도 손을 댔다. 문제 구조가 비슷했다. LLM이 vtuber의 소속사나 활동 정보를 환각하거나 오분류해서 내보내는 케이스가 종종 있었는데, 걸러낼 수단이 없었다. 이쪽은 Holodex API를 교차검증 소스로 활용했다. vtuber_guard.py를 새로 만들어서, 봇이 생성한 데이터와 Holodex 응답을 비교해 불일치가 임계치를 넘으면 발행을 보류하는 구조다. publish.sh에도 이 가드를 호출하는 단계를 끼워 넣었다.

사이트 오류 유형 방어 수단
kpopdex 변형판/가짜컴백 오발행 is_newsworthy 게이트 + news_guard 모니터
vtuberprofile 소속오분류/환각 Holodex 교차검증 + vtuber_guard 보류

두 사이트 모두 LLM 기반 파이프라인이고, 둘 다 "생성된 데이터를 그대로 신뢰"하는 구조가 약점이었다. 해결 방향도 같았다. 외부 권위 소스(Holodex 데이터, 뉴스가치 기준)와 교차 검증하는 레이어를 중간에 끼워 넣는 것. LLM 출력을 신뢰하지 않는 게 원칙인데, 빠르게 기능을 쌓다 보면 이 원칙이 흐려지는 경험을 오늘 제대로 했다.

Admin 전면 동선 리뉴얼

사고 수습이 일단락되고 나서 Admin 쪽으로 넘어갔는데, 여기서 오늘 작업 시간의 절반 이상을 썼다. 갑자기 시작한 게 아니라 쌓여있던 UX 부채를 이 참에 한 번에 털기로 한 것. 비개발자가 admin을 쓸 때 "이게 어디 있더라"를 반복한다는 피드백이 계속 있었다. 메뉴 항목을 봐도 무슨 기능인지 감이 안 온다는 얘기도.

리뉴얼의 핵심 원칙은 두 가지였다.

  • 타고타고 드릴다운: 상위 목록에서 특정 사이트 클릭 - 해당 사이트 개요 - 인사이트/GSC/PV 각 상세로 내려가는 단일 동선
  • 비개발자 설명: 각 메뉴 항목 옆에 무엇을 하는 곳인지 짧은 설명 추가, 기술 용어 최소화

가장 손이 많이 간 건 사이트 목록 구조였다. 기존엔 모든 사이트가 플랫하게 나열됐는데, 도메인 수가 늘면서 스크롤해서 찾는 게 비효율적이었다. 도메인 기준 그룹핑을 적용해서, 메인 도메인 아래 관련 사이트들이 묶이는 구조로 바꿨다. SitesGrouped.tsx를 새로 만들고 sites/page.tsx를 그걸 쓰도록 교체했다. 기존 SitesTable.tsx는 드릴다운 내부에서 여전히 쓰기 때문에 건드리지 않았다.

그 다음은 사이트 인사이트 허브가 핵심이었다. 기존 구조에선 특정 사이트의 상태를 파악하려면 메뉴 세 군데를 돌아다녀야 했다. PV는 site-pv에서, 발행 현황은 대시보드 위젯에서, GSC는 gsc 메뉴에서 따로 봐야 했다. 이걸 /sites/[id]/insights 한 페이지로 모았다.

인사이트 페이지 안에 InsightTrafficChart로 PV 추이를 보여주고, 발행 진행 상황, GSC 검색성과 카드를 같은 화면에 배치했다. 컨텍스트 전환 없이 한 사이트의 전체 건강 상태를 한 눈에 볼 수 있는 구조다. 이런 허브 페이지를 만들 때 항상 고민되는 건 "어디까지 이 페이지에 때려넣을 것인가"인데, 오늘은 "드릴다운을 더 들어가야 보이는 것"과 "이 페이지에서 바로 보여야 하는 것"을 나누는 기준으로 카드 vs. 링크를 구분했다.

인사이트 페이지 내에 SiteSwitcher도 달았다. 사이트 인사이트를 보다가 다른 사이트 인사이트로 넘어갈 때, 상위 목록으로 다시 올라가지 않아도 되는 셀렉터다. 드릴다운 UX에서 이런 횡단 이동 수단이 없으면 결국 뒤로가기 연타가 된다. 추가하고 나서 실제로 써보니 없을 때랑 경험이 꽤 달랐다.

대시보드에는 사이트 바로가기 카드를 추가했다. 기존 대시보드가 집계 숫자 위주였다면, 이제는 "자주 보는 사이트 빠른 진입"이라는 허브 역할도 겸한다. 처음 admin에 로그인했을 때 "지금 보고 싶은 것"으로 바로 들어갈 수 있는 동선이다.

발행 진행 위젯에는 오늘/어제/주간 탭을 추가했다. PublishProgressBars가 기존엔 오늘 데이터만 보여줬는데, "오늘 발행이 적은 건지 원래 이 정도인지"를 비교할 방법이 없었다. getPublishProgress에 기간 파라미터를 추가하고 탭 전환으로 처리했다. dashboard-stats.ts에서 날짜 범위 계산 로직을 넣는 게 약간 귀찮았는데, 어제 기준 날짜 계산이 자정을 기준으로 잘라야 해서 타임존 처리를 신경 써야 했다.

전 메뉴에 걸쳐 변경된 범위를 요약하면:

  • ad-units, audit-log, batch-jobs, categories 목록 페이지: 설명 텍스트 + 연관 동선 링크
  • daily-bible calendar/push: 기능 설명과 상태 표시 개선
  • gsc 메인 페이지에서 도메인 클릭하면 해당 도메인 GSC 상세로 드릴다운
  • site-pv 페이지에서 사이트 인사이트로 링크

이 정도 규모를 한 번에 밀어붙이면 중간에 깨지는 링크나 라우팅 충돌이 생기기 마련이다. 실제로 인사이트 허브로 넘어오는 링크를 여러 군데서 걸어야 해서, 기존 SitesTable.tsx에도 링크 컬럼을 건드렸다. 이런 광범위한 동선 변경을 할 때는 변경하면서 바로 CLAUDE.md에 현 구조를 기록하는 게 필수다. 나중에 AI 툴이나 팀원이 "이 페이지 어디 있어요?"를 물을 때 stale 정보를 주지 않으려면.

GSC 파이프라인 - SEO 데이터를 admin에 연결

admin 인사이트 허브에 GSC 검색성과 카드를 넣으려면 당연히 데이터가 있어야 했다. 기존 gsc_monitor.py는 사이트 레벨 클릭/노출 집계만 적재했다. 사이트 전체 클릭 수는 알 수 있어도, "어떤 글이 잘 나가는지", "어떤 키워드로 들어오는지"는 알 수 없는 구조였다.

cms_gsc_pagecms_gsc_query 테이블을 추가하고, gsc_monitor.py에 page/query 차원 적재 로직을 붙였다. GSC API에서 dimension을 page, query로 각각 끊어서 가져와 upsert하는 식이다.

# gsc_monitor.py 차원 적재 요약
for dimension in ["page", "query"]:
    rows = fetch_gsc(site_url, dimension=dimension, date_range=date_range)
    upsert_table(
        f"cms_gsc_{dimension}",
        rows,
        key=["site_id", "date", dimension]
    )

적재 주기 설계가 살짝 까다로웠다. GSC API는 quota 제한이 있는데, 사이트 수가 늘어날수록 page/query 두 차원을 동시에 자주 돌리면 quota를 초과할 수 있다. 사이트 레벨은 매시간 돌려도 괜찮지만, page/query 차원은 일 단위로 배치를 돌리는 쪽이 안전하다. 기존 cron 스케줄과 겹치지 않게 타이밍 조정도 필요하다. 이 부분을 CLAUDE.md에 "배포 주의"로 별도 항목으로 박아뒀다. 다음에 gsc_monitor를 건드릴 때 이 맥락을 잊으면 quota 초과 사고가 날 수 있으니까.

site-insights.ts에 page/query 데이터를 뽑는 쿼리 함수를 추가하고, 인사이트 페이지에서 GSC 카드를 그걸로 채우는 것까지 오늘 완료했다. 도메인 키 매핑이 함정이 있었는데, GSC에서 쓰는 사이트 URL 형식이 DB에 저장된 도메인 형식과 다르게 들어와서 join이 안 맞는 케이스가 있었다. sc-domain: prefix 처리를 normalize 함수에서 통일하는 것으로 잡았다. 이것도 CLAUDE.md에 기록.

day-master - 결정론적 테이블로 환각 차단

day-master에 Ten Gods(십신) 요소 테이블을 추가한 것도 오늘 작업에 포함됐다. 사주 분야는 LLM이 특히 환각을 잘 낸다. 비주류 지식이라 학습 데이터가 적고, 규칙이 복잡하면서 예외도 많아서 그럴 듯하게 틀린 설명을 생성하는 빈도가 높다.

기존엔 십신 계산이나 설명 생성을 LLM에 어느 정도 맡기는 부분이 있었다. 이걸 learn.ts에 테이블 형태로 하드코딩해서, 십신 매핑이 코드 레벨에서 결정론적으로 뽑히게 바꿨다. "갑목 일주에서 정인이 어떤 의미인가" 같은 것이 테이블에 이미 정의돼 있으면, LLM이 생성 중에 틀릴 여지가 없다.

[slug]/page.tsx에서 슬러그에 따라 관련 십신 개념들을 교차 링크하는 구조도 추가했다. 특정 일주 페이지에서 관련 십신 개념 페이지로 자연스럽게 연결하는 내부 링크다. SEO 측면에서 내부 링크 밀도가 올라가고, 사용자가 관련 개념을 이어서 볼 수 있다는 점에서 체류 시간에도 도움이 될 것이라 예상한다.


저녁 6시부터 자정까지 돌아보면, 공통 키워드가 하나 있다. "그냥 통과되면 안 되는 것이 통과되는" 문제. kpopdex 오발행은 발행 기준 없이 기사가 나간 것이고, vtuber 환각은 검증 없이 데이터가 나간 것이고, admin UX는 이유를 모른 채 사용자가 길을 잃는 것이고, GSC 데이터 미적재는 의미 있는 지표가 화면에 안 나오는 것이었다. 전부 "중간 검증 레이어가 없던" 문제였다. 사고가 터진 날 오히려 여러 군데의 같은 패턴을 한 번에 고친 셈이다.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.