증상: 켰다는 본문 대신 496자짜리 옛 본문이 나왔다
crontab 환경변수로 켠 기능이 실제로는 꺼진 채였던 원인은 플래그가 아니라 크론이 도는 코드 사본이 낡았다는 데 있었습니다. 2026년 8월 28일, 저희는 매일 아침 08:50에 영업 메일 발송 큐를 만드는 crontab 줄에 `COLDMAIL_REPLY_ONLY_LIVE=1`을 붙였습니다. 이 값이 켜지면 실험군 B에 속한 수신자는 새 본문을 받아야 했습니다.
새 본문은 630자였습니다. 그런데 켠 직후 결과를 확인하던 중, 한 실행에서 496자짜리 본문이 나왔습니다. 496자는 정정 전 구버전 본문의 길이였습니다. 플래그를 켰다는 기록과 실제로 생성된 글자 수가 서로 다른 말을 하고 있었습니다.

이 차이를 놓쳤다면 무슨 일이 생겼을까요. 크론은 다음 날 아침 같은 체크아웃에서 그대로 돌고, 실험군 B에는 새 본문이 아니라 정정 전 카피가 나갑니다. 실험 기록에는 새 본문 라벨이 붙은 채로요. 그러면 2주 뒤 회신율을 비교해도 무엇을 비교했는지 알 수 없습니다.
이 환경변수는 무엇을 바꾸려던 것인가?
배경부터 짚겠습니다. 당시 라이브 본문(내부 버전명 v3)은 받는 사람에게 세 가지를 한 번에 요구했습니다. 정정(틀렸다면 “아니오” 한 줄), 링크 클릭(“지금 확인해 보기”), 회신(“한 줄만 회신”)입니다. 요청이 여러 개면 받는 사람은 고르다 멈추고, 멈춘 메일의 기본 결과는 삭제나 보류입니다.
실측이 그 모양이었습니다. v3로 나간 669건에서 클릭은 27건(4.2%)이었고, 그 클릭에서 나온 리드는 0건이었습니다. 리드 목록 파일에서 메일 직접 유입으로 분류된 4건을 하나씩 열어도 콜드메일 회신은 없었습니다. 8월 13일 퍼널도 같은 그림이었습니다. 클릭 24 → 진단 시작 7 → 문의 0. 30일 창으로 보면 클릭 128, 열람 410이었습니다. 유일하게 움직이던 지표가 매출로 이어진 적은 한 번도 없었습니다.
그래서 요청 수 하나만 바꾸기로 했습니다. 새 본문은 라이브 v3에서 요청 두 개만 뺀 것이고, 실제 차이는 4줄 삭제 · 2줄 추가입니다. 금액을 계산한 문단과 “절감 보장 아님” 같은 표시광고법 방어 문구는 v3와 글자 그대로 같습니다. 바꾼 축이 하나여야 결과를 그 축에 귀속시킬 수 있기 때문입니다. 이 실험의 회신률 쪽 이야기는 콜드메일 회신률, 클릭 4.2%가 리드 0건이었던 이유에 따로 정리했습니다.

이 변경은 처음부터 한 번에 끝나지 않았습니다. 본문은 PR #423으로 신설했고, 어느 본문에서 파생할지 기준을 PR #425로 정정했습니다. 코드가 두 번 바뀌었다는 점이 뒤에서 원인과 이어집니다.
처음 믿은 것: crontab 한 줄에 환경변수를 붙이면 끝난다
켜는 방식은 단순하게 골랐습니다. 발송 큐를 만드는 crontab 08:50 줄 앞에 환경변수 하나를 붙이는 방식입니다. 되돌릴 때도 그 한 줄에서 값을 지우면 됩니다. 코드는 이미 원격 저장소에 머지돼 있었고, 플래그 이름도 맞게 적었습니다.
그래서 처음 세운 가정은 이랬습니다. “코드는 머지됐고 플래그는 켰으니, 내일 아침 크론은 새 본문을 낸다.” 이 가정이 틀릴 수 있는 자리로 먼저 꼽은 건 플래그 쪽이었습니다. 환경변수 이름이 코드가 읽는 이름과 다르거나, 실험군 B를 고르는 조건이 생각과 다르게 걸려서 수신자가 옛 경로로 빠지는 경우입니다. 검증 항목은 이 두 가능성을 먼저 지우도록 짰습니다.
코드는 머지됐고 플래그는 켰으니, 내일 아침 크론은 새 본문을 낸다.
두 가설 모두 그럴듯했습니다. 환경변수는 오타가 나도 에러 없이 기본값으로 떨어지고, 실험군 조건은 해시 짝수와 직원수 인용 가능 여부 두 개를 동시에 봐야 해서 헷갈리기 쉬웠습니다.
무엇으로 확인했나?
상수 값을 다시 읽는 대신 실행으로 확인했습니다. 원칙은 하나였습니다. 크론이 하는 방식 그대로 돌린다. 직접 함수를 부를 때도 인자로 값을 넘기지 않고 크론처럼 환경변수로 넣었습니다.
- 크론과 같은 방식(환경변수)으로 본문 함수 `arm_b_body()`를 실행해 출력 확인: 본문 종류 `reply_only`, 요청 목록 `['회신']`, 링크 버튼 없음, 금액 문단 있음, 630자
- 크론과 같은 명령을 실전으로 실행해 큐 확인: 391행 중 `reply_only` 178행, 그 178행 안의 링크·HTML 버튼 0개
- 플래그를 뺀 상태로 다시 실행해 본문 종류가 `v3`로 돌아오는지 확인
- 같은 검증을 하는 동안 실제로 크론이 들어가는 체크아웃의 커밋 위치를 원격과 비교

핵심은 세 번째와 네 번째입니다. 켰을 때 바뀌는 것만 보면 “항상 새 본문이 나오는 버그”와 구분이 안 됩니다. 껐을 때 원래대로 돌아오는 것까지 봐야 플래그가 실제로 갈림길 역할을 한다고 말할 수 있습니다. 그리고 어느 코드 사본에서 돌렸는지를 따로 묻지 않으면, 앞의 세 단계가 전부 통과해도 크론 경로에 대해서는 아무것도 증명하지 못합니다.
알리바이: 플래그도 실험군 조건도 범인이 아니었다
첫 가설인 “환경변수 이름이 틀렸다”는 첫 번째 실행에서 무너졌습니다. 환경변수로 넣었을 때 본문 종류가 `reply_only`로 나왔고, 요청은 회신 하나, 링크는 없었습니다. 코드는 플래그를 정확히 읽고 있었습니다.
두 번째 가설인 “실험군 조건이 잘못 걸린다”도 큐에서 반증했습니다. 391행 가운데 178행이 새 본문이었고, 나머지는 원래 실험군 설계대로 다른 본문을 받는 행이었습니다. 실험군 B는 v3 대상 중 해시가 짝수이고 직원수를 본문에 인용할 수 있는 수신자로 한정했고, 나머지 묶음은 그대로 두었습니다. 의도한 비율이 큐에 그대로 나타났습니다.
세 번째 확인에서 플래그를 빼자 본문이 `v3`로 돌아왔습니다. 플래그는 켜고 끄는 대로 정확히 움직였습니다. 그렇다면 496자는 어디서 나왔을까요. 플래그도 조건도 멀쩡한데 옛 본문이 나왔다면, 남는 가능성은 실행한 코드 자체가 옛것인 경우뿐이었습니다.
진짜 원인은 무엇이었나? 크론이 도는 체크아웃이 1커밋 뒤처져 있었다
이 크론은 실행할 때 먼저 `cd /mnt/c/Users/kimky/b2b-consulting`로 PC의 로컬 체크아웃에 들어간 다음 그 안의 코드를 실행합니다. 원격 저장소에 무엇이 머지됐는지는 크론과 상관이 없습니다. 크론이 보는 건 그 폴더에 지금 놓인 파일뿐입니다.
그 체크아웃이 원격보다 1커밋 뒤처져 있었습니다. 원격에는 정정된 630자 본문이 있었지만, 크론이 들어가는 폴더는 아직 그 커밋을 받지 않은 상태였고 거기서 돌린 실행은 496자짜리 구버전 본문을 냈습니다. pull 없이 다음 날 아침이 왔다면 정정 전 카피가 실험군 B로 나갈 뻔했습니다.

돌아보면 처음 가정의 빈칸이 정확히 여기였습니다. “코드는 머지됐다”는 원격 저장소에 대한 사실이고, “크론은 새 본문을 낸다”는 로컬 폴더에 대한 주장입니다. 두 문장 사이를 잇는 단계, 곧 로컬 폴더가 원격을 따라잡았는가를 아무도 확인하지 않았습니다.
고친 방법: git pull --ff-only 로 당겼다
수리 자체는 한 줄이었습니다. 크론이 쓰는 체크아웃에서 `git pull --ff-only`로 원격을 당겼습니다. 원격에 있는 정정 본문이 630자였으니, 당긴 뒤에야 크론 경로의 본문이 검증한 본문과 같아졌습니다.
`--ff-only`를 고른 이유가 있습니다. 이 옵션은 로컬이 원격의 단순한 과거일 때만 앞으로 감아 주고, 로컬에 원격에 없는 변경이 섞여 있으면 병합 커밋을 만들지 않고 멈춥니다. 크론이 도는 폴더에서 몰래 병합이 일어나면 그 결과는 원격 어디에도 없는 코드가 됩니다. 멈추게 두는 쪽이 원인을 덮지 않습니다.
되돌리는 길도 같이 확인했습니다. 실험을 끝내야 하면 crontab 그 한 줄에서 `COLDMAIL_REPLY_ONLY_LIVE=1`만 지우면 됩니다. 손대기 전 crontab 전체(292줄)는 백업 파일로 남겼습니다.
같은 실수를 막는 장치
이번에 얻은 교훈은 한 문장입니다. 플래그를 켰다는 것과 그 코드가 크론 경로에 있다는 것은 다른 사실입니다. 이 둘을 따로 확인하게 만드는 장치를 넷 두었습니다.
- 실행으로 검증: 상수를 읽는 대신 크론과 같은 방식(환경변수)·같은 명령으로 돌리고, 켰을 때와 껐을 때를 둘 다 본다
- 코드 위치 확인: 검증은 크론이 실제로 들어가는 폴더에서 한다. 원격에 머지된 것과 그 폴더에 있는 것을 같은 것으로 치지 않는다
- 저장 라벨 분리: 큐와 발송 기록에 `v3`와 `reply_only`를 따로 적어, 어느 본문이 실제로 나갔는지 나중에 셀 수 있게 한다
- 되돌리기 한 줄: 끄는 방법을 crontab 한 줄로 고정하고 바꾸기 전 crontab 전체(292줄)를 백업한다
판정 기준도 실험을 켜기 전에 못 박았습니다. 이 저장소에서는 “2주 뒤 애매하다”로 끝나는 실패가 반복됐기 때문입니다. 실험군 B는 평상시 하루 25~35통, 주 5일이라 2주면 약 300통이 쌓입니다. 판정일은 2026년 9월 11일로 잡았습니다.

| 축 | 값 | 뜻 |
|---|---|---|
| 표본 | 하루 25~35통 × 주 5일, 2주 약 300통 | 이 양이 쌓이기 전에는 판정하지 않는다 |
| 채택 | 회신 3건 이상(1%) | v3 전체 확대 검토 |
| 기각 | 회신 0~1건 | 플래그 제거, 원인은 본문이 아니라 수신자 목록으로 보고 타깃 쪽으로 이동 |
| 즉시 중단 | 반송률 v3 대비 +2%p 초과 | 본문이 630자로 짧아져 스팸 필터 반응이 달라질 수 있다 |
이 표가 체크아웃 문제와 무슨 상관이냐고 물을 수 있습니다. 상관이 큽니다. 저장 라벨과 판정 기준은 “실제로 무엇이 나갔는가”를 전제로 세운 장치입니다. 크론이 옛 본문을 냈다면 `reply_only` 라벨이 붙은 옛 본문으로 2주를 세고, 그 숫자로 채택이나 기각을 결정했을 겁니다. 코드 위치 확인은 나머지 장치가 의미를 갖게 만드는 첫 단추였습니다.
이 기록이 아직 모르는 것은 무엇인가?
몇 가지는 이 기록 시점에 답이 없었습니다. 먼저 반송률입니다. 본문이 630자로 짧아지면서 스팸 필터가 다르게 반응할 수 있는데, 이건 실제로 보내 보기 전에는 알 수 없습니다. 그래서 +2%p 초과를 즉시 중단 조건으로 따로 두었습니다.
기준선에도 주의할 점이 있었습니다. 8월 27일에 v3 라벨로 경리 담당자 대상 379건이 나갔고, 그 회신은 아직 도착하는 중이었습니다. 다만 v3의 리드 0건은 669건으로 이미 확정된 값이라, 그 회신 창이 닫히기를 기다리지 않고 실험을 켰습니다. 기다려도 새로 얻을 정보가 없었고, 그동안 실험군 B는 매출로 이어지지 않는 클릭만 계속 만들었을 테니까요.
마지막으로, 이번에 잡은 건 체크아웃이 뒤처진 한 번의 사례입니다. 크론이 로컬 폴더에서 도는 구조가 그대로인 한, 머지와 pull 사이의 틈은 다음 변경에서도 다시 열릴 수 있습니다. 위의 장치는 그 틈을 닫는다기보다 열렸을 때 발송 전에 보이게 만드는 장치입니다.
정리: 플래그가 켜진 자리와 코드가 도는 자리를 따로 확인한다
crontab 환경변수로 기능을 켜는 방식은 간단하고 되돌리기도 쉽습니다. 그 간단함 때문에 “켰으니 돈다”고 넘어가기 쉽습니다. 저희는 플래그 이름과 실험군 조건을 먼저 의심했고, 둘 다 실행으로 반증한 뒤에야 크론이 들어가는 폴더가 원격보다 1커밋 뒤처져 있다는 걸 봤습니다.
크론이 실제로 돌았는지를 로그와 산출물로 다시 세는 방법은 크론 작업 실행 여부를 로그로 추적한 기록에 정리했습니다. 예약 실행으로 돌아가는 영업 메일·업무 자동화를 점검하거나 새로 설계해야 한다면 상담에서 이야기를 나눌 수 있습니다.