
Google Apps Script 메일 자동 발송 스크립트에서 트리거 중복 실행으로 같은 메일이 2번 발송된 실제 실패 후기입니다. 시간 기반 트리거와 수동 테스트 트리거가 겹친 원인을 실행 로그, LockService, 발송 이력 시트로 해결한 과정을 정리했습니다.
자동화가 편해진 줄 알았는데 같은 메일이 두 번 갔다
2026년 3월 6일 오전, 첫 번째 문의를 받았다
문제가 처음 드러난 건 2026년 3월 6일 오전 9시 05분이었다. 전날까지는 업무 자동화가 잘 돌아간다고 생각하고 있었는데, 한 명에게서 “같은 안내 메일이 두 번 왔다”는 문의가 들어왔다.
당시 나는 Google Apps Script로 문서 발송 자동화를 만들어 둔 상태였다. 자동화 적용 기간은 2026년 3월 1일부터 2026년 3월 12일까지였고, 자동 발송 대상자는 총 84명이었다.
처음에는 단순한 착오라고 생각했다. 그런데 발송 기록을 확인해보니 전체 발송 메일 수가 168건으로 찍혀 있었고, 그중 중복 발송된 메일 수가 27건이나 됐다.
처음에는 Gmail 발송 지연 문제라고 생각했다
처음 의심한 건 Gmail이었다. 메일 발송이 지연되면서 재시도되었거나, 수신자 쪽에서 같은 메일이 두 번 보이는 오류가 생긴 줄 알았다.
하지만 Gmail 보낸편지함을 확인해보니 실제로 같은 제목, 같은 본문, 같은 수신자에게 메일이 두 번 발송돼 있었다. 수신자 착각도 아니고, Gmail 화면 표시 오류도 아니었다.
이때부터 문제는 발송 시스템이 아니라 내 Apps Script 구조에 있을 가능성이 커졌다. 자동화가 편해졌다고 생각했지만, 실제로는 중복 실행을 막는 장치를 거의 넣지 않은 상태였다.
실행 로그를 보니 스크립트가 두 번 돌고 있었다
트리거가 4개나 남아 있었다
가장 먼저 한 일은 Google Apps Script 실행 로그를 확인하는 것이었다. 문제를 찾는 동안 스크립트 실행 로그 확인 횟수는 총 43회까지 늘어났다.
로그를 보니 같은 시간대에 발송 함수가 거의 연속으로 실행되고 있었다. 처음에는 “왜 한 함수가 두 번 호출되지?”라고 생각했지만, 트리거 화면을 열어보고 바로 이유를 알았다.
기존 트리거 수는 4개였다. 그런데 실제 필요한 트리거 수는 1개뿐이었다.
| 구분 | 문제 발생 전 상태 | 수정 후 상태 | 비고 |
|---|---|---|---|
| 전체 트리거 수 | 4개 | 1개 | 불필요한 트리거 3개 삭제 |
| 시간 기반 트리거 | 2개 남아 있음 | 1개만 유지 | 동일 함수가 중복 실행됨 |
| 수동 테스트 트리거 | 1개 남아 있음 | 삭제 | 테스트 후 정리하지 않은 항목 |
| 기타 테스트 트리거 | 1개 존재 | 삭제 | 초기 테스트 과정에서 생성 |
| 실제 필요한 트리거 | 구분 안 됨 | 1개로 문서화 | 운영용 트리거만 유지 |
수동 테스트용 트리거를 지우지 않은 게 문제였다
중복 실행 원인은 명확했다. 시간 기반 트리거 2개와 수동 테스트 트리거 1개가 남아 있었다.
테스트할 때는 빠르게 확인하려고 트리거를 여러 개 만들었다. 오전 발송용, 임시 확인용, 특정 시간 재실행용처럼 나눠서 만들어 놓고 운영 전 정리를 하지 않았다.
문제는 자동화가 실제 대상자 84명에게 적용된 뒤에도 이 트리거들이 그대로 남아 있었다는 점이다. 결국 같은 발송 함수가 서로 다른 트리거에 의해 실행되면서 일부 대상자에게 같은 메일이 2번 발송됐다.
중복 발송을 막기 위해 바꾼 구조
LockService를 추가했다
기존 코드는 너무 단순했다. 대상자 목록을 읽고, 조건이 맞으면 바로 GmailApp.sendEmail을 실행하는 구조였다.
이 방식은 정상 상황에서는 빨랐다. 하지만 트리거가 겹치거나 같은 함수가 동시에 실행되면, 서로가 이미 발송 중인지 알 수 없었다.
// 중복 방지 전 코드 예시
function sendNoticeEmails() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('발송대상');
const rows = sheet.getDataRange().getValues();
for (let i = 1; i < rows.length; i++) {
const email = rows[i][1];
const name = rows[i][2];
const documentId = rows[i][3];
const status = rows[i][4];
if (status !== '발송완료') {
GmailApp.sendEmail(
email,
'[안내] 문서 확인 요청',
name + '님, 문서를 확인해 주세요. 문서 ID: ' + documentId
);
sheet.getRange(i + 1, 5).setValue('발송완료');
}
}
}
겉으로 보면 문제가 없어 보인다. 하지만 두 개의 트리거가 거의 동시에 실행되면 둘 다 status 값을 ‘발송 전’으로 읽을 수 있다.
그러면 첫 번째 실행이 발송하고 상태값을 바꾸기 전에, 두 번째 실행도 같은 행을 발송 대상으로 판단한다. 이번 중복 발송의 핵심이 바로 이 부분이었다.
발송 이력 시트로 중복 키를 검사했다
수정하면서 가장 먼저 추가한 방어 로직은 LockService였다. 동시에 실행되는 스크립트가 있으면 하나만 작업하도록 잠금을 걸었다.
두 번째로 발송 이력 시트를 따로 만들었다. 발송 이력 저장 항목은 날짜, 이메일, 문서 ID, 발송 상태 네 가지로 정했다.
단순히 대상자 시트의 상태값만 믿지 않고, 이메일과 문서 ID를 합친 중복 키를 만들어 이미 발송된 조합인지 먼저 확인하도록 바꿨다.
// LockService를 적용한 수정 후 코드 예시
function sendNoticeEmailsSafely() {
const lock = LockService.getScriptLock();
try {
lock.waitLock(30000);
const ss = SpreadsheetApp.getActiveSpreadsheet();
const targetSheet = ss.getSheetByName('발송대상');
const historySheet = ss.getSheetByName('발송이력');
const targets = targetSheet.getDataRange().getValues();
const histories = historySheet.getDataRange().getValues();
const sentKeys = new Set();
for (let i = 1; i < histories.length; i++) {
const email = histories[i][1];
const documentId = histories[i][2];
const status = histories[i][3];
if (status === '발송완료') {
sentKeys.add(email + '_' + documentId);
}
}
for (let i = 1; i < targets.length; i++) {
const email = targets[i][1];
const name = targets[i][2];
const documentId = targets[i][3];
const status = targets[i][4];
const key = email + '_' + documentId;
if (status === '발송완료' || sentKeys.has(key)) {
continue;
}
GmailApp.sendEmail(
email,
'[안내] 문서 확인 요청',
name + '님, 문서를 확인해 주세요. 문서 ID: ' + documentId
);
targetSheet.getRange(i + 1, 5).setValue('발송완료');
historySheet.appendRow([new Date(), email, documentId, '발송완료']);
sentKeys.add(key);
}
} finally {
lock.releaseLock();
}
}
실행 전 상태값을 먼저 확인하도록 바꿨다
수정 후에는 발송 전에 세 가지를 확인하게 했다. 현재 실행 중인 스크립트가 있는지, 대상자 시트의 상태값이 발송 전인지, 발송 이력 시트에 같은 이메일과 문서 ID 조합이 있는지 확인했다.
이 구조로 바꾸니 마음이 훨씬 편해졌다. 트리거가 한 번 더 실행되더라도 발송 이력 시트에서 걸러질 수 있었고, 동시에 실행되더라도 LockService가 먼저 막아줬다.
수정에 걸린 시간은 총 2시간 15분이었다. 코드 자체보다 로그를 따라가며 원인을 확정하는 데 시간이 더 많이 들었다.
수정 전후 결과 비교
중복 발송률이 16.1%에서 0%로 줄었다
수정 전 중복 발송률은 16.1%였다. 전체 발송 메일 수 168건 중 중복 발송된 메일이 27건이었으니, 업무 자동화라고 하기에는 위험한 수준이었다.
수정 후 중복 발송률은 0%로 줄었다. 트리거를 1개만 남기고, LockService와 발송 이력 시트, 중복 키 검사를 추가한 뒤에는 같은 유형의 중복 발송이 다시 나오지 않았다.
| 항목 | 수정 전 | 수정 후 | 정리 |
|---|---|---|---|
| 자동 발송 대상자 | 84명 | 84명 | 대상자는 동일 |
| 전체 발송 메일 수 | 168건 | 정상 발송 기준 관리 | 중복 발송 포함 여부 분리 |
| 중복 발송된 메일 수 | 27건 | 0건 | 중복 키 검사 적용 |
| 기존 트리거 수 | 4개 | 1개 | 운영용만 유지 |
| 중복 발송률 | 16.1% | 0% | 핵심 개선 지표 |
| 수정에 걸린 시간 | – | 2시간 15분 | 로그 확인과 코드 수정 포함 |
자동화 시간은 줄었지만 로그 관리는 더 중요해졌다
이 일을 겪고 나니 자동화에서 중요한 것은 “실행되게 만드는 것”만이 아니었다. 언제, 어떤 조건으로, 몇 번 실행됐는지 확인할 수 있어야 했다.
처음 만든 스크립트는 메일을 보내는 기능만 있었다. 하지만 운영에 필요한 것은 발송 함수보다 실행 이력, 트리거 목록, 예외 상황 기록이었다.
자동화는 사람이 하던 일을 줄여주지만, 실수까지 알아서 막아주지는 않는다. 특히 메일 발송처럼 외부 사용자에게 바로 영향을 주는 작업은 중복 방지가 먼저였다.
지금 내가 쓰는 Apps Script 자동화 기준
트리거는 반드시 문서화한다
지금은 Apps Script 자동화를 만들면 트리거부터 문서화한다. 어떤 함수가 어떤 시간에 실행되는지, 테스트용인지 운영용인지, 삭제 예정인지까지 적어둔다.
예전에는 테스트가 끝나면 기억하고 지우면 된다고 생각했다. 하지만 실제 업무에서는 다른 요청이 들어오고, 급한 수정이 생기고, 며칠 지나면 어떤 트리거가 왜 있는지 흐려진다.
이번 문제도 코드가 아주 어려워서 생긴 일이 아니었다. 정리하지 않은 트리거가 남아 있었고, 그 트리거가 조용히 같은 함수를 다시 실행했을 뿐이다.
발송 자동화는 실행 이력을 남긴다
메일 발송 자동화에는 반드시 실행 이력을 남기기로 했다. 최소한 날짜, 이메일, 문서 ID, 발송 상태는 저장한다.
이 네 가지가 있으면 나중에 문제가 생겼을 때 누가, 언제, 어떤 문서를 받았는지 확인할 수 있다. 반대로 이력이 없으면 Gmail 보낸편지함과 스크립트 로그를 뒤져야 해서 원인 분석이 오래 걸린다.
또 발송 이력은 단순 기록용이 아니라 중복 방지 장치로도 쓸 수 있다. 이미 발송된 이메일과 문서 ID 조합이면 다시 보내지 않도록 막을 수 있기 때문이다.
마무리하며, 자동화는 실행보다 중복 방지가 먼저였다
이번 Google Apps Script 트리거 중복 실행 문제는 꽤 부끄러운 실패였다. 자동 발송 대상자 84명에게 보내는 메일이었고, 그중 27건이 중복 발송됐다.
처음에는 Gmail 오류를 의심했지만, 실제 원인은 내 자동화 관리 방식에 있었다. 시간 기반 트리거 2개와 수동 테스트 트리거 1개가 남아 있었고, 기존 트리거 수는 4개였지만 실제 필요한 트리거 수는 1개뿐이었다.
다행히 실행 로그를 43회 확인하면서 원인을 좁혔고, LockService, 발송 이력 시트, 중복 키 검사를 추가해 수정 후 중복 발송률을 0%로 낮출 수 있었다.
자동화는 잘 실행되는 순간보다 잘못 실행되지 않게 만드는 순간이 더 중요했다. 특히 메일, 결제, 알림처럼 사용자가 바로 체감하는 자동화라면 중복 방지는 선택이 아니라 기본값이어야 한다.
FAQ
Q1. Google Apps Script에서 메일이 두 번 발송되면 Gmail 문제인가요?
항상 Gmail 문제라고 보기는 어렵다. 이번 경우에는 Gmail 오류가 아니라 Apps Script 트리거가 중복으로 실행된 것이 원인이었다.
보낸편지함에 같은 메일이 실제로 두 번 남아 있다면, 먼저 Apps Script 실행 로그와 트리거 목록을 확인하는 것이 좋다.
Q2. LockService만 추가하면 중복 발송을 완전히 막을 수 있나요?
LockService는 동시에 실행되는 스크립트를 막는 데 도움이 된다. 하지만 그것만으로 모든 중복 발송을 막는다고 생각하면 위험하다.
나는 LockService와 함께 발송 이력 시트, 이메일과 문서 ID 기반 중복 키 검사를 같이 넣었다. 여러 방어 로직을 겹쳐야 실무에서 더 안전했다.
Q3. Apps Script 트리거는 몇 개까지 만들어도 괜찮나요?
개수 자체보다 관리가 중요하다. 테스트용 트리거와 운영용 트리거가 섞이면 어떤 함수가 언제 실행되는지 헷갈리기 쉽다.
이번 사례에서는 기존 트리거 수가 4개였지만 실제 필요한 트리거는 1개였다. 운영 전에는 불필요한 트리거를 반드시 삭제해야 한다.
재발 방지 체크리스트
| 번호 | 자동화 배포 전 체크리스트 | 확인 이유 |
|---|---|---|
| 1 | 운영용 트리거가 몇 개인지 확인한다 | 중복 실행 방지 |
| 2 | 테스트용 트리거를 모두 삭제한다 | 테스트 함수가 운영 중 실행되는 문제 방지 |
| 3 | 시간 기반 트리거 실행 시간을 문서화한다 | 같은 시간대 중복 실행 여부 확인 |
| 4 | 발송 전 LockService를 적용한다 | 동시 실행 방지 |
| 5 | 발송 이력 시트를 만든다 | 누가 어떤 메일을 받았는지 추적 |
| 6 | 날짜, 이메일, 문서 ID, 발송 상태를 저장한다 | 중복 키 검사 기준 확보 |
| 7 | 이메일과 문서 ID로 중복 키를 만든다 | 같은 문서 재발송 방지 |
| 8 | 배포 후 첫 실행 로그를 반드시 확인한다 | 예상하지 못한 반복 실행 발견 |
| 9 | 자동화 수정 후 트리거 목록을 다시 캡처하거나 기록한다 | 이후 문제 발생 시 비교 기준 확보 |