구글 시트에 줄이 차면 메일이 오게 만들었고, 그 앞에 폼을 붙여 문의가 들어오면 알림이 오게 만들었습니다. 둘 다 무언가가 일어났을 때 도는 것이었습니다. 이번에는 아무 일이 없어도 매일 정해진 시각에 도는 것을 만들었습니다. 시트에 쌓인 어제 기록을 항목별로 합쳐 아침 8시에 메일 한 통.
5~50인 회사에서 이 일은 대개 사람이 합니다. 아침에 누군가 시트를 열고, 어제 줄만 세고, 단톡방이나 메일에 숫자를 적습니다. 그 사람이 휴가면 그날은 안 옵니다. 이 글은 그 일을 Apps Script 의 시간 기반 트리거에 넘긴 기록이고, 순서는 실제로 막힌 순서입니다.
먼저 결론 하나. 「매일 08:00」 은 못 겁니다. 걸 수 있는 것은 「오전 8시~오전 9시 사이」 입니다. 이 한 시간 안 어디에서 도는지는 아래에 실측했습니다.
무엇을 만들었나
시트는 일자 | 항목 | 건수 세 칸입니다. 업무 기록이 한 줄씩 쌓인다고 놓고 샘플 20줄을 직접 넣었습니다. 9월 8일부터 15일까지, 항목은 문의·견적·출고·반품 넷, 같은 날 같은 항목이 두 줄인 날(9월 14일 「문의」 5건 + 2건)도 넣었고, 일요일(9월 13일)은 한 줄도 안 넣었습니다.

시트에 붙은 Apps Script 가 매일 아침 돌면서, 어제 날짜 줄만 골라 항목별로 합치고, 합계와 함께 메일을 보냅니다. 받는 쪽은 시트를 만든 계정 자기 자신입니다.
들어가는 길은 지난 두 글과 같습니다. 시트 메뉴 확장 프로그램 → Apps Script. 다른 것은 트리거뿐입니다.
시간대부터 봤습니다
n8n 글에서 워크플로 시간대 기본값이 뉴욕이었던 것이 있어서, 코드를 넣기 전에 시간대부터 확인했습니다. 두 군데입니다.
| 어디 | 메뉴 | 이 계정의 값 |
|---|---|---|
| 시트 | 파일 → 설정 → 시간대 | (GMT+09:00) 서울 |
| Apps Script | 왼쪽 톱니(프로젝트 설정) → 시간대 | (GMT+09:00) 한국 표준시 – 서울 |
둘 다 서울이었습니다. 시트에서 만든 스크립트라 시트를 따라간 것으로 보이는데, 이 계정 하나만 본 것이라 단정은 못 합니다. 실행 로그에서도 Session.getScriptTimeZone() 이 Asia/Seoul 로 찍혔습니다. 다만 코드 안에서 날짜를 글자로 만들 때는 시간대를 매번 명시했습니다(Utilities.formatDate(d, 'Asia/Seoul', 'yyyy-MM-dd')). 프로젝트 설정이 바뀌어도 「어제」가 밀리지 않게 하려는 것입니다.
막힌 지점 1 — 합계 0건
첫 코드는 이렇게 썼습니다. 시트를 다 읽고, 첫 칸이 어제 날짜인 줄만 더합니다.
var TO = 'xxxx@xxxx.xx';
function sendDailyReport() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
var rows = sheet.getDataRange().getValues(); // [일자, 항목, 건수]
var y = new Date(); y.setDate(y.getDate() - 1); // 어제
var key = Utilities.formatDate(y, 'Asia/Seoul', 'yyyy-MM-dd');
var sum = {}, total = 0;
for (var i = 1; i < rows.length; i++) {
var d = rows[i][0];
if (!(d instanceof Date)) continue; // 날짜가 아닌 줄은 건너뜀
if (Utilities.formatDate(d, 'Asia/Seoul', 'yyyy-MM-dd') !== key) continue;
sum[rows[i][1]] = (sum[rows[i][1]] || 0) + Number(rows[i][2]);
total += Number(rows[i][2]);
}
var lines = Object.keys(sum).map(function (k) { return k + ': ' + sum[k] + '건'; });
MailApp.sendEmail(TO, '[일일 보고] ' + key + ' 합계 ' + total + '건', lines.join('\n'));
}
이번 함수는 지난 글의 onEdit 과 달리 이벤트 값을 안 받으니 편집기의 「실행」 버튼으로 시험해도 됩니다. 눌렀더니 권한 창이 떴습니다. 지난 글들과 생김새가 달랐습니다.
모든 Google Sheets 스프레드시트 확인, 수정, 생성, 삭제
내 이메일 주소로 이메일 보내기
항목마다 체크박스가 붙어 있고, 체크하지 않고 「계속」을 누르면 넘어가지 않습니다. 「모두 선택」을 체크하고 「계속」. 그리고 창 제목이 「제목 없는 프로젝트에서 … 요청합니다」 였습니다. 프로젝트 이름은 이미 바꿔 뒀는데, 권한 창은 첫 이름을 기억하고 있었습니다.
승인이 끝나고 실행이 「완료됨」이 됐고, 메일도 왔습니다. 제목이 이랬습니다.
[일일 보고] 2026-09-14 합계 0건
시트에는 9월 14일 줄이 다섯 개 있습니다. 0건입니다. 실행 로그에 줄마다 값을 찍어 봤습니다.
![Apps Script 실행 로그 — 모든 줄이 type=[object Date] 인데 isDate=false 로 찍힌 화면](https://www.changups.kr/wp-content/uploads/2026/09/07-final-04.png)
row 16.0 raw=Mon Sep 14 00:00:00 GMT+09:00 2026 type=[object Date] fmt=- isDate=false
날짜 객체가 맞는데([object Date]) instanceof Date 가 false 입니다. 그래서 스무 줄이 전부 「날짜가 아닌 줄」로 걸러졌습니다. getValues() 가 주는 날짜는 스크립트 안의 Date 와 다른 것으로 취급됩니다. 이유는 공식 문서에서 찾지 못했고, 이 계정·이 시트에서 스무 줄 전부 그랬다는 것만 적습니다.
고친 것은 한 줄입니다. getValues() 대신 getDisplayValues() — 시트에 보이는 글자 그대로 받아서, 글자끼리 비교합니다. 첫 글에서 날짜가 Sat Sep 12 2026 00:00:00 GMT+0900 … 로 오던 것을 고칠 때 쓴 것과 같은 방법입니다.
var rows = sheet.getDataRange().getDisplayValues(); // 시트에 보이는 글자 그대로
for (var i = 1; i < rows.length; i++) {
if (rows[i][0] !== key) continue; // '2026-09-14' 와 글자로 비교
다시 실행했더니 합계 17건이 왔습니다. 5+2+7+1+2 입니다. 같은 날 「문의」 두 줄(5건, 2건)은 7건으로 합쳐졌습니다.
이 방법은 시트의 표시 형식에 기댑니다. 일자 칸이 2026-09-14 로 보이면 되고, 9/14 로 보이게 서식을 바꾸면 한 줄도 안 잡힙니다. 서식 → 숫자 에서 일자 칸을 연-월-일 로 고정해 두는 편이 안전합니다.
트리거 — 「매일 08:00」 은 없습니다
왼쪽 시계 아이콘 → 「트리거 추가」. 이벤트 소스 기본값은 지난 글들과 같이 「스프레드시트에서」인데, 이번에는 「시간 기반」 으로 바꿉니다. 그러면 「트리거 기반 시간 유형 선택」이 나옵니다.
특정 날짜 및 시간
분 단위 타이머
시간 단위 타이머
일 단위 타이머
주 단위 타이머
월 단위 타이머
「일 단위 타이머」를 고르면 「시간 선택」이 나오는데, 여기가 이 글의 요점입니다. 목록이 한 시간 폭입니다.

자정~오전 1시 사이
오전 1시~오전 2시 사이
…
오전 8시~오전 9시 사이
…
오후 11시~자정 사이
(GMT+09:00)
08:00 을 고르는 칸이 없습니다. 「오전 8시~오전 9시 사이」가 있을 뿐이고, 그 한 시간 안 어디에서 돌지는 구글이 정합니다. 목록 아래 (GMT+09:00) 이 붙어 있어서 이 시각이 프로젝트 시간대 기준이라는 것은 화면에서 확인됩니다.
08:00 까지 기다릴 수 없어서 두 가지를 걸었습니다.
- 「특정 날짜 및 시간」 에 몇 분 뒤 시각을 넣어, 트리거가 실제로 도는지와 지정한 분에서 얼마나 어긋나는지
- 「일 단위 타이머」 를 지금 시각 바로 다음 창(오후 9시~오후 10시 사이)으로 걸어, 한 시간 폭 안 어디에서 도는지
「특정 날짜 및 시간」은 입력칸에 YYYY-MM-DD HH:MM 형식으로 넣습니다. 칸 이름에 (GMT+09:00) 이 붙어 있습니다. 트리거 실패 알림은 둘 다 첫 글에서 배운 대로 「즉시 알림」 으로 바꿨습니다.

저장하고 나서 트리거 목록을 보면 이렇게 됩니다.
나 - Head 시간 기반 sendDailyReport
나 - Head 시간 기반 sendDailyReport
두 줄이 똑같습니다. 하나는 한 번만 도는 것이고 하나는 매일 도는 것인데, 목록에는 「시간 기반」이라고만 나옵니다. 줄에 마우스를 올리면 나오는 연필(트리거 수정)을 눌러 대화상자를 열어야 어느 것인지 보입니다. 줄 끝 ⋮ 메뉴에는 「트리거 삭제」는 있어도 내용을 보여 주는 항목은 없었습니다.
실측 1 — 「특정 날짜 및 시간」 은 지정한 분에서 9초 뒤에 돌았습니다
20:43 에 2026-09-15 20:52 로 저장했습니다. 실행 기록입니다.
sendDailyReport 시간 기반 2026. 9. 15. 오후 8:52:09 3.053초 완료됨
20:52:09. 지정한 분의 9초 뒤에 시작했고, 메일은 메일함에 오후 8:52 로 찍혔습니다. 유형 칸에 「편집기」가 아니라 「시간 기반」 이라고 찍히므로, 편집기에서 누른 것과 트리거가 돈 것을 실행 기록에서 구분할 수 있습니다.
돌고 난 뒤 트리거 목록을 다시 열었더니 그 줄이 빨간 글씨로 「사용 중지됨」 이 되어 있었습니다. 옆의 ⓘ 를 누르면 이렇게 나옵니다.

이유 사용 안함
이 트리거는 사용이 만료되었습니다. 이는 이 트리거는 원래 한 번만 실행되도록 설정되었으며
실행이 완료되어 사용 중지되었음을 의미합니다.
한 번 돌고 나서도 지워지지 않고 목록에 남습니다. 시험용으로 몇 번 걸면 그만큼 쌓이니 직접 지워야 합니다.
「몇 분 뒤」 시험은 이 방법이 제일 빨랐습니다. 저장에서 실행까지 기다린 시간이 9분이었고, 그 사이에 코드도 트리거도 안 건드렸습니다. 이 방법이 값을 한 이유는 조금 뒤에 나옵니다.
빈 날과 주말 — 그 사람이 매일 아침 하던 판단
시트를 열어 어제 숫자를 세던 사람은 매일 아침 판단을 셋 합니다. 어제 기록이 없으면 「없음」이라고 알릴지, 토·일요일 아침에도 보낼지, 월요일에는 금요일부터 셋을 합칠지. 코드는 그걸 대신 못 하니 정해 줘야 합니다. 저는 이렇게 정했습니다.
- 어제 줄이 없어도 보냅니다. 제목에 「(기록 없음)」을 붙입니다. 안 보내면 「트리거가 죽은 것」과 「기록이 없는 것」을 구분할 수 없습니다
- 토·일요일에는 안 보냅니다. 볼 사람이 없습니다
- 월요일에는 금·토·일 사흘을 합칩니다
var TO = 'xxxx@xxxx.xx';
var TZ = 'Asia/Seoul';
// 시간 기반 트리거용 — 전날(월요일이면 금~일) 기록을 항목별로 합쳐 메일 한 통
function sendDailyReport(today) {
today = today || new Date();
var dow = today.getDay(); // 0 일 … 6 토
if (dow === 0 || dow === 6) { Logger.log('주말 — 보내지 않음'); return; }
var back = (dow === 1) ? 3 : 1; // 월요일은 금·토·일 사흘
var keys = [];
for (var k = back; k >= 1; k--) {
var d = new Date(today); d.setDate(d.getDate() - k);
keys.push(Utilities.formatDate(d, TZ, 'yyyy-MM-dd'));
}
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
var rows = sheet.getDataRange().getDisplayValues(); // 시트에 보이는 글자 그대로 [일자, 항목, 건수]
var sum = {}, total = 0, n = 0;
for (var i = 1; i < rows.length; i++) {
if (keys.indexOf(rows[i][0]) < 0) continue;
var item = rows[i][1], cnt = Number(rows[i][2]) || 0;
sum[item] = (sum[item] || 0) + cnt; total += cnt; n++;
}
var range = keys.length === 1 ? keys[0] : keys[0] + ' ~ ' + keys[keys.length - 1];
var lines = Object.keys(sum).map(function (k) { return k + ': ' + sum[k] + '건'; });
var subject = '[일일 보고] ' + range + ' 합계 ' + total + '건' + (n === 0 ? ' (기록 없음)' : '');
var body = (n === 0 ? '이 기간에 시트에 기록된 줄이 없습니다.' : range + ' 기록 ' + n + '줄
' + lines.join('
'))
+ '
' + sheet.getParent().getUrl();
MailApp.sendEmail(TO, subject, body);
}
// 편집기에서 시험용 — 트리거는 sendDailyReport 만 겁니다
function testAsMonday() { sendDailyReport(new Date('2026-09-14T08:00:00+09:00')); }
function testEmptyDay() { sendDailyReport(new Date('2026-09-08T08:00:00+09:00')); }
function testAsSunday() { sendDailyReport(new Date('2026-09-13T08:00:00+09:00')); }
today 를 밖에서 넣을 수 있게 한 것은 시험 때문입니다. 「오늘이 월요일이면」을 시험하려고 월요일까지 기다릴 수는 없으니, 날짜를 넣어 부르는 함수 셋을 아래에 두고 편집기 위의 함수 선택 칸에서 골라 「실행」을 눌렀습니다. 트리거에는 sendDailyReport 만 겁니다. 셋 다 이 시트로 돌린 결과입니다.
| 시험 | 넣은 날짜 | 결과 |
|---|---|---|
testAsMonday |
9월 14일(월) | [일일 보고] 2026-09-11 ~ 2026-09-13 합계 13건 — 금·토·일 다섯 줄 |
testEmptyDay |
9월 8일(화) | [일일 보고] 2026-09-07 합계 0건 (기록 없음) |
testAsSunday |
9월 13일(일) | 메일 없음 — 로그에 주말 — 보내지 않음 |
공휴일은 넣지 않았습니다. 화요일이 공휴일이어도 코드는 평일로 보고 화요일 아침에 월요일 기록을, 수요일 아침에 화요일(공휴일) 기록을 보냅니다. 연휴 뒤 첫 출근일에 며칠치를 합칠지는 회사마다 달라서, 여기까지는 코드가 아니라 그 사람의 판단으로 남겨 두는 편이 맞다고 봤습니다.
실측 2 — 「일 단위 타이머」 는 창이 열리고 28분 뒤에 돌았습니다. 그리고 실패했습니다
위 코드로 바꾼 뒤, 20:44 에 걸어 둔 「오후 9시~오후 10시 사이」 트리거를 기다렸습니다. 창이 아직 안 지났으니 오늘 돌지, 내일부터 돌지도 몰랐습니다. 5분마다 실행 기록을 열어 봤습니다.
sendDailyReport 시간 기반 2026. 9. 15. 오후 9:28:21 1.282초 실패
21:28:21. 오늘 돌았고, 창이 열리고 28분 뒤였습니다. 그리고 빨간 글씨로 실패입니다. 줄을 누르면 로그가 펼쳐집니다.

TypeError: today.getDay is not a function
at sendDailyReport(Code:7:19)
같은 시각에 메일이 한 통 더 와 있었습니다. 트리거를 걸 때 「즉시 알림」으로 바꿔 둔 그 알림입니다. 보낸 주소는 noreply-apps-scripts-notifications@google.com, 제목은 영어입니다.
Summary of failures for Google Apps Script: 일일보고-블로그
Start Function Error Message Trigger End
9/15/26 9:28:21 PM KST sendDailyReport TypeError: today.getDay is not a function time-based 9/15/26 9:28:22 PM KST
막힌 지점 2 — 트리거는 첫 인자로 이벤트를 넣어 줍니다
today.getDay is not a function — today 가 날짜가 아니라는 뜻입니다. 위 코드에서 sendDailyReport(today) 의 today 는 시험용으로 날짜를 넣어 부르려고 만든 인자이고, 트리거가 부를 때는 비어 있을 것으로 생각했습니다. 비어 있지 않았습니다. 시간 기반 트리거는 「수정 시」「양식 제출 시」와 마찬가지로 첫 인자에 이벤트 객체를 넣어 줍니다. 편집기에서 「실행」을 누르면 인자가 없어서 잘 돌고, 트리거로 돌 때만 죽습니다. 첫 글의 첫 에러와 정확히 반대입니다 — 그때는 편집기에서만 죽었습니다.
편집기 시험을 세 번 다 통과하고 걸어 둔 트리거가 첫 실행에서 죽은 것이라, 편집기에서 됐다는 것이 트리거에서도 된다는 뜻이 아닙니다. 이것을 확인하는 데는 「특정 날짜 및 시간」으로 한 번 돌려 보는 것이 제일 빨랐습니다.
고친 것은 한 줄입니다. 첫 인자가 날짜가 아니면 오늘로 봅니다.
function sendDailyReport(today) {
// 시간 트리거는 첫 인자로 이벤트 객체를 넣어 줍니다 — 날짜가 아니면 오늘로
if (!today || typeof today.getDay !== 'function') today = new Date();
고치고 나서 두 가지를 다시 걸었습니다. 「특정 날짜 및 시간」에 4분 뒤(21:41), 그리고 「일 단위 타이머」를 다음 창인 「오후 10시~오후 11시 사이」로.
sendDailyReport 시간 기반 2026. 9. 15. 오후 9:41:54 4.608초 완료됨
sendDailyReport 시간 기반 2026. 9. 15. 오후 10:42:47 2.87초 완료됨
둘 다 완료됨이고, 메일이 오후 9:41 과 오후 10:42 에 각각 왔습니다. 제목은 편집기에서 돌렸을 때와 같은 [일일 보고] 2026-09-14 합계 17건 입니다. 트리거가 넣어 준 이벤트를 무시하고 오늘 날짜로 돈 것입니다.
네 번의 실측을 놓고 보면 이렇습니다.
| 걸어 둔 것 | 실제 시작 시각 | 어긋남 |
|---|---|---|
특정 날짜 및 시간 20:52 |
20:52:09 | 9초 |
특정 날짜 및 시간 21:41 |
21:41:54 | 54초 |
| 일 단위 타이머 「오후 9시~오후 10시 사이」 | 21:28:21 | 창이 열리고 28분 |
| 일 단위 타이머 「오후 10시~오후 11시 사이」 | 22:42:47 | 창이 열리고 42분 |
「특정 날짜 및 시간」은 두 번 다 지정한 분 안에 시작했고, 「일 단위 타이머」는 두 번 다 창의 한가운데 근처였습니다. 두 번뿐이라 「보통 몇 분」이라고는 못 합니다. 다만 「오전 8시~오전 9시 사이」로 걸고 8시에 메일이 와 있기를 기대하면 안 된다는 것은 이 두 번으로 충분했습니다. 8시 회의 전에 보려면 「오전 7시~오전 8시 사이」 입니다.
한 가지 더. 일 단위 타이머를 저녁에 걸었는데 그날 저녁 창에 바로 돌았습니다. 「내일부터」가 아닙니다. 아침 창으로 걸면 다음 날 아침이 첫 실행이 될 텐데, 그것은 기다리지 못해 확인하지 못했습니다.
이 방법으로 쓸 때 걸리는 것
정각이 아닙니다. 위에서 본 대로 한 시간 창입니다. 「8시 회의 전에 온다」가 필요하면 「오전 7시~오전 8시 사이」로 거십시오.
트리거는 만든 사람 계정으로 돕니다. 첫 글에 적은 것과 같습니다. 그 사람이 퇴사하면 매일 아침 메일이 조용히 끊깁니다. 「(기록 없음)」이라도 보내게 한 이유가 이것입니다 — 매일 오던 것이 안 오면 그날 압니다.
실행 시간 제한이 있습니다. 실행 기록의 「기간」이 이 시트에서는 1.3~4.6초였습니다. 스무 줄이라 그렇고, 수천 줄을 매일 훑는 것이 되면 얘기가 달라집니다. 공식 한도표에 실행 한 번의 시간 제한이 있는데, 이 시트로는 근처에도 못 가서 실측은 못 했습니다.
메일 한도는 지난 글과 같은 계정이라 같습니다. 하루 한 통이니 문제될 것이 없습니다.
요약
| 「매일 08:00」 | 없습니다. 「오전 8시~오전 9시 사이」 한 시간 창입니다 |
| 시간대 | 시트(파일 → 설정)와 프로젝트 설정 둘 다 확인. 이 계정은 둘 다 서울 |
instanceof Date 가 false |
getValues() 의 날짜는 그렇게 옵니다. getDisplayValues() 로 글자끼리 비교하십시오 |
| 권한 창에 체크박스 | 「모두 선택」을 체크해야 「계속」이 넘어갑니다. 창 제목은 첫 프로젝트 이름을 기억합니다 |
| 몇 분 뒤 시험 | 「특정 날짜 및 시간」 에 YYYY-MM-DD HH:MM. 지정한 분 안(9초·54초 뒤)에 돌았습니다 |
| 한 번 돈 트리거 | 목록에 「사용 중지됨」으로 남습니다. 직접 지우십시오 |
today.getDay is not a function |
시간 트리거도 첫 인자에 이벤트를 넣어 줍니다. 편집기에서 됐어도 트리거로 한 번 돌려 보십시오 |
| 실패 알림 「즉시」 | 실패한 그 분에 noreply-apps-scripts-notifications@google.com 에서 왔습니다 |
| 트리거 목록 | 「시간 기반」이라고만 나옵니다. 연필(트리거 수정)을 눌러야 어떤 것인지 보입니다 |
| 어제 기록 없음 | 그래도 보내십시오. 트리거가 죽은 것과 구분해야 합니다 |
| 주말·월요일 | 토·일 안 보내고 월요일에 사흘. 공휴일은 안 넣었습니다 |
| 실행 시간 | 스무 줄에 1.3~4.6초 |
| 「일 단위 타이머」 실측 | 창이 열리고 28분·42분 뒤. 8시 회의 전이면 7~8시 창 |
| 저녁에 건 일 단위 타이머 | 그날 저녁 창에 바로 돌았습니다 |
시간이 간 곳은 이번에도 코드가 아니었습니다. 「매일 8시」라는 말이 구글에서는 무엇인가 — 그것을 확인하는 데 한 시간 창을 두 번 기다렸고, 그 첫 번째에서 편집기로는 못 잡는 에러를 하나 잡았습니다.
참고
– Google Apps Script 공식 문서 — 설치형 트리거 (Installable Triggers: Time-driven triggers)
– Google Apps Script 공식 문서 — 서비스 할당량 (Quotas for Google Services)
– Google Apps Script 공식 문서 — Utilities.formatDate · Range.getDisplayValues