hakwon · fee reminder · starter

수강료·재등록, 오늘 챙길 것

원생 시트에 납부 예정일과 수강 종료일을 적어 두면, 매일 아침 스크립트가 시트를 훑어 닥친 것만 색으로 표시하고 오늘 할 일 시트에 모아 둡니다. 학부모에게 연락할지, 언제 할지는 원장님이 정합니다. 스크립트는 아무것도 보내지 않습니다.

공식 문서 기준 도구 Google Sheets · Apps Script (시간 트리거) · 문서 확인 2026-10-11

시작하기 전에 — 보내지 않는 자동화

이 스크립트는 학부모에게 아무것도 보내지 않습니다. 수강료 안내와 재등록 권유는 관계가 걸린 말입니다. 누구에게, 언제, 어떤 말로 할지는 원장님이 정합니다. 스크립트가 하는 일은 "오늘 챙길 사람이 누구인가"를 놓치지 않게 보여 주는 것까지입니다.

그래서 이 가이드에는 메일 발송, 문자, 카카오 연동이 없습니다. 권한 승인도 시트 하나와 시간 트리거뿐이고, API 키도 AI도 필요 없습니다. 이전 편의 "확정 관문"이 이번 편에서는 "사람이 결정하는 칸"입니다.

한 가지 더. 공식 문서에 따르면 설치형 트리거는 그것을 만든 사람의 계정으로 실행됩니다. 원장님이 켜면 원장님 계정이 매일 아침 시트를 읽습니다. 강사 계정으로 켜 두고 그 강사가 퇴사하면 점검이 멈춥니다. 원장 계정으로 켜세요. — 설치형 트리거 가이드, 2026-09-03 갱신본

완성 상태와 준비물

완성 상태 — 원생 시트에서 ②를 누르면 7일 안 납부 예정 행은 노랑, 지난 행은 빨강, 14일 안 수강 종료 행은 주황으로 칠해지고, 오늘 할 일 시트에 그 행들만 급한 순서로 모입니다. ③을 켜 두면 매일 아침 7시에서 8시 사이에 같은 일이 저절로 일어납니다.
준비물메모
구글 계정원장 본인 계정. 트리거는 켠 사람의 계정으로 돕니다.
PC의 크롬 브라우저스크립트 편집기는 모바일에서 다루기 어렵습니다.
원생 5명 정도의 실제 날짜납부 예정일과 수강 종료일. 연습용으로 지어내지 말고 이번 달 실제 날짜를 쓰세요.

1. 시트를 만들고 스크립트를 붙인다

Google Sheets · Apps Script 편집기

  1. 새 구글 스프레드시트를 만들고 이름을 정합니다. 예: 오늘챙길것_연습
  2. 상단 메뉴 확장 프로그램 → Apps Script를 엽니다. 실행 확인: 사용자
  3. 편집기에 있는 function myFunction() {}를 지우고 아래 코드 전체를 붙여 넣습니다.
  4. 저장(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;
}

코드에서 눈여겨볼 것 세 가지입니다.

2. 메뉴 확인과 ① 시트 준비

onOpen · 권한

  1. 스프레드시트 탭으로 돌아가 새로고침합니다. 메뉴 줄에 오늘 챙길 것이 나타납니다.
  2. ① 시트 준비를 누릅니다. 처음 한 번은 권한 승인 창이 뜹니다. 본인 계정으로 승인합니다. 실행 확인: 사용자
  3. 승인 뒤 ①을 한 번 더 누릅니다. 원생, 학생명단, 오늘 할 일 세 시트와 머리글이 생깁니다.
확인 — 원생 1행에 학생ID · 반 · 납부예정일 · 수강종료일 · 메모, 오늘 할 일 1행에 구분 · 학생 · 반 · 날짜 · 남은 일수 · 메모가 굵게 보입니다. ①은 몇 번을 눌러도 빠진 머리글만 채우고 기존 내용은 건드리지 않습니다.

3. 원생과 학생명단을 채운다

날짜 형식 · 학생ID

  1. 학생명단에 학생ID와 이름을 적습니다. ID 예: S-014.
  2. 원생에는 학생ID, 반, 납부예정일, 수강종료일을 적습니다. 이름은 적지 않습니다.
  3. 날짜는 2026-10-18처럼 연-월-일로 적습니다. 구글 시트가 날짜로 알아보면 셀이 오른쪽 정렬됩니다.

스크립트는 시트가 날짜로 인식한 셀과 2026-10-18·2026.10.18 같은 글자 둘 다 읽습니다. 다만 10-18처럼 연도가 없는 글자는 읽지 않고 건너뜁니다. 추측해서 잘못 칠하는 것보다 비워 두는 편이 낫기 때문입니다.

이름은 학생명단에만 있고, 오늘 할 일에 모을 때 ID로 찾아 붙입니다. 이 시트는 원장님만 보는 시트이므로 거기서는 이름이 보여야 쓸모가 있습니다. 강사와 공유하는 시트라면 학생명단을 다른 파일로 빼는 것은 이 최소 버전의 범위 밖입니다.

4. ② 지금 점검 — 색이 뜻하는 것

classify · setBackgrounds

  1. ② 지금 점검을 누릅니다.
  2. 원생 시트의 행 색을 봅니다.
  3. 오늘 할 일 시트를 엽니다. 색이 칠해진 학생만, 급한 순서로 모여 있습니다. 오른쪽 위에 마지막 점검 시각이 적힙니다.
색기준구분 열
빨강납부예정일이 지났다납부 지남
노랑납부예정일이 오늘부터 7일 안납부 예정
주황수강종료일이 오늘부터 14일 안재등록 상담
없음그 밖의 모든 행. 이미 끝난 수강도 색을 칠하지 않습니다—
확인 — 한 학생의 납부예정일을 어제로 바꾸고 ②를 다시 누르면 그 행이 빨강이 되고 오늘 할 일 맨 위로 올라옵니다. 다시 다음 달로 바꾸고 ②를 누르면 색이 사라지고 목록에서 빠집니다. 점검할 때마다 전부 다시 칠하므로 지난 색이 남지 않습니다.

여기서부터는 사람의 일입니다. 오늘 할 일을 보고 누구에게 어떤 말을 할지 정하고, 연락이 끝나면 원생의 날짜를 고칩니다. 날짜를 고치는 것이 곧 처리 완료 표시입니다.

5. ③ 매일 아침 점검 켜기

시간 트리거 · everyDays(1).atHour(7)

  1. ③ 매일 아침 점검 켜기를 누릅니다. 트리거 권한을 묻는 창이 한 번 더 뜰 수 있습니다. 실행 확인: 사용자
  2. 안내창에 점검 시각과 시간대가 나옵니다. 시간대가 Asia/Seoul이 아니면 아래 "자주 막히는 곳"을 보세요.
  3. 다음 날 아침에 오늘 할 일의 점검 시각이 바뀌어 있으면 됩니다.

공식 문서는 시각을 정확히 보장하지 않습니다. 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 갱신본.

혼자 다시 해 보기

이 가이드를 닫고, 새 스프레드시트에서 처음부터 다시 만듭니다. 아래가 전부 되면 끝입니다.

이 최소 버전에 없는 것

가르치기 위해 줄인 버전입니다. 실제 운영에는 아래가 더 필요합니다.

이 글은 리드마스터 AX 컨설팅(학원 AI 전환 1-Day)에서 하루 동안 다루는 내용 가운데 하나를, 집에서 혼자 다시 해 볼 수 있게 줄인 복습 자료입니다. 이전 편: 수업 메모를 학부모 리포트로 · 다음 편: 학부모 문의, 매번 처음처럼 답하지 않기.

도구 사용법과 한도는 각 서비스의 공식 문서에서 확인했고, 확인한 주소와 날짜는 출처 장부에 있습니다. 실행 확인: 사용자 표시는 구글 로그인이 필요해 문서로만 확인한 단계입니다. 결과는 학원 환경에 따라 다를 수 있습니다.