← 전체 코드 목록

📖 사용 방법

구글 앱스 스크립트와 Gemini AI를 활용하여, 어떤 글이든 논문, 보고서, 요약, 공문, 발표문 등 원하는 문서 스타일에 맞춰 자동으로 변환해주는 나만의 AI 글쓰기 도우미를 만들어 보세요.

이런 분께 추천해요

  • 딱딱한 글을 특정 문서 스타일에 맞춰 변환하고 싶은 분
  • 앱스 스크립트(Apps Script)를 배우고 싶은 구글 시트 사용자
  • AI 기술을 업무에 적용해 자동화하고 싶은 분들

완성하면 이렇게 됩니다

원하는 텍스트를 입력하고, '논문', '보고서', '요약', '공문', '발표문' 중 원하는 스타일을 선택하면, AI가 자동으로 글을 변환해 주는 웹 애플리케이션(Web Application)이 완성됩니다. 이 웹 앱은 구글 계정만 있다면 누구나 쉽게 접근하여 사용할 수 있습니다.

준비물

  • 구글 계정: Gmail 계정만 있으면 충분합니다.
  • Gemini API Key: 구글 AI 스튜디오(Google AI Studio) 등에서 발급받을 수 있는 Gemini API 키가 필요합니다.

따라하기

1단계: 구글 앱스 스크립트 프로젝트 만들기

  1. 구글 드라이브(Google Drive)에 접속합니다.
  2. 왼쪽 상단의 '+ 새로 만들기' 버튼을 클릭합니다.
  3. '더보기'를 선택한 후, 'Google Apps Script'를 클릭하여 새 앱스 스크립트 프로젝트를 생성합니다. (만약 'Google Apps Script'가 보이지 않는다면, '앱 연결하기'를 통해 추가할 수 있습니다.)

2단계: 코드 붙여넣기

  1. Code.gs 파일에 코드 붙여넣기:
    • 새로 만든 앱스 스크립트 프로젝트에는 기본적으로 Code.gs라는 파일이 있습니다. 이 파일의 기존 내용을 모두 지우고, 위에 제공된 자바스크립트(JavaScript) 코드를 복사하여 붙여넣으십시오.
  2. Index.html 파일 추가하기:
    • 이 웹 앱은 사용자 인터페이스(UI)를 위해 Index.html 파일이 필요합니다. 앱스 스크립트 편집기 왼쪽 메뉴에서 '파일' 옆의 '+' 버튼을 클릭한 후, 'HTML'을 선택하고 파일 이름을 Index로 입력하여 Index.html 파일을 만드십시오.
    • 주의: Index.html 파일의 구체적인 내용은 이 가이드에서 제공되지 않습니다. 웹 앱을 완전히 실행하려면 해당 파일에 적절한 HTML 코드가 있어야 합니다. 이 가이드에서는 AI 변환기의 핵심 기능인 백엔드(Backend) 스크립트 설정에 집중합니다.

3단계: Gemini API 키 설정

  1. API 키 발급받기: 구글 AI 스튜디오(Google AI Studio) 등에서 Gemini API 키를 발급받으십시오.
  2. 앱스 스크립트 편집기에서 setApiKey 함수 실행:
    • Code.gs 파일 상단에 있는 함수 선택 드롭다운 메뉴에서 setApiKey를 선택합니다.
    • 상단의 '실행' 버튼(▶ 모양)을 클릭합니다.
    • 처음 실행할 때는 구글 계정 승인 요청이 나타날 수 있습니다. 안내에 따라 승인을 완료하십시오.
    • 함수 실행 시 '매개변수(Parameter)' 입력 창이 나타나면, 발급받은 Gemini API 키를 입력하고 '실행'을 클릭합니다.
    • 이 과정을 통해 API 키가 스크립트 속성(Properties Service)에 안전하게 저장됩니다. 이 작업은 최초 1회만 진행하면 됩니다.

4단계: 웹 앱으로 배포하기

  1. 앱스 스크립트 편집기 오른쪽 상단의 '배포' 버튼을 클릭한 후, '새 배포'를 선택합니다.
  2. '배포 유형 선택'에서 '웹 앱'을 선택합니다.
  3. 설정:
    • 설명: 웹 앱의 이름을 입력합니다 (예: 'AI 글쓰기 도우미').
    • 실행 계정: '나 자신'으로 설정합니다.
    • 액세스 권한: '모든 사용자'로 설정합니다. (이렇게 해야 누구나 웹 앱에 접근할 수 있습니다.)
  4. '배포' 버튼을 클릭합니다.
  5. 배포가 완료되면 웹 앱 URL이 나타납니다. 이 URL을 복사하여 웹 브라우저에 붙여넣으면 웹 앱이 실행됩니다.

자주 막히는 부분

  • "API Key가 없습니다." 또는 "API Key가 비어 있습니다." 에러: 3단계의 'Gemini API 키 설정'을 다시 확인하고, setApiKey 함수를 통해 키가 올바르게 저장되었는지 확인하십시오.
  • "유효하지 않은 필터입니다." 에러: 웹 앱에서 선택한 스타일(필터)이 코드에 정의된 CFG.FILTERS (논문, 보고서, 요약, 공문, 발표문) 중 하나인지 확인하십시오.
  • "원문이 비어 있습니다." 에러: 변환할 텍스트를 입력하지 않고 변환을 시도했을 때 발생합니다. 텍스트 입력란이 비어있지 않은지 확인하십시오.
  • 웹 앱이 실행되지 않거나 흰 화면만 나옵니다: Index.html 파일이 없거나 내용이 비어있을 가능성이 큽니다. 2단계의 Index.html 파일 추가 부분을 다시 확인하십시오.

이렇게도 써보세요 (응용)

  • 새로운 변환 스타일 추가: CFG.FILTERS 배열에 '이메일', '블로그 글' 등 새로운 스타일을 추가하고, buildPrompt_ 함수에 해당 스타일에 맞는 프롬프트(Prompt) 규칙을 정의하여 더 다양한 변환 기능을 만들 수 있습니다.
  • AI의 창의성 조절: callGemini_ 함수 내 generationConfig: { temperature: 0.3, ... } 부분에서 temperature 값을 조절해 보세요. 값을 높이면 AI의 답변이 더 창의적이고 다양해지고, 낮추면 더 일관적이고 보수적인 답변을 얻을 수 있습니다.
  • 구글 시트와 연동: 현재는 웹 앱 형태로 작동하지만, transformText 함수를 구글 시트의 사용자 정의 함수로 만들거나, 시트의 특정 셀에 있는 텍스트를 자동으로 변환하여 다른 셀에 결과를 출력하는 방식으로 응용할 수도 있습니다.

한 줄 정리

구글 앱스 스크립트와 Gemini AI로 나만의 글쓰기 비서, AI 문서 스타일 변환기를 쉽고 빠르게 만들어 보세요!

💻 코드

code.gs
const CFG = {
  MODEL: 'gemini-2.5-flash',
  FILTERS: ['논문', '보고서', '요약', '공문', '발표문'],
};

function doGet() {
  return HtmlService.createHtmlOutputFromFile('Index')
    .setTitle('AI 변환 웹앱')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// (선택) 최초 1회 키 저장용
function setApiKey(key) {
  key = (key || '').trim();
  if (!key) throw new Error('API Key가 비어 있습니다.');
  PropertiesService.getScriptProperties().setProperty('GEMINI_API_KEY', key);
  return true;
}

function getFilters() {
  return CFG.FILTERS.slice();
}

function transformText(source, filter) {
  source = String(source || '').trim();
  filter = String(filter || '').trim();

  if (!source) throw new Error('원문이 비어 있습니다.');
  if (!CFG.FILTERS.includes(filter)) throw new Error('유효하지 않은 필터입니다.');

  const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (!apiKey) throw new Error('API Key가 없습니다. (스크립트 속성 GEMINI_API_KEY)');

  const prompt = buildPrompt_(source, filter);

  const out = callGemini_(apiKey, prompt, 2048);
  let finalOut = out;

  // 끊김 감지 → 이어쓰기 (논문/보고서/발표문)
  const trimmed = finalOut.trim();
  const looksCut = trimmed.length >= 1200 && !/[.?!…]|[。?!]|ns*$/.test(trimmed);
  if (looksCut && (filter === '발표문' || filter === '보고서' || filter === '논문')) {
    const cont = continueGeneration_(apiKey, finalOut);
    if (cont) finalOut = (finalOut + 'n' + cont).trim();
  }

  // 요약 후처리(명사형 종결 보정)
  if (filter === '요약') finalOut = postprocessSummaryToNounEnding_(finalOut);

  return finalOut;
}

function decideSummaryBullets_(source) {
  const n = (source || '').replace(/s+/g, ' ').trim().length;
  if (n <= 80) return 2;
  if (n <= 200) return 3;
  if (n <= 450) return 5;
  if (n <= 900) return 7;
  return 9;
}

function buildPrompt_(source, filter) {
  const commonRules = [
    '출력은 한국어로만 작성.',
    '원문에 없는 사실/수치/근거를 임의로 추가하지 말 것.',
    '고유명사/수치/전문용어는 가능한 한 원문을 유지.',
    '불필요한 사족(서론, 자기소개, 메타 발언) 금지.',
    '요청된 형식/문체만 출력.'
  ].join('n');

  const summaryBullets = decideSummaryBullets_(source);

  const styleMap = {
    '논문': [
      '문체: 학술 논문체(객관적, 서술은 "~다/~이다" 계열).',
      '표현: 구어체/감탄/주관적 평가 최소화.',
      '구성: 필요 시 (정의)-(특징)-(함의) 순으로 간결 재구성.',
      '제한: 원문에 없는 참고문헌/저자/연도/주장 추가 금지.'
    ].join('n'),
    '보고서': [
      '문체: 실무 보고서체(명료, 단락/불릿 허용).',
      '구성: (요약)-(핵심 내용)-(시사점/권고) 순서 권장.',
      '표현: 실행 가능 문장 중심.'
    ].join('n'),
    '요약': [
      `형식: 불릿 ${summaryBullets}개 내외.`,
      '✅ 각 불릿은 명사형/체언형으로 종결(예: "~탐구", "~경계", "~가능성", "~미래상").',
      '✅ "이다/입니다/합니다/된다/되다" 등 서술어 종결 금지.',
      '중복 제거, 과장 금지, 원문 범위 내 핵심만.'
    ].join('n'),
    '공문': [
      '문체: 공문/행정 문체(격식, 간결).',
      '형식: 목적-내용-요청(또는 협조) 순.',
      '필요 시 날짜/수신/참조는 [대괄호] placeholder 처리.'
    ].join('n'),
    '발표문': [
      '문체: 발표 스크립트(과도한 구어체 금지, 흐름 중심).',
      '구성: 도입-핵심 3포인트-정리.',
      '마무리: 끝맺음 문장으로 자연스럽게 종료.'
    ].join('n')
  };

  const style = styleMap[filter] || `문체: "${filter}"에 맞춰 자연스럽게 변환.`;

  return [
    '너는 한국어 글쓰기 편집자다.',
    commonRules,
    '',
    '요청 스타일:',
    style,
    '',
    '원문:',
    source
  ].join('n');
}

function callGemini_(apiKey, prompt, maxOutputTokens) {
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(CFG.MODEL)}:generateContent`;
  const payload = {
    contents: [{ role: 'user', parts: [{ text: prompt }] }],
    generationConfig: { temperature: 0.3, maxOutputTokens: maxOutputTokens || 1024 }
  };

  const res = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json; charset=utf-8',
    headers: { 'x-goog-api-key': apiKey },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });

  const code = res.getResponseCode();
  const text = res.getContentText();
  if (code < 200 || code >= 300) throw new Error(`Gemini API HTTP ${code}: ${text}`);

  const json = JSON.parse(text);
  const out = (json.candidates?.[0]?.content?.parts || []).map(p => p.text || '').join('').trim();
  if (!out) throw new Error('빈 응답이 반환되었습니다.');
  return out;
}

function continueGeneration_(apiKey, partialText) {
  const prompt =
`아래 글이 중간에서 끊겼다. 직전 문장 흐름을 이어서 자연스럽게 끝까지 완성하라.
- 기존 문장 반복 최소화
- 출력은 이어지는 부분만
- 마지막은 마무리 문장으로 종결

[이미 작성된 내용]
${partialText}

[이어서 작성]`;

  try {
    return callGemini_(apiKey, prompt, 1024);
  } catch (e) {
    return '';
  }
}

function postprocessSummaryToNounEnding_(text) {
  const lines = String(text || '').split('n');
  const fixed = lines.map(line => {
    let s = line;
    const isBullet = /^s*([*-•]|d+[.)])s+/.test(s);
    if (!isBullet) return s;

    s = s
      .replace(/s*(입니다|이다|합니다|된다|되다).?s*$/u, '')
      .replace(/.s*$/u, '');

    return s;
  });
  return fixed.join('n').trim();
}
Index.html
<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>AI 변환</title>

  <!-- Google-ish system font stack -->
  <style>
    :root{
      --bg:#f6f8fc;
      --card:#ffffff;
      --text:#1f1f1f;
      --sub:#5f6368;
      --border:#e0e3e7;
      --shadow: 0 1px 2px rgba(60,64,67,.10), 0 1px 3px rgba(60,64,67,.08);
      --radius:16px;
      --focus: 0 0 0 3px rgba(26,115,232,.20);
      --blue:#1a73e8;
      --blue2:#1557b0;
      --chip:#eef3fd;
    }

    *{box-sizing:border-box}
    html,body{height:100%}
    body{
      margin:0;
      font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", Arial, sans-serif;
      color:var(--text);
      background:var(--bg);
      line-height:1.5;
    }

    /* Top app bar */
    .appbar{
      position:sticky; top:0; z-index:10;
      background:rgba(246,248,252,.85);
      backdrop-filter:saturate(1.2) blur(10px);
      border-bottom:1px solid var(--border);
    }
    .appbar-inner{
      max-width:1080px;
      margin:0 auto;
      padding:14px 16px;
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap:12px;
    }
    .brand{
      display:flex; align-items:center; gap:10px;
      min-width:0;
    }
    .logo{
      width:34px; height:34px;
      border-radius:10px;
      background:linear-gradient(135deg,#e8f0fe,#ffffff);
      border:1px solid var(--border);
      display:grid; place-items:center;
      box-shadow: var(--shadow);
      flex:0 0 auto;
    }
    .logo svg{width:18px;height:18px;fill:var(--blue)}
    .title{
      display:flex; flex-direction:column; min-width:0;
    }
    .title strong{font-size:14px; letter-spacing:.2px}
    .title span{font-size:12px; color:var(--sub); white-space:nowrap; overflow:hidden; text-overflow:ellipsis}

    .bar-actions{display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end}

    /* Layout */
    .wrap{
      max-width:1080px;
      margin:18px auto 60px;
      padding:0 16px;
      display:grid;
      grid-template-columns: 1.15fr .85fr;
      gap:14px;
    }
    @media (max-width: 980px){
      .wrap{grid-template-columns:1fr}
    }

    .card{
      background:var(--card);
      border:1px solid var(--border);
      border-radius:var(--radius);
      box-shadow: var(--shadow);
      overflow:hidden;
    }
    .card-hd{
      padding:14px 16px;
      border-bottom:1px solid var(--border);
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap:10px;
    }
    .card-hd .hd-left{
      display:flex; flex-direction:column; gap:2px;
      min-width:0;
    }
    .card-hd .hd-left b{font-size:13px}
    .card-hd .hd-left small{font-size:12px; color:var(--sub)}

    .card-bd{padding:14px 16px; display:grid; gap:10px}

    /* Controls */
    label{font-size:12px; color:var(--sub)}
    .row{display:flex; gap:10px; flex-wrap:wrap; align-items:center}
    .row > * {flex:0 0 auto}

    .select, .input, .textarea{
      width:100%;
      border:1px solid var(--border);
      border-radius:12px;
      padding:10px 12px;
      background:#fff;
      color:var(--text);
      outline:none;
      transition: box-shadow .12s ease, border-color .12s ease;
      font-size:14px;
    }
    .select:focus, .input:focus, .textarea:focus{
      border-color: rgba(26,115,232,.55);
      box-shadow: var(--focus);
    }

    .textarea{min-height:240px; resize:vertical}

    .btn{
      border:1px solid var(--border);
      background:#fff;
      color:var(--text);
      padding:10px 12px;
      border-radius:12px;
      font-size:14px;
      cursor:pointer;
      transition: transform .04s ease, background .12s ease, border-color .12s ease;
      display:inline-flex; align-items:center; gap:8px;
      user-select:none;
    }
    .btn:hover{background:#f8f9fa}
    .btn:active{transform: translateY(1px)}
    .btn:focus{outline:none; box-shadow: var(--focus)}

    .btn-primary{
      background: var(--blue);
      color:#fff;
      border-color: transparent;
    }
    .btn-primary:hover{background: var(--blue2)}
    .btn-primary:disabled{
      opacity:.6;
      cursor:not-allowed;
      transform:none;
    }

    .btn-ghost{
      background:transparent;
      border-color:transparent;
      color:var(--sub);
    }
    .btn-ghost:hover{background:#f1f3f4; color:var(--text)}
    .btn-ghost:focus{box-shadow: var(--focus); border-color: rgba(26,115,232,.25)}

    .pill{
      display:inline-flex;
      align-items:center;
      gap:8px;
      padding:8px 10px;
      border-radius:999px;
      background:var(--chip);
      border:1px solid rgba(26,115,232,.12);
      color:#174ea6;
      font-size:12px;
      max-width:100%;
      white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
    }

    /* Output box */
    .output{
      border:1px dashed var(--border);
      border-radius:12px;
      padding:12px;
      min-height:240px;
      white-space:pre-wrap;
      background: #fcfdff;
    }

    .muted{color:var(--sub); font-size:12px}
    .sep{height:1px; background:var(--border); margin:4px 0}

    /* Toast */
    .toast{
      position: fixed;
      left: 50%;
      bottom: 18px;
      transform: translateX(-50%);
      background: rgba(32,33,36,.92);
      color:#fff;
      padding:10px 12px;
      border-radius:999px;
      font-size:13px;
      box-shadow: 0 6px 18px rgba(0,0,0,.18);
      opacity:0;
      pointer-events:none;
      transition: opacity .18s ease, transform .18s ease;
    }
    .toast.show{
      opacity:1;
      transform: translateX(-50%) translateY(-4px);
    }

    /* Small icon */
    .icon{width:16px;height:16px;display:inline-block}
    .spin{animation: spin 1s linear infinite}
    @keyframes spin{to{transform: rotate(360deg)}}
  </style>
</head>
<body>
  <!-- Appbar -->
  <header class="appbar">
    <div class="appbar-inner">
      <div class="brand">
        <div class="logo" aria-hidden="true">
          <svg viewBox="0 0 24 24"><path d="M12 2a7 7 0 0 0-7 7v3a5 5 0 0 0 5 5h1v3H8a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-3v-3h1a5 5 0 0 0 5-5V9a7 7 0 0 0-7-7Zm5 10a3 3 0 0 1-3 3h-4a3 3 0 0 1-3-3V9a5 5 0 0 1 10 0v3Z"/></svg>
        </div>
        <div class="title">
          <strong>AI 변환</strong>
          <span>원문 + 필터 선택 → 결과 생성 (Gemini)</span>
        </div>
      </div>
      <div class="bar-actions">
        <button class="btn btn-ghost" id="clearTop" title="비우기">비우기</button>
        <button class="btn btn-primary" id="runTop">
          <svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
          변환
        </button>
      </div>
    </div>
  </header>

  <main class="wrap">
    <!-- Left: Input -->
    <section class="card">
      <div class="card-hd">
        <div class="hd-left">
          <b>입력</b>
          <small>필터는 고정 목록에서만 선택</small>
        </div>
        <div class="pill" id="statusPill">대기</div>
      </div>

      <div class="card-bd">
        <div class="row" style="gap:12px">
          <div style="min-width:220px; flex: 1 1 240px">
            <label for="filter">필터</label>
            <select class="select" id="filter"></select>
          </div>
          <div style="flex:1 1 220px; min-width:220px">
            <label>도움말</label>
            <div class="pill" title="요약은 원문 길이에 따라 항목 수가 자동 조절됩니다.">
              요약: 가변 길이 · 명사형 종결
            </div>
          </div>
        </div>

        <div>
          <label for="src">원문</label>
          <textarea class="textarea" id="src" placeholder="텍스트를 입력하세요."></textarea>
          <div class="muted" id="counter">0자</div>
        </div>

        <div class="row">
          <button class="btn btn-primary" id="run">
            <svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
            변환
          </button>
          <button class="btn" id="copy">
            <svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1zm4 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h12v14z"/></svg>
            결과 복사
          </button>
          <button class="btn" id="clear">
            <svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.4 17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z"/></svg>
            비우기
          </button>
        </div>

        <div class="muted">
          • “논문”은 학술체(~다/~이다) • “발표문”은 끊김 시 자동 이어쓰기
        </div>
      </div>
    </section>

    <!-- Right: Output + Key -->
    <aside class="card">
      <div class="card-hd">
        <div class="hd-left">
          <b>결과</b>
          <small>생성된 텍스트가 여기에 표시</small>
        </div>
        <div class="muted" id="latency"></div>
      </div>
      <div class="card-bd">
        <div class="output" id="out"></div>

        <div class="sep"></div>

        <details>
          <summary class="muted">API Key 저장(최초 1회)</summary>
          <div style="margin-top:10px; display:grid; gap:10px">
            <input class="input" id="key" type="password" placeholder="Gemini API Key" autocomplete="off">
            <div class="row">
              <button class="btn" id="saveKey">
                <svg class="icon" viewBox="0 0 24 24" fill="currentColor"><path d="M17 3H5a2 2 0 0 0-2 2v14h18V7l-4-4zm-2 16H9v-6h6v6zm3-10H5V5h11.17L18 6.83V9z"/></svg>
                저장
              </button>
              <span class="muted">키는 서버 ScriptProperties에 저장</span>
            </div>
          </div>
        </details>
      </div>
    </aside>
  </main>

  <div class="toast" id="toast"></div>

  <script>
    const $ = (id) => document.getElementById(id);

    function toast(msg){
      const t = $("toast");
      t.textContent = msg;
      t.classList.add("show");
      setTimeout(()=>t.classList.remove("show"), 1800);
    }

    function setStatus(text, loading=false){
      const pill = $("statusPill");
      pill.textContent = text;
      pill.style.background = loading ? "#e8f0fe" : "var(--chip)";
      pill.style.borderColor = loading ? "rgba(26,115,232,.25)" : "rgba(26,115,232,.12)";
      pill.style.color = loading ? "#174ea6" : "#174ea6";
    }

    function setLoading(on){
      $("run").disabled = on;
      $("runTop").disabled = on;
      if(on){
        $("out").textContent = "⏳ 생성 중...";
        setStatus("생성 중…", true);
        $("latency").textContent = "";
      } else {
        setStatus("대기");
      }
    }

    function loadFilters(){
      google.script.run.withSuccessHandler(filters => {
        const sel = $("filter");
        sel.innerHTML = "";
        filters.forEach(f => {
          const opt = document.createElement("option");
          opt.value = f;
          opt.textContent = f;
          sel.appendChild(opt);
        });
      }).withFailureHandler(err => {
        $("out").textContent = "⚠️ 필터 로딩 실패: " + (err?.message || err);
      }).getFilters();
    }

    function runTransform(){
      const src = $("src").value.trim();
      const filter = $("filter").value;

      if(!src){ $("out").textContent = "⚠️ 원문을 입력하세요."; toast("원문이 비어 있습니다"); return; }

      const t0 = performance.now();
      setLoading(true);

      google.script.run
        .withSuccessHandler(res => {
          $("out").textContent = res || "";
          const dt = Math.round(performance.now() - t0);
          $("latency").textContent = dt ? `${dt}ms` : "";
          setLoading(false);
          toast("완료");
        })
        .withFailureHandler(err => {
          $("out").textContent = "⚠️ " + (err?.message || err);
          setLoading(false);
          toast("오류");
        })
        .transformText(src, filter);
    }

    function clearAll(){
      $("src").value = "";
      $("out").textContent = "";
      $("counter").textContent = "0자";
      setStatus("대기");
      toast("초기화");
    }

    $("run").addEventListener("click", runTransform);
    $("runTop").addEventListener("click", runTransform);
    $("clear").addEventListener("click", clearAll);
    $("clearTop").addEventListener("click", clearAll);

    $("copy").addEventListener("click", async () => {
      const text = $("out").textContent || "";
      if(!text.trim()){ toast("복사할 내용이 없습니다"); return; }
      try{
        await navigator.clipboard.writeText(text);
        toast("복사됨");
      }catch(e){
        toast("복사 실패(브라우저 권한)");
      }
    });

    $("saveKey").addEventListener("click", () => {
      const key = $("key").value.trim();
      if(!key){ toast("API Key를 입력하세요"); return; }
      google.script.run
        .withSuccessHandler(() => {
          $("key").value = "";
          toast("API Key 저장 완료");
        })
        .withFailureHandler(err => {
          $("out").textContent = "⚠️ " + (err?.message || err);
          toast("저장 실패");
        })
        .setApiKey(key);
    });

    $("src").addEventListener("input", () => {
      const n = $("src").value.length;
      $("counter").textContent = `${n.toLocaleString()}자`;
    });

    // init
    loadFilters();
    setStatus("대기");
  </script>
</body>
</html>

🎬 관련 영상