무엇이 문제였나 — 왜 발송 코드 대신 준비 코드부터 만들었나
SGK 스튜디오는 스레드에서 매 15분마다 잠재 고객 의뢰 글을 자동으로 찾는 파이썬 프로그램을 이미 돌리고 있었다. 다음 단계로 자연스러운 것은 찾은 글에 자동으로 답글을 다는 코드였지만, 회사는 그 코드를 만들지 않기로 했다.
지금까지 준비한 작업을 끝냈으니, 이제 답글을 자동으로 올리는 코드로 넘어가면 되나요?
이유는 발송 스위치를 켜는 조건에 있었다. 이 스위치를 켜는 조건은 3개였고, 그중 하나가 사람이 먼저 7건을 연속으로 답글을 달아 오탐 0건을 확인해야 한다는 조건이었다. 즉 지금 필요한 일은 답을 자동으로 다는 기능이 아니라, 사람이 그 7건을 최대한 빨리 채우도록 돕는 준비 작업이었다. 발송 코드는 조건이 갖춰진 뒤에 만들어도 늦지 않는다.
그래서 만든 준비 프로그램(`lead-reply.py`)은 후보를 고르고, 여러 게이트를 통과시키고, 실시간으로 그 글이 아직 살아 있는지 확인하고, 답글 초안을 조립하고, 접촉 이력을 남기고, 기록에 남기는 일까지 전부 한다. 다만 게시 버튼을 누르는 일 하나만은 하지 않는다. 사람이 화면에 뜬 카드를 보고 직접 복사해 붙여넣는다.
이 프로그램은 세 가지 명령으로 나뉜다. 붙여넣기용 카드를 화면에 띄우고 초안을 대기 목록에 저장하는 명령, 사람이 실제로 게시한 뒤 그 사실을 기록에 남기는 명령(이미 초안이 있어 다시 입력할 필요가 없다), 지금까지 준비된 초안 목록을 보여주는 명령이다. 사람이 하는 일은 카드를 복사해 붙여넣고 게시 버튼을 누르는 것, 그리고 게시한 사실을 알리는 명령을 한 번 실행하는 것뿐이다.
답글 초안은 감시 알림에 쓰는 조립 방식을 그대로 재사용한다. 다른 방식을 새로 만들면 상대가 화면에서 이미 밝힌 정보(예: 업종)를 다시 묻는 어색한 초안이 나올 위험이 있다. 이를테면 상대가 글에 '온라인 쇼핑몰 운영 중'이라고 이미 적어 놓았는데 초안이 업종을 또 물으면, 상대는 이 답글을 사람이 아니라 기계가 기계적으로 붙였다고 느낄 수 있다. 후보를 고르는 판정 로직도 한 곳으로 모았다. 미리보기 화면과 실제로 사람 손에 쥐어지는 결과가 서로 다르면 안 되기 때문이다.
처음 잰 숫자 — 자동 발송 후보가 20건 중 2건으로 늘었을 때
게이트를 통과한 후보 수는 이미 한 번 측정돼 있었다. 앞선 작업에서 원글 주소가 사라지는 세 가지 원인을 찾아 고친 적이 있는데(원글 주소가 사라지는 세 가지 원인을 고친 기록), 그 수정 뒤 같은 명령(`--days 14 --min-score 5`)으로 다시 잰 자동 발송 후보는 20건 중 2건이었다.
그 앞선 수정은 원글 주소가 사라지는 세 군데를 따로 막는 작업이었다. 로그인하지 않은 경로가 원글 주소를 통째로 비우는 조건문 하나, 같은 글을 시점마다 다른 글로 잘못 인식하는 조회 방식 하나, 15분마다 그날 치 데이터를 지우고 새로 쓰던 파일 하나였다. 이 세 가지를 다 막고 나서, 점수 7점 이상 글의 원글 주소 결측은 8건 중 3건에서 8건 중 1건으로 줄었고, 특정 경로의 원글 주소 보유율은 0%에서 100%로 올랐다. 이 수정이 먼저 없었다면, 이번에 다룰 게이트 문제는 애초에 후보 화면에 오르지도 않았다.
세 번째 원인을 찾다가 발견한 사고도 있었다. 여러 주제를 한 번에 긁는 경로는 데이터를 덧붙이는 방식으로 쌓았는데, 한 주제만 따로 긁는 경로는 파일을 매번 새로 쓰는 방식이었다. 그날 새로 등록된 15분 주기 작업이 하필 이 방식을 8개 주제에 대해 불렀고, 한 번 실행에 '홈페이지 제작' 주제는 157줄이 7줄로, 'AI 자동화' 주제는 96줄이 6줄로 줄어드는 데이터 손실이 있었다. 다행히 원본 저장소에서 병합해 복구했지만, 이 사고를 못 봤다면 원글 주소 결측 문제의 절반은 데이터가 아니라 그 데이터를 지운 코드 쪽에 있다는 사실도 놓쳤다.
그전까지는 20건 중 0건이었으니, 2건은 첫눈에 두 배 이상 좋아진 숫자로 보였다. 자동 발송을 켠 뒤에도 한 달에 2건까지만 내보내도록 잡아 둔 상한과도 맞아떨어져서, 후보 2건은 나쁘지 않은 출발처럼 보였다.
이번에도 후보를 재는 명령은 `--days 14 --min-score 5`로 고정해 두고 그대로 다시 돌렸다. 기준을 바꾸지 않고 같은 명령을 다시 돌려야, 숫자가 달라진 이유가 기준 변경이 아니라 코드 수정 때문이라고 말할 수 있다. 기준까지 같이 바뀌면 전후 비교 자체가 의미를 잃는다.
준비 프로그램을 만드는 도중 화면에 뜨는 카드를 눈으로 확인하다가 이상한 점을 하나 봤다. 후보 카드 하나가 '올라온 지 0일'이라고 찍혀 있었는데, 글 내용 자체는 최근에 올라온 느낌이 아니었다. 숫자가 이상하면 대부분 진짜 원인이 있다.

세운 가설 — 날짜를 읽는 함수 자체가 고장 났다고 봤다
처음 든 생각은 단순했다. '3일 전' 같은 상대 표기를 읽는 함수가 그 표기를 아예 처리하지 못해, 못 읽은 값을 전부 기본값 0으로 떨어뜨리는 게 아닐까 하는 가설이었다.
함수 하나를 고치면 끝나는, 흔한 종류의 버그로 보였다. 그래서 먼저 함수를 직접 실행해 상대 표기와 절대 날짜를 각각 넣어 보기로 했다. 코드를 뜯기 전에 세운 가정이 실제로 맞는지는 함수를 실행해 봐야 알 수 있었다.
이 가설이 먼저 떠오른 데는 이유가 있었다. 앞서 원글 주소 문제를 고칠 때, 같은 글이 시점마다 다른 조회 값으로 인식되는 문제를 겪은 적이 있었다. 겉모습이 비슷한 문제를 한 번 겪고 나면 다음에도 같은 자리를 먼저 의심하게 된다. 검증은 `test_age.py`로 하기로 했다 — 실제 서비스 코드를 건드리기 전에, 경과일 계산 함수만 따로 불러 값을 넣어 보는 스크립트다.
이 가설이 맞다면 고칠 자리는 하나였다. 날짜 계산 함수 안에서 상대 표기를 처리하는 코드 몇 줄만 바로잡으면 됐다. 실제로 함수를 열어 보기 전까지는, 이 가정이 맞는지 틀리는지 알 방법이 없었다.
가설이 틀린 지점 — 왜 27일 된 글이 신규로 잡혔나
함수가 고장 났다는 가설은 틀렸다. 함수를 직접 실행해 보면 절대 날짜(예: 2026-07-13) 형식은 정상적으로 계산했다. 문제는 함수 자체가 아니라 함수에 들어가는 값 쪽에 있었다.
정확한 사례가 하나 있었다. `@french._.al`이라는 계정의 원글은 2026년 7월 13일에 올라온 글이었다. 이 사실을 확인한 시점(2026년 8월 9일) 기준으로 27일이 지난 글이다. 그런데 신선도 게이트가 계산한 경과 시간은 0시간이었고, 그 값 그대로 24시간 게이트를 통과했다.
이 오류는 그동안 드러나지 않고 있었다. 게이트를 통과하는 후보 자체가 적어서 카드를 하나씩 눈으로 확인할 기회가 많지 않았고, 통과한 후보의 점수가 애매하면 사람이 굳이 원문까지 다시 열어 보지 않았기 때문이다. 이번엔 준비 프로그램을 만드는 도중이라 카드 하나하나를 유심히 봤고, 그래서 잡을 수 있었다. 자동화가 사람의 눈을 완전히 대신하기 전까지는, 이런 우연한 발견이 여전히 중요한 역할을 한다.
이 글이 눈에 띈 것은 우연이었다. 준비 프로그램이 화면에 띄운 후보 카드에는 점수 8점이 찍혀 있었는데, 점수가 높은 후보일수록 사람이 한 번 더 들여다보게 된다. 카드에 적힌 '올라온 지 0일'이라는 문구와 글 내용의 분위기가 어긋난다는 느낌이 그 확인의 출발점이었다.

진짜 원인 — 상대 표기가 이기고, 그 값이 통째로 0일이 됐다
원인을 좁혀 보니 두 가지가 겹쳐 있었다. 첫째, 게시일을 계산하는 로직이 절대 날짜보다 상대 표기를 먼저 신뢰하도록 짜여 있었다. 둘 다 값에 남아 있으면 상대 표기 쪽이 이겼다.
둘째, 그 상대 표기 값이 1일에서 6일 사이일 때 전부 0일로 뭉개지는 계산 오류가 있었다. 두 가지가 합쳐지자, 절대 날짜(2026-07-13)를 갖고 있어도 상대 표기가 남아 있으면 그쪽을 먼저 보고, 그 상대 표기가 며칠 전이든 상관없이 0일로 계산했다.
이건 이미 한 번 겪은 구조의 문제였다. 앞서 원글 주소 문제를 고칠 때도 정확히 같은 병에 걸려 있었다 — 상대 표기('3일 전')는 긁어 온 그 순간을 기준으로 적힌 숫자인데, 이 숫자가 코퍼스에 한 번 박히면 시간이 지나도 그대로 남는다. 7월 17일에 긁어 '3일 전'으로 저장된 스니펫은 8월에 다시 봐도 여전히 '3일 전'이라고 말한다.
그때는 이 문제가 같은 글을 다른 글로 착각하는 조회 오류로 나타났다. 원본 텍스트에는 '작성자+날짜+더보기+본문'이 한 덩어리로 들어 있는데, 스레드는 같은 글의 날짜를 볼 때마다 다르게 적는다 — 방금 올라온 글은 '3일'로, 시간이 지나면 '2026-07-13'으로 바뀐다. 그래서 같은 글인데도 조회 값이 시점마다 달라졌다. 그때 만들어 둔 해법은 '더보기' 뒤 본문만 따로 떼어 조회 값으로 쓰는 방식이었고, 이번에도 그 본문 기준 조회 방식이 있었기 때문에 여러 저장 시점을 가로질러 같은 글의 절대 날짜를 다시 찾아낼 수 있었다.
상대 표기가 위험한 이유는 단순하다. 긁어 오는 시점의 시계는 계속 흐르는데, 상대 표기로 저장된 값은 그 순간에 멈춘 시계를 코퍼스 안에 그대로 넣는다. 절대 날짜는 언제 다시 읽어도 같은 계산이 나오지만, 상대 표기는 읽는 시점이 달라지는 순간 거짓말이 된다. 이 성질을 모르고 상대 표기를 그대로 신뢰하면, 데이터를 오래 쌓아 둘수록 오래된 글이 계속 새 글처럼 보이는 방향으로 오류가 쌓인다.
고치는 과정에서 반대 방향 실수도 한 번 났다. 처음에는 게시글 텍스트 전체(`thread_text`라는 통째 텍스트 덩어리)에서 날짜처럼 보이는 문자열을 다 찾도록 짰는데, 이 텍스트 안에는 댓글이나 다른 글의 날짜까지 섞여 있었다. 그 결과 `@1m.hound`라는 계정의 18시간 전 글이 26일 전 글로 뒤집혀 표시되는 반대 방향 오류가 새로 생겼다. 게시일은 화면 상단(작성자·날짜·더보기 버튼이 있는 구간)에만 있으므로, 검색 범위를 그 구간으로 좁혀 되돌렸다.
조치 — 날짜를 찾는 범위를 헤더 구간으로 좁히다
조치는 두 가지였다. 하나는 날짜를 찾는 범위를 게시물 헤더 구간(작성자·날짜·더보기 버튼)으로 한정하는 일이었고, 다른 하나는 절대 날짜가 있으면 상대 표기보다 그 값을 우선하도록 순서를 바꾸는 일이었다.
- 날짜 검색 범위를 게시물 헤더 구간으로 한정 — 댓글·다른 글의 날짜가 섞이지 않게 한다
- 절대 날짜와 상대 표기가 함께 있으면 절대 날짜를 먼저 신뢰하도록 순서 변경
- 1일에서 6일 사이 상대 표기가 0일로 뭉개지던 계산 오류 수정
- 본문 기준 조회 값으로 여러 저장 시점을 가로질러 같은 글의 절대 날짜를 재조회

이 두 가지가 정말로 회귀 없이 고쳐졌는지는 두 스크립트로 확인했다 — 경과일 계산만 따로 검증하는 `test_age.py`와, 리드 판정 전체의 재현율·정밀도를 재는 `eval_golden.py`다. 두 스크립트 모두 수정 전과 같은 명령으로 다시 돌렸다. 코드를 고치고 나서 확인하지 않으면, 고쳤다는 말은 그냥 주장으로 남는다.
두 스크립트가 확인하는 범위는 서로 다르다. `test_age.py`는 날짜 계산 함수 하나만 떼어 절대 날짜·상대 표기·헤더 밖 날짜 같은 입력을 일일이 넣어 보고 값이 맞는지 확인한다. `eval_golden.py`는 사람이 미리 정답을 매긴 표본을 다시 돌려, 리드 판정 전체의 재현율과 정밀도가 이전과 같은지 본다. 함수 하나만 보는 검사와 전체 결과를 보는 검사를 같이 돌려야, 국지적으로는 맞았는데 전체로는 무언가 깨진 경우를 놓치지 않는다.
재측정 결과 — 후보는 늘지 않고 줄었다
다시 잰 숫자는 이랬다. 자동 발송 적격 후보(같은 명령 `--days 14 --min-score 5`)는 20건 중 2건에서 20건 중 1건으로 줄었다. 늘어난 게 아니라 줄었다. 그중 실제로 신선한 글은 원래도 1건뿐이었고, 나머지 1건(`@french._.al`, 점수 8점)이 27일 된 글이 신선한 글로 둔갑해 통과한 가짜였기 때문이다.

이 대목은 정정이 필요했다. 이전 보고에서는 '게이트 통과가 0건에서 2건으로 늘었다'고 적었고, 회사 대표에게도 그렇게 알렸다. 실제 유효 적격은 1건이었다. 이런 정정을 그대로 남겨 두는 이유는 간단하다 — 숫자를 조용히 고쳐 쓰면, 다음번에 비슷한 숫자를 다시 믿을 근거가 사라진다.
다행히 원글 주소 결측과 관련한 다른 수치들은 경과일과 무관한 축이라 영향이 없었다. 점수 7점 이상 글의 원글 주소 결측은 8건 중 3건에서 8건 중 1건으로 줄어든 값 그대로였고, 조회 열쇠 7,318개 가운데 한 열쇠에 원글 주소가 둘 이상 매달린 경우는 여전히 0건이었다.
골든셋으로 재는 재현율과 정밀도는 수정 전후 모두 88%·88%로 그대로였다. 숫자가 안 움직였다는 사실 자체가, 이번 수정이 다른 기능을 건드리지 않았다는 확인이었다.
후보가 준 것을 실패로 부르지는 않는다. 지금 단계의 목표는 통과 후보 숫자를 늘리는 데 있지 않고, 통과한 후보를 전부 진짜 신선한 글로 만드는 데 있다. 다음 단계는 사람이 7건을 연속으로 오탐 없이 확인하는 절차이고, 여기서 가짜 후보 하나가 섞이면 그 절차 전체를 다시 세야 한다. 적더라도 전부 진짜인 후보가, 많지만 그중 일부가 가짜인 후보보다 다음 단계에 유리하다.
남는 한계 — 왜 아직 답글은 사람이 직접 올리나
이 프로그램은 아직 답글을 실제로 올리지 않는다. 자동화된 크롬 브라우저와 수집용 계정 3개 모두 스레드 로그인이 안 돼 있고, 인증 파일(`threads_auth.json`)의 저장 시각은 7월 6일이라 이미 만료됐다. 로그인 화면에는 문자 인증과 이미지 인증이 걸려 있어 사람이 한 번은 직접 처리해야 하고, 지금은 그게 유일하게 밖에 기대야 하는 지점이다.
적격 후보가 1건뿐이라는 점도 남는 문제다. 자동 발송을 켜려면 사람이 먼저 7건을 연속으로 오탐 없이 처리해야 하는데, 후보가 하루에 1건도 안 나오는 속도라면 그 7건을 채우는 데만 며칠이 걸린다. 점수 문턱을 낮추면 후보 수는 늘겠지만, 그건 정확도를 팔아 속도를 사는 셈이라 하지 않기로 했다. 후보가 느리게 쌓이더라도, 쌓이는 후보가 전부 진짜라는 확신을 먼저 만드는 쪽을 택했다.
같은 종류의 버그가 나중에 다른 코드 경로에서 한 번 더 나왔다. 후보를 판정하는 프로그램(`lead-detect.py`)은 날짜 계산 함수를 원본 주소와 작성자 정보까지 함께 넘겨 호출하도록 고쳤지만, 알림을 보내는 프로그램(`lead-watch.py`)의 같은 호출 지점은 그대로 남아 있었다. 그 결과 같은 글을 두 프로그램이 서로 다른 경과일로 계산하는 사고가 한 번 더 있었다 — 한쪽은 6일, 다른 쪽은 12일이었다.
이번엔 반대 방향이었다. 처음 발견한 사고는 오래된 글이 신규 글로 잡히는 쪽이었는데, 이 두 번째 사고에서는 신선한 글에는 문제가 없었고 실제로는 오래된 글 한 건이 알림 프로그램 쪽에서만 신선한 글로 계산되고 있었다. 판정 프로그램은 12일로 정확히 계산해 걸러 냈지만, 같은 값을 다시 계산하는 알림 프로그램은 6일로 잘못 계산해 통과시킬 뻔했다. 값 하나를 고쳐도 그 값을 부르는 모든 자리를 같이 고치지 않으면, 같은 버그가 다른 얼굴로 돌아온다는 뜻이다. 함수를 한 곳에서 고쳤다는 사실이, 그 함수를 부르는 모든 곳이 같이 고쳐졌다는 뜻은 아니다.
이 두 번째 사고 이후로는 값 자체가 아니라 부르는 방식을 검사하는 장치를 하나 더 붙였다. `test_age.py`에 날짜 계산 함수를 원본 주소·작성자 정보 없이 단독으로 부르는 코드가 있는지 찾는 가드를 추가했다. 함수 자체는 멀쩡한데 부르는 쪽이 인자를 안 주면 방어가 통째로 사라지는 유형이라, 값만 테스트해서는 안 잡히기 때문이다. 옛 호출 방식으로 한 번 되돌려 가드가 정말로 잡아내는지 확인하고, 다시 고친 방식으로 되돌려 통과하는지도 확인했다.
이번 사이클에서 새로 만든 코드는 준비 프로그램 하나뿐이었다. 나머지는 이미 있던 스크립트 (`test_age.py`·`eval_golden.py`)를 다시 돌려 확인한 것이다. 새 기능을 만드는 김에 오래된 값의 신뢰도부터 확인하는 순서가, 결과적으로 숨어 있던 문제를 먼저 찾게 해 줬다.
비슷하게 데이터가 시점에 따라 조용히 굳어 버리는 자동화를 겪고 있다면, 이런 수집·검증 로직을 직접 진단받고 싶을 때 상담에서 어디부터 봐야 하는지 같이 확인할 수 있다.