시작하기 전에 — 보내지 않는 자동화
그래서 이 가이드에는 메일 발송, 문자, 카카오 연동이 없습니다. 권한 승인도 시트 하나와 시간 트리거뿐이고, API 키도 AI도 필요 없습니다. 이전 편의 "확정 관문"이 이번 편에서는 "사람이 결정하는 칸"입니다.
한 가지 더. 공식 문서에 따르면 설치형 트리거는 그것을 만든 사람의 계정으로 실행됩니다. 원장님이 켜면 원장님 계정이 매일 아침 시트를 읽습니다. 강사 계정으로 켜 두고 그 강사가 퇴사하면 점검이 멈춥니다. 원장 계정으로 켜세요. — 설치형 트리거 가이드, 2026-09-03 갱신본
완성 상태와 준비물
원생 시트에서 ②를 누르면 7일 안 납부 예정 행은 노랑, 지난 행은 빨강, 14일 안 수강 종료 행은 주황으로 칠해지고, 오늘 할 일 시트에 그 행들만 급한 순서로 모입니다. ③을 켜 두면 매일 아침 7시에서 8시 사이에 같은 일이 저절로 일어납니다.| 준비물 | 메모 |
|---|---|
| 구글 계정 | 원장 본인 계정. 트리거는 켠 사람의 계정으로 돕니다. |
| PC의 크롬 브라우저 | 스크립트 편집기는 모바일에서 다루기 어렵습니다. |
| 원생 5명 정도의 실제 날짜 | 납부 예정일과 수강 종료일. 연습용으로 지어내지 말고 이번 달 실제 날짜를 쓰세요. |
1. 시트를 만들고 스크립트를 붙인다
Google Sheets · Apps Script 편집기
- 새 구글 스프레드시트를 만들고 이름을 정합니다. 예:
오늘챙길것_연습 - 상단 메뉴 확장 프로그램 → Apps Script를 엽니다. 실행 확인: 사용자
- 편집기에 있는
function myFunction() {}를 지우고 아래 코드 전체를 붙여 넣습니다. - 저장(Ctrl+S)한 뒤 프로젝트 이름을 정합니다.
이전 편과 같은 이유로 스크립트는 시트 안에서 엽니다. 공식 문서상 사용자 메뉴는 문서에 묶인(bound) 스크립트만 만들 수 있습니다.
Code.gs 전체 보기 · 복사
/**
* Fee & re-enrollment reminder — minimal teaching version.
* Roster sheet (dates) -> daily scan -> colored rows + "today" list. A person decides what to do.
*
* Rules this file enforces:
* 1. Columns are found by header name, never by letter.
* 2. Nothing is sent to parents. The script only colors rows and writes a to-do sheet the owner reads.
* 3. Exactly one daily trigger exists at a time; turning it on replaces any older one.
*/
var SHEET_ROSTER = '원생';
var SHEET_TODO = '오늘 할 일';
var SHEET_NAMES = '학생명단';
var ROSTER_HEADERS = ['학생ID', '반', '납부예정일', '수강종료일', '메모'];
var NAMES_HEADERS = ['학생ID', '이름'];
var TODO_HEADERS = ['구분', '학생', '반', '날짜', '남은 일수', '메모'];
var HANDLER = 'scanAndMark';
var RUN_HOUR = 7;
var RULES = {
dueSoonDays: 7, // payment due within this many days -> yellow
endSoonDays: 14 // enrollment ends within this many days -> orange
};
var COLORS = { red: '#f8cbc4', yellow: '#fff2a8', orange: '#ffd8a8' };
var LABELS = { red: '납부 지남', yellow: '납부 예정', orange: '재등록 상담' };
// ---------- Menu (simple trigger: menu only) ----------
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('오늘 챙길 것')
.addItem('① 시트 준비', 'setupSheets')
.addItem('② 지금 점검', 'scanAndMark')
.addSeparator()
.addItem('③ 매일 아침 점검 켜기', 'enableDaily')
.addItem('④ 매일 아침 점검 끄기', 'disableDaily')
.addToUi();
}
// ---------- Pure helpers (no Apps Script services; testable in Node) ----------
function headerMap(headerRow) {
var map = {};
for (var i = 0; i < headerRow.length; i++) {
var key = String(headerRow[i]).trim();
if (key) map[key] = i;
}
return map;
}
function missingHeaders(map, required) {
return required.filter(function (h) { return !(h in map); });
}
/** Accepts a Date, a yyyy-mm-dd / yyyy.mm.dd string, or blank. Returns a Date at local midnight, or null. */
function toDate(value) {
if (value === '' || value === null || value === undefined) return null;
var d;
if (Object.prototype.toString.call(value) === '[object Date]') {
d = new Date(value.getTime());
} else {
var m = String(value).trim().match(/^(\d{4})[.\-\/](\d{1,2})[.\-\/](\d{1,2})/);
if (!m) return null;
d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
if (isNaN(d.getTime())) return null;
d.setHours(0, 0, 0, 0);
return d;
}
/** Whole days from today to the date. Negative means the date has passed. */
function daysUntil(today, date) {
var t = toDate(today), d = toDate(date);
if (!t || !d) return null;
return Math.round((d.getTime() - t.getTime()) / 86400000);
}
/**
* Decides the row's color and label. Payment rules win over enrollment rules,
* because an unpaid fee is the more urgent conversation.
*/
function classify(row, today, rules) {
rules = rules || RULES;
var due = daysUntil(today, row.dueDate);
var end = daysUntil(today, row.endDate);
if (due !== null && due < 0) return { tone: 'red', label: LABELS.red, days: due, date: toDate(row.dueDate) };
if (due !== null && due <= rules.dueSoonDays) return { tone: 'yellow', label: LABELS.yellow, days: due, date: toDate(row.dueDate) };
if (end !== null && end >= 0 && end <= rules.endSoonDays) return { tone: 'orange', label: LABELS.orange, days: end, date: toDate(row.endDate) };
return { tone: null, label: '', days: null, date: null };
}
/** Most urgent first: overdue, then fewest days remaining, then student id. Does not mutate. */
function sortTodo(items) {
var rank = { red: 0, yellow: 1, orange: 2 };
return items.slice().sort(function (a, b) {
if (rank[a.tone] !== rank[b.tone]) return rank[a.tone] - rank[b.tone];
if (a.days !== b.days) return a.days - b.days;
return String(a.studentId).localeCompare(String(b.studentId));
});
}
// ---------- ① Sheets ----------
function setupSheets() {
var ss = SpreadsheetApp.getActive();
ensureSheet_(ss, SHEET_ROSTER, ROSTER_HEADERS);
ensureSheet_(ss, SHEET_NAMES, NAMES_HEADERS);
ensureSheet_(ss, SHEET_TODO, TODO_HEADERS);
SpreadsheetApp.getUi().alert('시트 준비가 끝났습니다. 이미 있던 열과 내용은 건드리지 않았습니다.');
}
/** Idempotent: creates the sheet if absent and appends only the headers that are missing. */
function ensureSheet_(ss, name, headers) {
var sheet = ss.getSheetByName(name) || ss.insertSheet(name);
var lastCol = Math.max(sheet.getLastColumn(), 1);
var existing = headerMap(sheet.getRange(1, 1, 1, lastCol).getValues()[0]);
var toAdd = missingHeaders(existing, headers);
if (toAdd.length) {
var start = Object.keys(existing).length ? sheet.getLastColumn() + 1 : 1;
sheet.getRange(1, start, 1, toAdd.length).setValues([toAdd]).setFontWeight('bold');
sheet.setFrozenRows(1);
}
return sheet;
}
/** { studentId: name } from the names sheet. Names appear only on the owner-facing to-do sheet. */
function readNames_() {
var sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAMES);
var byId = {};
if (!sheet || sheet.getLastRow() < 2) return byId;
var data = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
var map = headerMap(data[0]);
for (var r = 1; r < data.length; r++) {
var id = String(data[r][map['학생ID']]).trim();
if (id) byId[id] = String(data[r][map['이름']]).trim();
}
return byId;
}
// ---------- ② Scan: color rows, rebuild the to-do sheet ----------
function scanAndMark() {
var ss = SpreadsheetApp.getActive();
var roster = ss.getSheetByName(SHEET_ROSTER);
var todo = ss.getSheetByName(SHEET_TODO);
if (!roster || !todo) throw new Error('메뉴 ① 시트 준비를 먼저 실행하세요.');
var lastRow = roster.getLastRow(), lastCol = roster.getLastColumn();
var map = headerMap(roster.getRange(1, 1, 1, lastCol).getValues()[0]);
var missing = missingHeaders(map, ROSTER_HEADERS);
if (missing.length) throw new Error('빠진 열이 있습니다: ' + missing.join(', ') + ' — 메뉴 ①을 먼저 실행하세요.');
var names = readNames_();
var tz = Session.getScriptTimeZone();
var today = toDate(Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd'));
var items = [];
var colors = [];
if (lastRow >= 2) {
var values = roster.getRange(2, 1, lastRow - 1, lastCol).getValues();
for (var i = 0; i < values.length; i++) {
var v = values[i];
var row = {
studentId: String(v[map['학생ID']]).trim(),
className: String(v[map['반']]).trim(),
dueDate: v[map['납부예정일']],
endDate: v[map['수강종료일']],
memo: String(v[map['메모']]).trim()
};
var c = row.studentId ? classify(row, today) : { tone: null };
var fill = c.tone ? COLORS[c.tone] : null;
var rowColors = [];
for (var k = 0; k < lastCol; k++) rowColors.push(fill);
colors.push(rowColors);
if (c.tone) {
items.push({
tone: c.tone, label: c.label, days: c.days, date: c.date,
studentId: row.studentId, name: names[row.studentId] || row.studentId,
className: row.className, memo: row.memo
});
}
}
roster.getRange(2, 1, lastRow - 1, lastCol).setBackgrounds(colors);
}
var sorted = sortTodo(items);
if (todo.getLastRow() > 1) {
todo.getRange(2, 1, todo.getLastRow() - 1, todo.getLastColumn()).clearContent().setBackground(null);
}
if (sorted.length) {
var out = sorted.map(function (it) {
return [it.label, it.name, it.className, Utilities.formatDate(it.date, tz, 'yyyy-MM-dd'), it.days, it.memo];
});
var block = todo.getRange(2, 1, out.length, TODO_HEADERS.length);
block.setValues(out);
block.setBackgrounds(sorted.map(function (it) {
return TODO_HEADERS.map(function () { return COLORS[it.tone]; });
}));
}
todo.getRange(1, TODO_HEADERS.length + 1).setValue('점검 ' + Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd HH:mm'));
}
// ---------- ③ ④ Daily trigger: never more than one ----------
function enableDaily() {
deleteOurTriggers_();
ScriptApp.newTrigger(HANDLER).timeBased().everyDays(1).atHour(RUN_HOUR).create();
SpreadsheetApp.getUi().alert('매일 아침 ' + RUN_HOUR + '시에서 ' + (RUN_HOUR + 1) + '시 사이에 점검합니다. 시간대: ' + Session.getScriptTimeZone());
}
function disableDaily() {
var n = deleteOurTriggers_();
SpreadsheetApp.getUi().alert(n ? '매일 점검을 껐습니다.' : '켜져 있는 점검이 없습니다.');
}
/** Removes every trigger that points at scanAndMark. Returns how many were removed. */
function deleteOurTriggers_() {
var n = 0;
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === HANDLER) { ScriptApp.deleteTrigger(t); n++; }
});
return n;
}
코드에서 눈여겨볼 것 세 가지입니다.
- 기준이 맨 위에 있습니다.
RULES의 7과 14를 바꾸면 색 기준이 바뀝니다.RUN_HOUR를 바꾸면 점검 시각이 바뀝니다. 다른 곳은 손대지 않아도 됩니다. - 납부가 재등록보다 먼저입니다.
classify는 지난 납부, 닥친 납부, 닥친 종료 순서로 판단합니다. 두 가지가 겹친 학생은 더 급한 색 하나만 받습니다. - 트리거는 항상 하나입니다. ③을 몇 번 눌러도
enableDaily가 기존 것을 지우고 하나만 만듭니다. 같은 점검이 두 번 돌지 않습니다.
2. 메뉴 확인과 ① 시트 준비
onOpen · 권한
- 스프레드시트 탭으로 돌아가 새로고침합니다. 메뉴 줄에 오늘 챙길 것이 나타납니다.
- ① 시트 준비를 누릅니다. 처음 한 번은 권한 승인 창이 뜹니다. 본인 계정으로 승인합니다. 실행 확인: 사용자
- 승인 뒤 ①을 한 번 더 누릅니다.
원생,학생명단,오늘 할 일세 시트와 머리글이 생깁니다.
학생ID · 반 · 납부예정일 · 수강종료일 · 메모, 오늘 할 일 1행에 구분 · 학생 · 반 · 날짜 · 남은 일수 · 메모가 굵게 보입니다. ①은 몇 번을 눌러도 빠진 머리글만 채우고 기존 내용은 건드리지 않습니다.3. 원생과 학생명단을 채운다
날짜 형식 · 학생ID
학생명단에 학생ID와 이름을 적습니다. ID 예:S-014.원생에는 학생ID, 반, 납부예정일, 수강종료일을 적습니다. 이름은 적지 않습니다.- 날짜는
2026-10-18처럼 연-월-일로 적습니다. 구글 시트가 날짜로 알아보면 셀이 오른쪽 정렬됩니다.
스크립트는 시트가 날짜로 인식한 셀과 2026-10-18·2026.10.18 같은 글자 둘 다 읽습니다. 다만 10-18처럼 연도가 없는 글자는 읽지 않고 건너뜁니다. 추측해서 잘못 칠하는 것보다 비워 두는 편이 낫기 때문입니다.
이름은 학생명단에만 있고, 오늘 할 일에 모을 때 ID로 찾아 붙입니다. 이 시트는 원장님만 보는 시트이므로 거기서는 이름이 보여야 쓸모가 있습니다. 강사와 공유하는 시트라면 학생명단을 다른 파일로 빼는 것은 이 최소 버전의 범위 밖입니다.
4. ② 지금 점검 — 색이 뜻하는 것
classify · setBackgrounds
- ② 지금 점검을 누릅니다.
원생시트의 행 색을 봅니다.오늘 할 일시트를 엽니다. 색이 칠해진 학생만, 급한 순서로 모여 있습니다. 오른쪽 위에 마지막 점검 시각이 적힙니다.
| 색 | 기준 | 구분 열 |
|---|---|---|
| 빨강 | 납부예정일이 지났다 | 납부 지남 |
| 노랑 | 납부예정일이 오늘부터 7일 안 | 납부 예정 |
| 주황 | 수강종료일이 오늘부터 14일 안 | 재등록 상담 |
| 없음 | 그 밖의 모든 행. 이미 끝난 수강도 색을 칠하지 않습니다 | — |
오늘 할 일 맨 위로 올라옵니다. 다시 다음 달로 바꾸고 ②를 누르면 색이 사라지고 목록에서 빠집니다. 점검할 때마다 전부 다시 칠하므로 지난 색이 남지 않습니다.여기서부터는 사람의 일입니다. 오늘 할 일을 보고 누구에게 어떤 말을 할지 정하고, 연락이 끝나면 원생의 날짜를 고칩니다. 날짜를 고치는 것이 곧 처리 완료 표시입니다.
5. ③ 매일 아침 점검 켜기
시간 트리거 · everyDays(1).atHour(7)
- ③ 매일 아침 점검 켜기를 누릅니다. 트리거 권한을 묻는 창이 한 번 더 뜰 수 있습니다. 실행 확인: 사용자
- 안내창에 점검 시각과 시간대가 나옵니다. 시간대가
Asia/Seoul이 아니면 아래 "자주 막히는 곳"을 보세요. - 다음 날 아침에
오늘 할 일의 점검 시각이 바뀌어 있으면 됩니다.
공식 문서는 시각을 정확히 보장하지 않습니다. 7시로 설정하면 "7시에서 8시 사이"의 어느 분에 실행되고, 한 번 정해진 분은 날마다 비슷하게 유지됩니다. 분까지 맞출 필요가 있는 일이라면 이 방식은 맞지 않습니다. — ClockTriggerBuilder 레퍼런스, 2026-04-13 갱신본
점검을 멈추려면 ④ 매일 아침 점검 끄기를 누릅니다. 트리거가 없는데 ④를 누르면 "켜져 있는 점검이 없습니다"라고만 답합니다.
자주 막히는 곳
| 증상 | 원인과 조치 |
|---|---|
| 메뉴가 안 보인다 | 새로고침하지 않았거나 스크립트를 시트 밖(script.google.com)에서 만들었습니다. 보기 전용으로 연 시트에서는 메뉴가 뜨지 않습니다. |
| "메뉴 ① 시트 준비를 먼저 실행하세요" | 시트 이름이 바뀌었습니다. 원생·오늘 할 일 이름을 되돌리거나 ①을 다시 누릅니다. |
| "빠진 열이 있습니다" | 머리글을 고쳐 썼습니다. ①을 다시 누르면 빠진 머리글이 맨 오른쪽에 추가됩니다. |
| 날짜를 적었는데 색이 안 칠해진다 | 연도가 빠졌거나(10-18) 글자 사이에 공백·요일이 섞였습니다. 2026-10-18로 다시 적습니다. 학생ID가 비어 있는 행도 건너뜁니다. |
| 아침 점검이 돌지 않는다 | ③을 누른 계정이 더는 시트를 열 수 없거나, 트리거가 삭제됐습니다. 편집기 왼쪽 트리거 메뉴에서 scanAndMark 항목이 있는지 보고, 없으면 ③을 다시 누릅니다. |
| 엉뚱한 시각에 돈다 | 스크립트 시간대가 한국이 아닙니다. 편집기 프로젝트 설정에서 시간대를 (GMT+09:00) Seoul로 바꾸고 ③을 다시 누릅니다. 확인 필요: 설정 화면의 정확한 항목 이름 |
| 같은 날 두 번 돈다 | 이 코드로는 생기지 않습니다. ③이 기존 트리거를 지우고 하나만 만듭니다. 다른 스크립트의 트리거가 아닌지 트리거 메뉴에서 확인합니다. |
한도
개인 구글 계정 기준으로 공식 할당량 표에 적힌 값입니다. 하루 한 번 시트 한 장을 읽는 일에는 여유가 큽니다.
| 항목 | 개인 계정 | Workspace |
|---|---|---|
| 트리거 수 (사용자·스크립트당) | 20개 | 20개 |
| 트리거 총 실행 시간 | 90분 / 일 | 6시간 / 일 |
| 한 번 실행 시간 | 6분 | 6분 |
Apps Script 할당량 문서, 2026-09-03 갱신본.
혼자 다시 해 보기
이 가이드를 닫고, 새 스프레드시트에서 처음부터 다시 만듭니다. 아래가 전부 되면 끝입니다.
- 새 시트에 스크립트를 붙이고 메뉴를 띄웠다
- 실제 원생 5명의 날짜를 넣고 ②로 색을 봤다
- 날짜 하나를 어제로 바꿔 빨강이 되는 것과 맨 위로 올라오는 것을 봤다
- ③을 켜고 다음 날 아침 점검 시각이 바뀐 것을 봤다
RULES의 숫자 하나를 우리 학원 기준으로 바꾸고 결과가 달라지는 것을 봤다
이 최소 버전에 없는 것
가르치기 위해 줄인 버전입니다. 실제 운영에는 아래가 더 필요합니다.
- 연락했다는 기록(언제, 누가, 어떤 답). 지금은 날짜를 고치는 것으로 대신합니다
- 재등록 성공·이탈 집계와 월별 통계
- 학부모에게 보내는 안내문 초안. 보내는 것 자체는 이 시리즈에서 자동화하지 않습니다
- 강사와 공유할 때 이름 시트를 분리하는 구조
- 납부 완료 입력과 다음 납부일 자동 계산
이 글은 리드마스터 AX 컨설팅(학원 AI 전환 1-Day)에서 하루 동안 다루는 내용 가운데 하나를, 집에서 혼자 다시 해 볼 수 있게 줄인 복습 자료입니다. 이전 편: 수업 메모를 학부모 리포트로 · 다음 편: 학부모 문의, 매번 처음처럼 답하지 않기.
도구 사용법과 한도는 각 서비스의 공식 문서에서 확인했고, 확인한 주소와 날짜는 출처 장부에 있습니다. 실행 확인: 사용자 표시는 구글 로그인이 필요해 문서로만 확인한 단계입니다. 결과는 학원 환경에 따라 다를 수 있습니다.