엔지니어링

Python 메일 자동 발송: 구분선 파서가 내부 메모를 본문에 실은 이유

Python 메일 자동 발송 스크립트에서 본문 시작을 “몇 번째 줄 이후 첫 구분선”으로 찾으면, 사람이 헤더에 선 하나만 더 그어도 내부 메모가 고객 메일에 실립니다. 저희 회신 발송기가 실제로 그랬고, 본문은 정상 3,276자가 아니라 3,944자였습니다. 형식 검사는 통과했고, 미리보기 본문을 눈으로 읽고서야 잡았습니다.

2026-11-062026년 9월 7일 회신 초안 미리보기: 본문 3,944자(정상 3,276자), 가격 판단 근거·PR 번호·내부 파일 경로 포함예전 파서 규칙 “6번째 줄 이후 첫 ----”, 형식 검사(grep 2줄) 통과수리: scripts/send_inbound_reply.py 본문 기준점을 [제목] 블록 뒤로 이동, 회귀 테스트 tests/test_send_inbound_reply_draft_parse.py 5건같은 발송기의 이후 검사: 서명 변형 허용(2026년 8월 28일·10월 20일), 채우지 않은 칸 발송 거부(2026년 10월 20일), 발송 지시 원문 대조 180분 창

증상: 미리보기 본문은 왜 3,944자였나?

Python 메일 자동 발송 스크립트가 고객 회신 본문에 내부 메모를 통째로 싣고 있었습니다. 2026년 9월 7일 한 고객사 문의에 보낼 회신 초안을 미리보기 실행으로 찍어 보니, 본문 길이가 정상 3,276자가 아니라 3,944자였습니다. 늘어난 부분에는 가격을 어떻게 판단했는지 적은 근거, 작업 PR 번호, 내부 파일 경로가 들어 있었습니다.

이 내용은 모두 초안 파일 맨 위 헤더에 적어 둔 메모였습니다. 헤더는 회신을 검토하는 사람이 보려고 적는 칸이고, 고객에게 나가면 안 되는 칸입니다. 고객 입장에서는 업체가 가격을 어떤 계산으로 정했는지, 내부에서 어떤 파일을 열었는지가 그대로 보이는 메일을 받게 됩니다.

더 아찔한 부분은 발견 경로였습니다. 이 오류는 자동 검사가 아니라 사람이 미리보기 출력을 눈으로 읽다가 잡았습니다. 그 단계를 건너뛰고 실제 발송 옵션(`--live`)을 붙여 실행했다면, 메일은 그대로 고객 메일함에 도착했을 상황이었습니다.

결과 카드: 미리보기 본문 3,944자, 정상 본문 3,276자, 형식 검사 2줄 통과, 수리 후 회귀 테스트 5건
형식 검사는 통과했고, 본문은 내부 메모만큼 길어져 있었다

이 발송 스크립트는 초안 파일을 어떻게 읽나?

구조부터 설명하겠습니다. 저희 발송 스크립트는 메일 본문을 코드 안에 적지 않습니다. 사람이 메모장으로 검토하고 승인한 텍스트 파일을 읽어서 그대로 보냅니다. 승인한 글과 보낸 글이 어긋날 수 없게 하려는 설계입니다. 받는 사람 주소와 초안 경로도 실행할 때 인자로 넘겨서, 고객 개인정보가 소스 코드에 남지 않게 했습니다.

초안 파일은 네 부분으로 이뤄집니다. 맨 위에 `====` 줄로 감싼 메타 블록이 있고, 여기에 접수일·초안일과 검토용 메모를 적습니다. 그 아래 `[제목]` 줄과 메일 제목이 오고, 다시 `----` 구분선이 나옵니다. 구분선 다음부터 서명까지가 고객에게 나가는 본문입니다.

파서가 할 일은 두 가지입니다. `[제목]` 다음 줄을 메일 제목으로 꺼내고, 본문이 어디서 시작하는지 찾아 그 아래만 잘라 내는 일입니다. 이번 사고는 두 번째 일, 즉 본문 시작점을 찾는 규칙에서 났습니다.

흐름도: 메타 블록(접수일·초안일·내부 메모) 아래 [제목] 줄과 제목, 그 아래 ---- 구분선, 그 아래 본문과 서명. 예전 규칙은 6번째 줄 이후 첫 ----를 찾았고 새 규칙은 [제목] 뒤의 ----를 찾는다
초안 파일의 네 부분과, 두 규칙이 본문 시작을 찾는 위치

처음 의심한 것: 초안을 쓴 사람의 실수

가장 먼저 떠오른 설명은 사람의 실수였습니다. 그 초안의 헤더 안에는 구분선이 하나 더 그어져 있었습니다. 메모를 보기 좋게 나누려고 그은 선이었고, 파서는 그 선을 본문 시작으로 읽었습니다. 선을 긋지 않았다면 사고도 없었으니, 작성 규칙에 “헤더에는 구분선을 긋지 말 것” 한 줄을 더하면 끝난다는 가설이었습니다.

이 가설은 그럴듯했습니다. 이전 초안들은 같은 파서로 문제없이 나갔고, 이번 초안만 헤더 모양이 달랐습니다. 차이가 하나뿐이면 그 차이를 원인으로 보는 판단은 자연스럽습니다. 하지만 이 가설이 맞다면 규칙 한 줄로 같은 사고를 막을 수 있어야 하고, 그 점을 따로 확인해야 했습니다.

어떻게 확인했나: 형식 검사, 미리보기 본문, 파서 코드

확인 수단은 세 가지였습니다. 첫째는 발송 전에 돌던 형식 검사입니다. grep 2줄로 초안에 필요한 표지가 들어 있는지 보는 검사였고, 이번 초안도 통과했습니다. 표지는 다 있었기 때문입니다. 이 검사는 표지가 있는지만 묻고, 그 표지 사이에 무엇이 끼었는지는 묻지 않았습니다.

둘째는 미리보기 실행 출력을 사람이 처음부터 끝까지 읽는 방법입니다. 이번 유출을 잡은 수단은 이것 하나였습니다. 출력 길이 3,944자와 정상 초안 3,276자의 차이도 이 출력에서 나왔습니다. 셋째는 파서 코드를 직접 읽는 방법입니다. 본문 시작을 정하는 규칙이 “6번째 줄 이후 처음 나오는 `----`”였다는 사실을 여기서 확인했습니다.

어떻게 확인했나: 형식 검사, 미리보기 본문, 파서 코드
수단무엇을 물었나이번 유출을 잡았나
형식 검사 (grep 2줄)필요한 표지가 파일에 있는가못 잡음 (표지는 다 있었다)
미리보기 출력 읽기실제로 나갈 본문이 무엇인가잡음 (3,944자, 내부 메모 포함)
파서 코드 읽기본문 시작을 무엇으로 정하는가원인 확인: 6번째 줄 이후 첫 ----

세 수단을 나란히 놓으면 차이가 분명합니다. 자동으로 돌던 검사는 “형식이 맞는가”를 물었고, 사람이 읽은 출력은 “고객이 받을 글이 무엇인가”를 보여 줬습니다. 유출은 두 질문 사이의 틈에 있었습니다.

알리바이: 작성자 실수는 왜 원인이 아니었나?

파서 코드를 읽고 나서 첫 가설을 내려놓았습니다. 헤더는 형식이 정해진 칸이 아닙니다. 사람이 회신마다 새로 쓰고, 그때그때 필요한 메모를 자유롭게 적습니다. 메모를 나누려고 선을 긋는 행동은 그 칸의 용도 안에 있는 행동이지 규칙 위반이 아닙니다.

“헤더에 구분선을 긋지 말자”는 규칙을 더해도 결과는 같습니다. 규칙은 사람이 기억할 때만 작동하고, 헤더를 쓰는 사람은 매번 바뀌거나 매번 급합니다. 언젠가 누군가 또 선을 긋고, 그날 미리보기를 건너뛰면 메모는 고객에게 나갑니다. 규율로 막는 방법은 사고 확률을 낮출 뿐 0으로 만들지 못합니다.

헤더는 자유 형식이고 사람이 매번 새로 쓴다. “헤더에 구분선을 긋지 말자”는 규율로는 못 막는다.

그래서 질문을 바꿨습니다. “누가 실수했나”가 아니라 “왜 파서는 사람이 자유롭게 쓰는 칸 안의 선을 본문 시작으로 믿었나”였습니다.

진짜 원인: 기준점이 바뀌는 영역 위에 있었다

원인은 파서의 기준점이었습니다. 예전 규칙은 “6번째 줄 이후 처음 나오는 `----`”를 본문 시작으로 잡았습니다. 이 규칙은 헤더 길이가 대략 일정하고, 헤더 안에 `----`가 없다는 두 전제 위에 서 있었습니다. 두 전제 모두 헤더를 쓰는 사람이 지켜 줘야 성립합니다.

즉 기준점이 파일에서 가장 잘 바뀌는 영역, 사람이 매번 다르게 쓰는 헤더 쪽에 걸려 있었습니다. 헤더 안에 선이 하나 생기자 파서는 그 선을 본문 시작으로 읽었고, 그 아래의 메모와 `[제목]` 블록까지 전부 본문에 담았습니다. 오류도 나지 않았습니다. 파서 입장에서는 규칙대로 선을 찾았으니 정상 동작이었습니다.

이런 결함은 조용합니다. 예외가 나지 않으니 로그에도 남지 않고, 표지 검사도 통과합니다. 같은 시기 같은 접수 챗봇 작업에서 응답 검사는 통과했는데 메일이 사라지던 결함도 같은 성격이었습니다. 그 이야기는 챗봇 알림 메일 누락 디버깅기에 따로 적었습니다.

고친 방법: 본문 기준점을 [제목] 뒤로 옮겼다

수리는 기준점 이동 하나입니다. 본문 시작을 찾을 때 파일의 줄 번호가 아니라 `[제목]` 블록의 위치에서 출발하게 했습니다. `[제목]` 줄과 그 다음 제목 줄을 건너뛴 뒤, 그 아래에서 처음 나오는 `----` 다음 줄부터를 본문으로 봅니다. 이제 헤더에 선을 몇 개 긋든 파서는 헤더를 보지 않습니다. 새 표지를 만들 필요도 없었습니다. `[제목]`은 파서가 메일 제목을 꺼낼 때 이미 쓰던 표지라, 초안 형식은 그대로 두고 본문 쪽 기준만 같은 자리로 맞췄습니다.

코드 비교: 왼쪽은 6번째 줄 이후 첫 ---- 를 본문 시작으로 잡던 예전 규칙, 오른쪽은 [제목] 줄 위치에서 두 줄 아래부터 ---- 를 찾고 없으면 발송을 멈추는 새 코드
기준점을 줄 번호에서 [제목] 표지로 옮겼다

찾지 못했을 때의 동작도 정했습니다. `[제목]`이 없으면 제목을 찾지 못했다는 메시지와 함께 발송을 멈추고, `[제목]` 아래에 구분선이 없으면 “[제목] 아래에 ---- 줄이 있어야 한다”는 메시지와 함께 멈춥니다. 애매할 때 아무 선이나 골라 보내는 대신, 보내지 않고 사람에게 돌려주는 쪽을 택했습니다.

그리고 초안 파싱 회귀 테스트 5건을 더했습니다. 헤더 안에 구분선이 있는 초안처럼 이번 사고를 만든 모양을 테스트로 고정해 두면, 나중에 누가 파서를 고쳐도 같은 유출이 돌아오는 순간 테스트가 먼저 실패합니다. 회귀 테스트는 고친 자리가 다시 깨지지 않게 하는 장치입니다.

같은 실수를 막는 장치는 무엇인가?

장치는 두 겹입니다. 첫째는 설계 원칙입니다. 파서의 시작 기준점은 사람이 자유롭게 쓰는 가변 영역이 아니라, 형식상 바뀌지 않는 표지에 둡니다. 이번에는 `[제목]`이 그 표지입니다. 규칙을 사람에게 지키라고 요구하는 대신, 지키지 않아도 사고가 날 수 없는 구조로 바꾸는 방식입니다.

둘째는 발송 전 습관입니다. 미리보기 출력의 본문을 실제로 읽습니다. 이번 사고에서 grep 2줄짜리 형식 검사는 유출을 못 잡았고, 사람의 눈은 잡았습니다. 형식 검사는 빠지면 안 되는 것을 확인하는 데 쓰고, 들어가면 안 되는 것이 들어갔는지는 실제 본문을 읽어서 확인합니다.

  • 본문 시작 기준점은 바뀌지 않는 표지(`[제목]`)에 둔다. 줄 번호나 자유 형식 영역에 걸지 않는다.
  • 표지를 못 찾으면 추측하지 않고 발송을 멈춘다.
  • 사고를 만든 초안 모양을 회귀 테스트 5건으로 고정한다.
  • 실제 발송 전에는 미리보기 본문을 처음부터 끝까지 읽는다. 형식 검사 통과는 본문이 맞다는 증거가 아니다.

이 발송기에는 어떤 검사가 더 붙어 있나?

같은 원칙은 이 발송기의 다른 자리에도 들어가 있습니다. 공통점은 “사람에게 조심하라고 하지 않고, 어긋나면 보내지 않는다”입니다. 몇 가지는 이번 사고보다 먼저, 몇 가지는 뒤에 붙었습니다.

이 발송기에는 어떤 검사가 더 붙어 있나?
검사언제 생겼나어긋나면
서명 형식: 정식 서명 그대로 쓴 초안도 받기2026년 8월 28일표준 서명으로 통일해서 발송
서명 첫 줄로 끝낸 초안도 받기2026년 10월 20일표준 서명을 붙여 발송
본문 기준점: [제목] 뒤 첫 ----2026년 9월 7일구분선이 없으면 발송 중단
채우지 않은 칸 {{이름}}·고르지 않은 선택 단락2026년 10월 20일남은 칸이 하나라도 있으면 발송 중단
발송 지시 원문 대조2026년 9월 8일같은 세션·최근 180분 안의 실제 지시가 없으면 실발송 거부

채우지 않은 칸 검사는 이번 사고와 닮았습니다. 초안에는 비밀번호 같은 값을 적지 않고 `{{이름}}` 칸으로 남겨 두고, 발송 직전 스크립트가 금고에서 값을 꺼내 채웁니다. 화면과 기록에는 채운 값을 가려서 보여 주고, 채우지 못한 칸이 하나라도 남으면 보내지 않습니다. 사람이 사본을 만들어 손으로 채우면 그 사본이 디스크에 남기 때문입니다.

발송 지시 대조도 같은 생각입니다. 실발송은 사람이 직접 입력한 “발송해” 류 문장이 같은 작업 세션의 최근 180분 안에 원문 그대로 남아 있을 때만 나갑니다. 프로그램이 승인을 스스로 추론해서 메일을 보내는 길을 막으려는 장치입니다.

Python 메일 자동 발송 스크립트를 만든다면

이번 사고에서 가져갈 점은 하나입니다. 사람이 쓰는 텍스트 파일을 파싱해 밖으로 내보내는 코드라면, 기준점을 어디에 거느냐가 곧 유출 범위를 정합니다. 줄 번호, “처음 나오는 구분선”처럼 파일 모양에 기대는 규칙은 사람이 모양을 바꾸는 날 조용히 틀립니다. 반드시 있어야 하고 다른 용도로 쓰이지 않는 표지를 정해서, 그 표지에서 출발하게 하십시오.

그리고 “검사 통과”와 “받는 사람이 볼 글이 맞다”를 같은 말로 쓰지 마십시오. 저희는 같은 시기 접수 챗봇 시험판에서도 확인 항목 20개 중 18개가 초록인데 폭주 시 메일이 8건 중 2건만 나가는 결함을 봤고, 재시도를 넣어 8/8로 복구했습니다. 두 사고 모두 검사는 응답과 형식만 보고 있었고, 실제로 나가는 결과는 따로 봐야 했습니다.

문의 회신이나 알림 메일을 자동으로 보내는 시스템을 만들고 있는데 이런 조용한 유출이 걱정된다면, 상담으로 지금 구조를 보내 주십시오. 어디서 본문이 잘리고 어디서 검사가 비는지 같이 보겠습니다.

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

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

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

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