hakwon · parent report · starter

수업 메모를 학부모 리포트로

구글 시트에 수업 메모 한 줄을 적고 메뉴를 누르면 AI가 초안을 씁니다. 선생님이 읽고 고쳐서 확정한 것만 학부모용 구글 문서가 됩니다. 확정하지 않은 글은 어디로도 나가지 않습니다.

공식 문서 기준 도구 Google Sheets · Apps Script · Gemini API · Google Docs · 문서 확인 2026-09-21

시작하기 전에 — 학생 정보

무료 등급에는 개인정보를 보내지 않습니다. Gemini API 약관은 무료(Unpaid) 서비스에 대해 사람이 입력과 출력을 읽고 처리할 수 있다고 밝히고, 민감하거나 개인적인 정보를 제출하지 말라고 적고 있습니다. 유료(Paid) 서비스는 프롬프트와 응답을 제품 개선에 쓰지 않는다고 되어 있습니다. — Gemini API 추가 약관, 2026-04-28 갱신본

그래서 이 가이드의 스크립트는 처음부터 이렇게 짜여 있습니다.

자동으로 가려지지 않는 것도 있습니다. 별명, 한 글자 이름, 학교 이름, 전화번호, 가정 사정은 그대로 나갑니다. 메모에는 수업에서 본 것만 적는 습관이 가장 확실한 안전장치입니다.

완성 상태와 준비물

완성 상태 — 수업기록 시트의 한 행에서 ②를 누르면 AI초안 칸이 채워지고, ③을 누르면 확정일시와 확정피드백이 찍히고, ④를 누르면 리포트링크 칸에 구글 문서 주소가 들어옵니다. ③을 건너뛰고 ④를 누르면 거절됩니다.
메모, AI 초안, 확정, 문서가 한 줄로 이어진다. 확정에서 문서로 가는 선만 굵다.
한 행이 거치는 네 칸. 확정에서 문서로 가는 길만 굵습니다. 눌러 보며 따라가려면 흐름도를 여세요.
준비물메모
구글 계정만 18세 이상. Gemini API 약관의 이용 조건입니다.
PC의 크롬 브라우저스크립트 편집기는 모바일에서 다루기 어렵습니다.
Gemini API 키3단계에서 발급합니다. 카드 등록 없이 시작할 수 있습니다.
실제 수업 메모 2~3개연습용으로 지어내지 말고 어제 수업의 메모를 쓰세요.

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

Google Sheets · Apps Script 편집기

  1. 새 구글 스프레드시트를 만들고 이름을 정합니다. 예: 학부모리포트_연습
  2. 상단 메뉴 확장 프로그램 → Apps Script를 엽니다. 실행 확인: 사용자
  3. 편집기에 있는 function myFunction() {}를 지우고 아래 코드 전체를 붙여 넣습니다.
  4. 저장(Ctrl+S)한 뒤 프로젝트 이름을 정합니다.

스크립트를 이렇게 시트 안에서 열어야 하는 이유가 있습니다. 공식 문서에 따르면 사용자 메뉴는 문서에 묶인(bound) 스크립트만 만들 수 있습니다. script.google.com에서 따로 만든 스크립트에는 메뉴가 붙지 않습니다.

Code.gs 전체 보기 · 복사
/**
 * Parent report starter — minimal teaching version.
 * Lesson memo (Sheet) -> AI draft -> human confirmation -> Google Doc.
 *
 * Rules this file enforces:
 *  1. Columns are found by header name, never by letter.
 *  2. Student names never leave the sheet. Only the memo (with names masked) goes to the AI.
 *  3. A report Doc is built only from a confirmed row. No confirmation, no report.
 */

var SHEET_LESSONS = '수업기록';
var SHEET_STUDENTS = '학생명단';
var LESSON_HEADERS = ['날짜', '학생ID', '반', '수업내용', '관찰메모', 'AI초안', '확정일시', '확정피드백', '리포트링크'];
var STUDENT_HEADERS = ['학생ID', '이름'];
var MODEL = 'gemini-3.8-flash';
var PROP_API_KEY = 'GEMINI_API_KEY';

// ---------- Menu (simple trigger: builds the menu only, touches no authorized service) ----------

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('학부모 리포트')
    .addItem('① 시트 준비', 'setupSheets')
    .addSeparator()
    .addItem('② 선택한 행 AI 초안 만들기', 'draftSelectedRow')
    .addItem('③ 선택한 행 확정하기', 'confirmSelectedRow')
    .addItem('④ 선택한 행 리포트 문서 만들기', 'buildReportForSelectedRow')
    .addToUi();
}

// ---------- Pure helpers (no Apps Script services; testable in Node) ----------

/** Maps header text to a 0-based column index. */
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;
}

/** Returns the header names that are required but absent. */
function missingHeaders(map, required) {
  return required.filter(function (h) { return !(h in map); });
}

/** Replaces every known student name in the text with a neutral word. Longest names first. */
function maskNames(text, names) {
  var out = String(text || '');
  var sorted = names
    .map(function (n) { return String(n || '').trim(); })
    .filter(function (n) { return n.length >= 2; })
    .sort(function (a, b) { return b.length - a.length; });
  sorted.forEach(function (name) {
    out = out.split(name).join('학생');
  });
  return out;
}

/** Builds the request body. The student ID and name are deliberately not included. */
function buildRequestBody(lesson, names) {
  var memo = maskNames(lesson.memo, names);
  var content = maskNames(lesson.content, names);
  return {
    systemInstruction: {
      parts: [{
        text: [
          '너는 학원 선생님이 학부모에게 보낼 수업 피드백의 초안을 돕는다.',
          '주어진 메모에 있는 사실만 쓴다. 메모에 없는 점수, 등수, 칭찬을 지어내지 않는다.',
          '3~4문장, 존댓말, 과장 없이. 학생은 "학생"이라고만 부른다.',
          '성적 향상을 약속하거나 보장하는 표현을 쓰지 않는다.'
        ].join('\n')
      }]
    },
    contents: [{
      parts: [{ text: '반: ' + lesson.className + '\n수업 내용: ' + content + '\n관찰 메모: ' + memo }]
    }]
  };
}

/** Pulls the generated text out of a generateContent response. Throws with a readable reason. */
function extractText(json) {
  var candidate = json && json.candidates && json.candidates[0];
  var part = candidate && candidate.content && candidate.content.parts && candidate.content.parts[0];
  if (!part || !part.text) {
    var reason = (candidate && candidate.finishReason) || (json && json.promptFeedback && json.promptFeedback.blockReason) || '응답에 본문이 없습니다';
    throw new Error('AI 초안을 받지 못했습니다: ' + reason);
  }
  return String(part.text).trim();
}

/** The gate. A row may become a report only when it has both a confirmation time and confirmed text. */
function canPublish(lesson) {
  return Boolean(lesson.confirmedAt) && String(lesson.confirmedText || '').trim().length > 0;
}

// ---------- Sheet access ----------

function setupSheets() {
  var ss = SpreadsheetApp.getActive();
  ensureSheet_(ss, SHEET_LESSONS, LESSON_HEADERS);
  ensureSheet_(ss, SHEET_STUDENTS, STUDENT_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;
}

/** Reads the currently selected lesson row into an object keyed by meaning, not by column letter. */
function readSelectedLesson_() {
  var sheet = SpreadsheetApp.getActiveSheet();
  if (sheet.getName() !== SHEET_LESSONS) throw new Error('"' + SHEET_LESSONS + '" 시트에서 행을 선택해 주세요.');
  var row = sheet.getActiveRange().getRow();
  if (row < 2) throw new Error('머리글이 아닌 수업 행을 선택해 주세요.');

  var map = headerMap(sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]);
  var missing = missingHeaders(map, LESSON_HEADERS);
  if (missing.length) throw new Error('빠진 열이 있습니다: ' + missing.join(', ') + ' — 메뉴 ①을 먼저 실행하세요.');

  var values = sheet.getRange(row, 1, 1, sheet.getLastColumn()).getValues()[0];
  return {
    sheet: sheet, row: row, map: map,
    date: values[map['날짜']],
    studentId: String(values[map['학생ID']]).trim(),
    className: String(values[map['반']]).trim(),
    content: String(values[map['수업내용']]).trim(),
    memo: String(values[map['관찰메모']]).trim(),
    draft: String(values[map['AI초안']]).trim(),
    confirmedAt: values[map['확정일시']],
    confirmedText: String(values[map['확정피드백']]).trim(),
    reportUrl: String(values[map['리포트링크']]).trim()
  };
}

function writeCell_(lesson, header, value) {
  lesson.sheet.getRange(lesson.row, lesson.map[header] + 1).setValue(value);
}

/** Returns { names: [...], byId: {id: name} } from the roster sheet. */
function readRoster_() {
  var sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_STUDENTS);
  if (!sheet || sheet.getLastRow() < 2) return { names: [], byId: {} };
  var data = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
  var map = headerMap(data[0]);
  var byId = {};
  var names = [];
  for (var r = 1; r < data.length; r++) {
    var id = String(data[r][map['학생ID']]).trim();
    var name = String(data[r][map['이름']]).trim();
    if (id && name) { byId[id] = name; names.push(name); }
  }
  return { names: names, byId: byId };
}

// ---------- ② AI draft ----------

function draftSelectedRow() {
  var ui = SpreadsheetApp.getUi();
  try {
    var lesson = readSelectedLesson_();
    if (!lesson.memo) throw new Error('관찰메모가 비어 있습니다. 메모를 먼저 적어 주세요.');
    if (lesson.confirmedAt) throw new Error('이미 확정된 행입니다. 확정일시를 지운 뒤 다시 시도하세요.');

    var roster = readRoster_();
    var text = callGemini_(buildRequestBody(lesson, roster.names));
    writeCell_(lesson, 'AI초안', text);
    ui.alert('AI초안 칸에 초안을 넣었습니다. 읽고 고친 뒤 ③ 확정하기를 누르세요.');
  } catch (err) {
    ui.alert('초안을 만들지 못했습니다.\n\n' + err.message);
  }
}

function callGemini_(body) {
  var key = PropertiesService.getScriptProperties().getProperty(PROP_API_KEY);
  if (!key) throw new Error('스크립트 속성에 ' + PROP_API_KEY + ' 가 없습니다.');

  var url = 'https://generativelanguage.googleapis.com/v1beta/models/' + MODEL + ':generateContent';
  var response = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    headers: { 'x-goog-api-key': key },
    payload: JSON.stringify(body),
    muteHttpExceptions: true
  });

  var code = response.getResponseCode();
  var raw = response.getContentText();
  if (code !== 200) throw new Error('Gemini API 응답 ' + code + ': ' + raw.slice(0, 300));
  return extractText(JSON.parse(raw));
}

// ---------- ③ Confirm (the only place that writes 확정피드백) ----------

function confirmSelectedRow() {
  var ui = SpreadsheetApp.getUi();
  try {
    var lesson = readSelectedLesson_();
    if (!lesson.draft) throw new Error('AI초안 칸이 비어 있습니다. 직접 써 넣어도 됩니다.');

    var answer = ui.alert('이 내용으로 확정할까요?', lesson.draft, ui.ButtonSet.YES_NO);
    if (answer !== ui.Button.YES) return;

    writeCell_(lesson, '확정피드백', lesson.draft);
    writeCell_(lesson, '확정일시', new Date());
  } catch (err) {
    ui.alert('확정하지 못했습니다.\n\n' + err.message);
  }
}

// ---------- ④ Report Doc (refuses unconfirmed rows) ----------

function buildReportForSelectedRow() {
  var ui = SpreadsheetApp.getUi();
  try {
    var lesson = readSelectedLesson_();
    if (!canPublish(lesson)) throw new Error('확정되지 않은 행입니다. ③ 확정하기를 먼저 누르세요.');

    var roster = readRoster_();
    var name = roster.byId[lesson.studentId];
    if (!name) throw new Error('학생명단에서 ' + lesson.studentId + ' 를 찾지 못했습니다.');

    var tz = Session.getScriptTimeZone();
    var dateText = Utilities.formatDate(new Date(lesson.date), tz, 'yyyy-MM-dd');

    var doc = DocumentApp.create(name + ' 수업 리포트 ' + dateText);
    var body = doc.getBody();
    body.appendParagraph(name + ' 학생 수업 리포트').setHeading(DocumentApp.ParagraphHeading.HEADING1);
    body.appendParagraph(dateText + ' · ' + lesson.className);
    body.appendParagraph('오늘 수업').setHeading(DocumentApp.ParagraphHeading.HEADING2);
    body.appendParagraph(lesson.content);
    body.appendParagraph('선생님 피드백').setHeading(DocumentApp.ParagraphHeading.HEADING2);
    body.appendParagraph(lesson.confirmedText);
    var url = doc.getUrl();
    doc.saveAndClose();

    writeCell_(lesson, '리포트링크', url);
    ui.alert('리포트 문서를 만들었습니다. 리포트링크 칸을 확인하세요.');
  } catch (err) {
    ui.alert('문서를 만들지 못했습니다.\n\n' + err.message);
  }
}

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

2. 메뉴 확인과 첫 권한 승인

onOpen · 권한

  1. 스프레드시트 탭으로 돌아가 새로고침합니다. 몇 초 뒤 메뉴 줄에 학부모 리포트가 나타납니다.
  2. ① 시트 준비를 누릅니다. 처음 한 번은 권한 승인 창이 뜹니다. 본인 계정으로 승인합니다. 실행 확인: 사용자
  3. 승인 뒤 ①을 한 번 더 누릅니다. 수업기록, 학생명단 두 시트와 머리글이 생깁니다.
확인 — 수업기록 1행에 날짜 · 학생ID · 반 · 수업내용 · 관찰메모 · AI초안 · 확정일시 · 확정피드백 · 리포트링크가 굵게 보입니다. ①은 몇 번을 눌러도 빠진 머리글만 채우고 기존 내용은 건드리지 않습니다.

메뉴가 뜨는 것과 메뉴가 동작하는 것은 단계가 다릅니다. onOpen은 단순 트리거라서 공식 문서상 권한이 필요한 서비스를 쓸 수 없고 30초 안에 끝나야 합니다. 그래서 onOpen에서는 메뉴만 만들고, 외부 호출이나 문서 생성은 메뉴 항목을 눌렀을 때 실행되는 함수에 둡니다. 권한 승인 창이 ①을 누를 때 처음 뜨는 것도 이 때문입니다.

권한 창에 "확인되지 않은 앱"이라는 경고가 나올 수 있습니다. 본인이 방금 붙여 넣은 스크립트를 본인 계정으로 승인하는 상황입니다. 확인 필요: 경고 화면의 정확한 문구와 버튼 이름

3. API 키를 스크립트 속성에 넣는다

Google AI Studio · 프로젝트 설정

  1. aistudio.google.com/apikey에서 키를 만듭니다. 처음이면 약관 동의 뒤 기본 프로젝트가 자동으로 생깁니다.
  2. Apps Script 편집기 왼쪽의 프로젝트 설정(톱니바퀴) → 스크립트 속성에서 속성을 추가합니다. 실행 확인: 사용자
  3. 속성 이름은 GEMINI_API_KEY, 값은 방금 만든 키입니다.

키를 코드에 직접 적지 않는 이유는 공식 안내에 있습니다. API 키는 비밀번호처럼 다루고, 소스 관리에 올리지 말고, 클라이언트 쪽 코드에 넣지 말라고 되어 있습니다. 시트를 복사해 다른 선생님에게 줄 때 코드에 키가 박혀 있으면 키도 같이 넘어갑니다.

시트를 공유하는 학원이라면 — 공식 문서는 스크립트 속성을 "스크립트의 모든 사용자"가 접근하는 저장소라고 설명합니다. 시트 편집자에게 키 값이 보이는지는 문서에 명시되어 있지 않습니다. 강사에게 편집 권한을 주기 전에 부계정으로 직접 확인하세요. 확인 필요 확인 전까지는 이 시트를 원장 혼자 쓰는 것이 안전합니다.

4. 가명 규칙을 세운다

학생명단 시트

  1. 학생명단 시트에 학생ID와 이름을 적습니다. ID 예: S-014. 이름이나 생일이 드러나지 않는 번호로 짓습니다.
  2. 수업기록에는 이름 대신 학생ID만 적습니다.
  3. 관찰메모에는 수업에서 본 것만 적습니다. 가정사, 건강, 다른 학생과의 비교는 적지 않습니다.

학생명단은 두 가지 일을 합니다. 메모에 섞여 들어간 이름을 보내기 전에 가리는 사전이고, 마지막에 문서 제목과 첫 줄에 이름을 붙이는 조회표입니다. 이름이 시트 밖으로 나가는 경로는 구글 문서 하나입니다.

5. 메모 → 초안 → 확정 → 문서

메뉴 ② ③ ④

  1. 수업기록 2행에 날짜, 학생ID, 반, 수업내용, 관찰메모를 적습니다.
  2. 그 행의 아무 칸이나 선택하고 ② 선택한 행 AI 초안 만들기를 누릅니다. 외부 호출 권한을 묻는 창이 한 번 더 뜰 수 있습니다.
  3. AI초안 칸의 글을 읽고 직접 고칩니다. 메모에 없는 내용이 들어갔는지, 약속처럼 읽히는 문장이 있는지 봅니다.
  4. ③ 선택한 행 확정하기를 누르고, 뜨는 창에서 내용을 확인한 뒤 예를 누릅니다.
  5. ④ 선택한 행 리포트 문서 만들기를 누릅니다. 리포트링크 칸의 주소를 열어 봅니다.
확인 — 새 행을 하나 더 만들고 ②만 한 뒤 바로 ④를 눌러 보세요. "확정되지 않은 행입니다"라는 안내가 뜨면 관문이 제대로 작동하는 것입니다. 이 거절이 이 시스템에서 가장 중요한 동작입니다.

③을 거친 뒤에 AI초안을 다시 고쳐도 확정피드백은 바뀌지 않습니다. 고친 내용을 반영하려면 확정일시를 지우고 ③을 다시 누릅니다. 학부모가 받는 글은 언제나 선생님이 마지막으로 "예"를 누른 그 글입니다.

자주 막히는 곳

증상원인과 조치
메뉴가 안 보인다시트를 새로고침하지 않았거나, 스크립트를 시트 밖(script.google.com)에서 만들었습니다. 시트의 확장 프로그램 메뉴에서 열어 다시 붙여 넣습니다. 보기 전용으로 연 시트에서는 onOpen이 실행되지 않습니다.
"스크립트 속성에 GEMINI_API_KEY 가 없습니다"속성 이름의 철자와 대소문자를 확인합니다. 앞뒤 공백도 다른 이름으로 취급됩니다.
"Gemini API 응답 400 / 403"키가 잘못됐거나 키에 제한이 걸려 있습니다. 안내창에 응답 본문 앞부분이 함께 나오니 그 문구를 읽습니다.
"Gemini API 응답 404"모델 이름이 바뀌었을 수 있습니다. 코드 위쪽 MODEL 값을 공식 문서의 현재 모델 이름으로 바꿉니다.
"Gemini API 응답 429"분당 또는 일일 호출 한도를 넘었습니다. 잠시 뒤 다시 누릅니다.
"AI 초안을 받지 못했습니다: SAFETY"메모의 표현이 안전 필터에 걸렸습니다. 메모를 사실 위주로 고쳐 다시 시도합니다.
"빠진 열이 있습니다"머리글을 고쳐 썼습니다. ①을 다시 누르면 빠진 머리글이 맨 오른쪽에 추가됩니다.
"학생명단에서 S-014 를 찾지 못했습니다"수업기록의 학생ID와 학생명단의 학생ID가 글자 단위로 같아야 합니다.

한도

개인 구글 계정 기준으로 공식 할당량 표에 적힌 값입니다. 학원 한 곳의 하루 분량으로는 여유가 큽니다.

항목개인 계정Workspace
외부 호출(URL Fetch)20,000회 / 일100,000회 / 일
한 번 실행 시간6분6분
문서 생성250개 / 일1,500개 / 일

Apps Script 할당량 문서, 2026-09-03 갱신본. Gemini API 쪽 호출 한도는 등급과 모델에 따라 따로 있습니다.

혼자 다시 해 보기

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

이 최소 버전에 없는 것

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

이 글은 리드마스터 AX 컨설팅(학원 AI 전환 1-Day)에서 하루 동안 다루는 내용 가운데 하나를, 집에서 혼자 다시 해 볼 수 있게 줄인 복습 자료입니다. 다음 편: 수강료·재등록, 오늘 챙길 것.

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