매주 월요일, 지난주 데이터를 메일로 보내는 일부터 정합니다

이번에 붙잡은 문제는 소박합니다. 매주 월요일 아침, 지난주에 스프레드시트에 쌓인 신청·매출 데이터를 요약해 팀 채널이 아니라 관계자 이메일로 보내는 일입니다. 사람이 하면 시트를 열고, 지난주 범위를 필터링하고, 합계를 계산하고, 표를 복사해 메일 본문에 붙여넣는 네 단계입니다. 이 네 단계가 매주 반복된다면 자동화할 이유는 충분합니다.

n8n이나 다른 워크플로우 도구를 쓸 수도 있었지만, 데이터가 이미 구글 스프레드시트 안에 있고 발송도 같은 Google 계정의 Gmail이면 충분하다면 별도 서버나 외부 자동화 플랫폼을 새로 붙일 이유가 없습니다. Google Apps Script는 구글 자체가 제공하는 브라우저 기반 코드 편집기이며, 별도 설치 없이 스크립트를 Google Drive에 저장하고 Google 서버에서 실행합니다. Google Workspace 안에서 완결되는 자동화라면 이 경계 안에서 해결하는 편이 관리 지점을 하나 줄입니다.

Google Apps Script 공식 개요 문서에서 브라우저 기반 편집기, Drive 저장, Google 서버 실행 방식을 설명하는 화면
Apps Script 공식 개요 문서 설치 없이 브라우저에서 바로 코드를 작성하고, 스크립트는 Drive에 저장되어 Google 서버에서 실행된다는 점을 확인할 수 있습니다. 촬영 2026.09.01 · 출처 Google for Developers

이 글은 실제로 팀에 발송한 기록이 아니라, 공식 문서를 하나씩 확인하면서 이 자동화를 처음부터 끝까지 설계한 과정입니다. 발송 로직은 DRY_RUN 값으로 막아 두고, 실제로 메일이 나가는 대신 로그로만 결과를 확인하는 단계까지를 목표로 잡았습니다. 운영에 올리기 전에 거쳐야 할 판단들, 트리거 종류, 중복 발송 방지, 실행 한도까지가 이번 설계의 범위입니다.

시트 하나가 아니라 세 개로 나눈 이유

처음에는 데이터가 있는 시트에 스크립트만 붙이면 될 것 같았습니다. 그런데 수신자 이메일이나 발송 여부 같은 설정값을 코드 안에 그대로 적으면, 수신자가 바뀔 때마다 스크립트 편집기를 열어야 합니다. 그래서 역할을 세 시트로 나눴습니다. Config는 수신자, 제목, DRY_RUN 여부 같은 값을 담고, Raw는 원본 데이터가 쌓이는 곳이며, ErrorLog는 실행 중 발생한 예외를 기록합니다.

Config 시트를 key-value로 읽는 함수function getConfig_() { const sheet = SpreadsheetApp.getActive().getSheetByName('Config'); const values = sheet.getRange('A2:B20').getValues(); const config = {}; values.forEach(function (row) { const key = row[0]; const value = row[1]; if (key) config[key] = value; }); return config; }

이렇게 해두면 DRY_RUN을 TRUE에서 FALSE로 바꾸는 일도, 수신자를 추가하는 일도 시트 셀 하나만 고치면 됩니다. 코드를 다시 배포할 필요가 없다는 점이 실무에서는 꽤 큽니다. 스크립트를 건드리는 사람과 수신자를 관리하는 사람이 다른 조직이라면 이 분리가 더 중요해집니다.

이름 짓기도 그냥 넘기지 않았습니다. getConfig_()처럼 함수 이름 끝에 밑줄을 붙인 이유는, Apps Script에서 이 표기가 강제되는 문법은 아니지만 관례적으로 “외부에서 직접 실행하지 않을 내부 헬퍼 함수”를 표시하는 방법이기 때문입니다. 스크립트 편집기의 실행 드롭다운에 헬퍼 함수까지 잔뜩 나열되면 실제로 실행해야 할 진입점 함수를 찾기 번거로워집니다.

셀 하나씩 읽지 않고 범위를 통째로 읽습니다

Raw 시트에서 지난주 데이터를 가져올 때 가장 먼저 걸리는 유혹은 반복문 안에서 getRange(i, 1).getValue()처럼 셀 하나씩 읽는 코드입니다. 행이 몇 개 안 될 때는 문제가 없어 보이지만, Apps Script 공식 모범 사례 문서는 이런 방식이 Google 서버와의 API 호출을 행 수만큼 반복시켜 실행 시간을 크게 늘린다고 설명합니다. 대신 범위를 한 번에 읽고 자바스크립트 배열로 처리하는 편이 훨씬 빠릅니다.

Raw 시트를 한 번의 호출로 통째로 읽기function readRawRows_() { const sheet = SpreadsheetApp.getActive().getSheetByName('Raw'); const lastRow = sheet.getLastRow(); if (lastRow < 2) return []; const lastCol = sheet.getLastColumn(); return sheet.getRange(2, 1, lastRow - 1, lastCol).getValues(); }

getValues()는 지정한 범위를 2차원 배열 하나로 통째로 가져옵니다. 이후 필터링과 합계 계산은 서버 API 호출 없이 스크립트 실행 환경 안에서, 즉 자바스크립트 배열 연산으로 처리됩니다. 행이 수백 개를 넘어가는 시점부터 이 차이는 체감할 수 있는 실행 시간 차이로 나타난다고 공식 문서는 설명합니다.

요약을 만들고 메일 발송은 DRY_RUN으로 막아둡니다

데이터를 읽었으면 직전 월요일 0시부터 이번 월요일 0시 전까지로 범위를 잘라 합계를 냅니다. 단순히 현재 시각에서 7일을 빼면 월요일 아침 실행 시 이번 주 월요일의 이른 데이터가 섞이고 지난주 월요일의 일부가 빠질 수 있습니다. Raw 시트의 날짜 열은 실제 Date 값이어야 하며, 프로젝트 시간대도 Asia/Seoul로 맞춰야 달력 경계가 흔들리지 않습니다.

지난주 범위 합계와 보고서 HTML 생성function previousWeek_() { const now = new Date(); const thisMonday = new Date(now); thisMonday.setDate(now.getDate() - ((now.getDay() + 6) % 7)); thisMonday.setHours(0, 0, 0, 0); const lastMonday = new Date(thisMonday); lastMonday.setDate(thisMonday.getDate() - 7); return { start: lastMonday, end: thisMonday }; } function summarize_(rows) { const period = previousWeek_(); const inRange = rows.filter(function (r) { return r[0] instanceof Date && r[0] >= period.start && r[0] < period.end; }); const total = inRange.reduce(function (sum, r) { return sum + (Number(r[2]) || 0); }, 0); return { count: inRange.length, total: total, rows: inRange }; } function escapeHtml_(value) { const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; return String(value).replace(/[&<>"']/g, function (ch) { return map[ch]; }); } function renderReportHtml_(summary) { const rowsHtml = summary.rows.map(function (r) { return '<tr><td>' + escapeHtml_(r[1]) + '</td><td>' + escapeHtml_(r[2]) + '</td></tr>'; }).join(''); return '<p>지난주 건수: ' + summary.count + ', 합계: ' + summary.total + '</p>' + '<table border="1" cellpadding="6"><tr><th>항목</th><th>금액</th></tr>' + rowsHtml + '</table>'; }

메일을 실제로 보내는 함수는 처음부터 DRY_RUN 분기를 넣었습니다. 설계 단계에서는 이 값을 항상 참으로 두고, 실제 발송 대신 Logger.log()로 결과 HTML만 확인합니다. 발송 전용인 MailApp과 Gmail의 라벨·스레드까지 다루는 GmailApp 중에서, 이번에는 필요한 권한과 기능을 넓히지 않도록 MailApp만 사용합니다. 시트 값을 HTML에 넣기 전에는 특수문자를 이스케이프해 항목명에 꺾쇠나 따옴표가 있어도 메일 표가 깨지지 않게 했습니다.

DRY_RUN 가드가 있는 발송 함수function buildAndSendReport() { const config = getConfig_(); const rows = readRawRows_(); const summary = summarize_(rows); const html = renderReportHtml_(summary); if (String(config.DRY_RUN).toUpperCase() === 'TRUE') { Logger.log(html); return false; } MailApp.sendEmail({ to: config.RECIPIENTS, subject: config.SUBJECT, body: 'HTML 형식의 주간 보고서입니다.', htmlBody: html }); return true; }

반환값을 둔 이유도 있습니다. DRY_RUN일 때는 false, 실제 발송을 마친 뒤에는 true를 돌려줍니다. 상위 실행 함수는 이 값이 참일 때만 ‘오늘 발송 완료’ 기록을 남깁니다. 그렇지 않으면 오전에 시험 실행한 날짜가 발송 완료로 저장되어, 같은 날 DRY_RUN을 끈 뒤 실제 발송이 막힐 수 있습니다.

이 코드를 누가, 언제 실행하게 할 것인가

함수는 완성했지만 지금까지는 스크립트 편집기에서 사람이 직접 실행 버튼을 눌러야 동작합니다. 매주 월요일 아침에 자동으로 실행되게 하려면 트리거가 필요합니다. Apps Script 공식 트리거 문서는 트리거를 단순 트리거와 설치형 트리거 두 종류로 나눕니다.

Google Apps Script 공식 트리거 문서에서 onOpen, onEdit, onInstall, doGet, doPost 등 사용 가능한 트리거 유형을 정리한 표
Apps Script 트리거 유형 공식 표 onOpen·onEdit 같은 단순 트리거와 시간 기반을 포함한 설치형 트리거가 어떤 파일 유형에서 쓰이는지 정리돼 있습니다. 촬영 2026.09.01 · 출처 Google for Developers
구분대표 함수제약
단순 트리거onOpen(e), onEdit(e), onInstall(e), onSelectionChange(e), doGet(e), doPost(e)인증이 필요한 서비스를 호출할 수 없고, 실행 시간도 최대 30초로 제한됩니다
설치형 트리거ScriptApp.newTrigger()로 등록, 시간 기반 포함인증이 필요한 서비스도 호출 가능, 트리거를 만든 사용자 계정 권한으로 실행됩니다

월요일 아침 발송처럼 “누가 파일을 열거나 편집해서”가 아니라 “정해진 시각에 저절로” 실행돼야 하는 작업은 단순 트리거로는 애초에 불가능합니다. onOpen이나 onEdit은 사람의 행동에 반응할 뿐 시계를 기준으로 동작하지 않습니다. 게다가 이 함수는 GmailApp처럼 인증이 필요한 서비스를 호출할 수 없어서, 설령 시간 기반 트리거가 있었다 해도 단순 트리거로는 메일 발송 자체가 막혔을 것입니다. 그래서 이 프로젝트는 처음부터 설치형 시간 기반 트리거로만 성립합니다.

매주 월요일 오전 8시, 서울 시간대로 등록function installWeeklyTrigger() { ScriptApp.getProjectTriggers() .filter(function (t) { return t.getHandlerFunction() === 'weeklyReportJob'; }) .forEach(function (t) { ScriptApp.deleteTrigger(t); }); ScriptApp.newTrigger('weeklyReportJob') .timeBased() .onWeekDay(ScriptApp.WeekDay.MONDAY) .atHour(8) .inTimezone('Asia/Seoul') .create(); }

설치 함수를 다시 실행해도 같은 핸들러의 트리거가 쌓이지 않도록 기존 항목을 먼저 지웠습니다. atHour(8)은 오전 8시 정각을 보장하는 예약이 아니라 8시대에 실행되는 시간 창입니다. 정확한 분 단위 마감이 있는 업무라면 이 변동을 허용할 수 있는지부터 결정해야 합니다. 트리거는 생성한 사용자 계정 권한으로 실행되므로, 설치한 사람이 조직을 떠나거나 해당 계정의 권한이 바뀌면 자동화도 영향을 받습니다. 팀 자동화라면 개인 계정 대신 조직이 관리하는 계정과 인수인계 절차를 정해 두는 편이 안전합니다.

설치형 트리거가 실패하면 어떻게 될까요. 공식 문서는 트리거 실행이 실패했을 때 스크립트 편집기 화면이 아니라 이메일로 알림을 보낸다고 설명합니다. 트리거를 만든 사용자에게 실패 요약과 함께 트리거를 재설정하거나 끌 수 있는 링크가 담긴 메일이 발송됩니다. 이 알림이 오는지 여부로 트리거가 살아있는지, 코드에 예외가 났는지를 간접적으로 확인할 수 있습니다. 다만 이 알림에만 의존하기보다는 아래에서 다룰 자체 오류 로그를 함께 두는 편이 확인이 빠릅니다.

같은 메일을 두 번 보내지 않게 만듭니다

시간 기반 트리거는 정해진 시각 근처에 실행되는 것이지, 정확히 그 1초에 실행된다는 보장은 아닙니다. 그리고 실행 도중 스크립트를 수동으로 다시 실행하거나, 같은 트리거를 실수로 두 번 등록하는 상황도 있을 수 있습니다. 이런 경우 같은 주간 보고서가 중복 발송될 위험이 생깁니다. 이를 막기 위해 두 가지 장치를 더했습니다. 동시 실행을 막는 LockService와, 오늘 이미 보냈는지 기록해 두는 PropertiesService입니다.

락과 발송 기록으로 중복을 막는 실행 함수function weeklyReportJob() { const lock = LockService.getScriptLock(); const gotLock = lock.tryLock(5000); if (!gotLock) return; try { const props = PropertiesService.getScriptProperties(); const todayKey = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyy-MM-dd'); if (props.getProperty('LAST_SENT_DATE') === todayKey) return; const sent = buildAndSendReport(); if (sent) props.setProperty('LAST_SENT_DATE', todayKey); } catch (err) { logError_(err); } finally { lock.releaseLock(); } } function logError_(err) { console.error(err); const sheet = SpreadsheetApp.getActive().getSheetByName('ErrorLog'); if (sheet) sheet.appendRow([new Date(), err.message, String(err.stack || '')]); }

LockService.getScriptLock()은 같은 스크립트가 동시에 두 번 실행되는 상황을 막습니다. tryLock(5000)은 최대 5초 동안 락을 기다리고, 그 사이에 다른 실행이 끝나지 않으면 조용히 포기하고 반환합니다. 락을 잡았다면 PropertiesService에 저장해 둔 마지막 발송 날짜와 오늘 날짜를 비교해, 이미 오늘 보냈다면 두 번째 실행은 그냥 종료합니다. 이 두 장치를 합치면 트리거가 겹쳐 실행되거나 사람이 실수로 다시 실행 버튼을 눌러도 메일이 두 번 나가지 않습니다.

try/catch/finally 구조도 의도적으로 넣었습니다. 요약 계산이나 발송 도중 예외가 나더라도 finally에서 반드시 락을 풉니다. 오류는 먼저 실행 기록에 남기고, ErrorLog 시트가 존재할 때만 행을 추가합니다. 로그 시트가 삭제된 상황에서 오류 기록 함수까지 다시 실패해 원래 예외가 가려지는 일을 막기 위해서입니다. 자동화가 조용히 실패하는 것이 가장 늦게 발견되는 실패입니다.

할당량을 넘으면 자동화는 어떻게 멈추는가

Apps Script는 무료로 넉넉하게 쓸 수 있지만 무제한은 아닙니다. 공식 할당량 문서는 계정 종류에 따라 다른 상한을 제시합니다. 개인 Gmail 계정과 Google Workspace 계정의 수치가 다르다는 점이 특히 중요한데, 팀 자동화를 개인 계정에서 돌리다가 조직 계정으로 옮기면 같은 코드라도 여유가 크게 달라집니다.

Google Apps Script 공식 할당량 문서에서 소비자 계정과 Workspace 계정의 트리거 실행시간, 동시 실행, URL Fetch 한도를 비교한 표
Apps Script 할당량 공식 표 소비자 계정과 Workspace 계정의 한도가 항목마다 다르며, 문서는 예고 없이 바뀔 수 있다고 명시합니다. 촬영 2026.09.01 · 출처 Google for Developers
항목개인(Gmail) 계정Workspace 계정
단일 실행 최대 시간6분6분
트리거 총 실행 시간(하루)90분6시간
URL Fetch 호출(하루)20,000회100,000회
사용자당 동시 실행30개30개
스크립트당 트리거 개수(사용자 기준)20개20개

이번 주간 보고서 자동화는 매주 한 번, 실행 시간도 짧아 이 한도에 크게 못 미칩니다. 그런데도 이 표를 확인해야 하는 이유는 나중에 이 스크립트가 커질 가능성 때문입니다. 예를 들어 같은 트리거 안에서 데이터를 외부 API로 보강하려고 UrlFetchApp 호출을 행마다 반복하면, 데이터 양이 늘어난 어느 날 갑자기 하루 호출 한도에 걸릴 수 있습니다. 공식 문서는 이 값이 “예고 없이 바뀔 수 있다”고 분명히 밝히고 있어서, 정확한 숫자를 코드에 하드코딩된 전제로 삼기보다는 여유를 두고 설계하는 편이 안전합니다.

한도를 넘기면 스크립트는 조용히 실패하지 않고 구체적인 예외 메시지를 던집니다. 공식 문서에 예시로 나오는 문구 중 하나가 하루 누적 실행 시간을 넘긴 경우이고, 다른 하나는 짧은 시간 안에 같은 스크립트가 너무 자주 호출됐을 때입니다. 이런 메시지가 ErrorLog 시트에 그대로 남는다면, 코드 버그가 아니라 할당량 문제라는 것을 바로 구분할 수 있습니다. 오류 로그에 메시지 원문을 그대로 저장해 둔 이유가 여기 있습니다.

V8 런타임이라고 브라우저 JavaScript와 똑같지는 않습니다

Apps Script는 V8 런타임을 기본으로 쓰고 있어서 let, const, 화살표 함수, 클래스, 템플릿 리터럴 같은 최신 문법을 그대로 쓸 수 있습니다. 이번 코드에서도 화살표 함수 대신 일반 함수 표기를 섞어 썼는데, 이는 문법이 안 돼서가 아니라 this 바인딩이 헷갈리는 콜백 상황에서 의도를 더 분명히 하기 위해서입니다.

쓸 수 있는 것let·const, 화살표 함수, 클래스, 템플릿 리터럴, 구조 분해 할당 같은 최신 문법은 V8 런타임에서 그대로 동작합니다.

브라우저 fetch 아님웹페이지의 fetch API는 없습니다. 외부 HTTP 요청은 Apps Script 전용 서비스인 UrlFetchApp으로 해야 합니다.

setTimeout·setInterval 아님브라우저의 타이머 함수는 지원되지 않습니다. 짧은 지연은 Utilities.sleep(), 정해진 시각 실행은 시간 기반 트리거로 대체합니다.

import·export 아님ES6 모듈 문법으로 파일을 나눠 불러올 수 없습니다. Apps Script 프로젝트 안의 여러 .gs·.js 파일은 하나의 전역 스코프로 합쳐져 실행됩니다.

이 네 가지를 미리 알아두지 않으면 온라인에서 찾은 일반 Node.js 예제 코드를 그대로 옮기다가 fetch is not defined 같은 오류를 만나게 됩니다. 특히 여러 사람이 함께 관리하는 프로젝트라면, 파일이 나뉘어 있어도 실제로는 전역 스코프를 공유한다는 점 때문에 서로 다른 파일에서 같은 이름의 전역 함수나 변수를 선언하면 충돌합니다. 이번 프로젝트에서 헬퍼 함수 이름 끝에 밑줄을 붙이고 getConfig_, readRawRows_처럼 구체적으로 지은 것도 이런 전역 충돌을 줄이려는 습관입니다.

권한 승인 화면에서 무엇을 보게 되는지

이 스크립트를 처음 실행하면 Google 계정 권한 승인 화면이 뜹니다. Apps Script 공식 인증 문서에 따르면, 어떤 권한 범위(OAuth scope)를 요청할지는 개발자가 직접 나열하는 것이 아니라 코드를 스캔해서 자동으로 결정됩니다. SpreadsheetApp을 쓰면 스프레드시트 접근 권한이, GmailApp을 쓰면 Gmail 발송 권한이 요청 목록에 자동으로 추가되는 방식입니다.

이 자동 결정 방식은 편리하지만 동시에 코드 리뷰에서 신경 써야 할 부분이기도 합니다. 스크립트에 별 이유 없이 DriveApp이나 GmailApp 호출이 섞여 있다면, 실제로 필요하지 않은 광범위한 권한까지 요청 목록에 오를 수 있습니다. 이번 프로젝트는 스프레드시트 읽기·쓰기와 Gmail 발송만 필요하므로, 코드에도 그 두 서비스 호출만 남겨 요청 권한 범위를 그 이상으로 넓히지 않았습니다.

운영에 올리기 전 확인 순서

설계는 끝났지만, 이 코드를 실제로 켜기 전에 순서를 정해두는 편이 안전합니다. 자동화가 잘못됐을 때 되돌리기 어려운 지점은 언제나 “외부로 실제 발송되는 순간”이기 때문입니다.

DRY_RUN에서 실 운영까지 넘어가는 순서

1) DRY_RUN을 TRUE로 둔 채 여러 번 수동 실행하고 로그로 요약 내용을 확인합니다
2) RECIPIENTS를 본인 주소 하나로만 두고 DRY_RUN을 FALSE로 바꿔 실제 발송 형식을 확인합니다
3) installWeeklyTrigger()를 실행해 시간 기반 트리거를 등록합니다
4) 한두 주 동안 ErrorLog 시트와 트리거 실패 알림 메일을 함께 지켜봅니다
5) 문제가 없으면 RECIPIENTS를 실제 팀 주소로 넓힙니다

이 순서에서 3번과 5번 사이에 의도적으로 간격을 둔 이유는, 시간 기반 트리거는 사람이 매번 지켜보며 실행하는 것이 아니라서 데이터 형식이 바뀌거나 시트 구조가 살짝 달라지는 변화를 곧바로 알아채기 어렵기 때문입니다. 소규모로 먼저 몇 주 돌려보고 오류 로그가 비어 있는 상태를 확인한 뒤에 수신 범위를 넓히면, 잘못된 보고서가 여러 사람에게 동시에 나가는 상황을 피할 수 있습니다.

이 설계에서 다루지 않은 부분도 남아 있습니다. Raw 시트에 데이터를 채워 넣는 과정, 즉 다른 시스템에서 스프레드시트로 데이터를 가져오는 부분은 이번 글의 범위 밖입니다. 그 부분에 외부 API가 필요하다면 UrlFetchApp과 이번에 살펴본 URL Fetch 호출 할당량을 함께 고려해서 별도로 설계해야 합니다.

확인한 공식 자료

Google Apps Script 공식 문서를 2026년 9월 1일 확인했습니다.

Apps Script 개요 ↗ · Sheets 서비스 ↗ · 모범 사례 ↗ · 단순 트리거 ↗ · 설치형 트리거 ↗ · 인증(Authorization) ↗ · 할당량과 제한 ↗ · V8 런타임 ↗