hakwon · inquiry triage · starter

학부모 문의, 매번 처음처럼 답하지 않기

구글폼으로 들어온 문의가 제출되는 순간, 스크립트가 유형을 정하고 그 유형의 답변 초안을 붙여 문의함 시트에 넣습니다. 원장님은 초안을 읽고 고쳐서 직접 답하고, 상태를 "답변함"으로 바꿉니다. 미답변은 색으로 남습니다.

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

시작하기 전에 — 적게 받고, 혼자 봅니다

문의 본문은 개인정보 덩어리입니다. 학부모 이름, 전화번호, 아이 이름, 가정 사정이 자유 서술로 섞여 들어옵니다. 이전 편처럼 이름만 가리는 방법으로는 안전하게 가릴 수 없습니다. 그래서 이 가이드는 문의 본문을 AI로도, 메일로도, 어디로도 보내지 않습니다. 시트 안에서만 읽고 시트 안에만 적습니다.

세 가지를 처음부터 지킵니다.

자동 회신도 없습니다. 답변 초안은 원장님이 읽고 고쳐서 본인 손으로 보냅니다. 이전 두 편의 "확정 관문"과 "사람이 결정하는 칸"이 이번 편에서는 "사람이 답하는 칸"입니다.

완성 상태와 준비물

완성 상태 — 폼에 시험 문의를 제출하면 몇 초 안에 문의함에 한 행이 생기고, 유형과 답변 초안이 채워져 있고, 행이 노랑(미답변)입니다. "환불"이 들어간 문의는 초안 없이 빨강(직접 통화)입니다. 상태를 "답변함"으로 바꾸고 ③을 누르면 색이 사라집니다.
준비물메모
구글 계정원장 본인 계정. 트리거는 켠 사람의 계정으로 돕니다.
PC의 크롬 브라우저폼 편집기와 스크립트 편집기는 모바일에서 다루기 어렵습니다.
최근에 실제로 받은 문의 5개유형표의 키워드와 초안을 우리 학원 말로 고칠 때 씁니다. 지어내지 마세요.

1. 폼을 만든다 — 질문 다섯 개

Google Forms · 응답을 스프레드시트로

  1. forms.google.com에서 새 폼을 만들고 제목을 정합니다. 예: ○○학원 문의 실행 확인: 사용자
  2. 질문 다섯 개를 아래 제목 그대로 만듭니다. 스크립트가 질문 제목으로 답을 찾기 때문에 제목이 다르면 찾지 못합니다.
  3. 응답 탭에서 스프레드시트에 연결을 눌러 새 스프레드시트를 만듭니다. 이 스프레드시트가 이번 편의 작업 파일입니다. 확인 필요: 한국어 UI의 정확한 버튼 이름
질문 제목 (그대로)형식필수선택지
문의 유형객관식예상담 신청 · 수강료 · 시간표·반 배정 · 결석·보강 · 기타
자녀 반단답 또는 드롭다운아니오우리 학원 반 이름
내용장문예—
연락 방법객관식아니오카카오 · 전화 · 답장 불필요
연락처단답아니오—

폼의 문의 유형 선택지는 유형표의 유형 이름과 같아야 합니다. 나중에 유형을 늘리면 폼 선택지와 유형표 둘 다 고칩니다.

2. 시트에 스크립트를 붙이고 ① 시트 준비

Apps Script 편집기 · onOpen

  1. 폼이 만든 스프레드시트를 엽니다. 응답 시트 한 장이 이미 있습니다. 확인 필요: 응답 시트의 기본 이름
  2. 상단 메뉴 확장 프로그램 → Apps Script를 열고, function myFunction() {}를 지운 뒤 아래 코드 전체를 붙여 넣고 저장합니다. 실행 확인: 사용자
  3. 스프레드시트로 돌아가 새로고침합니다. 메뉴 줄에 문의 정리가 생깁니다.
  4. ① 시트 준비를 누릅니다. 처음 한 번은 권한 승인 창이 뜹니다. 승인 뒤 ①을 한 번 더 누릅니다.
확인 — 문의함과 유형표 시트가 생기고, 유형표에 기본 유형 여섯 줄이 들어 있습니다. 안내창에 폼 응답 시트 이름이 보이면 스크립트가 폼을 찾은 것입니다. "아직 폼 응답 시트가 보이지 않습니다"가 뜨면 폼에서 시험 제출을 한 번 한 뒤 ①을 다시 누릅니다.
Code.gs 전체 보기 · 복사
/**
 * Parent inquiry triage — minimal teaching version.
 * Google Form -> response sheet -> on submit: pick a type, attach a draft reply -> 문의함. A person replies.
 *
 * Rules this file enforces:
 *  1. Nothing is sent to parents. The script labels and drafts; the owner reads, edits, and replies by hand.
 *  2. The rules live in the 유형표 sheet, not in code. Types, keywords and drafts are edited there.
 *  3. Exactly one form-submit trigger exists at a time; connecting again replaces the old one.
 */

var SHEET_INBOX = '문의함';
var SHEET_RULES = '유형표';
var Q = { type: '문의 유형', cls: '자녀 반', body: '내용', how: '연락 방법', contact: '연락처' };
var FORM_QUESTIONS = [Q.type, Q.cls, Q.body, Q.how, Q.contact];
var INBOX_HEADERS = ['접수시각', '유형', '반', '내용', '연락 방법', '연락처', '답변 초안', '상태', '처리 메모', '응답행'];
var RULE_HEADERS = ['유형', '키워드', '답변 초안', '직접 통화'];
var HANDLER = 'classifySubmission';
var STATUS = { open: '미답변', call: '직접 통화', done: '답변함' };
var COLORS = { open: '#fff2a8', call: '#f8cbc4' };
var UNKNOWN = '확인 필요';
var OTHER = '기타';

// Seeded by ①. Edit them in the sheet afterwards, not here.
var DEFAULT_RULES = [
  ['상담 신청', '상담, 레벨테스트, 등록, 입학, 신규', '안녕하세요. 상담 신청 감사합니다. 가능한 요일과 시간대를 두 개 정도 알려 주시면 맞춰 연락드리겠습니다.', ''],
  ['수강료', '수강료, 학원비, 결제, 입금, 영수증, 카드', '안녕하세요. 수강료 관련 문의 주셔서 감사합니다. 확인 후 정확한 금액과 방법을 다시 안내드리겠습니다.', ''],
  ['시간표·반 배정', '시간표, 시간, 요일, 반 배정, 반 변경, 옮기', '안녕하세요. 시간표와 반 배정은 현재 인원을 확인해야 해서, 가능한 선택지를 정리해 다시 연락드리겠습니다.', ''],
  ['결석·보강', '결석, 보강, 빠지, 못 가, 아파, 여행, 결석계', '안녕하세요. 알려 주셔서 감사합니다. 보강 가능한 시간을 확인해 안내드리겠습니다.', ''],
  ['직접 통화', '환불, 불만, 민원, 항의, 실망, 화가, 그만', '', 'Y'],
  [OTHER, '', '안녕하세요. 문의 주셔서 감사합니다. 내용 확인 후 연락드리겠습니다.', '']
];

// ---------- Menu (simple trigger: menu only) ----------

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('문의 정리')
    .addItem('① 시트 준비', 'setupSheets')
    .addItem('② 폼 연결 (제출되면 바로 분류)', 'enableFormTrigger')
    .addItem('③ 밀린 응답 다시 분류', 'recheckAll')
    .addSeparator()
    .addItem('④ 폼 연결 끊기', 'disableFormTrigger')
    .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); });
}

/** namedValues maps a question title to an array; take the first non-empty string. */
function firstValue(namedValues, question) {
  var arr = namedValues && namedValues[question];
  if (!arr) return '';
  if (!Array.isArray(arr)) return String(arr).trim();
  for (var i = 0; i < arr.length; i++) {
    var s = String(arr[i] || '').trim();
    if (s) return s;
  }
  return '';
}

/** 유형표 rows (header included) -> [{ type, keywords[], draft, call }]. Blank types are skipped. */
function parseRules(rows) {
  if (!rows || !rows.length) return [];
  var map = headerMap(rows[0]);
  var out = [];
  for (var r = 1; r < rows.length; r++) {
    var type = String(rows[r][map['유형']] || '').trim();
    if (!type) continue;
    var kw = String(rows[r][map['키워드']] || '').split(/[,,/]/)
      .map(function (k) { return k.trim().toLowerCase(); })
      .filter(function (k) { return k; });
    out.push({
      type: type,
      keywords: kw,
      draft: String(rows[r][map['답변 초안']] || '').trim(),
      call: /^y|^예|^true/i.test(String(rows[r][map['직접 통화']] || '').trim())
    });
  }
  return out;
}

function countHits(text, keywords) {
  var n = 0;
  for (var i = 0; i < keywords.length; i++) if (text.indexOf(keywords[i]) !== -1) n++;
  return n;
}

/**
 * Picks a type and a draft.
 *  1. Any "직접 통화" keyword anywhere in the body wins: no draft, the owner calls.
 *  2. The form's own 유형 choice is trusted unless it is blank or 기타.
 *  3. Otherwise the type whose keywords hit the body most often; ties go to the earlier row.
 *  4. Nothing hits: 확인 필요 with no draft.
 */
function classify(answers, rules) {
  var body = String(answers.body || '').toLowerCase();
  var chosen = String(answers.type || '').trim();
  var byType = {};
  rules.forEach(function (r) { byType[r.type] = r; });

  for (var i = 0; i < rules.length; i++) {
    if (rules[i].call && countHits(body, rules[i].keywords) > 0) {
      return { type: rules[i].type, draft: '', status: STATUS.call, call: true };
    }
  }

  var type = null;
  if (chosen && chosen !== OTHER && byType[chosen]) {
    type = chosen;
  } else {
    var best = 0;
    rules.forEach(function (r) {
      if (r.call || r.type === OTHER) return;
      var hits = countHits(body, r.keywords);
      if (hits > best) { best = hits; type = r.type; }
    });
    if (!type && chosen === OTHER && byType[OTHER]) type = OTHER;
  }

  if (!type) return { type: UNKNOWN, draft: '', status: STATUS.open, call: false };
  return { type: type, draft: byType[type].draft, status: STATUS.open, call: false };
}

/** Row color by status. Done rows get no color. */
function statusColor(status) {
  if (status === STATUS.call) return COLORS.call;
  if (status === STATUS.open) return COLORS.open;
  return null;
}

// ---------- ① Sheets ----------

function setupSheets() {
  var ss = SpreadsheetApp.getActive();
  ensureSheet_(ss, SHEET_INBOX, INBOX_HEADERS);
  var rules = ensureSheet_(ss, SHEET_RULES, RULE_HEADERS);
  if (rules.getLastRow() < 2) {
    rules.getRange(2, 1, DEFAULT_RULES.length, RULE_HEADERS.length).setValues(DEFAULT_RULES);
  }
  var resp = findResponseSheet_(ss);
  SpreadsheetApp.getUi().alert(resp
    ? '시트 준비가 끝났습니다. 폼 응답 시트: "' + resp.getName() + '"'
    : '시트 준비가 끝났습니다. 아직 폼 응답 시트가 보이지 않습니다. 폼을 이 스프레드시트에 연결한 뒤 시험 제출을 한 번 해 주세요.');
}

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;
}

/** The form-linked sheet is the one whose header row holds every form question. */
function findResponseSheet_(ss) {
  var sheets = ss.getSheets();
  for (var i = 0; i < sheets.length; i++) {
    var s = sheets[i];
    if (s.getName() === SHEET_INBOX || s.getName() === SHEET_RULES) continue;
    if (s.getLastColumn() < 1) continue;
    var map = headerMap(s.getRange(1, 1, 1, s.getLastColumn()).getValues()[0]);
    if (missingHeaders(map, FORM_QUESTIONS).length === 0) return s;
  }
  return null;
}

function readRules_(ss) {
  var sheet = ss.getSheetByName(SHEET_RULES);
  if (!sheet || sheet.getLastRow() < 2) throw new Error('유형표가 비어 있습니다. 메뉴 ①을 먼저 실행하세요.');
  return parseRules(sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues());
}

// ---------- On submit: classify one response ----------

function classifySubmission(e) {
  var ss = SpreadsheetApp.getActive();
  var rules = readRules_(ss);
  var nv = e && e.namedValues ? e.namedValues : {};
  var answers = {
    type: firstValue(nv, Q.type), cls: firstValue(nv, Q.cls), body: firstValue(nv, Q.body),
    how: firstValue(nv, Q.how), contact: firstValue(nv, Q.contact)
  };
  var when = e && e.values && e.values[0] ? e.values[0] : new Date();
  var rowNo = e && e.range ? e.range.getRow() : '';
  appendToInbox_(ss, answers, when, rowNo, classify(answers, rules));
}

/** ③ Classifies every response row that is not in 문의함 yet, then recolors 문의함 by status. */
function recheckAll() {
  var ss = SpreadsheetApp.getActive();
  var resp = findResponseSheet_(ss);
  if (!resp) throw new Error('폼 응답 시트를 찾지 못했습니다. 폼을 이 스프레드시트에 연결하고 시험 제출을 한 번 해 주세요.');
  var rules = readRules_(ss);
  var inbox = ss.getSheetByName(SHEET_INBOX);
  var imap = headerMap(inbox.getRange(1, 1, 1, inbox.getLastColumn()).getValues()[0]);
  var seen = {};
  if (inbox.getLastRow() > 1) {
    inbox.getRange(2, imap['응답행'] + 1, inbox.getLastRow() - 1, 1).getValues()
      .forEach(function (v) { if (v[0] !== '') seen[String(v[0])] = true; });
  }
  var rmap = headerMap(resp.getRange(1, 1, 1, resp.getLastColumn()).getValues()[0]);
  var added = 0;
  if (resp.getLastRow() > 1) {
    var rows = resp.getRange(2, 1, resp.getLastRow() - 1, resp.getLastColumn()).getValues();
    for (var i = 0; i < rows.length; i++) {
      var rowNo = i + 2;
      if (seen[String(rowNo)]) continue;
      var v = rows[i];
      var answers = {
        type: String(v[rmap[Q.type]] || '').trim(), cls: String(v[rmap[Q.cls]] || '').trim(),
        body: String(v[rmap[Q.body]] || '').trim(), how: String(v[rmap[Q.how]] || '').trim(),
        contact: String(v[rmap[Q.contact]] || '').trim()
      };
      if (!answers.body && !answers.type) continue;
      appendToInbox_(ss, answers, v[0], rowNo, classify(answers, rules));
      added++;
    }
  }
  recolorInbox_(inbox);
  SpreadsheetApp.getUi().alert(added + '건을 새로 분류했습니다. 상태 색을 다시 칠했습니다.');
}

function appendToInbox_(ss, a, when, rowNo, c) {
  var inbox = ss.getSheetByName(SHEET_INBOX);
  if (!inbox) throw new Error('메뉴 ① 시트 준비를 먼저 실행하세요.');
  var tz = Session.getScriptTimeZone();
  var whenText = Object.prototype.toString.call(when) === '[object Date]' ? Utilities.formatDate(when, tz, 'yyyy-MM-dd HH:mm') : String(when);
  var row = [whenText, c.type, a.cls, a.body, a.how, a.contact, c.draft, c.status, '', rowNo];
  inbox.appendRow(row);
  var r = inbox.getLastRow();
  inbox.getRange(r, 1, 1, INBOX_HEADERS.length).setBackground(statusColor(c.status));
}

function recolorInbox_(inbox) {
  if (inbox.getLastRow() < 2) return;
  var map = headerMap(inbox.getRange(1, 1, 1, inbox.getLastColumn()).getValues()[0]);
  var n = inbox.getLastRow() - 1;
  var statuses = inbox.getRange(2, map['상태'] + 1, n, 1).getValues();
  var colors = statuses.map(function (s) {
    var fill = statusColor(String(s[0]).trim());
    var line = [];
    for (var k = 0; k < INBOX_HEADERS.length; k++) line.push(fill);
    return line;
  });
  inbox.getRange(2, 1, n, INBOX_HEADERS.length).setBackgrounds(colors);
}

// ---------- ② ④ Form-submit trigger: never more than one ----------

function enableFormTrigger() {
  var ss = SpreadsheetApp.getActive();
  if (!findResponseSheet_(ss)) throw new Error('폼 응답 시트를 찾지 못했습니다. 폼을 먼저 연결하고 시험 제출을 한 번 해 주세요.');
  deleteOurTriggers_();
  ScriptApp.newTrigger(HANDLER).forSpreadsheet(ss).onFormSubmit().create();
  SpreadsheetApp.getUi().alert('폼이 연결되었습니다. 이제 제출되는 즉시 문의함에 분류됩니다. 이미 쌓인 응답은 ③으로 처리하세요.');
}

function disableFormTrigger() {
  var n = deleteOurTriggers_();
  SpreadsheetApp.getUi().alert(n ? '폼 연결을 끊었습니다.' : '연결된 폼 트리거가 없습니다.');
}

function deleteOurTriggers_() {
  var n = 0;
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === HANDLER) { ScriptApp.deleteTrigger(t); n++; }
  });
  return n;
}

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

3. 유형표를 우리 학원 말로 고친다

유형표 시트 · 유형 · 키워드 · 답변 초안 · 직접 통화

  1. 최근 실제 문의 5개를 꺼내, 학부모가 실제로 쓴 낱말을 해당 유형의 키워드 칸에 쉼표로 덧붙입니다. "빠지", "못 가"처럼 어미를 뺀 짧은 형태가 더 잘 잡힙니다.
  2. 답변 초안을 우리 학원 말투로 고칩니다. 약속처럼 읽히는 말("꼭 넣어 드리겠습니다")은 피합니다.
  3. 직접 통화 행의 키워드를 봅니다. 여기 있는 낱말이 들어오면 초안 없이 빨강이 됩니다. 늘리거나 줄이세요.
  4. 유형을 늘리려면 행을 추가하고, 폼의 문의 유형 선택지에도 같은 이름을 추가합니다.

유형표가 커질수록 키워드가 겹치기 시작합니다. "시간"은 시간표 문의에도, 상담 가능 시간을 묻는 문의에도 나옵니다. 스크립트는 더 많이 맞는 유형을 고르고, 같으면 위에 있는 행을 고릅니다. 겹침이 잦아지면 폼의 유형 선택을 필수로 두는 것이 키워드를 늘리는 것보다 낫습니다.

4. ② 폼 연결, 시험 제출, ③ 밀린 응답

onFormSubmit 트리거 · 하나만

  1. ② 폼 연결을 누릅니다. 트리거 권한을 묻는 창이 한 번 더 뜰 수 있습니다. 실행 확인: 사용자
  2. 폼을 열어 시험 문의를 하나 제출합니다. 예: 유형 "기타", 내용 "다음 주 수요일 결석하고 보강 가능할까요".
  3. 문의함을 엽니다. 몇 초 안에 한 행이 생기고 유형이 "결석·보강", 초안이 채워져 있고 노랑입니다.
  4. ②를 켜기 전에 쌓인 응답이 있으면 ③ 밀린 응답 다시 분류를 누릅니다. 문의함에 없는 응답만 골라 넣고, 전체 행의 색을 상태에 맞게 다시 칠합니다.
확인 — 내용에 "환불"을 넣어 한 번 더 제출하면 그 행은 초안 없이 빨강이고 상태가 "직접 통화"입니다. 이 행이 이 시스템에서 가장 중요한 동작입니다. 틀에 박힌 초안이 붙지 않는 문의가 있어야 합니다.

②는 이전 편의 ③과 같은 방식으로 트리거를 하나만 유지합니다. 공식 문서에 따르면 트리거는 만든 사람의 계정으로 실행되고, 같은 스프레드시트에 다른 계정이 설치한 트리거는 보이지 않습니다. 원장 계정으로 켜세요. — 설치형 트리거 가이드, 2026-09-03 갱신본

5. 문의함에서 일하는 법

읽기 · 고치기 · 직접 답하기 · 상태 바꾸기

  1. 노랑 행의 답변 초안을 읽고, 그 문의에 맞게 고칩니다. 초안은 출발점이지 답이 아닙니다.
  2. 고친 글을 학부모가 고른 연락 방법으로 직접 보냅니다. 카카오면 카카오로, 전화면 전화로.
  3. 상태를 답변함으로 바꾸고, 필요하면 처리 메모에 한 줄 남깁니다.
  4. 빨강 행은 초안이 없습니다. 바로 전화합니다. 통화 뒤 상태를 답변함으로 바꿉니다.
  5. 하루 끝에 ③을 한 번 누르면 답변함 행의 색이 사라지고, 남은 노랑·빨강이 내일 할 일입니다.

색은 ③을 누를 때 다시 칠해집니다. 상태를 바꾸는 즉시 색이 바뀌지는 않습니다. 이것은 일부러 둔 단순함입니다. 셀을 고칠 때마다 반응하는 트리거를 넣으면 코드와 권한이 늘어나고, 하루 한 번 ③이면 충분합니다.

자주 막히는 곳

증상원인과 조치
"폼 응답 시트를 찾지 못했습니다"폼이 이 스프레드시트에 연결되지 않았거나, 질문 제목이 1단계와 다르거나, 아직 제출이 한 건도 없습니다. 질문 제목 다섯 개를 글자 단위로 맞추고 시험 제출을 한 번 합니다.
제출했는데 문의함에 안 생긴다②를 누르지 않았거나 다른 계정으로 눌렀습니다. 편집기 왼쪽 트리거 메뉴에 classifySubmission이 있는지 보고, 없으면 ②를 다시 누릅니다. 이미 제출된 응답은 ③으로 가져옵니다.
유형이 엉뚱하다키워드가 겹쳤습니다. 폼의 유형 선택을 필수로 두면 선택이 키워드보다 먼저입니다. 겹치는 낱말은 한쪽 키워드에서 뺍니다.
"확인 필요"가 많다유형표 키워드가 학부모 말과 다릅니다. 실제 문의에서 쓴 낱말을 키워드에 더합니다. 유형을 "기타"로 고른 문의는 키워드가 없어도 기타 초안을 받습니다.
같은 문의가 두 번 들어온다②를 두 번 눌러도 트리거는 하나입니다. ③은 응답행 번호로 이미 넣은 것을 건너뜁니다. 문의함의 응답행 열을 지우면 ③이 다시 넣으니 그 열은 비워 두지 마세요.
상태를 바꿨는데 색이 그대로다정상입니다. ③을 누를 때 다시 칠해집니다.

한도

개인 구글 계정 기준으로 공식 할당량 표에 적힌 값입니다. 문의 한 건에 실행 한 번이니 하루 수십 건이어도 여유가 큽니다.

항목개인 계정Workspace
트리거 수 (사용자·스크립트당)20개20개
트리거 총 실행 시간90분 / 일6시간 / 일
한 번 실행 시간6분6분

Apps Script 할당량 문서, 2026-09-03 갱신본.

혼자 다시 해 보기

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

이 최소 버전에 없는 것

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

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

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