sgkstudio.
엔지니어링

텔레그램 알림 중복 제거, 고점수 글 84%가 재알림이었던 이야기

텔레그램 알림 중복 제거를 하지 않으면 «고점수 글을 발굴했다»는 알림의 대부분은 이미 본 글입니다. 저희 점수 파일 120개에서 8점 이상 글은 456건이었고, 서로 다른 글은 75건뿐이었습니다.

2026-10-062026년 8월 25일자 결정 기록 — 고점수 알림의 기준·점수 6축 가중치·임계 8.0 재보정(표본 7,673건, 99번째 백분위수 7.9)·스레드 +1.5 보정최근 채점 파일 120개 실측 — 8점 이상 456건 중 고유 75건, 한 글 33회 재채점알림 스크립트(`threads-notify.py` 361~362행 억제 장치·`threads-cron-wrapper.sh` 104행 호출)와 채점 스크립트(`threads-bs-detect.py` 461행 경고문) 원문 인용

텔레그램 고점수 알림은 무엇을 기준으로 올라오나?

텔레그램 알림 중복 제거를 하지 않으면 «고점수 글을 발굴했다»는 알림의 대부분은 이미 본 글입니다. 저희가 최근 점수 파일 120개를 열어 보니 8점 이상 글은 456건이었는데, 서로 다른 글은 75건뿐이었습니다. 같은 글 하나가 열흘 동안 33번 다시 점수를 받았고, 그때마다 알림 후보로 올라왔습니다.

SGK 스튜디오는 스레드(Threads)·네이버 블로그·카페에서 사업 얘기를 하는 글을 모아 점수를 매기고, 점수가 높은 글을 텔레그램으로 보냅니다. 어느 날 대표가 이렇게 물었습니다. «텔레그램에 고점수 포스트 발굴했다고 보내는 건 뭘 기준으로 하나.» 저는 답하려고 코드를 열었고, 기준을 설명하기도 전에 결함부터 나왔습니다.

알림 기준부터 적습니다. 알림 스크립트는 하루 4회 `threads-notify.py --days 1 --min 8 --top 5` 를 돌립니다. 옵션은 순서대로 조회 기간 1일, 최소 점수 8, 상위 5건을 뜻합니다. 하루 네 번, 한 번에 최대 다섯 건이 텔레그램으로 나가는 구조입니다.

점수는 무엇을 세어서 매기나?

점수는 `threads-bs-detect.py` 가 여섯 축으로 글을 보고 가중 합을 낸 뒤 1~15점으로 환산한 값입니다. 축마다 가중치가 다릅니다. 숫자 앞뒤가 맞는지(수치 일관성)에는 1.5를, 직접 해 본 사업 경험이 드러나는지(실증성)에는 2.0을 주고, 나머지 네 축은 1.0입니다.

고점수 알림의 여섯 축과 가중치
축가중치
채널 구체성1.0
제작 현실성1.0
유통 전환1.0
수치 일관성1.5
리스크 인식1.0
실증성·비즈니스 경험2.0

여섯 축의 가중 합을 1~15점으로 환산한다. 전부 키워드 카운트 휴리스틱이다.

여섯 축은 전부 단어를 세는 방식입니다. `포트원`·`stripe`·`next.js`·`파워링크` 같은 단어가 들어 있는지, 금액과 고객사 수가 몇 개 나오는지, `실패`·`리스크` 같은 말이 있는지를 봅니다. 글이 사실인지, 쓴 사람이 정말 그 일을 해 봤는지는 보지 않습니다.

알림 문턱인 8.0점은 2026년 7월 12일에 실제 점수 분포를 보고 다시 맞춘 값입니다. 표본 7,673건의 99번째 백분위수가 7.9점이라, 8.0점 이상이면 상위 1% 안쪽에 드는 글입니다. 스레드 글에는 +1.5점 보정이 붙습니다. 스레드는 본문이 500자로 제한돼 블로그 6,000자 글과 같은 자로는 높은 점수에 오르기 어렵기 때문입니다.

이 점수는 키워드 카운트 휴리스틱이다 — 주장의 진위를 검증하지 않는다. «✅ Trusted»는 «표면 신호가 좋다»는 뜻이지 «사실로 확인됐다»가 아니다.

점수를 계산하는 코드(`threads-bs-detect.py` 461번째 줄)가 스스로 이렇게 경고를 달아 두고 있었습니다. 그러니 이 알림이 올리는 글은 «사업 얘기를 구체적인 단어로 하고 있는 글»입니다. 좋은 글이라는 보증도, 살 사람이라는 보증도 아닙니다. 구매 의도를 가리는 일에 이 점수를 잘못 쓴 적이 이미 한 번 있었고, 이번에도 같은 자를 다른 곳에 대고 있었습니다.

문턱 8.0점과 스레드 +1.5점 보정은 어떻게 정했나?

문턱을 정한 방법도 적어 둡니다. 처음부터 8점이었던 것이 아니라, 2026년 7월 12일에 쌓인 점수 7,673건의 분포를 보고 다시 맞췄습니다. 이 분포에서 99번째 백분위수가 7.9점이었으니, 문턱을 8.0으로 두면 알림 후보는 전체의 상위 1%쯤으로 좁혀집니다. 하루 최대 5건씩 네 번이라는 알림 한도와도 맞는 크기입니다(추정: 한도와의 관계는 숫자를 맞춰 본 해석이며 원문에 명시되지 않았습니다).

채널별 보정도 있습니다. 같은 가중치로 채점하면 스레드 글은 불리합니다. 스레드는 본문이 500자로 제한돼 있어 금액, 고객사 수, 실패담 같은 근거를 담을 자리가 모자랍니다. 블로그 글은 6,000자까지 쓰니 같은 자에 올리면 스레드 글은 거의 못 넘습니다. 그래서 스레드 글에만 +1.5점을 더합니다. 이 보정은 프로젝트 신호 설정 파일의 `_channel_score_adjust` 항목에 있습니다.

여기서 눈여겨볼 점은 문턱과 보정이 모두 «글의 길이와 분포»를 맞추는 조정이라는 사실입니다. 문턱을 아무리 정교하게 맞춰도 점수가 재는 대상은 바뀌지 않습니다. 같은 글이 몇 번이고 문턱을 넘는 문제도 문턱 조정으로는 풀리지 않습니다. 문턱을 올리면 후보는 줄지만 같은 글이 되풀이해 넘는 구조는 그대로 남습니다(추정: 문턱을 바꿔 다시 돌려 보지는 않았습니다).

재다가 나온 숫자: 8점 이상 456건 중 서로 다른 글은 75건

기준을 확인하려고 최근 점수 파일 120개에서 8점 이상 글을 세어 봤습니다. 456건이 나왔고, 글 주소와 내용으로 묶으니 서로 다른 글은 75건이었습니다. 알림 후보의 84%가 이미 한 번 이상 본 글이었다는 뜻입니다.

막대그래프. 최근 점수 파일 120개에서 8점 이상 글은 456건, 그중 서로 다른 글은 75건, 가장 많이 반복된 글 하나는 열흘 동안 33번 다시 점수를 받았다
8점 이상 456건 중 서로 다른 글은 75건 — 한 글이 33번까지 돌아왔다

가장 심한 글 하나는 열흘 동안 33번 다시 점수를 받았습니다. 알림이 하루 네 번이니 열흘이면 40회 가까이 도는데, 그 글이 거의 매번 후보 목록에 있었다는 계산입니다. 사람이 이 알림을 읽으면 «또 이 글이네»를 몇 번이고 겪습니다.

원인은 수집과 채점이 따로 돈다는 점이었습니다. 수집기는 매일 같은 글을 다시 긁어 옵니다. 채점기는 다시 들어온 글을 다시 채점하고, 이때 `processed_at`(처리 시각)을 새로 찍습니다. 시각이 새로우니 알림 쪽에서는 «방금 점수가 나온 새 글»로 보이고, 문턱을 넘으면 그대로 통과합니다.

왜 기존 중복 억제는 고점수 알림을 못 막았나?

이미 중복을 막는 장치는 있었습니다. `dedupe_notified` 라는 함수가 알린 글을 기록해 두고 7일 안에는 다시 알리지 않습니다. 7일이 지나면 다시 알립니다. 문제는 이 장치가 리드(잠재 고객으로 보이는 글)에만 걸려 있다는 점이었습니다(`threads-notify.py` 361~362번째 줄).

고점수 글 알림에는 다른 방어가 하나 있었습니다. 한 번 알림을 돌릴 때 모인 후보 묶음(배치) 안에서 `text_preview[:100]`, 곧 본문 앞 100자가 같은 글을 하나로 합치는 처리입니다. 이 처리는 같은 묶음 안의 쌍둥이만 잡습니다. 하루 네 번 도는 알림이 회차마다 새로 시작하니, 어제 보낸 글을 오늘 회차가 기억할 방법이 없었습니다(추정: 배치 안만 본다는 코드 설명에서 나온 해석입니다).

한 가지 더 짚습니다. 억제 장치가 있다는 사실만 알면 «중복은 막혀 있다»고 믿기 쉽습니다. 저희도 그랬습니다. 장치가 리드에만 걸려 있는지 알림 전체에 걸려 있는지는 코드를 열어 봐야 보입니다.

정리하면 두 방어 모두 구멍이 있었습니다. 리드에는 기록 파일이 있는데 고점수 글에는 없었고, 고점수 글에는 묶음 안 중복 제거만 있어서 회차를 넘는 반복을 보지 못했습니다.

고려한 대안은 무엇이었고 왜 탈락했나?

고칠 방법은 여러 갈래였습니다. 각각 무엇이 걸렸는지 적습니다.

대안 4개와 판정
대안판정사유
기존 방식 유지 (묶음 안 앞 100자 중복만 제거)탈락회차를 넘는 반복을 못 막는다. 456건 중 75건이라는 숫자가 그 증거였다
리드용 `dedupe_notified` 를 고점수 글에 그대로 적용 (7일 뒤 재알림)탈락이미 읽은 글은 다시 읽어도 새 정보가 아니다. 7일마다 같은 글을 또 받을 이유가 없다
기록 키를 글 주소(URL)로만 잡기탈락스레드에서 모은 글은 `post_meta.url` 이 비어 있다. 주소로만 묶으면 억제가 통째로 돌지 않는다
고점수 알림과 구매 의도 알림을 하나로 합치기보류구매 의도는 `lead_intent` 가 따로 잰다. 합칠지는 다음 판단으로 남겼다

선택한 안은 이 셋의 단점을 뺀 조합이다. 고점수 글 전용 기록 파일을 두고, 재알림은 없애고, 주소가 없으면 작성자와 본문 앞머리로 키를 만든다.

두 번째 대안이 가장 끌렸습니다. 코드가 이미 있었고 리드에서 검증된 방식이니까요. 그런데 리드와 고점수 글은 알림의 목적이 다릅니다. 리드는 시간이 지나면 상황이 바뀔 수 있어(답이 달리거나 거래가 끝나거나) 7일 뒤 다시 보는 데 의미가 있습니다. 고점수 글은 점수를 매긴 시점의 글 자체가 알림의 전부라서, 한 번 읽으면 끝입니다.

선택한 구조: 고점수 글 전용 기록 파일 하나

고점수 글 전용 기록 파일 `data/notified-posts.jsonl` 을 새로 만들고, 알림을 보내기 전에 `dedupe_posts()` 로 이미 알린 글을 걸러냅니다. 리드와 달리 재알림은 없습니다. 한 번 알린 글은 다시 알리지 않습니다.

흐름도. 수집기가 글을 모으고 채점기가 점수를 매긴 뒤, 8점 이상 후보를 dedupe_posts가 기록 파일과 대조한다. 처음 보는 글만 텔레그램으로 보내고 기록 파일에 추가한다. 시험 실행은 기록 파일에 쓰지 않는다
알림 흐름 — 기록 파일 대조가 텔레그램 발송 앞에 들어갔다

재알림을 없앤 이유는 리드와 다른 점이 하나뿐입니다. 이미 읽은 글은 7일이 지나도 새 정보가 아닙니다. 그래서 기록 파일에는 만료일이 없고, 한 번 적힌 글은 계속 걸러집니다. 대신 같은 사람이 새 글을 올리면 주소나 본문이 달라 새 키가 생기므로 새 글은 정상으로 통과합니다. 기록 파일은 한 줄에 글 하나를 적는 `jsonl` 형식이라 한 줄씩 덧붙이기만 하면 되고, 사람이 열어 눈으로 확인하기도 쉽습니다.

기록의 키는 두 단계로 만듭니다. 글 주소가 있으면 주소를 그대로 쓰고, 없으면 작성자와 본문 앞머리를 합쳐 해시(고정 길이의 지문 값)를 만듭니다. 같은 글이 몇 번 다시 긁혀도 키는 같은 값으로 나옵니다.

코드. post_key 함수는 글 주소가 있으면 주소를, 없으면 작성자와 본문 앞머리의 해시를 키로 돌려준다. dedupe_posts 함수는 기록 파일에 없는 글만 남기고, 시험 실행이 아닐 때만 보낸 글을 기록한다
중복 제거 개념 코드 — 키는 주소 우선, 없으면 작성자와 본문 해시

구현에서 막힌 지점은 어디였나?

막힌 곳은 둘이었습니다. 첫째는 키입니다. 처음에는 글 주소만 키로 쓰면 된다고 생각했습니다. 그런데 스레드에서 모은 글은 `post_meta.url` 칸이 비어 있었습니다. 주소만 키로 쓰면 스레드 글은 전부 «주소 없음»이라는 같은 키로 묶이거나 아예 대조 대상에서 빠집니다. 어느 쪽이든 억제가 통째로 돌지 않으니, 주소가 없을 때는 작성자와 본문 앞머리 해시로 넘어가는 두 번째 경로가 필요했습니다.

둘째는 시험 실행(`--dry-run`)입니다. 알림을 실제로 보내지 않고 결과만 보는 옵션인데, 이 실행이 기록 파일에 글을 적어 버리면 사고가 납니다. 보내지 않은 글이 «이미 알린 글»로 남아서, 정작 진짜 알림이 나갈 차례에 그 글이 영영 막힙니다. 그래서 시험 실행은 기록 파일에 쓰지 않도록 했고, 보낸 글만 기록합니다.

두 문제는 서로 이어져 있었습니다. 키 설계가 틀리면 억제가 안 돌고, 시험 실행이 기록을 오염시키면 진짜 알림이 막힙니다. 하나는 «너무 적게 막는» 실패이고 다른 하나는 «너무 많이 막는» 실패라, 한쪽만 고치면 반대쪽 사고가 남습니다. 알림은 놓치는 쪽 비용이 더 크니, 의심스러울 때는 보내는 쪽으로 기울도록 기록 시점을 발송 뒤로 잡았습니다.

두 곳 모두 회귀 테스트 3건으로 고정했습니다. 같은 입력을 두 번 넣으면 두 번째는 걸러지는지, 주소가 없어도 걸러지는지, 시험 실행이 기록을 남기지 않는지를 확인합니다(앞의 둘은 기록 방식에서 나온 해석이고, 테스트 3건의 세부 항목은 원문에 적혀 있지 않습니다).

같은 점수를 알림과 자동 답글에 함께 쓰면 무슨 일이 생기나?

이 점수의 한계는 알림에서 끝나지 않습니다. 2026년 8월 9일에 저희는 점수가 높은 글에 답글을 자동으로 다는 방안을 검토했고, 자동 발송이 켜져 있었다면 나갔을 답글 20건을 전부 열어 봤습니다. 잘못된 대상이 여럿 있었습니다.

  • 중학생 인강을 고르는 학부모 글에 «검색광고 운영하고 있습니다»라는 답글이 나갈 뻔했다. 본문에 `파워링크`라는 단어가 한 번 나왔다는 이유로 광고 후보가 됐다.
  • 물류대행 업체의 홍보 글에 «홈페이지 제작하고 있습니다»가 나갈 뻔했다. 파는 사람에게 파는 셈이었다.
  • 같은 글이 11건으로 중복 검출돼 한 사람에게 답글이 11번 나갈 뻔했다.

세 사례 모두 단어를 세는 점수가 문맥을 보지 못해서 생긴 일입니다. 그래서 자동 답글 쪽은 알림용 점수와 따로 판정기를 두기로 했습니다. 알림은 사람이 읽는 목록이라 애매한 글도 올려 두는 편이 낫습니다. 안 읽으면 그만이니까요. 자동 발송은 통과한 글이 전부 나갑니다. 같은 점수를 두 용도에 쓰지 않는다는 원칙이 그때 나왔고, 이번 중복 문제도 같은 원칙의 연장입니다. 알림 쪽의 실패는 «또 같은 글을 본다»는 피로이고, 자동 발송 쪽의 실패는 «엉뚱한 사람에게 답글이 나간다»는 신뢰 손실이라 무게가 다릅니다.

운영에서 드러난 것: 반복 목록 속에 진짜 구매자가 있었다

기록 파일을 만들면서 반복 목록을 열어 봤고, 예상하지 못한 것이 나왔습니다. 가장 많이 반복된 글들 가운데 실제로 일을 맡길 사람이 쓴 글이 섞여 있었습니다.

표. 반복해서 올라온 글 세 건. 홈페이지 제작 업체 추천을 묻는 글은 19회, 디자이너를 구하는 글은 33회, 브랜드 디자이너를 모집하는 글은 25회 다시 올라왔다
반복 목록의 구매자 3명 — 한 글이 19~33번 돌아왔다

홈페이지 제작 업체를 추천해 달라는 글이 19번, 디자이너를 구한다는 글이 33번, 브랜드 디자이너를 모집한다는 글이 25번 다시 올라왔습니다. 알림은 이미 이 사람들을 보고 있었습니다. 그런데 같은 글을 스무 번씩 반복해서 보내니, 읽는 사람 눈에는 «또 그 글» 한 덩어리로 보였습니다. 중복이 소음을 만들었고, 소음이 가장 값진 신호를 가렸습니다.

이 발견은 중복 제거의 값어치를 다시 매기게 했습니다. 중복을 지우는 일은 알림 건수를 줄이는 청소처럼 보이지만, 실제로는 읽는 사람이 한 건 한 건에 쓰는 주의를 되돌려 주는 일입니다. 같은 글이 스무 번 오면 사람은 목록 전체를 훑고 넘기는 습관이 생기고, 그 습관이 새로 올라온 진짜 구매자 글까지 같이 넘깁니다.

여기서 점수의 한계도 다시 보입니다. 위 세 글이 8점 이상으로 올라온 건 구체적인 단어가 들어 있어서이지 구매 의도를 재서가 아닙니다. 구매자가 섞인 건 운이 좋았던 부분이고, 점수가 높다고 구매자라는 뜻은 아닙니다.

지금 상태와 다음 단계

2026년 8월 25일 기준으로 기록 파일, `dedupe_posts()`, 회귀 테스트 3건이 들어갔습니다. 아직 못 잰 것도 있습니다. 이 글을 쓰는 시점의 기록에는 조치 뒤에 하루 알림 건수가 실제로 얼마나 줄었는지가 없습니다. 456건 대 75건은 조치 전 숫자이고, 조치 후 숫자는 다음에 같은 방식으로 재야 합니다.

정리하면 이번 조치는 «같은 글을 두 번 보내지 않는다»는 가장 작은 약속을 코드로 만든 것입니다. 점수가 무엇을 재는지는 바꾸지 않았고, 문턱도 8.0 그대로 둡니다. 알림이 맞는 글을 올리고 있는지와 같은 글을 반복하지 않는지는 별개의 문제라, 하나씩 따로 고쳤습니다.

남는 문제는 더 큽니다. 이 알림은 여전히 «구체적으로 말하는 글»을 올릴 뿐 «살 사람»을 올리지 않습니다. 살 사람을 가리는 축은 `lead_intent` 가 따로 재고 있습니다. 두 알림을 하나로 합칠지는 다음 판단입니다.

비슷한 구조의 문제를 스레드 게시물 수집에서 좋아요 필터가 엉뚱한 글을 고른 사례에서도 다뤘고, 점수가 높은 글에 자동으로 답글을 달 때 생긴 문제는 자동 답글에 엉뚱한 경험이 섞인 사례에 적었습니다. 수집·알림 자동화를 직접 만들어야 하는 회사라면 상담에서 현재 구조를 같이 점검해 드립니다.

알림 자동화를 만든다면 무엇부터 확인하나?

  • 점수가 무엇을 재는지 코드에서 확인한다 — 우리 점수는 키워드 카운트라 «구체적으로 말하는 글»을 올릴 뿐 «살 사람»을 올리지 않았다. 알림 이름과 점수가 재는 것이 같은지 먼저 본다.
  • 알림 후보의 고유 비율을 센다 — 최근 파일 120개에서 8점 이상 456건 중 고유는 75건이었다. 한 번만 세어도 소음의 크기가 나온다.
  • 중복 억제가 모든 알림 종류에 걸려 있는지 본다 — 리드에는 7일 억제가 있었고 고점수 글에는 없었다. 같은 파일 안에서도 종류마다 방어가 다를 수 있다.
  • 수집 쪽 필드가 비어 있지 않은지 본다 — 스레드 글은 `post_meta.url` 이 비어 있어 주소 키만으로는 억제가 돌지 않았다.
  • 시험 실행이 기록을 남기지 않는지 확인한다 — 보내지 않은 글을 적어 두면 진짜 알림이 영영 막힌다.

같은 방식으로 우리 업무를 재보고 싶다면

이 글에 쓴 계측·판정·자동화는 저희가 실제로 매일 돌리는 방식 그대로입니다. 어떤 작업을 자동화할 수 있고 어디를 사람이 잡아야 하는지, 현재 업무 흐름을 놓고 같이 짚어 드립니다.

새 글이 올라오면 알려 드립니다

이런 계측 기록을 새로 쓰면 메일로 한 통 보냅니다. 광고나 판촉은 보내지 않습니다.