hakwon · one post, four channels · starter

블로그 글 하나를 네 군데에

이미 올린 블로그 글 한 편을 원고 시트에 붙여 넣고 메뉴를 누르면, 같은 행에 인스타 카드 5장 문구·학부모 공지·숏폼 25초 대본·검색용 메타 설명이 생깁니다. 네 가지 모두 초안입니다. 읽고 고쳐서 원장님이 직접 올립니다.

공식 문서 기준 도구 Google Sheets · Apps Script · Gemini API (JSON 응답) · 문서 확인 2026-10-11

시작하기 전에 — 공개한 글만, 이름 없는 글만

이번 편은 AI를 씁니다. 그래서 입력 규칙이 곧 개인정보 규칙입니다. 첫 편(학부모 리포트)에서는 수업 메모가 학생 정보라서 이름을 가리고 보냈습니다. 블로그 글은 이미 세상에 공개된 글이라 AI로 보내도 새로 드러나는 것이 없습니다. 단, 두 가지 조건이 붙습니다. 이미 올린 글이어야 하고, 학생 이름이 없는 글이어야 합니다. — Gemini API 약관(무료 등급은 사람이 입력을 읽을 수 있음, 민감·개인 정보 제출 금지), 2026-04-28 갱신본

스크립트는 두 번째 조건을 대신 지켜 줍니다. 학생명단에 있는 이름이 본문에 보이면 만들지 않고 거절하며 어느 이름인지 알려 줍니다. 첫 편처럼 "학생"으로 가리지 않는 이유가 있습니다. 가린 글로 홍보물을 만들면 "학생이 학생에게 말했습니다" 같은 어색한 문장이 그대로 인스타에 올라갑니다. 홍보 글은 처음부터 이름 없이 쓰는 편이 맞고, 스크립트는 그것을 확인만 합니다.

자동 게시도 없습니다. 이 시리즈는 네 편 내내 같은 자리를 지킵니다. 첫 편의 "확정 관문", 둘째 편의 "사람이 결정", 셋째 편의 "사람이 답함", 이번 편은 "사람이 올림"입니다.

완성 상태와 준비물

완성 상태 — 원고의 한 행에 제목과 본문을 붙여 넣고 ②를 누르면 인스타·학부모 공지·숏폼 대본·메타 설명 네 칸이 채워지고 생성일시가 찍힙니다. 본문에 학생 이름이 있으면 네 칸이 비어 있는 채로 "만들지 않았습니다" 안내가 뜹니다. ③을 누르면 금지어와 본문에 없는 숫자를 알려 줍니다.
준비물메모
구글 계정만 18세 이상. Gemini API 약관의 이용 조건입니다.
PC의 크롬 브라우저스크립트 편집기는 모바일에서 다루기 어렵습니다.
Gemini API 키첫 편과 같은 키를 써도 됩니다. 없으면 2단계에서 발급합니다.
이미 올린 블로그 글 1편학생 이름이 없는 글. 연습용으로 지어내지 말고 실제 글을 쓰세요.

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

Google Sheets · Apps Script 편집기

  1. 새 구글 스프레드시트를 만들고 이름을 정합니다. 예: 한편네군데_연습
  2. 상단 메뉴 확장 프로그램 → Apps Script를 엽니다. 실행 확인: 사용자
  3. 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);
  }
}

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

2. ① 시트 준비, API 키

onOpen · 스크립트 속성

  1. 스프레드시트로 돌아가 새로고침합니다. 메뉴 줄에 한 편, 네 군데가 생깁니다.
  2. ① 시트 준비를 누르고 권한을 승인한 뒤 한 번 더 누릅니다. 원고·톤·학생명단 시트가 생깁니다. 실행 확인: 사용자
  3. 첫 편과 같은 방법으로 aistudio.google.com/apikey에서 키를 만들고, 편집기 프로젝트 설정 → 스크립트 속성에 GEMINI_API_KEY로 넣습니다. 첫 편의 키가 있으면 그대로 씁니다.
  4. 학생명단에 학생 이름을 채웁니다. 이 시트는 거절 검사에만 쓰이고 AI로 보내지 않습니다.
확인 — 톤 시트에 "학원 이름 · 한 줄 소개 · 말투 예시 · 금지어" 네 줄이 들어 있고, 금지어 칸에 보장, 1등, 무조건, 완벽, 혁신적, 최고가 보입니다.

3. 톤 시트를 우리 학원 말로 고친다

톤 · 항목 · 내용

  1. 학원 이름과 한 줄 소개를 실제로 바꿉니다. 생성물에 그대로 들어갑니다.
  2. 말투 예시에 원장님이 평소 쓰는 문장 하나를 넣습니다. 추상적인 설명("친근하게")보다 실제 문장 하나가 더 잘 통합니다.
  3. 금지어를 봅니다. 기본값은 홍보 글에서 과장이 되기 쉬운 말들입니다. 학원에서 쓰지 않는 말을 더합니다.

톤 시트는 코드가 아니라 시트이므로 글을 만들 때마다 다시 읽힙니다. 말투가 마음에 안 들면 코드를 열지 말고 이 시트를 고치고 ②를 다시 누르면 됩니다.

4. ② 채널 글 만들기 — 네 칸이 채워진다

원고 시트 · JSON 응답

  1. 원고 2행에 제목, 본문(블로그 글 전체를 붙여 넣기), 원문 링크를 적습니다.
  2. 그 행의 아무 칸이나 선택하고 ② 선택한 행 채널 글 만들기를 누릅니다. 외부 호출 권한을 묻는 창이 한 번 더 뜰 수 있습니다. 실행 확인: 사용자
  3. 잠시 뒤 네 칸이 채워지고 생성일시가 찍힙니다.
칸무엇이 들어오나어디에 쓰나
인스타카드 5장, 장마다 한 문장. 첫 장은 궁금하게, 마지막 장은 행동 하나인스타 카드뉴스 문구
학부모 공지존댓말 3문장, 첫 문장에 무엇을 알리는지카카오 단체방·학부모 공지
숏폼 대본5줄, 한 줄 약 5초, 소리 내어 읽는 구어체25초 숏폼 보이스오버
메타 설명150자 안, 글의 핵심 한 문장블로그 검색 설명
확인 — 본문에 학생 이름을 하나 넣고 ②를 다시 누르면 "본문에 학생 이름이 있어 만들지 않았습니다: ○○○"가 뜨고 네 칸은 그대로입니다. 이 거절이 이 시스템에서 가장 중요한 동작입니다. 이름을 지우고 다시 누르면 만들어집니다.

5. ③ 검사, 고치기, 올리기

금지어 · 본문에 없는 숫자 · 사람이 올림

  1. ③ 선택한 행 검사를 누릅니다. 네 칸에 금지어가 있는지, 본문에 없는 숫자가 들어갔는지 알려 줍니다.
  2. 네 칸을 직접 읽습니다. 글에 없는 사실, 약속처럼 읽히는 문장, 어색한 말투를 고칩니다. 초안은 출발점이지 결과물이 아닙니다.
  3. 고친 글을 각 채널에 직접 올립니다. 올린 뒤 올린 곳 칸에 "인스타 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)에서 하루 동안 다루는 내용 가운데 하나를, 집에서 혼자 다시 해 볼 수 있게 줄인 복습 자료입니다. 이전 편: 학부모 문의, 매번 처음처럼 답하지 않기 · 수강료·재등록, 오늘 챙길 것 · 수업 메모를 학부모 리포트로.

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