월말 청구서 마감 배치(batch)가 동일한 청구서를 두 번씩 처리하는 문제가 발생했습니다. 스프레드시트의 잔액 검산을 할 수 없어 역추적하여 원인을 조사한 결과, 트리거가 2개 등록되어 있었기 때문이었습니다. 분할 배포(split deploy)를 하면서 트리거를 재설정할 때마다 이전 트리거가 남아있었던 것입니다. LockService를 사용해도 문제가 해결되지 않았습니다. 중복 실행은 '계층을 하나만 넣어도 구멍이 생기는' 구조적인 문제였으며, 실제로 고장 난 후에 설계한 것이 본 기사의 내용입니다. 3가지 계층(트리거 정리・배타적 잠금(LockService)・멱등 키(Idempotency Key))의 언제 무엇을 선택할지 판단 기준을 그대로 복사하여 작동하는 Code.gs 파일과 함께 작성합니다.
| 계층 | 수단 | 막을 수 있는 중복 | 막을 수 없는 중복 |
|---|
| 계층 1: 트리거 정리 | getProjectTriggers + deleteTrigger | 이중 등록된 잔류 트리거 | 동시 시점의 병렬 실행 |
| ... | | | |
판단 기준은 하나입니다. '언제, 어떤 종류의 중복이 발생하는가'에 따라 계층이 결정됩니다. 계층 1은 발화원을 1개로 만드는 예방책이며, 계층 2는 동시 시점 충돌을 막는 보험이고, 계층 3은 그래도 겹쳤을 때 피해를 주지 않는 마지막 방어선입니다. 저의 야간 배치는 계층 2만 사용했기 때문에, '트리거 이중 등록'과 '30초 잠금(Lock) 자동 해제 후 미끄러져 들어오는 실행' 두 가지 모두에 취약했습니다.
| 실패 사례 | 발생한 일 | 대책 (해당하는 계층) |
|---|
| 트리거 2개 | 분할 배포로 재설정된 구(舊) 트리거가 남아있어, 2시경 두 번 발화함 | 계층 1: 설치 전에 동일 이름의 핸들러를 삭제한 후 등록 |
| ... | | |
스프레드시트의 work 시트에 A열=ID, B열=처리 텍스트, C열=처리 완료 키(비워도 무방)를 준비한 후, 아래 내용을 그대로 복사합니다.
// Code.gs — 트리거 정리・배타적 잠금・행 단위 멱등 키의 3계층 구성
// 절차: installTriggerSafely를 단 한 번만 실행 → 이후에는 트리거가 매일 2시경에 시작
// ==== 계층 1: 잔류 트리거를 정리한 후 1개만 설치한다 ===
...
왜 3계층으로 나누는가
- 계층 1 (트리거 정리): '발화원이 1개'임을 코드로 보장하는 계층입니다. 수동으로 트리거 화면을 정리해도, 다음 배포 시에는 다시 중복될 수 있습니다. 설치 함수를 한 번 실행하는 것만으로 정리와 등록이 함께 완료되도록 하는 것이 유지비 제로의 비결입니다.
- 계층 2 (LockService): '동시 시점에 2개가 돌아가는 것'만을 막습니다. 가져오기 성공을
if문으로 반드시 확인하고, 해제는 finally를 사용합니다. 이것만으로 충분합니다.
- 계층 3 (멱등 키): '같은 행을 두 번 건드리지 않는다'는 보장입니다. 잠금은 30초로 자동 해제되기 때문에, 6분에 가까운 긴 배치에서는 해제 후 들어오는 재실행/끊어졌다 다시 시작하는 것을 이 계층이 흡수합니다.
LockService의 함정: 30초 만에 자동 해제됨
잠금(Lock)은 스크립트가 다운되어도 자동으로 해제되는 것이 사양입니다. 이는 '데드락(Deadlock)'을 발생시키지 않는 안전장치인 동시에, 30초를 초과하는 배치에서는 계층 2 단독으로는 중복 실행을 막을 수 없음을 의미합니다. 저의 실패 사례 1이 바로 이것입니다. 트리거 이중 등록 + 장시간 처리의 조합으로 인해, 첫 번째 실행이 30초를 넘겨 잠금을 잃은 타이밍에 두 번째 실행이 미끄러져 들어와 같은 행을 두 번 처리했습니다. 계층 2는 '입구 정렬'을 담당하고, 지속성 보장은 계층 3에 맡긴다는 분담으로 설계해야 합니다.
어떤 경우에 무엇을 선택할까 (선정 기준표)
| 상황 | 추가해야 할 계층 | 이유 | 주의 사항 |
|---|
| 분할 배포로 트리거를 재설정하는 경우 | 계층 1 | 잔류 트리거가 중복 발화의 시발점이 될 수 있음 | 삭제 대상은 '동일 이름 핸들러'로 한정한다 |
| ... | | | |
동작 확인 절차 (이 4가지를 실제로 수행)
- 에디터에서
installTriggerSafely를 단 한 번만 실행 → 로그에서 installed=runNightBatch를 확인
- 트리거 화면(시계 아이콘)을 열어,
runNightBatch가 단 하나 있는지 육안으로 확인
runNightBatch를 연속으로 2번 실행 → 두 번째는 처리 완료 키로 모두 continue하고 빠르게 끝나 'completed' 로그가 나오지 않음을 확인
3행 처리 중 실행이 강제 정지됨 → 재실행 시 미처리 행만 계속 흐르는 것을 확인
보조: 로그로 트리거 중복을 점검하는 함수 1개
절차 2의 육안 검사를 로그를 통해 미리 할 수 있는 작은 점검 함수도 준비했습니다. Code.gs 파일 끝에 그대로 추가할 수 있습니다. checkTriggers를 실행하여 runNightBatch:2가 나오면, 잔류 트리거가 살아있다는 의미입니다 (레이어 1 청소가 필요합니다).
// checkTriggers — 핸들러명별 트리거 개수를 로그로 출력하는 점검 함수
function checkTriggers() {
var counts = {};
...
- 자신의 프로젝트에서 트리거 목록을 열고,
동일한 핸들러가 2개 이상 없는지 즉시 확인합니다 (잔류 트리거는 조용히 중복 발화합니다) - 배치 시작 지점에
tryLock
(if 조건문 포함)을 배치하고, finally
에서 해제합니다.
- 1행씩 처리하는 경우, 처리 완료 키 열을 추가하고,
행마다 즉시 기록합니다 — 나중에 모아서 작성하는 것은 6분 제한 시간으로 인해 반드시 손실됩니다.
installTriggerSafely를 단 한 번만 실행했습니다 (청소 기능 포함 설치).
- 트리거 화면에서 동일한 핸들러가 1개뿐임을 육안으로 확인했습니다.
tryLock의 반환값을 if로 보고, 값을 가져오지 못하면 return 처리합니다.
releaseLock은 finally 블록에 배치했습니다.
- 처리가 30초를 초과할 것으로 예상될 경우 → 행 단위의 Idempotency Key(멱등 키)를 도입했습니다.
- 처리 완료 키는 행마다 즉시 기록합니다 (나중에 모아서 작성하는 것은 손실됩니다).
- 일일 완료 플래그는 '모든 행 완료 시'에만 기록합니다.
- 외부 API에는 Idempotency-Key를 전달합니다 (API 측의 이중 계상은 API 측에서 처리).
- 수동 실행과 트리거 발화가 동시에 겹치는 상황을 가정하여 2회 실행 테스트를 진행했습니다.
- 트리거 목록에 대한 육안 점검을 운영 프로세스에 포함시켰습니다.
본 기사 같은 구현 패턴을 가로지르는 치트 시트와, Obsidian/Notion용 템플릿 모음을 무료 배포하고 있습니다. 총 8권 분량의 내용에는 '무엇을 어떤 도구로 할 것인지' 판단 차트도 포함되어 있습니다.
관련 기사:
- GAS×AI 배치 작업이 6분 제한으로 잘려 요약본 380개를 이중 전송한 경우 — 체크포인트 재개 및 LockService를 이용해 수정한 실패 사례 3가지
- GAS×AI — 스프레드시트에 도착하는 청구서를 자동으로 분개 처리하기
📗 Google Apps Script × AI 실전 입문 — 스프레드시트에서 구동되는 AI 워크플로우 (Kindle・Kindle Unlimited 무제한 읽기 대상)