시작하기 전에 — 학생 정보
그래서 이 가이드의 스크립트는 처음부터 이렇게 짜여 있습니다.
- 학생 이름과 학생ID는 AI로 보내지 않습니다. 보내는 것은 반 이름, 수업 내용, 관찰 메모 셋뿐입니다.
- 메모 안에 이름을 적었더라도, 보내기 직전에 학생명단의 이름을 찾아 "학생"으로 바꿉니다.
- 이름은 마지막 단계, 구글 문서를 만들 때 시트 안에서만 다시 붙습니다.
자동으로 가려지지 않는 것도 있습니다. 별명, 한 글자 이름, 학교 이름, 전화번호, 가정 사정은 그대로 나갑니다. 메모에는 수업에서 본 것만 적는 습관이 가장 확실한 안전장치입니다.
완성 상태와 준비물
| 준비물 | 메모 |
|---|---|
| 구글 계정 | 만 18세 이상. Gemini API 약관의 이용 조건입니다. |
| PC의 크롬 브라우저 | 스크립트 편집기는 모바일에서 다루기 어렵습니다. |
| Gemini API 키 | 3단계에서 발급합니다. 카드 등록 없이 시작할 수 있습니다. |
| 실제 수업 메모 2~3개 | 연습용으로 지어내지 말고 어제 수업의 메모를 쓰세요. |
1. 시트를 만들고 스크립트를 붙인다
Google Sheets · Apps Script 편집기
- 새 구글 스프레드시트를 만들고 이름을 정합니다. 예:
학부모리포트_연습 - 상단 메뉴 확장 프로그램 → Apps Script를 엽니다. 실행 확인: 사용자
- 편집기에 있는
function myFunction() {}를 지우고 아래 코드 전체를 붙여 넣습니다. - 저장(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);
}
}
코드에서 눈여겨볼 것 세 가지입니다.
- 열을 글자가 아니라 이름으로 찾습니다.
headerMap이 1행의 머리글을 읽어 위치를 알아냅니다. 중간에 열을 끼워 넣거나 순서를 바꿔도 깨지지 않습니다. - 확정피드백 칸에 쓰는 함수는 하나뿐입니다.
confirmSelectedRow만 그 칸을 채웁니다. AI가 직접 쓰는 길이 없습니다. - 문서를 만드는 함수가 맨 먼저 하는 일은 거절입니다.
canPublish가 확정일시와 확정피드백 둘 다 있는지 확인하고, 없으면 멈춥니다.
2. 메뉴 확인과 첫 권한 승인
onOpen · 권한
- 스프레드시트 탭으로 돌아가 새로고침합니다. 몇 초 뒤 메뉴 줄에 학부모 리포트가 나타납니다.
- ① 시트 준비를 누릅니다. 처음 한 번은 권한 승인 창이 뜹니다. 본인 계정으로 승인합니다. 실행 확인: 사용자
- 승인 뒤 ①을 한 번 더 누릅니다.
수업기록,학생명단두 시트와 머리글이 생깁니다.
날짜 · 학생ID · 반 · 수업내용 · 관찰메모 · AI초안 · 확정일시 · 확정피드백 · 리포트링크가 굵게 보입니다. ①은 몇 번을 눌러도 빠진 머리글만 채우고 기존 내용은 건드리지 않습니다.메뉴가 뜨는 것과 메뉴가 동작하는 것은 단계가 다릅니다. onOpen은 단순 트리거라서 공식 문서상 권한이 필요한 서비스를 쓸 수 없고 30초 안에 끝나야 합니다. 그래서 onOpen에서는 메뉴만 만들고, 외부 호출이나 문서 생성은 메뉴 항목을 눌렀을 때 실행되는 함수에 둡니다. 권한 승인 창이 ①을 누를 때 처음 뜨는 것도 이 때문입니다.
권한 창에 "확인되지 않은 앱"이라는 경고가 나올 수 있습니다. 본인이 방금 붙여 넣은 스크립트를 본인 계정으로 승인하는 상황입니다. 확인 필요: 경고 화면의 정확한 문구와 버튼 이름
3. API 키를 스크립트 속성에 넣는다
Google AI Studio · 프로젝트 설정
- aistudio.google.com/apikey에서 키를 만듭니다. 처음이면 약관 동의 뒤 기본 프로젝트가 자동으로 생깁니다.
- Apps Script 편집기 왼쪽의 프로젝트 설정(톱니바퀴) → 스크립트 속성에서 속성을 추가합니다. 실행 확인: 사용자
- 속성 이름은
GEMINI_API_KEY, 값은 방금 만든 키입니다.
키를 코드에 직접 적지 않는 이유는 공식 안내에 있습니다. API 키는 비밀번호처럼 다루고, 소스 관리에 올리지 말고, 클라이언트 쪽 코드에 넣지 말라고 되어 있습니다. 시트를 복사해 다른 선생님에게 줄 때 코드에 키가 박혀 있으면 키도 같이 넘어갑니다.
4. 가명 규칙을 세운다
학생명단 시트
학생명단시트에 학생ID와 이름을 적습니다. ID 예:S-014. 이름이나 생일이 드러나지 않는 번호로 짓습니다.수업기록에는 이름 대신 학생ID만 적습니다.- 관찰메모에는 수업에서 본 것만 적습니다. 가정사, 건강, 다른 학생과의 비교는 적지 않습니다.
학생명단은 두 가지 일을 합니다. 메모에 섞여 들어간 이름을 보내기 전에 가리는 사전이고, 마지막에 문서 제목과 첫 줄에 이름을 붙이는 조회표입니다. 이름이 시트 밖으로 나가는 경로는 구글 문서 하나입니다.
5. 메모 → 초안 → 확정 → 문서
메뉴 ② ③ ④
수업기록2행에 날짜, 학생ID, 반, 수업내용, 관찰메모를 적습니다.- 그 행의 아무 칸이나 선택하고 ② 선택한 행 AI 초안 만들기를 누릅니다. 외부 호출 권한을 묻는 창이 한 번 더 뜰 수 있습니다.
- AI초안 칸의 글을 읽고 직접 고칩니다. 메모에 없는 내용이 들어갔는지, 약속처럼 읽히는 문장이 있는지 봅니다.
- ③ 선택한 행 확정하기를 누르고, 뜨는 창에서 내용을 확인한 뒤 예를 누릅니다.
- ④ 선택한 행 리포트 문서 만들기를 누릅니다. 리포트링크 칸의 주소를 열어 봅니다.
③을 거친 뒤에 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)에서 하루 동안 다루는 내용 가운데 하나를, 집에서 혼자 다시 해 볼 수 있게 줄인 복습 자료입니다. 다음 편: 수강료·재등록, 오늘 챙길 것.
도구 사용법과 한도는 각 서비스의 공식 문서에서 확인했고, 확인한 주소와 날짜는 출처 장부에 있습니다. 실행 확인: 사용자 표시는 구글 로그인이 필요해 문서로만 확인한 단계입니다. 결과는 학원 환경에 따라 다를 수 있습니다.