오발행 사고가 터진 날
목차
아침 일찍 kpopdex 발행 로그를 열었을 때 처음엔 뭔가 이상하다는 느낌만 있었다. 로그 라인 몇 개를 스크롤하다가 눈에 들어왔다. 변형판, 가짜 컴백 기사. 실서비스에 나간 흔적이 찍혀 있었다.
얼마나 나간 건지 추적하는 동안 머릿속이 좀 하얘졌다. kpop 팬 커뮤니티에서 컴백 소식은 단순한 뉴스가 아니다. 기다리던 소식이고, 직접 반응하고 공유하는 정보다. 그 민감한 영역에 허위 정보가 실서비스에 올라간 거다. 내가 만든 봇이 낸 사고니까 변명의 여지가 없었다.
왜 게이트가 없었나
사고 경위를 뜯어보면 구조적으로 당연한 결과였다. 콘텐츠 생성 파이프라인에 "이게 진짜 뉴스인가?"를 판단하는 레이어가 없었다. 봇이 생성한 글이 형식 요건만 맞으면 그냥 발행으로 넘어갔다. "설마 이런 게 나가겠어" 하고 넘겼던 가정이 결국 전제를 깼다.
처음부터 안 만든 데는 이유가 있었다. 봇을 빠르게 붙이는 게 우선이었고, 게이트 로직을 설계하려면 "무엇이 뉴스 가치가 있는가"를 정의해야 한다. 그 판단 기준 자체를 명문화하는 게 귀찮았다. 그걸 미룬 대가를 오늘 치렀다.
사고 나고 나서 두 가지를 박았다.
첫째, is_newsworthy 플래그. 발행 직전 단계에서 기사가 실제 보도 가치가 있는 내용인지 판단하는 필터다. 변형판이나 출처가 불명확한 루머성 컴백 소식은 여기서 걸러진다. 형식이 아니라 내용 레이어에서 거르는 거다.
def check_is_newsworthy(article: dict) -> bool:
# 출처 불명확하면 탈락
if article.get("source_type") == "unverified":
return False
# 제목 변형판 패턴 감지
if is_duplicate_variation(article["title"]):
return False
return True
둘째, news_guard 모니터. 이건 사후 감시 레이어다. 발행된 콘텐츠 중에서 같은 패턴이 나오면 자동으로 잡아서 알림을 던진다. is_newsworthy를 통과했더라도 이상 패턴이 감지되면 경고가 뜨게 했다.
| 레이어 | 시점 | 역할 |
|---|---|---|
is_newsworthy 플래그 |
발행 직전 | 콘텐츠 품질 게이트 |
news_guard 모니터 |
발행 후 상시 | 이상 패턴 감지·알림 |
두 가지를 같이 붙인 이유는 게이트 하나만으로는 신뢰가 안 됐기 때문이다. 게이트가 놓치는 케이스는 분명히 있을 거고, 그걸 잡을 백스톱이 필요했다. 방어는 단일 레이어로 하는 게 아니다. 완벽한 게이트를 만들려다 시간을 쓰는 것보다 약한 게이트 두 개를 겹치는 게 현실적으로 더 낫다. 두 번 다 놓칠 확률이 한 번 놓칠 확률의 제곱이니까.
docs도 꼼꼼히 남겼다. 사고 경위, 게이트 동작 방식, 어떤 케이스를 커버하고 어떤 건 여전히 열려 있는지까지. 다음에 이 코드를 볼 때, 혹은 다른 사람이 볼 때 "왜 이게 있지?" 하고 헤매지 않도록. 사고 난 코드에 docs가 없으면 나중에 용감하게 지워버리는 사람이 나온다. "이거 왜 있는지 모르겠는데 안 쓰이는 것 같아서 뺐어요"가 다음 사고의 씨앗이 된다.
humanizer SSOT 전환
오래 미뤄온 일을 오늘 결국 했다. 산문봇 7개에 각각 인라인으로 박혀 있던 AI 티 차단 로직을 전부 단일 import 방식으로 전환했다.
왜 이 꼴이 됐냐면, 처음엔 한두 봇에 로직을 넣으면서 "나중에 정리하자"고 미뤘고, 다음 봇을 만들 때 그냥 복붙했다. 복붙이 반복되면서 어느 봇은 최신 로직이 적용돼 있고, 어느 봇은 두세 버전 전 로직으로 돌고 있었다. 같은 역할을 하는 코드가 7개 버전으로 존재하는 상태. 단일진실이 없으니 humanizer를 개선할 때마다 7곳을 다 뒤져야 했고, 빠뜨리는 곳이 생겼다.
# 이전: 봇마다 인라인 복붙, 각자 다른 패치 수준
class ArticleBot:
def humanize(self, text: str) -> str:
text = re.sub(r'\b(Additionally|Furthermore)\b', '', text)
# 어느 봇은 여기까지, 어느 봇은 더 아래까지 있고...
return text
# 이후: 단일 import
from humanizer import humanize_text
class ArticleBot:
def humanize(self, text: str) -> str:
return humanize_text(text)
이제 humanizer 손볼 일 있으면 한 곳만 고치면 된다. 7개 봇이 동시에 업데이트된다.
트레이드오프는 있다. 중앙 로직을 건드리면 7개 봇이 동시에 영향을 받는다는 얘기기도 하다. 변경 전에 테스트를 더 신중하게 해야 하고, 롤백 단위도 커진다. 그래도 통제가 분산된 것보다는 낫다. 분산 상태에서는 "최소한 이 봇은 제대로 되겠지" 하는 확신 자체가 불가능했다. 어느 봇이 어떤 버전인지 추적하는 것 자체가 일이 됐으니까.
오래 미룬 기술 부채를 한 번에 털어낸 날이 사고 난 날이라는 게 씁쓸하긴 하다.
admin 작업들
나머지는 비교적 순조로웠다. GSC 검색성과 카드, 사이트 인사이트 드릴다운, 도메인 그룹핑을 하루에 몰아서 붙였다. 각각은 크지 않은 기능인데, 연결되고 나니 어드민이 눈에 띄게 달라졌다.
GSC 적재를 page/query 차원까지 내린 게 핵심이다. 사이트 수준 집계만 있었을 때는 "트래픽이 늘었다/줄었다"밖에 못 봤는데, 이제 어떤 키워드로 어떤 페이지에 유입되는지 바로 볼 수 있다. 드릴다운이 가능해지면 데이터가 갑자기 읽힌다. 같은 숫자인데 맥락이 붙으면서 다르게 보이는 느낌이다. 집계 숫자는 상황을 숨기고, 차원이 생기면 상황이 드러난다.
발행 진행 위젯에 오늘/어제/주간 탭 추가한 건 작은 변경이다. 근데 어제 탭이 생각보다 자주 쓰일 것 같다. 오늘 발행 수를 혼자 봐서는 좋은 건지 나쁜 건지 알기 어렵다. 어제랑 비교하는 기준이 생기면 판단이 빨라진다. 숫자보다 비교 맥락이 먼저다.
오늘 많이 했는데 기분은 복잡하다. 사고 처리를 잘 마무리했고 기술 부채도 좀 덜었는데, 사고가 났다는 사실 자체가 남는다. 내일은 오발행 게이트가 실제로 의도한 대로 작동하는지 로그를 한 번 더 확인하고, GSC 적재 결과가 admin에 제대로 뜨는지 봐야 한다. 로직이 맞아도 데이터가 안 붙으면 말짱 도루묵이니까.
댓글 0
첫 댓글 달아줘.