Google
Apps Script

Apps Script로 만드는 나만의 웹앱

시작하기 전에

아이스브레이킹 · 다 같이 게임 한 판

휴대폰이나 노트북으로 접속해 이름을 적고 함께 들어와 보세요.

▶ BOMB TAG ARENA 들어가기

들어가기

웹앱 기초 · 웹페이지는 어떻게 만들어질까?

웹페이지는 세 가지로 만들어요

HTML무엇이 있나글자, 버튼, 입력칸, 사진
집으로 치면 뼈대
CSS어떻게 보이나색, 크기, 글꼴, 배치
집으로 치면 인테리어
JS무엇을 하나누르면 보내기, 돌리기, 점수 세기
집으로 치면 전기와 수도

HTML 파일의 뼈대

<!DOCTYPE html>

이 파일은 HTML이라고 알려 줘요

<head>

화면에 안 보이는 설정한글 설정, 브라우저 탭 제목, CSS

<body>

화면에 보이는 모든 것제목, 버튼, 입력칸, 그리고 JS

직접 살펴보기 · 지금 이 화면도 HTML이에요

  1. Ctrl + Shift + C 누르기요소 선택 모드가 켜져요 · Mac은 Cmd + Option + C
  2. 이 제목 누르기개발자 도구에 <h2> 태그가 표시돼요
  3. 위쪽 태그 따라 올라가기<body>와 <html> 상자 안에 들어 있어요
  4. <head> 펼쳐 보기<title>과 <style>이 들어 있어요

CSS · 어떻게 보일지 정해요

CSS는 HTML 요소의 색 · 크기 · 모양을 정해요.

<button class="css-demo">

직접 살펴보기 · CSS 값 바꿔 보기

  1. Ctrl + Shift + C 누르기요소 선택 모드가 켜져요 · Mac은 Cmd + Option + C
  2. 왼쪽 [예시 버튼] 누르기Elements 창에 <button class="css-demo">가 선택돼요
  3. 옆의 Styles 창에서 .css-demo 찾기class 이름 앞에 점(.)을 붙여 CSS를 적어요
  4. #1A5FD0은 red로, 20px은 40px로값을 눌러 고치면 버튼이 바로 바뀌어요 · 새로고침하면 원래대로

JavaScript · 무엇을 할지 정해요

JS는 누르면 화면이 반응하게 만들어요.

<button id="js-demo">

직접 살펴보기 · 버튼에 붙은 JS 찾기

  1. Ctrl + Shift + C 누르기요소 선택 모드가 켜져요 · Mac은 Cmd + Option + C
  2. 왼쪽에 [눌러 보세요] 버튼 선택Elements 창에 <button id="js-demo">가 선택돼요
  3. 옆의 Event Listeners 탭 열기click이 붙어 있어요 · 누르면 실행할 JS예요
  4. click 옆 Remove 누르고 버튼 눌러 보기숫자가 안 올라가요 · JS가 없으면 버튼은 그림일 뿐이에요

index.html 살짝씩 수정해 보기

index.html · 고치면 바로 오른쪽에 반영돼요
화면

직접 꺼 보기 : 카톡과 네이버

새 창으로 크게 보기 ↗

고칠 부분만 콕 집어 AI에게

통째로 부탁하면

다른 곳까지 바뀔 수 있어요멀쩡하던 기능이 사라지기도 해요

콕 집어 부탁하면

그 부분만 바뀌어요무엇이 달라졌는지 확인하기도 쉬워요

부탁 예시

"아래 코드에서 버튼 색만 초록색으로 바꿔 줘"+ 복사한 코드 붙여넣기

이따가 AI로 웹앱을 고칠 때

  1. Ctrl + Shift + C 누르고 고칠 부분 누르기개발자 도구에 그 부분의 태그가 표시돼요
  2. 그 태그에 마우스 오른쪽 버튼 → Copy → Copy element그 부분의 코드만 복사돼요
  3. Gemini에 붙여넣고 "이 부분만 수정해 줘"무엇을 어떻게 바꿀지 함께 적어요
  4. 받은 코드로 그 부분만 바꾸기index.html에서 Ctrl + F로 같은 코드를 찾아 바꿔요

파트 1

Apps Script 소개와 돌림판 만들기

▶ 완성된 돌림판 미리 보기

Google Apps Script란?

자바스크립트를 기반으로 구글 스프레드시트, 문서, Gmail 등 구글 서비스를 자동화하고 기능을 확장할 수 있는 개발 도구입니다.

  • 스프레드시트
  • 문서
  • Gmail
  • 캘린더
  • 드라이브
  • 폼

특징

JavaScript

익숙한 언어로 작성

구글 서버

내 컴퓨터에서 안 돌려도 됨

구글 연동

시트 · 메일 · 캘린더 · 폼

장점

무료

구글 계정만 있으면 OK

설치 없음

브라우저에서 바로 개발

자동화

반복 작업을 알아서 처리

공유

링크 하나로 웹앱 배포

여기서 시작하세요

script.google.com

구글 로그인 → 새 프로젝트

이런 파일을 만들 수 있어요

자바스크립트 파일

기능을 만드는 코드 (.gs)

HTML 파일

화면을 만드는 웹페이지

실습 1 : 돌림판 만들기

Code.gs

페이지를 여는 역할

index.html

화면을 만드는 역할

1단계 · Code.gs (돌림판)

  1. Code.gs 안의 내용을 모두 지우고 이 코드를 붙여넣기
  2. Ctrl + S로 저장
Code.gs · 7줄
// Spin the Wheel app (no spreadsheet connection)
// 2 files: Code.gs (opens the page) + index.html (wheel screen and behavior)
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('Spin the Wheel')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

HTML 파일 이름이 왜 index일까?

이름표

Code.gs가 'index'라는 파일을 찾아 화면으로 보여 줘요

다른 이름이면

파일을 찾지 못해 오류가 나요Index, index 1도 다른 이름

.html은 빼고

이름 칸에는 index만 적어요.html은 편집기가 붙여요

꼭 index?

아니에요. 두 곳을 똑같이 바꾸면 돼요파일 wheel이면 Code.gs도 'wheel'

Code.gs
function doGet() {
  return HtmlService
    .createHtmlOutputFromFile('index')
}

// 'index'  ↔  파일 목록의 index.html

  ✓ index        → index.html
  ✗ Index        → 대문자는 다른 이름
  ✗ index.html   → index.html.html

2단계 · index.html (돌림판)

  1. 파일 옆 + 버튼 → HTML → 이름: index
  2. 안을 모두 지우고 이 코드를 붙여넣은 뒤 저장
index.html · 183줄
<!DOCTYPE html>
<html lang="en">
<head>
<base target="_top">
<meta charset="UTF-8">
<style>
  * { box-sizing: border-box; }
  body {
    margin: 0; background: #F2F5FA; color: #1C2434;
    font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    line-height: 1.6;
  }
  .wrap { max-width: 720px; margin: 0 auto; padding: 24px 16px 48px; text-align: center; }
  h1 { font-size: 28px; margin: 0 0 12px; letter-spacing: -0.02em; }
  canvas { width: 100%; max-width: 360px; height: auto; }
  button {
    font: inherit; font-weight: 700; cursor: pointer;
    padding: 12px 32px; border: 0; border-radius: 10px;
    background: #1A5FD0; color: #fff; font-size: 18px;
  }
  button:disabled { opacity: .5; cursor: default; }
  button:focus-visible, textarea:focus-visible { outline: 3px solid #F29900; outline-offset: 2px; }
  #result { min-height: 3rem; margin: 16px 0 0; font-size: 28px; font-weight: 900; }
  label { display: block; margin: 20px 0 6px; font-weight: 700; }
  textarea {
    width: 100%; max-width: 360px; padding: 10px 12px; font: inherit;
    border: 1px solid #D9DFEA; border-radius: 8px; resize: vertical;
  }
</style>
</head>
<body>
<div class="wrap">
  <h1>Spin the Wheel</h1>
  <canvas id="wheel" aria-label="Spinning wheel"></canvas>
  <div><button id="spin" type="button" onclick="spin()">Spin</button></div>
  <p id="result" role="status"></p>

  <label for="items">Items (one per line)</label>
  <textarea id="items" rows="7">Ana
Luis
Sofia
Mateo
Camila
Daniel</textarea>
</div>

<script>
  var W = 360, H = 380, CX = 180, CY = 200, R = 150;
  var COLORS = ['#1A73E8', '#D93025', '#188038', '#7B3FBF', '#00796B', '#C25E00'];
  var FONT_FAMILY = 'system-ui, "Segoe UI", Roboto, Arial, sans-serif';
  var FONT = 'bold 18px ' + FONT_FAMILY;
  var canvas = document.getElementById('wheel');
  var ctx = canvas.getContext('2d');
  var dpr = window.devicePixelRatio || 1;
  canvas.width = W * dpr;
  canvas.height = H * dpr;
  ctx.scale(dpr, dpr);

  var items = [];
  var rot = 0;
  var spinning = false;

  function readItems() {
    items = document.getElementById('items').value
      .split('\n')
      .map(function (t) { return t.trim(); })
      .filter(function (t) { return t; });
  }

  function draw() {
    ctx.clearRect(0, 0, W, H);

    if (items.length < 2) {
      ctx.beginPath();
      ctx.arc(CX, CY, R, 0, Math.PI * 2);
      ctx.fillStyle = '#D9DFEA';
      ctx.fill();
      ctx.fillStyle = '#5D6879';
      ctx.font = FONT;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText('Enter at least 2 items', CX, CY);
      return;
    }

    var seg = (Math.PI * 2) / items.length;
    // Shrink the label font as slices get thinner (18px max, 8px min)
    var fontSize = Math.max(8, Math.min(18, Math.floor(R * 0.55 * seg)));
    var maxChars = fontSize >= 14 ? 10 : 14;
    items.forEach(function (text, i) {
      var a0 = rot + i * seg;
      var a1 = a0 + seg;
      var c = i % COLORS.length;
      if (i === items.length - 1 && c === 0 && i > 0) c = 2; // avoid matching the first slice's color

      ctx.beginPath();
      ctx.moveTo(CX, CY);
      ctx.arc(CX, CY, R, a0, a1);
      ctx.closePath();
      ctx.fillStyle = COLORS[c];
      ctx.fill();
      ctx.strokeStyle = '#fff';
      ctx.lineWidth = 2;
      ctx.stroke();

      ctx.save();
      ctx.translate(CX, CY);
      ctx.rotate(a0 + seg / 2);
      ctx.fillStyle = '#fff';
      ctx.font = 'bold ' + fontSize + 'px ' + FONT_FAMILY;
      ctx.textAlign = 'right';
      ctx.textBaseline = 'middle';
      ctx.fillText(text.length > maxChars ? text.slice(0, maxChars) + '…' : text, R - 16, 0);
      ctx.restore();
    });

    // Center circle
    ctx.beginPath();
    ctx.arc(CX, CY, 18, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();

    // Pointer at the top
    ctx.beginPath();
    ctx.moveTo(CX - 14, 22);
    ctx.lineTo(CX + 14, 22);
    ctx.lineTo(CX, 62);
    ctx.closePath();
    ctx.fillStyle = '#172033';
    ctx.fill();
  }

  function spin() {
    if (spinning) return;
    readItems();
    if (items.length < 2) { draw(); return; }

    spinning = true;
    document.getElementById('spin').disabled = true;
    document.getElementById('result').textContent = '';

    var start = rot;
    var total = Math.PI * 2 * (5 + Math.random() * 3); // 5–8 turns plus a random angle
    var duration = 4000;
    var t0 = null;

    function step(ts) {
      if (t0 === null) t0 = ts;
      var p = Math.min((ts - t0) / duration, 1);
      var eased = 1 - Math.pow(1 - p, 3); // slow down gradually
      rot = start + total * eased;
      draw();
      if (p < 1) {
        requestAnimationFrame(step);
      } else {
        finish();
      }
    }
    requestAnimationFrame(step);
  }

  function finish() {
    var seg = (Math.PI * 2) / items.length;
    var pointer = Math.PI * 1.5; // the pointer sits at the top (12 o'clock)
    var angle = (((pointer - rot) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
    var index = Math.floor(angle / seg);
    document.getElementById('result').textContent = '🎉 ' + items[index];
    document.getElementById('spin').disabled = false;
    spinning = false;
  }

  document.getElementById('items').addEventListener('input', function () {
    if (spinning) return;
    readItems();
    document.getElementById('result').textContent = '';
    draw();
  });

  readItems();
  draw();
</script>
</body>
</html>

웹앱으로 배포하기

  1. 배포 → 새 배포 → 웹 앱
  2. 실행 사용자: 나 · 액세스: 모든 사용자
  3. 주소(URL)를 열어 돌림판 확인

바꿔 보기 : 우리 반 돌림판으로

몇 번째 줄지금이렇게 바꿔 보기무엇이 바뀌나
33index.htmlSpin the Wheel우리 반 돌림판HTML맨 위 제목 글자
35index.html>Spin<>돌려라!<HTML버튼 글자 (> < 사이만 바꿔요)
19index.html#1A5FD0#E91E63CSS버튼 색 (파랑 → 분홍)
144index.html40008000JS도는 시간 (4초 → 8초, 더 두근두근)

모두 index.html 에서 바꿔요. 바꾼 뒤 Ctrl + S → 배포 → 배포 관리 → 연필(수정) → 버전: 새 버전 → 배포. 그다음 웹앱 주소를 새로고침하면 바뀌어 있어요.

파트 2

스프레드시트 연동 게시판 만들기

▶ 완성된 게시판 미리 보기

실습 2 : 게시판 만들기

글쓰기

누구나

글 읽기

선생님만

댓글 · 삭제

선생님만

게시글은 어디에 저장할까요?

필요

게시글을 저장할 데이터베이스

문제

서버 구축과 설정에 시간과 지식이 필요

해결

구글 스프레드시트를 데이터베이스로 활용

스프레드시트가 간단한 데이터베이스 역할을 해요

No.NameTitleContentComments
1민준안녕첫 글이에요!환영해요!
2서연질문숙제가 뭐예요?

한 줄이 글 하나, 사용자가 글을 남기면 새 줄이 생겨요

파일은 두 개, 역할이 달라요

index.html화면 · 글 쓰는 곳
Code.gs중간 다리 · 글을 시트에 적고, 선생님 비밀번호를 확인하고, 글을 지워 줘요
스프레드시트저장소 · 글이 쌓이는 곳

시작하기 : 스프레드시트 만들기

  1. 구글 스프레드시트를 새로 만들기
  2. 상단 메뉴에서 확장 프로그램 → Apps Script
  3. Code.gs 파일이 열리면 준비 완료

이렇게 열면 스프레드시트와 자동으로 연결됩니다.

1단계 · Code.gs (게시판)

  1. Code.gs 안의 내용을 모두 지우고 이 코드를 붙여넣기
  2. 6번째 줄 비밀번호(TEACHER_PASSWORD)를 내 것으로 바꾸기
  3. Ctrl + S로 저장
Code.gs · 166줄
// Class Board (Google Sheets + Apps Script practice)
//
// - Writing a post: anyone
// - Reading posts / commenting / deleting: teacher only (password required)
// ★ Be sure to change this to a password only you know.
const TEACHER_PASSWORD = '1234';

// Spreadsheet ID (optional)
// If you opened this from the spreadsheet via [Extensions → Apps Script], leave it empty.
// If you created the project directly at script.google.com, paste the long string
// between /d/ and /edit in the spreadsheet's URL here.
const SPREADSHEET_ID = '';

// Name of the sheet where posts are stored (created automatically if missing)
const SHEET_NAME = 'Board';

// Time zone
const TIME_ZONE = 'Asia/Seoul';

// ---------------------------------------------------------------
// Open the web app
// ---------------------------------------------------------------
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('Class Board')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

// ---------------------------------------------------------------
// What anyone can do: leave a post
// ---------------------------------------------------------------
function addPost(name, title, content) {
  name = clean_(name, 20, 'Name');
  title = clean_(title, 50, 'Title');
  content = clean_(content, 1000, 'Content');

  const lock = LockService.getScriptLock();
  lock.waitLock(10000); // handle posts one at a time so simultaneous writes don't collide
  try {
    const sheet = getSheet_();
    const row = sheet.getLastRow() + 1;
    const id = row === 2 ? 1 : Number(sheet.getRange(row - 1, 1).getValue()) + 1;
    const time = Utilities.formatDate(new Date(), TIME_ZONE, 'yyyy-MM-dd HH:mm');

    const range = sheet.getRange(row, 1, 1, 6);
    range.setNumberFormat('@'); // store as plain text (text starting with = or + won't become a formula)
    range.setValues([[String(id), time, name, title, content, '']]);
  } finally {
    lock.releaseLock();
  }
  return true;
}

// ---------------------------------------------------------------
// What only the teacher can do
// ---------------------------------------------------------------

// Read the list of posts (newest first)
function getPosts(password) {
  checkTeacher_(password);
  const sheet = getSheet_();
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return [];

  const values = sheet.getRange(2, 1, lastRow - 1, 6).getValues();
  return values
    .map(function (r) {
      return {
        id: String(r[0]),
        time: String(r[1]),
        name: String(r[2]),
        title: String(r[3]),
        content: String(r[4]),
        comment: String(r[5])
      };
    })
    .reverse();
}

// Add a comment (multiple comments stack up underneath)
function addComment(password, id, text) {
  checkTeacher_(password);
  text = clean_(text, 300, 'Comment');

  const lock = LockService.getScriptLock();
  lock.waitLock(10000);
  try {
    const sheet = getSheet_();
    const row = findRow_(sheet, id);
    if (row === -1) throw new Error('Post not found. It may have already been deleted.');

    const cell = sheet.getRange(row, 6);
    const stamp = Utilities.formatDate(new Date(), TIME_ZONE, 'MM-dd HH:mm');
    const line = '[' + stamp + '] ' + text;
    const old = String(cell.getValue());
    cell.setNumberFormat('@');
    cell.setValue(old ? old + '\n' + line : line);
  } finally {
    lock.releaseLock();
  }
  return true;
}

// Delete a post
function deletePost(password, id) {
  checkTeacher_(password);

  const lock = LockService.getScriptLock();
  lock.waitLock(10000);
  try {
    const sheet = getSheet_();
    const row = findRow_(sheet, id);
    if (row === -1) throw new Error('Post not found. It may have already been deleted.');
    sheet.deleteRow(row);
  } finally {
    lock.releaseLock();
  }
  return true;
}

// ---------------------------------------------------------------
// Helper functions (a name ending in _ can't be called from the page)
// ---------------------------------------------------------------

// Check the password — throws an error if wrong, so the code below it never runs
function checkTeacher_(password) {
  if (password !== TEACHER_PASSWORD) {
    throw new Error('Incorrect password.');
  }
}

// Get the board sheet (creates it with a header row if missing)
function getSheet_() {
  const ss = SPREADSHEET_ID
    ? SpreadsheetApp.openById(SPREADSHEET_ID)
    : SpreadsheetApp.getActiveSpreadsheet();
  if (!ss) {
    throw new Error('Spreadsheet not found. Check SPREADSHEET_ID at the top of Code.gs.');
  }
  let sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) {
    sheet = ss.insertSheet(SHEET_NAME);
    sheet.appendRow(['No.', 'Date', 'Name', 'Title', 'Content', 'Comments']);
    sheet.setFrozenRows(1);
  }
  return sheet;
}

// Find the row number of a post by its id (-1 if not found)
function findRow_(sheet, id) {
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return -1;
  const ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
  for (let i = 0; i < ids.length; i++) {
    if (String(ids[i][0]) === String(id)) return i + 2;
  }
  return -1;
}

// Tidy up input: trim spaces, check it's not empty or too long
function clean_(value, max, label) {
  const text = String(value || '').trim();
  if (!text) throw new Error('Please fill in the ' + label.toLowerCase() + '.');
  if (text.length > max) throw new Error(label + ' can be up to ' + max + ' characters.');
  return text;
}

2단계 · index.html (게시판)

  1. 파일 옆 + 버튼 → HTML → 이름: index
  2. 안을 모두 지우고 이 코드를 붙여넣은 뒤 저장
index.html · 286줄
<!DOCTYPE html>
<html lang="en">
<head>
<base target="_top">
<meta charset="UTF-8">
<style>
  :root {
    --bg: #F2F5FA;
    --card: #FFFFFF;
    --ink: #1C2434;
    --muted: #5D6879;
    --line: #D9DFEA;
    --accent: #1A5FD0;
    --accent-soft: #E3ECFC;
    --danger: #C5221F;
    --note: #FFF6D6;
  }
  * { box-sizing: border-box; }
  [hidden] { display: none !important; }
  body {
    margin: 0;
    background: var(--bg);
    color: var(--ink);
    font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    line-height: 1.6;
  }
  .wrap { max-width: 720px; margin: 0 auto; padding: 24px 16px 64px; }
  h1 { font-size: 28px; margin: 0 0 16px; letter-spacing: -0.02em; }

  .tabs { display: flex; gap: 8px; margin-bottom: 16px; }
  .tab {
    flex: 1; padding: 12px; font: inherit; font-weight: 700;
    border: 1px solid var(--line); background: var(--card); color: var(--muted);
    border-radius: 10px; cursor: pointer;
  }
  .tab.on { background: var(--accent); border-color: var(--accent); color: #fff; }

  .panel { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
  label { display: block; font-weight: 700; margin: 14px 0 6px; }
  label:first-child { margin-top: 0; }
  input, textarea {
    width: 100%; padding: 10px 12px; font: inherit; color: inherit;
    background: var(--card); border: 1px solid var(--line); border-radius: 8px;
  }
  textarea { resize: vertical; }
  button { font: inherit; cursor: pointer; }
  button:focus-visible, input:focus-visible, textarea:focus-visible {
    outline: 3px solid #F29900; outline-offset: 2px;
  }
  .primary {
    margin-top: 16px; padding: 12px 22px; font-weight: 700;
    background: var(--accent); color: #fff; border: 0; border-radius: 8px;
  }
  .primary:disabled { opacity: .5; cursor: default; }
  .msg { margin: 12px 0 0; min-height: 1.6em; font-weight: 500; }
  .msg.ok { color: #188038; }
  .msg.err { color: var(--danger); }

  .bar { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 12px; }
  .bar button {
    padding: 6px 12px; background: var(--card); border: 1px solid var(--line);
    border-radius: 8px; margin-left: 6px;
  }

  .post { border-top: 1px solid var(--line); padding: 18px 0; }
  .post:first-child { border-top: 0; padding-top: 0; }
  .post-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
  .post-head h2 { font-size: 20px; margin: 0; letter-spacing: -0.01em; }
  .del {
    flex: none; padding: 4px 12px; background: var(--card);
    color: var(--danger); border: 1px solid var(--danger); border-radius: 8px;
  }
  .meta { margin: 2px 0 10px; color: var(--muted); font-size: 14px; }
  .meta span + span { margin-left: 12px; }
  .body { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
  .comment {
    margin: 12px 0 0; padding: 10px 12px; background: var(--note);
    border-radius: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px;
  }
  .cform { display: flex; gap: 8px; margin-top: 12px; }
  .cform button {
    flex: none; padding: 0 14px; background: var(--accent-soft); color: var(--accent);
    border: 0; border-radius: 8px; font-weight: 700;
  }
  .empty { text-align: center; color: var(--muted); padding: 24px 0; margin: 0; }
</style>
</head>
<body>
<div class="wrap">
  <h1>Class Board Practice</h1>

  <div class="tabs">
    <button id="tab-write" class="tab on" type="button" onclick="showTab('write')">Write</button>
    <button id="tab-teacher" class="tab" type="button" onclick="showTab('teacher')">Teacher Only</button>
  </div>

  <!-- Anyone: write a post -->
  <section id="panel-write" class="panel">
    <label for="name">Name</label>
    <input id="name" maxlength="20" autocomplete="off">
    <label for="title">Title</label>
    <input id="title" maxlength="50" autocomplete="off">
    <label for="content">Content</label>
    <textarea id="content" rows="6" maxlength="1000"></textarea>
    <button id="send" class="primary" type="button" onclick="sendPost()">Post</button>
    <p id="write-msg" class="msg" role="status"></p>
  </section>

  <!-- Teacher only: read · comment · delete -->
  <section id="panel-teacher" class="panel" hidden>
    <div id="login">
      <label for="pw">Teacher password</label>
      <input id="pw" type="password" autocomplete="off" onkeydown="if (event.key === 'Enter') login()">
      <button class="primary" type="button" onclick="login()">Enter</button>
      <p id="login-msg" class="msg" role="status"></p>
    </div>

    <div id="admin" hidden>
      <div class="bar">
        <strong id="count"></strong>
        <span>
          <button type="button" onclick="loadPosts()">Refresh</button>
          <button type="button" onclick="logout()">Log out</button>
        </span>
      </div>
      <p id="admin-msg" class="msg" role="status"></p>
      <div id="list"></div>
    </div>
  </section>
</div>

<script>
  // After logging in, the password is kept here briefly and sent with every request to the server.
  // (The server only sends posts back when the password is correct.)
  var pw = '';

  function $(id) { return document.getElementById(id); }

  function el(tag, className, text) {
    var node = document.createElement(tag);
    if (className) node.className = className;
    if (text !== undefined) node.textContent = text; // textContent: any HTML in a post is shown as text, never run
    return node;
  }

  function setMsg(id, text, type) {
    var node = $(id);
    node.textContent = text || '';
    node.className = 'msg' + (type ? ' ' + type : '');
  }

  function errText(e) {
    return (e && e.message ? e.message : String(e)).replace(/^Error:\s*/, '');
  }

  // ---------- Tabs ----------
  function showTab(name) {
    $('panel-write').hidden = name !== 'write';
    $('panel-teacher').hidden = name !== 'teacher';
    $('tab-write').className = 'tab' + (name === 'write' ? ' on' : '');
    $('tab-teacher').className = 'tab' + (name === 'teacher' ? ' on' : '');
  }

  // ---------- Write a post (anyone) ----------
  function sendPost() {
    var name = $('name').value, title = $('title').value, content = $('content').value;
    if (!name.trim() || !title.trim() || !content.trim()) {
      setMsg('write-msg', 'Please fill in your name, title, and content.', 'err');
      return;
    }
    $('send').disabled = true;
    setMsg('write-msg', 'Sending…');
    google.script.run
      .withSuccessHandler(function () {
        $('send').disabled = false;
        $('name').value = ''; $('title').value = ''; $('content').value = '';
        setMsg('write-msg', 'Your post has been submitted!', 'ok');
      })
      .withFailureHandler(function (e) {
        $('send').disabled = false;
        setMsg('write-msg', errText(e), 'err');
      })
      .addPost(name, title, content);
  }

  // ---------- Teacher login ----------
  function login() {
    var input = $('pw').value;
    if (!input) { setMsg('login-msg', 'Please enter the password.', 'err'); return; }
    setMsg('login-msg', 'Checking…');
    google.script.run
      .withSuccessHandler(function (posts) {
        pw = input;
        $('pw').value = '';
        setMsg('login-msg', '');
        $('login').hidden = true;
        $('admin').hidden = false;
        render(posts);
      })
      .withFailureHandler(function (e) { setMsg('login-msg', errText(e), 'err'); })
      .getPosts(input);
  }

  function logout() {
    pw = '';
    $('admin').hidden = true;
    $('login').hidden = false;
    $('list').textContent = '';
    setMsg('admin-msg', '');
  }

  // ---------- Post list ----------
  function loadPosts() {
    setMsg('admin-msg', 'Loading…');
    google.script.run
      .withSuccessHandler(function (posts) { setMsg('admin-msg', ''); render(posts); })
      .withFailureHandler(function (e) { setMsg('admin-msg', errText(e), 'err'); })
      .getPosts(pw);
  }

  function render(posts) {
    $('count').textContent = posts.length + (posts.length === 1 ? ' post' : ' posts');
    var list = $('list');
    list.textContent = '';
    if (!posts.length) {
      list.appendChild(el('p', 'empty', 'No posts yet.'));
      return;
    }
    posts.forEach(function (p) { list.appendChild(buildPost(p)); });
  }

  function buildPost(p) {
    var art = el('article', 'post');

    var head = el('div', 'post-head');
    head.appendChild(el('h2', '', p.title));
    var del = el('button', 'del', 'Delete');
    del.type = 'button';
    del.onclick = function () { removePost(p.id); };
    head.appendChild(del);
    art.appendChild(head);

    var meta = el('p', 'meta');
    meta.appendChild(el('span', '', p.name));
    meta.appendChild(el('span', '', p.time));
    art.appendChild(meta);

    art.appendChild(el('p', 'body', p.content));
    if (p.comment) art.appendChild(el('p', 'comment', p.comment));

    var form = el('div', 'cform');
    var input = document.createElement('input');
    input.placeholder = 'Add a comment';
    input.maxLength = 300;
    input.setAttribute('aria-label', 'Comment');
    var btn = el('button', '', 'Add');
    btn.type = 'button';
    btn.onclick = function () { sendComment(p.id, input.value, btn); };
    input.onkeydown = function (e) { if (e.key === 'Enter') btn.click(); };
    form.appendChild(input);
    form.appendChild(btn);
    art.appendChild(form);

    return art;
  }

  // ---------- Comment · delete (teacher only) ----------
  function sendComment(id, text, btn) {
    if (!text.trim()) return;
    btn.disabled = true;
    google.script.run
      .withSuccessHandler(function () { loadPosts(); })
      .withFailureHandler(function (e) { btn.disabled = false; setMsg('admin-msg', errText(e), 'err'); })
      .addComment(pw, id, text);
  }

  function removePost(id) {
    if (!confirm('Delete this post? This cannot be undone.')) return;
    google.script.run
      .withSuccessHandler(function () { loadPosts(); })
      .withFailureHandler(function (e) { setMsg('admin-msg', errText(e), 'err'); })
      .deletePost(pw, id);
  }
</script>
</body>
</html>

배포하기

배포 전 확인! Code.gs 6번째 줄 비밀번호가 아직 '1234'인가요? 나만 아는 비밀번호로 바꾸고 저장하세요.

  1. 배포 → 새 배포 → 웹 앱 (설정은 실습 1과 동일)
  2. 스프레드시트 접근 권한 승인 경고 화면이 나와도 괜찮아요 → 다음 장
  3. 주소(URL)를 학생에게 공유

코드를 고친 뒤에는 배포 관리 → 수정 → 새 버전으로 다시 배포합니다.

이런 경고가 나와도 괜찮아요

  1. 권한 검토 → 내 계정 선택
  2. 고급 누르기
  3. (안전하지 않음)으로 이동 누르기
  4. 허용 누르기 체크박스가 보이면 모두 선택 후 계속

내가 직접 만든 앱이라 안전해요. 구글이 심사하지 않았다는 뜻일 뿐이에요. 처음 한 번만 승인하면 됩니다.

공유 설정 확인

스프레드시트

나만 접근 (일반 액세스: 제한됨)

학생

웹앱 주소로만 접속 (시트 권한 불필요)

웹앱 액세스

모든 사용자

파트 3

미사일 피하기 게임 만들기

▶ 완성된 게임 미리 보기

실습 3 : 미사일 피하기 게임

게임 플레이

사방에서 날아오는 미사일을 최대한 오래 피하기

기록 저장

이름과 생존 시간을 스프레드시트에 기록

랭킹 보기

가장 오래 살아남은 순서로 확인

기록은 어디에 저장할까요?

필요

누가 얼마나 오래 버텼는지 기록할 곳

문제

기록을 매번 손으로 적어 두기 번거로움

해결

구글 스프레드시트를 랭킹판으로 활용

파일은 두 개, 역할이 달라요

index.html화면 · 게임 플레이
Code.gs중간 다리 · 받은 기록을 시트에 적고, 빠른 순으로 랭킹을 골라 줘요
스프레드시트저장소 · 기록이 쌓이는 곳

시작하기 : 스프레드시트 만들기

  1. 구글 스프레드시트를 새로 만들기
  2. 상단 메뉴에서 확장 프로그램 → Apps Script
  3. Code.gs 파일이 열리면 준비 완료

첫 게임을 하면 Records 시트가 자동으로 만들어져요.

1단계 · Code.gs (게임)

  1. Code.gs 안의 내용을 모두 지우고 이 코드를 붙여넣기
  2. Ctrl + S로 저장
Code.gs · 93줄
var SHEET_NAME = 'Records';
var TOP_N = 10;

function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('Missile Dodge')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1, user-scalable=no');
}

// Works both as a sheet-bound script and as a standalone script
// (standalone: creates a spreadsheet once and remembers its ID).
function getSpreadsheet_() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  if (ss) return ss;
  var props = PropertiesService.getScriptProperties();
  var id = props.getProperty('SPREADSHEET_ID');
  if (id) return SpreadsheetApp.openById(id);
  ss = SpreadsheetApp.create('Missile Dodge Records');
  props.setProperty('SPREADSHEET_ID', ss.getId());
  return ss;
}

function getSheet_() {
  var ss = getSpreadsheet_();
  var sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) {
    sheet = ss.insertSheet(SHEET_NAME);
    sheet.appendRow(['Name', 'Time (s)', 'Date']);
    sheet.setFrozenRows(1);
    sheet.getRange('A:A').setNumberFormat('@'); // keep names as plain text
  }
  return sheet;
}

// Saves one run and returns the updated ranking plus this player's position.
function saveTime(name, seconds) {
  // Strip leading = + - @ so a name can never become a formula.
  var safeName = String(name || '').replace(/^[\s=+\-@]+/, '').trim().slice(0, 20) || 'Anonymous';
  var safeTime = Math.max(0, Math.round((Number(seconds) || 0) * 10) / 10);

  var lock = LockService.getScriptLock();
  lock.waitLock(10000);
  try {
    getSheet_().appendRow([safeName, safeTime, new Date()]);
    SpreadsheetApp.flush();
  } finally {
    lock.releaseLock();
  }
  return buildRanking_(safeName);
}

function getRanking() {
  return buildRanking_(null);
}

// Best time per player, longest survival first; ties go to whoever got there first.
function buildRanking_(playerName) {
  var sheet = getSheet_();
  var lastRow = sheet.getLastRow();
  var best = {};
  if (lastRow >= 2) {
    var rows = sheet.getRange(2, 1, lastRow - 1, 3).getValues();
    rows.forEach(function (r) {
      var name = String(r[0]).trim();
      var time = Number(r[1]);
      var at = r[2] instanceof Date ? r[2].getTime() : 0;
      if (!name || isNaN(time)) return;
      var cur = best[name];
      if (!cur || time > cur.time || (time === cur.time && at < cur.at)) {
        best[name] = { name: name, time: time, at: at };
      }
    });
  }

  var list = Object.keys(best).map(function (k) { return best[k]; });
  list.sort(function (a, b) { return b.time - a.time || a.at - b.at; });

  var myRank = 0, myBest = 0;
  if (playerName) {
    for (var i = 0; i < list.length; i++) {
      if (list[i].name === playerName) { myRank = i + 1; myBest = list[i].time; break; }
    }
  }

  // Only plain strings/numbers: google.script.run returns null if a Date is included.
  return {
    top: list.slice(0, TOP_N).map(function (e) { return { name: e.name, time: e.time }; }),
    total: list.length,
    me: playerName || '',
    myRank: myRank,
    myBest: myBest
  };
}

2단계 · index.html (게임)

  1. 파일 옆 + 버튼 → HTML → 이름: index
  2. 안을 모두 지우고 이 코드를 붙여넣은 뒤 저장
index.html · 398줄
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>
  :root {
    --bg: #EEF2F8; --card: #FFFFFF; --line: #DDE3EE; --text: #172033; --muted: #5A6577;
    --accent: #1A5FD0; --accent-soft: #E6EEFC; --danger: #D93025; --gold: #F2B600;
  }
  * { box-sizing: border-box; }
  body { margin: 0; font-family: system-ui, 'Segoe UI', 'Malgun Gothic', sans-serif; background: var(--bg); color: var(--text); height: 100vh; height: 100dvh; display: flex; justify-content: center; overflow: hidden; -webkit-tap-highlight-color: transparent; }
  #app { width: 100%; max-width: 420px; height: 100%; display: flex; flex-direction: column; }
  .screen { flex: 1; min-height: 0; display: none; flex-direction: column; align-items: center; justify-content: center; padding: 20px 16px; }
  .screen.show { display: flex; }

  .card { width: 100%; background: var(--card); border: 1px solid var(--line); border-radius: 18px; padding: 26px 22px; display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; box-shadow: 0 8px 28px rgba(23, 32, 51, 0.06); max-height: 100%; overflow-y: auto; }
  .logo { width: 56px; height: 56px; border-radius: 16px; background: var(--accent-soft); display: flex; align-items: center; justify-content: center; font-size: 28px; }
  h1, h2 { margin: 0; letter-spacing: -0.02em; }
  h1 { font-size: 26px; }
  h2 { font-size: 15px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
  .hint { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }

  input { font: inherit; font-size: 16px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: #F8FAFD; color: var(--text); width: 100%; text-align: center; outline: none; transition: border-color .15s, box-shadow .15s; }
  input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
  .btn { font: inherit; font-size: 16px; font-weight: 700; padding: 13px 20px; border-radius: 12px; border: 0; background: var(--accent); color: #fff; cursor: pointer; width: 100%; transition: filter .15s, transform .05s; }
  .btn:hover { filter: brightness(1.08); }
  .btn:active { transform: scale(0.98); }
  .btn.ghost { background: transparent; color: var(--muted); border: 1px solid var(--line); font-weight: 600; }
  .btn-row { display: flex; gap: 10px; width: 100%; }

  /* Leaderboard */
  .board { width: 100%; text-align: left; }
  .board-head { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin: 0 4px 6px; }
  .board ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
  .board li { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 10px; background: #F8FAFD; font-size: 14px; }
  .board li.me { background: var(--accent-soft); outline: 1px solid var(--accent); }
  .board .rk { width: 24px; text-align: center; font-weight: 700; color: var(--muted); flex: none; }
  .board .nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
  .board .tm { font-variant-numeric: tabular-nums; font-weight: 700; }
  .board .msg { padding: 14px; text-align: center; color: var(--muted); font-size: 14px; background: #F8FAFD; border-radius: 10px; }
  .board .msg.err { color: var(--danger); }

  /* Game */
  #gameScreen { justify-content: flex-start; gap: 10px; padding: 14px 16px 16px; }
  #hud { flex: none; width: 100%; display: flex; justify-content: space-between; align-items: center; }
  .pill { background: var(--card); border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px; font-size: 14px; color: var(--muted); font-variant-numeric: tabular-nums; }
  .pill b { color: var(--text); font-size: 18px; }
  #stage { flex: 1; min-height: 0; width: 100%; display: flex; align-items: center; justify-content: center; }
  canvas { display: block; touch-action: none; background: #FBFCFE; border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 8px 28px rgba(23, 32, 51, 0.06); cursor: crosshair; }

  /* Game over */
  .big-time { font-size: 56px; font-weight: 800; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1; }
  .big-time small { font-size: 22px; color: var(--muted); font-weight: 700; margin-left: 2px; }
  #rankLine { margin: 0; font-size: 14px; color: var(--muted); min-height: 1.4em; }
  #rankLine strong { color: var(--accent); }
  .badge { display: none; font-size: 12px; font-weight: 700; color: #7A5B00; background: #FFF4CC; border-radius: 999px; padding: 4px 10px; }
  .badge.show { display: inline-block; }
</style>
</head>
<body>
<div id="app">

  <div id="startScreen" class="screen show">
    <div class="card">
      <div class="logo">🚀</div>
      <h1>Missile Dodge</h1>
      <p class="hint">Drag to move your ship and dodge the missiles.<br>The longer you survive, the higher you rank.</p>
      <input id="nameInput" placeholder="Enter your name" maxlength="10" autocomplete="off">
      <button id="startBtn" class="btn">Start</button>
      <div class="board">
        <div class="board-head"><span>🏆 Top survivors</span><span id="startTotal"></span></div>
        <ol id="startBoard"><li class="msg">Loading ranking…</li></ol>
      </div>
    </div>
  </div>

  <div id="gameScreen" class="screen">
    <div id="hud">
      <span class="pill">⏱ <b id="timeLabel">0.0</b> s</span>
      <span class="pill" id="bestPill">Best <b id="bestLabel">–</b></span>
    </div>
    <div id="stage"><canvas id="canvas" width="340" height="520"></canvas></div>
  </div>

  <div id="overScreen" class="screen">
    <div class="card">
      <h2>Game Over</h2>
      <div class="big-time"><span id="finalTime">0.0</span><small>s</small></div>
      <span id="bestBadge" class="badge">✨ New personal best</span>
      <p id="rankLine"></p>
      <div class="board">
        <div class="board-head"><span>🏆 Top survivors</span><span id="overTotal"></span></div>
        <ol id="overBoard"></ol>
      </div>
      <div class="btn-row">
        <button id="menuBtn" class="btn ghost">Menu</button>
        <button id="retryBtn" class="btn">Play Again</button>
      </div>
    </div>
  </div>

</div>

<script>
(function () {
  var W = 340, H = 520;
  var PLAYER_R = 11, MISSILE_R = 6;
  var START_INTERVAL = 750, MIN_INTERVAL = 90, INTERVAL_DECAY_PER_SEC = 25;
  var START_SPEED = 120, MAX_SPEED = 520, SPEED_GROWTH_PER_SEC = 14;
  var START_HOMING = 0.4, MAX_HOMING = 2.2, HOMING_GROWTH_PER_SEC = 0.05;
  var MAX_SPAWN_PER_WAVE = 4;

  var canvas = document.getElementById('canvas');
  var ctx = canvas.getContext('2d');
  var stage = document.getElementById('stage');
  var nameInput = document.getElementById('nameInput');

  function fitCanvas() {
    var scale = Math.max(0.1, Math.min(stage.clientWidth / W, stage.clientHeight / H));
    canvas.style.width = Math.floor(W * scale) + 'px';
    canvas.style.height = Math.floor(H * scale) + 'px';
  }
  window.addEventListener('resize', fitCanvas);

  var player = { x: W / 2, y: H - 60, r: PLAYER_R };
  var missiles = [];
  var elapsed = 0;
  var spawnTimer = 0;
  var spawnInterval = START_INTERVAL;
  var missileSpeed = START_SPEED;
  var homingRate = START_HOMING;
  var playerName = '';
  var personalBest = 0;
  var running = false;
  var lastTime = null;

  // Remember the last name used on this device (storage may be unavailable).
  try { nameInput.value = localStorage.getItem('md-name') || ''; } catch (e) {}

  function clampPlayer() {
    player.x = Math.max(PLAYER_R, Math.min(W - PLAYER_R, player.x));
    player.y = Math.max(PLAYER_R, Math.min(H - PLAYER_R, player.y));
  }
  function pointerPos(evt) {
    var rect = canvas.getBoundingClientRect();
    var t = evt.touches ? evt.touches[0] : evt;
    var scaleX = canvas.width / rect.width;
    var scaleY = canvas.height / rect.height;
    return { x: (t.clientX - rect.left) * scaleX, y: (t.clientY - rect.top) * scaleY };
  }
  function movePlayerTo(evt) {
    var p = pointerPos(evt);
    player.x = p.x; player.y = p.y;
    clampPlayer();
  }
  canvas.addEventListener('mousedown', movePlayerTo);
  canvas.addEventListener('mousemove', function (e) { if (running) movePlayerTo(e); });
  canvas.addEventListener('touchstart', function (e) { movePlayerTo(e); }, { passive: true });
  canvas.addEventListener('touchmove', function (e) { e.preventDefault(); movePlayerTo(e); }, { passive: false });

  function spawnMissile() {
    var edge = Math.floor(Math.random() * 4);
    var x, y;
    if (edge === 0) { x = Math.random() * W; y = -20; }
    else if (edge === 1) { x = W + 20; y = Math.random() * H; }
    else if (edge === 2) { x = Math.random() * W; y = H + 20; }
    else { x = -20; y = Math.random() * H; }
    var angle = Math.atan2(player.y - y, player.x - x) + (Math.random() - 0.5) * 0.7;
    missiles.push({
      x: x, y: y, r: MISSILE_R,
      vx: Math.cos(angle) * missileSpeed,
      vy: Math.sin(angle) * missileSpeed,
      trail: []
    });
  }

  function updateHud() {
    document.getElementById('timeLabel').textContent = (elapsed / 1000).toFixed(1);
  }

  function showScreen(id) {
    ['startScreen', 'gameScreen', 'overScreen'].forEach(function (s) {
      document.getElementById(s).classList.toggle('show', s === id);
    });
  }

  /* ---------- Ranking ---------- */

  function renderBoard(listId, totalId, data) {
    var top = (data && data.top) || [];
    var medals = ['🥇', '🥈', '🥉'];
    var html = '';
    top.forEach(function (row, i) {
      var me = data.me && row.name === data.me;
      html += '<li' + (me ? ' class="me"' : '') + '>' +
        '<span class="rk">' + (medals[i] || (i + 1)) + '</span>' +
        '<span class="nm">' + escapeHtml(row.name) + (me ? ' (you)' : '') + '</span>' +
        '<span class="tm">' + Number(row.time).toFixed(1) + 's</span></li>';
    });
    if (!top.length) html = '<li class="msg">No records yet. Be the first!</li>';
    document.getElementById(listId).innerHTML = html;
    document.getElementById(totalId).textContent = data && data.total ? data.total + ' players' : '';
  }

  function boardMessage(listId, msg, isError) {
    document.getElementById(listId).innerHTML =
      '<li class="msg' + (isError ? ' err' : '') + '">' + escapeHtml(msg) + '</li>';
  }

  function loadStartRanking() {
    boardMessage('startBoard', 'Loading ranking…');
    google.script.run
      .withSuccessHandler(function (data) { renderBoard('startBoard', 'startTotal', data); })
      .withFailureHandler(function (err) { boardMessage('startBoard', 'Could not load ranking: ' + err.message, true); })
      .getRanking();
  }

  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, function (c) {
      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
    });
  }

  /* ---------- Game flow ---------- */

  function startGame() {
    playerName = nameInput.value.trim().slice(0, 10) || 'Anonymous';
    try { localStorage.setItem('md-name', playerName); } catch (e) {}
    missiles = []; elapsed = 0; spawnTimer = START_INTERVAL;
    missileSpeed = START_SPEED; homingRate = START_HOMING;
    player.x = W / 2; player.y = H - 60;
    document.getElementById('bestLabel').textContent = personalBest ? personalBest.toFixed(1) : '–';
    updateHud();
    showScreen('gameScreen');
    fitCanvas();
    draw();
    running = true; lastTime = null;
    requestAnimationFrame(loop);
  }

  function endGame() {
    running = false;
    var seconds = Math.round(elapsed / 100) / 10;
    var isBest = seconds > personalBest;
    if (isBest) personalBest = seconds;

    document.getElementById('finalTime').textContent = seconds.toFixed(1);
    document.getElementById('bestBadge').classList.toggle('show', isBest && seconds > 0);
    document.getElementById('rankLine').textContent = 'Saving your record…';
    document.getElementById('overTotal').textContent = '';
    boardMessage('overBoard', 'Loading ranking…');
    showScreen('overScreen');

    // Save first, then show the ranking that the save returns — so your run is always included.
    google.script.run
      .withSuccessHandler(function (data) {
        renderBoard('overBoard', 'overTotal', data);
        if (data && data.myBest) personalBest = Math.max(personalBest, data.myBest);
        document.getElementById('rankLine').innerHTML = data && data.myRank
          ? escapeHtml(data.me) + ' is ranked <strong>#' + data.myRank + '</strong> of ' + data.total +
            ' · best ' + Number(data.myBest).toFixed(1) + 's'
          : '';
      })
      .withFailureHandler(function (err) {
        document.getElementById('rankLine').textContent = '';
        boardMessage('overBoard', 'Could not save your record: ' + err.message, true);
      })
      .saveTime(playerName, seconds);
  }

  function step(dt) {
    elapsed += dt;
    var elapsedSec = elapsed / 1000;
    var dtSec = dt / 1000;

    spawnInterval = Math.max(MIN_INTERVAL, START_INTERVAL - elapsedSec * INTERVAL_DECAY_PER_SEC);
    missileSpeed = Math.min(MAX_SPEED, START_SPEED + elapsedSec * SPEED_GROWTH_PER_SEC);
    homingRate = Math.min(MAX_HOMING, START_HOMING + elapsedSec * HOMING_GROWTH_PER_SEC);

    spawnTimer -= dt;
    if (spawnTimer <= 0) {
      var count = Math.min(MAX_SPAWN_PER_WAVE, 1 + Math.floor(elapsedSec / 10));
      for (var c = 0; c < count; c++) spawnMissile();
      spawnTimer = spawnInterval;
    }

    for (var i = missiles.length - 1; i >= 0; i--) {
      var m = missiles[i];
      var desired = Math.atan2(player.y - m.y, player.x - m.x);
      var cur = Math.atan2(m.vy, m.vx);
      var diff = Math.atan2(Math.sin(desired - cur), Math.cos(desired - cur));
      var maxTurn = homingRate * dtSec;
      var turn = Math.max(-maxTurn, Math.min(maxTurn, diff));
      var newAngle = cur + turn;
      var spd = Math.hypot(m.vx, m.vy);
      m.vx = Math.cos(newAngle) * spd;
      m.vy = Math.sin(newAngle) * spd;
      m.trail.push({ x: m.x, y: m.y });
      if (m.trail.length > 8) m.trail.shift();
      m.x += m.vx * dtSec;
      m.y += m.vy * dtSec;

      if (m.x < -40 || m.x > W + 40 || m.y < -40 || m.y > H + 40) {
        missiles.splice(i, 1);
        continue;
      }
      var dx = m.x - player.x, dy = m.y - player.y;
      if (Math.hypot(dx, dy) < m.r + player.r) {
        draw();
        endGame();
        return;
      }
    }

    updateHud();
  }

  /* ---------- Drawing ---------- */

  function drawGrid() {
    ctx.strokeStyle = '#EDF1F7';
    ctx.lineWidth = 1;
    ctx.beginPath();
    for (var x = 20; x < W; x += 20) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
    for (var y = 20; y < H; y += 20) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
    ctx.stroke();
  }

  function drawPlayer() {
    ctx.beginPath();
    ctx.arc(player.x, player.y, player.r + 7, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(26, 95, 208, 0.12)';
    ctx.fill();
    ctx.beginPath();
    ctx.arc(player.x, player.y, player.r, 0, Math.PI * 2);
    ctx.fillStyle = '#1A5FD0';
    ctx.fill();
    ctx.lineWidth = 3;
    ctx.strokeStyle = '#FFFFFF';
    ctx.stroke();
  }

  function drawMissile(m) {
    for (var t = 0; t < m.trail.length; t++) {
      var p = m.trail[t];
      ctx.beginPath();
      ctx.arc(p.x, p.y, 1 + t * 0.3, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(217, 48, 37, ' + (0.04 + t * 0.03) + ')';
      ctx.fill();
    }
    var angle = Math.atan2(m.vy, m.vx);
    ctx.save();
    ctx.translate(m.x, m.y);
    ctx.rotate(angle);
    ctx.beginPath();
    ctx.moveTo(m.r * 1.7, 0);
    ctx.lineTo(-m.r, m.r * 0.8);
    ctx.lineTo(-m.r * 0.4, 0);
    ctx.lineTo(-m.r, -m.r * 0.8);
    ctx.closePath();
    ctx.fillStyle = '#D93025';
    ctx.fill();
    ctx.restore();
  }

  function draw() {
    ctx.clearRect(0, 0, W, H);
    drawGrid();
    missiles.forEach(drawMissile);
    drawPlayer();
  }

  function loop(ts) {
    if (!running) return;
    if (lastTime === null) lastTime = ts;
    var dt = Math.min(33, ts - lastTime);
    lastTime = ts;
    step(dt);
    if (running) {
      draw();
      requestAnimationFrame(loop);
    }
  }

  document.getElementById('startBtn').addEventListener('click', startGame);
  nameInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') startGame(); });
  document.getElementById('retryBtn').addEventListener('click', startGame);
  document.getElementById('menuBtn').addEventListener('click', function () {
    showScreen('startScreen');
    loadStartRanking();
  });

  loadStartRanking();
})();
</script>
</body>
</html>

배포하기

  1. 배포 → 새 배포 → 웹 앱 (설정은 실습 1과 동일)
  2. 스프레드시트 접근 권한 승인 경고 화면은 실습 2와 같아요 · 고급 → 이동 → 허용
  3. 주소(URL)를 학생에게 공유

코드를 고친 뒤에는 배포 관리 → 수정 → 새 버전으로 다시 배포합니다.

공유 설정 확인

스프레드시트

나만 접근 (일반 액세스: 제한됨)

학생

웹앱 주소로만 접속 (시트 권한 불필요)

웹앱 액세스

모든 사용자

파트 4

도전 : 나만의 웹앱 만들기

선생님의 아이디어를 웹앱으로 만들어 보세요

게임이든, 수업 도구든 무엇이든 좋아요!

  • 복습 게임
  • 퀴즈
  • 타이머
  • 모둠 뽑기
  • 학급 설문
  • 독서 기록장

응용 예시 · 이런 것도 만들 수 있어요

팀 오목

레드 팀과 블루 팀이 휴대폰으로 함께 두는 실시간 오목

  • 로비에서 팀을 고르고 방장이 시작
  • 팀 안에서 한 명씩 돌아가며 두기
  • 삼삼(3-3) 금지 자리 표시

Apps Script + 캐시 · 1.5초마다 판을 맞춰요

▶ 바로 해 보기

교실 호출

관리자 화면에서 교실을 골라 보내면 그 교실 PC에 바로 뜨는 호출

  • 교실 PC에 수신기 프로그램 하나만 실행
  • PPT 위에도 맨 앞에 뜨고 음성으로 읽기
  • 여러 교실에 한 번에 보내기

Apps Script + 교실 PC 프로그램(Receiver.exe)

▶ 바로 해 보기

둘 다 오늘 만든 것과 같은 Code.gs + index.html 구조예요. 아이디어만 있으면 AI와 함께 이렇게 키울 수 있어요.

GAS는 웹앱만이 아니에요 · 윈도우 프로그램과 연동

예시 · 교실 호출은 GAS 웹앱과 교실 PC의 윈도우 프로그램(Receiver.exe)을 이어서 만들었어요.

두 가지 역할

GAS 웹앱 주소는 사람에게는 화면을, 프로그램에게는 데이터를 줘요주소 뒤에 ?mode=poll 을 붙이면 화면 대신 글자(JSON)로 답해요

묻는 이유

GAS는 교실 PC에 먼저 연락할 수 없어요그래서 프로그램이 5초마다 물어봐요. 팀 오목도 같은 원리예요

프로그램과 GAS는 이렇게 주고받아요

Gemini에게 도움 요청하기

  1. 문장을 복사해서 [ ] 부분을 채우기
  2. 실습 때처럼 Code.gs와 index.html에 붙여넣기
  3. 배포 → 웹 앱
Gemini · 질문 틀
Google Apps Script로 웹앱을 만들고 싶어요.
아이디어: [만들고 싶은 앱을 설명해 주세요]
사용자: [학생 / 교사]
스프레드시트에 데이터를 저장하나요?: [예 / 아니요]
Code.gs와 index.html 두 개의 파일로 만들어 주세요.
휴대폰에서도 잘 보이게 하고, 화면 글자는 한국어로 해 주세요.
설명 없이 각 파일의 코드를 따로 나눠서 주세요.

오류가 나면 오류 메시지를 그대로 복사해서 Gemini에게 물어보세요.

AI가 고친 코드, 그대로 써도 될까?

Gemini에게 "뽑기 버튼을 초록색으로 바꿔 줘"라고 부탁해서 받은 코드예요. 직접 눌러 보고 원본과 비교해 보세요.

index.html
화면

  1. 버튼이 초록색이 됐나요?
  2. 뽑기를 눌러 보세요. 잘 되나요?
  3. 바뀐 곳은 몇 군데일까요?

하나를 부탁했는데, 세 곳이 바뀌었어요

① CSS · 부탁한 것

button background: #1A5FD0 → #188038버튼이 초록색이 됐어요 ✓

② CSS · 부탁 안 한 것

h1 color: #1A5FD0 → #188038제목 색도 바뀌었어요. 보기엔 괜찮지만 부탁하지 않은 변경이에요

③ JS · 부탁 안 한 것

split('\n') → split(',')줄마다 적은 명단을 쉼표로 나눠서, 늘 명단 전체가 한꺼번에 뽑혀요. 오류 메시지는 없어요

찾는 순서 · 화면에서 이상한 곳 → 그 일을 맡은 영역(HTML·CSS·JS) → 원본과 줄 비교. AI가 고쳐 줘도, 확인은 사람 몫이에요.

AI 코드를 받으면 확인할 세 가지

부탁한 것

요청한 기능이 실제로 됐나요?

부탁 안 한 것

잘 되던 버튼·입력·저장이 그대로인가요?

데이터

시트 이름·저장 위치가 그대로인가요?SHEET_NAME이 바뀌면 기록이 빈 것처럼 보여요

Gemini · 고쳐 달라고 할 때
아래 코드에서 [바꾸고 싶은 것]만 바꿔 주세요.
나머지는 그대로 두고, 바꾼 줄이 어디인지 알려 주세요.

[지금 코드 붙여넣기]

고치기 전에 지금 코드를 메모장에 복사해 두고, 배포 → 테스트 배포 주소로 먼저 확인해요.

막혔을 때 확인하세요

웹앱이 안 열려요

크롬에 구글 계정이 여러 개 로그인되어 있으면 생겨요시크릿 창(Ctrl + Shift + N)에서 주소를 열어 보세요

'모든 사용자'가 없어요

학교(Workspace) 계정은 학교 밖 공유가 막혀 있을 수 있어요'도메인 내 모든 사용자'를 고르거나 개인 Gmail 계정으로 만들어요

고쳤는데 그대로예요

저장만 하면 웹앱에 반영되지 않아요배포 → 배포 관리 → 연필(수정) → 버전: 새 버전 → 배포

파일을 찾을 수 없어요

HTML 파일 이름이 정확히 index인지 확인해요Index, index.html.html, 띄어쓰기 모두 오류가 나요

게시판에 공유해 주세요

  1. 배포한 웹앱 주소(URL) 복사하기
  2. 연수 게시판에 이름 · 제목(앱 이름) · 내용(주소 + 한 줄 소개) 올리기
  3. 마지막에 함께 열어 보며 공유하는 시간을 가져요

액세스가 '모든 사용자'여야 다른 선생님들도 열어 볼 수 있어요.

감사합니다

우리 교실에 가장 필요한 앱은
선생님의 경험과 아이디어에서 시작됩니다.

코드는 AI가,아이디어는 선생님이!

이제 선생님의 교육 경험과 아이디어를 바탕으로, 우리 반에 꼭 필요한 앱을 직접 만들어 보세요!

script.google.com