시작하기 전에 — 공개한 글만, 이름 없는 글만
스크립트는 두 번째 조건을 대신 지켜 줍니다. 학생명단에 있는 이름이 본문에 보이면 만들지 않고 거절하며 어느 이름인지 알려 줍니다. 첫 편처럼 "학생"으로 가리지 않는 이유가 있습니다. 가린 글로 홍보물을 만들면 "학생이 학생에게 말했습니다" 같은 어색한 문장이 그대로 인스타에 올라갑니다. 홍보 글은 처음부터 이름 없이 쓰는 편이 맞고, 스크립트는 그것을 확인만 합니다.
자동 게시도 없습니다. 이 시리즈는 네 편 내내 같은 자리를 지킵니다. 첫 편의 "확정 관문", 둘째 편의 "사람이 결정", 셋째 편의 "사람이 답함", 이번 편은 "사람이 올림"입니다.
완성 상태와 준비물
원고의 한 행에 제목과 본문을 붙여 넣고 ②를 누르면 인스타·학부모 공지·숏폼 대본·메타 설명 네 칸이 채워지고 생성일시가 찍힙니다. 본문에 학생 이름이 있으면 네 칸이 비어 있는 채로 "만들지 않았습니다" 안내가 뜹니다. ③을 누르면 금지어와 본문에 없는 숫자를 알려 줍니다.| 준비물 | 메모 |
|---|---|
| 구글 계정 | 만 18세 이상. Gemini API 약관의 이용 조건입니다. |
| PC의 크롬 브라우저 | 스크립트 편집기는 모바일에서 다루기 어렵습니다. |
| Gemini API 키 | 첫 편과 같은 키를 써도 됩니다. 없으면 2단계에서 발급합니다. |
| 이미 올린 블로그 글 1편 | 학생 이름이 없는 글. 연습용으로 지어내지 말고 실제 글을 쓰세요. |
1. 시트를 만들고 스크립트를 붙인다
Google Sheets · Apps Script 편집기
- 새 구글 스프레드시트를 만들고 이름을 정합니다. 예:
한편네군데_연습 - 상단 메뉴 확장 프로그램 → Apps Script를 엽니다. 실행 확인: 사용자
function myFunction() {}를 지우고 아래 코드 전체를 붙여 넣고 저장합니다.
Code.gs 전체 보기 · 복사
/**
* One post, four channels — minimal teaching version.
* A published blog post (pasted into a sheet) -> Gemini -> Instagram cards, parent notice, 25s short script, meta description.
* A person edits and posts each one by hand.
*
* Rules this file enforces:
* 1. Input must already be public and must not name a student. If a roster name appears, generation is refused.
* 2. The tone rules live in the 톤 sheet, not in code.
* 3. Nothing is posted anywhere. There is no trigger. The script runs only when a person clicks ②.
*/
var SHEET_POSTS = '원고';
var SHEET_TONE = '톤';
var SHEET_NAMES = '학생명단';
var POST_HEADERS = ['제목', '본문', '원문 링크', '인스타', '학부모 공지', '숏폼 대본', '메타 설명', '생성일시', '올린 곳'];
var TONE_HEADERS = ['항목', '내용'];
var NAMES_HEADERS = ['학생ID', '이름'];
var MODEL = 'gemini-3.8-flash';
var PROP_API_KEY = 'GEMINI_API_KEY';
var META_MAX = 150;
// Seeded by ①. Edit them in the sheet afterwards, not here.
var DEFAULT_TONE = [
['학원 이름', '○○학원'],
['한 줄 소개', '초·중등 영어 독해를 가르치는 동네 학원'],
['말투 예시', '과장 없이 담담하게. 학부모에게는 존댓말, 아이들 이야기는 구체적으로.'],
['금지어', '보장, 1등, 무조건, 완벽, 혁신적, 최고']
];
// Response shape we ask the model for. Field names are what parseOutput reads.
var OUTPUT_SCHEMA = {
type: 'OBJECT',
properties: {
insta: { type: 'ARRAY', items: { type: 'STRING' } },
notice: { type: 'STRING' },
shorts: { type: 'ARRAY', items: { type: 'STRING' } },
meta: { type: 'STRING' }
},
required: ['insta', 'notice', 'shorts', 'meta']
};
// ---------- Menu (simple trigger: menu only) ----------
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('한 편, 네 군데')
.addItem('① 시트 준비', 'setupSheets')
.addItem('② 선택한 행 채널 글 만들기', 'generateSelectedRow')
.addItem('③ 선택한 행 검사 (금지어·없는 숫자)', 'checkSelectedRow')
.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); });
}
/** Roster names (2+ chars) that appear in the text. Used to refuse, not to mask. */
function findNames(text, names) {
var t = String(text || '');
var found = [];
names.forEach(function (n) {
var name = String(n || '').trim();
if (name.length >= 2 && t.indexOf(name) !== -1 && found.indexOf(name) === -1) found.push(name);
});
return found;
}
/** 톤 sheet rows (header included) -> { '학원 이름': ..., ... } */
function parseTone(rows) {
var tone = {};
if (!rows || rows.length < 2) return tone;
var map = headerMap(rows[0]);
for (var r = 1; r < rows.length; r++) {
var k = String(rows[r][map['항목']] || '').trim();
if (k) tone[k] = String(rows[r][map['내용']] || '').trim();
}
return tone;
}
function splitList(text) {
return String(text || '').split(/[,,/]/).map(function (s) { return s.trim(); }).filter(function (s) { return s; });
}
/** Builds the request. Only the post title and body go out; the link and the roster never do. */
function buildRequestBody(post, tone) {
var banned = splitList(tone['금지어']);
var rules = [
'너는 작은 학원의 홍보 글을 돕는다. 아래 블로그 글 한 편을 네 가지 형식으로 다시 쓴다.',
'원칙: 글에 없는 사실, 숫자, 성과, 약속을 만들지 않는다. 학생 이름을 쓰지 않는다. 과장하지 않는다.',
'학원 이름: ' + (tone['학원 이름'] || '학원'),
'한 줄 소개: ' + (tone['한 줄 소개'] || ''),
'말투: ' + (tone['말투 예시'] || '담담한 존댓말'),
banned.length ? '쓰지 말 것: ' + banned.join(', ') : '',
'insta: 인스타그램 카드 5장, 카드당 한 문장, 첫 장은 궁금하게 만드는 한 줄, 마지막 장은 행동 한 가지.',
'notice: 학부모에게 보내는 카카오 공지, 존댓말 3문장, 첫 문장에 무엇을 알리는지.',
'shorts: 25초 숏폼 대본 5줄, 한 줄 약 5초, 소리 내어 읽기 좋은 구어체.',
'meta: 검색 결과에 보일 설명, ' + META_MAX + '자 안, 글의 핵심 한 문장.'
].filter(function (s) { return s; }).join('\n');
return {
systemInstruction: { parts: [{ text: rules }] },
contents: [{ parts: [{ text: '제목: ' + String(post.title || '').trim() + '\n\n본문:\n' + String(post.body || '').trim() }] }],
generationConfig: { responseMimeType: 'application/json', responseSchema: OUTPUT_SCHEMA }
};
}
/** Pulls and validates the JSON the model returned. Throws with a readable reason. */
function parseOutput(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('채널 글을 받지 못했습니다: ' + reason);
}
var out;
try { out = JSON.parse(part.text); } catch (e) { throw new Error('응답이 JSON이 아닙니다. 다시 눌러 보세요.'); }
// Exactly n items, none empty. A short or padded list is refused rather than patched.
var clean = function (arr, n) {
if (!Array.isArray(arr) || arr.length !== n) return null;
var a = arr.map(function (s) { return String(s || '').trim(); });
return a.every(function (s) { return s; }) ? a : null;
};
var insta = clean(out.insta, 5), shorts = clean(out.shorts, 5);
var notice = String(out.notice || '').trim(), meta = String(out.meta || '').trim();
if (!insta || !shorts || !notice || !meta) throw new Error('응답에 빠진 칸이 있습니다 (인스타 5장, 대본 5줄, 공지, 메타). 다시 눌러 보세요.');
if (meta.length > META_MAX) meta = meta.slice(0, META_MAX);
return { insta: insta, notice: notice, shorts: shorts, meta: meta };
}
/** Banned words that appear in any of the generated texts (substring, case-insensitive). */
function findBanned(texts, banned) {
var hay = texts.join('\n').toLowerCase();
return banned.filter(function (w) { return w && hay.indexOf(String(w).toLowerCase()) !== -1; });
}
/** Numbers in the output that do not appear in the source: a cheap check for invented figures. */
function numbersNotInSource(output, source) {
var src = String(source || '');
var nums = String(output || '').match(/\d+(?:[.,]\d+)?/g) || [];
var out = [];
nums.forEach(function (n) { if (src.indexOf(n) === -1 && out.indexOf(n) === -1) out.push(n); });
return out;
}
// ---------- ① Sheets ----------
function setupSheets() {
var ss = SpreadsheetApp.getActive();
ensureSheet_(ss, SHEET_POSTS, POST_HEADERS);
ensureSheet_(ss, SHEET_NAMES, NAMES_HEADERS);
var tone = ensureSheet_(ss, SHEET_TONE, TONE_HEADERS);
if (tone.getLastRow() < 2) tone.getRange(2, 1, DEFAULT_TONE.length, 2).setValues(DEFAULT_TONE);
SpreadsheetApp.getUi().alert('시트 준비가 끝났습니다. 톤 시트를 우리 학원 말로 고치고, 학생명단을 채워 두세요.');
}
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;
}
function readNames_(ss) {
var sheet = ss.getSheetByName(SHEET_NAMES);
if (!sheet || sheet.getLastRow() < 2) return [];
var data = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
var map = headerMap(data[0]);
var names = [];
for (var r = 1; r < data.length; r++) {
var n = String(data[r][map['이름']] || '').trim();
if (n) names.push(n);
}
return names;
}
function readTone_(ss) {
var sheet = ss.getSheetByName(SHEET_TONE);
if (!sheet || sheet.getLastRow() < 2) throw new Error('톤 시트가 비어 있습니다. 메뉴 ①을 먼저 실행하세요.');
return parseTone(sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues());
}
function readSelectedPost_(ss) {
var sheet = ss.getActiveSheet();
if (sheet.getName() !== SHEET_POSTS) throw new Error('"' + SHEET_POSTS + '" 시트에서 행을 선택해 주세요.');
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, POST_HEADERS);
if (missing.length) throw new Error('빠진 열이 있습니다: ' + missing.join(', ') + ' — 메뉴 ①을 먼저 실행하세요.');
var v = sheet.getRange(row, 1, 1, sheet.getLastColumn()).getValues()[0];
return {
sheet: sheet, row: row, map: map,
title: String(v[map['제목']] || '').trim(), body: String(v[map['본문']] || '').trim(),
insta: String(v[map['인스타']] || ''), notice: String(v[map['학부모 공지']] || ''),
shorts: String(v[map['숏폼 대본']] || ''), meta: String(v[map['메타 설명']] || '')
};
}
function writeCell_(post, header, value) {
post.sheet.getRange(post.row, post.map[header] + 1).setValue(value);
}
// ---------- ② Generate ----------
function generateSelectedRow() {
var ui = SpreadsheetApp.getUi();
try {
var ss = SpreadsheetApp.getActive();
var post = readSelectedPost_(ss);
if (!post.title || !post.body) throw new Error('제목과 본문을 먼저 채워 주세요.');
var names = findNames(post.title + '\n' + post.body, readNames_(ss));
if (names.length) throw new Error('본문에 학생 이름이 있어 만들지 않았습니다: ' + names.join(', ') + '\n이름을 지운 뒤 다시 눌러 주세요.');
var out = parseOutput(callGemini_(buildRequestBody(post, readTone_(ss))));
writeCell_(post, '인스타', out.insta.map(function (s, i) { return (i + 1) + '. ' + s; }).join('\n'));
writeCell_(post, '학부모 공지', out.notice);
writeCell_(post, '숏폼 대본', out.shorts.map(function (s, i) { return (i + 1) + '. ' + s; }).join('\n'));
writeCell_(post, '메타 설명', out.meta);
writeCell_(post, '생성일시', Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'));
ui.alert('네 칸을 채웠습니다. 읽고 고친 뒤 직접 올리세요. ③으로 금지어와 없는 숫자를 검사할 수 있습니다.');
} 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 JSON.parse(raw);
}
// ---------- ③ Check ----------
function checkSelectedRow() {
var ui = SpreadsheetApp.getUi();
try {
var ss = SpreadsheetApp.getActive();
var post = readSelectedPost_(ss);
var texts = [post.insta, post.notice, post.shorts, post.meta];
if (!texts.join('').trim()) throw new Error('아직 만든 글이 없습니다. ②를 먼저 누르세요.');
var banned = findBanned(texts, splitList(readTone_(ss)['금지어']));
var nums = numbersNotInSource(texts.join('\n'), post.title + '\n' + post.body);
var lines = [];
lines.push(banned.length ? '금지어가 보입니다: ' + banned.join(', ') : '금지어 없음');
lines.push(nums.length ? '본문에 없는 숫자가 보입니다: ' + nums.join(', ') + ' — 지어낸 수치인지 확인하세요' : '본문에 없는 숫자 없음');
lines.push('');
lines.push('이 검사는 전부를 잡지 못합니다. 올리기 전에 네 칸을 직접 읽으세요.');
ui.alert(lines.join('\n'));
} catch (err) {
ui.alert('검사하지 못했습니다.\n\n' + err.message);
}
}
코드에서 눈여겨볼 것 세 가지입니다.
- 응답 모양을 미리 정해 줍니다.
OUTPUT_SCHEMA가 "인스타는 문자열 배열, 공지는 문자열…"을 지정하고, 요청의generationConfig에 JSON 응답을 요구합니다. 그래서 답이 글 덩어리가 아니라 네 칸으로 바로 나뉩니다. - 보내는 것은 제목과 본문뿐입니다.
buildRequestBody는 원문 링크도, 학생명단도 보내지 않습니다. 톤 시트의 학원 이름·말투·금지어는 지시문에 들어갑니다. - 거절이 먼저입니다. ②는 API를 부르기 전에
findNames로 학생 이름을 찾고, 있으면 멈춥니다. 돈도 쓰지 않고 글도 나가지 않습니다.
2. ① 시트 준비, API 키
onOpen · 스크립트 속성
- 스프레드시트로 돌아가 새로고침합니다. 메뉴 줄에 한 편, 네 군데가 생깁니다.
- ① 시트 준비를 누르고 권한을 승인한 뒤 한 번 더 누릅니다.
원고·톤·학생명단시트가 생깁니다. 실행 확인: 사용자 - 첫 편과 같은 방법으로 aistudio.google.com/apikey에서 키를 만들고, 편집기 프로젝트 설정 → 스크립트 속성에
GEMINI_API_KEY로 넣습니다. 첫 편의 키가 있으면 그대로 씁니다. 학생명단에 학생 이름을 채웁니다. 이 시트는 거절 검사에만 쓰이고 AI로 보내지 않습니다.
보장, 1등, 무조건, 완벽, 혁신적, 최고가 보입니다.3. 톤 시트를 우리 학원 말로 고친다
톤 · 항목 · 내용
학원 이름과한 줄 소개를 실제로 바꿉니다. 생성물에 그대로 들어갑니다.말투 예시에 원장님이 평소 쓰는 문장 하나를 넣습니다. 추상적인 설명("친근하게")보다 실제 문장 하나가 더 잘 통합니다.금지어를 봅니다. 기본값은 홍보 글에서 과장이 되기 쉬운 말들입니다. 학원에서 쓰지 않는 말을 더합니다.
톤 시트는 코드가 아니라 시트이므로 글을 만들 때마다 다시 읽힙니다. 말투가 마음에 안 들면 코드를 열지 말고 이 시트를 고치고 ②를 다시 누르면 됩니다.
4. ② 채널 글 만들기 — 네 칸이 채워진다
원고 시트 · JSON 응답
원고2행에 제목, 본문(블로그 글 전체를 붙여 넣기), 원문 링크를 적습니다.- 그 행의 아무 칸이나 선택하고 ② 선택한 행 채널 글 만들기를 누릅니다. 외부 호출 권한을 묻는 창이 한 번 더 뜰 수 있습니다. 실행 확인: 사용자
- 잠시 뒤 네 칸이 채워지고 생성일시가 찍힙니다.
| 칸 | 무엇이 들어오나 | 어디에 쓰나 |
|---|---|---|
| 인스타 | 카드 5장, 장마다 한 문장. 첫 장은 궁금하게, 마지막 장은 행동 하나 | 인스타 카드뉴스 문구 |
| 학부모 공지 | 존댓말 3문장, 첫 문장에 무엇을 알리는지 | 카카오 단체방·학부모 공지 |
| 숏폼 대본 | 5줄, 한 줄 약 5초, 소리 내어 읽는 구어체 | 25초 숏폼 보이스오버 |
| 메타 설명 | 150자 안, 글의 핵심 한 문장 | 블로그 검색 설명 |
5. ③ 검사, 고치기, 올리기
금지어 · 본문에 없는 숫자 · 사람이 올림
- ③ 선택한 행 검사를 누릅니다. 네 칸에 금지어가 있는지, 본문에 없는 숫자가 들어갔는지 알려 줍니다.
- 네 칸을 직접 읽습니다. 글에 없는 사실, 약속처럼 읽히는 문장, 어색한 말투를 고칩니다. 초안은 출발점이지 결과물이 아닙니다.
- 고친 글을 각 채널에 직접 올립니다. 올린 뒤
올린 곳칸에 "인스타 10/12, 카카오 10/12"처럼 적습니다.
③의 숫자 검사는 단순합니다. 생성물에 나온 숫자가 본문에 그대로 없으면 알려 주는 것이 전부입니다. "30명 중 30명이 만족"처럼 모델이 지어낸 수치를 잡는 데는 쓸모가 있지만, 숫자가 아닌 지어낸 말(성과, 비교, 약속)은 잡지 못합니다. 그래서 2번이 3번보다 중요합니다.
자주 막히는 곳
| 증상 | 원인과 조치 |
|---|---|
| "본문에 학생 이름이 있어 만들지 않았습니다" | 정상 동작입니다. 본문에서 이름을 지웁니다. 학생명단에 흔한 두 글자 단어(예: "민수"가 아닌 "수업")가 이름으로 들어가 있으면 오탐이 나니 명단을 봅니다. |
| "스크립트 속성에 GEMINI_API_KEY 가 없습니다" | 속성 이름의 철자와 대소문자, 앞뒤 공백을 확인합니다. |
| "응답이 JSON이 아닙니다" | 드물게 모델이 형식을 어깁니다. 한 번 더 누릅니다. 계속되면 본문이 너무 길거나 특수문자가 많은지 봅니다. |
| "응답에 빠진 칸이 있습니다" | 인스타 5장이나 대본 5줄이 안 맞게 왔습니다. 한 번 더 누릅니다. |
| "Gemini API 응답 404" | 모델 이름이 바뀌었습니다. 코드 위쪽 MODEL을 공식 문서의 현재 이름으로 바꿉니다. |
| "Gemini API 응답 429" | 호출 한도를 넘었습니다. 잠시 뒤 다시 누릅니다. |
| 말투가 우리 학원 같지 않다 | 톤 시트의 말투 예시에 실제 문장을 넣고, 금지어를 더한 뒤 ②를 다시 누릅니다. |
한도
개인 구글 계정 기준으로 공식 할당량 표에 적힌 값입니다. 글 한 편에 호출 한 번이니 여유가 큽니다. Gemini API 쪽 호출 한도는 등급과 모델에 따라 따로 있습니다.
| 항목 | 개인 계정 | Workspace |
|---|---|---|
| 외부 호출(URL Fetch) | 20,000회 / 일 | 100,000회 / 일 |
| 한 번 실행 시간 | 6분 | 6분 |
Apps Script 할당량 문서, 2026-09-03 갱신본.
혼자 다시 해 보기
이 가이드를 닫고, 새 스프레드시트에서 처음부터 다시 만듭니다. 아래가 전부 되면 끝입니다.
- 새 시트에 스크립트를 붙이고 ①로 세 시트를 만들었다
- 톤 시트를 우리 학원 말로 고쳤다
- 실제 블로그 글로 ②를 눌러 네 칸을 받았다
- 이름을 넣은 글이 거절되는 것을 봤다
- ③으로 검사하고, 네 칸을 직접 고쳐 한 군데에 올렸다
- 금지어 하나를 더하고 ②를 다시 눌러 결과가 달라지는 것을 봤다
이 최소 버전에 없는 것
가르치기 위해 줄인 버전입니다. 실제 운영에는 아래가 더 필요합니다.
- 자동 게시. 이 시리즈에서는 밖으로 보내는 일을 자동화하지 않습니다
- 카드 이미지 생성과 해시태그
- 여러 편을 한 번에 만드는 일괄 처리
- 지어낸 말을 잡는 더 나은 검사. 지금은 금지어와 숫자만 봅니다
- 채널별 성과(조회·문의) 기록
이 글은 리드마스터 AX 컨설팅(학원 AI 전환 1-Day)에서 하루 동안 다루는 내용 가운데 하나를, 집에서 혼자 다시 해 볼 수 있게 줄인 복습 자료입니다. 이전 편: 학부모 문의, 매번 처음처럼 답하지 않기 · 수강료·재등록, 오늘 챙길 것 · 수업 메모를 학부모 리포트로.
도구 사용법과 한도는 각 서비스의 공식 문서에서 확인했고, 확인한 주소와 날짜는 출처 장부에 있습니다. 실행 확인: 사용자 표시는 구글 로그인이 필요해 문서로만 확인한 단계입니다. 결과는 학원 환경에 따라 다를 수 있습니다.