Google
Apps Script
Apps Script로 만드는 나만의 웹앱
만든 웹앱이야
AI 시대, 교사가 필요한 앱을 직접 만들어 쓰는 시대
예전
비슷한 앱을 찾거나, 누군가 만들어 주기를 기다림
지금
AI와 함께 우리 반에 딱 맞는 앱을 직접 만듦
핵심
프로그래머가 아니어도 좋은 아이디어만 있으면 충분
오늘 함께 할 일
- 돌림판 · 나의 첫 웹앱
- 게시판 · 스프레드시트에 데이터 저장하기
- 미사일 피하기 · 랭킹이 있는 게임
- 나만의 웹앱 · 아이디어를 만들고 함께 공유하기
실습마다 코드를 복사해서 붙여넣기만 하면 돼요. 누구나 따라 할 수 있어요!
Google Apps Script란?
구글 서비스를 자바스크립트로 다루고 확장할 수 있도록 구글이 제공하는 개발 플랫폼입니다.
- 스프레드시트
- 문서
- Gmail
- 캘린더
- 드라이브
- 폼
특징
JavaScript
익숙한 언어로 작성
구글 서버
내 컴퓨터에서 안 돌려도 됨
구글 연동
시트 · 메일 · 캘린더 · 폼
장점
무료
구글 계정만 있으면 OK
설치 없음
브라우저에서 바로 개발
자동화
반복 작업을 알아서 처리
공유
링크 하나로 웹앱 배포
이런 파일을 만들 수 있어요
자바스크립트 파일
기능을 만드는 코드 (.gs)
HTML 파일
화면을 만드는 웹페이지
실습 1 : 돌림판 만들기
Code.gs
페이지를 여는 역할
index.html
화면을 만드는 역할
1단계 · Code.gs (돌림판)
- Code.gs 안의 내용을 모두 지우고 이 코드를 붙여넣기
- Ctrl + S로 저장
// 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');
}
2단계 · index.html (돌림판)
- 파일 옆 + 버튼 → HTML → 이름: index
- 안을 모두 지우고 이 코드를 붙여넣은 뒤 저장
<!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>
웹앱으로 배포하기
- 배포 → 새 배포 → 웹 앱
- 실행 사용자: 나 · 액세스: 모든 사용자
- 주소(URL)를 열어 돌림판 확인
실습 2 : 게시판 만들기
글쓰기
누구나
글 읽기
선생님만
댓글 · 삭제
선생님만
게시글은 어디에 저장할까요?
필요
게시글을 저장할 데이터베이스
문제
서버 구축과 설정에 시간과 지식이 필요
해결
구글 스프레드시트를 데이터베이스로 활용
스프레드시트가 간단한 데이터베이스 역할을 해요
| No. | Name | Title | Content | Comments |
|---|---|---|---|---|
| 1 | 김철수 | 안녕하세요 | 첫 글이에요! | 환영해요 |
| 2 | 이영희 | 질문 있어요 | 숙제가 뭐예요? |
한 줄이 글 하나, 사용자가 글을 남기면 새 줄이 생겨요
파일은 두 개, 역할이 달라요
글 쓰는 곳
저장 · 확인 · 삭제
글이 쌓이는 곳
Code.gs는 자바스크립트로 쓴 파일이에요.
시작하기 : 스프레드시트 만들기
- 구글 스프레드시트를 새로 만들기
- 상단 메뉴에서 확장 프로그램 → Apps Script
- Code.gs 파일이 열리면 준비 완료
이렇게 열면 스프레드시트와 자동으로 연결됩니다.
1단계 · Code.gs (게시판)
- Code.gs 안의 내용을 모두 지우고 이 코드를 붙여넣기
- 6번째 줄 비밀번호(TEACHER_PASSWORD)를 내 것으로 바꾸기
- Ctrl + S로 저장
// 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 = 'America/Bogota';
// ---------------------------------------------------------------
// 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 (게시판)
- 파일 옆 + 버튼 → HTML → 이름: index
- 안을 모두 지우고 이 코드를 붙여넣은 뒤 저장
<!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>
배포하기
- 배포 → 새 배포 → 웹 앱 (설정은 실습 1과 동일)
- 스프레드시트 접근 권한 승인
- 주소(URL)를 학생에게 공유
코드를 고친 뒤에는 배포 관리 → 수정 → 새 버전으로 다시 배포합니다.
공유 설정 확인
스프레드시트
나만 접근 (일반 액세스: 제한됨)
학생
웹앱 주소로만 접속 (시트 권한 불필요)
웹앱 액세스
모든 사용자
실습 3 : 미사일 피하기 게임
게임 플레이
사방에서 날아오는 미사일을 최대한 오래 피하기
기록 저장
이름과 생존 시간을 스프레드시트에 기록
랭킹 보기
가장 오래 살아남은 순서로 확인
기록은 어디에 저장할까요?
필요
누가 얼마나 오래 버텼는지 기록할 곳
문제
기록을 매번 손으로 적어 두기 번거로움
해결
구글 스프레드시트를 랭킹판으로 활용
스프레드시트가 랭킹판이 돼요
| Name | Time (s) | Date |
|---|---|---|
| 김철수 | 42.3 | 2026-09-29 10:12 |
| 이영희 | 37.1 | 2026-09-29 10:15 |
게임이 끝날 때마다 새 줄이 자동으로 추가돼요
파일은 두 개, 역할이 달라요
게임 플레이
기록 저장 · 랭킹 계산
기록이 쌓이는 곳
Code.gs는 자바스크립트로 쓴 파일이에요.
시작하기 : 스프레드시트 만들기
- 구글 스프레드시트를 새로 만들기
- 상단 메뉴에서 확장 프로그램 → Apps Script
- Code.gs 파일이 열리면 준비 완료
첫 게임을 하면 Records 시트가 자동으로 만들어져요.
1단계 · Code.gs (게임)
- Code.gs 안의 내용을 모두 지우고 이 코드를 붙여넣기
- Ctrl + S로 저장
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 (게임)
- 파일 옆 + 버튼 → HTML → 이름: index
- 안을 모두 지우고 이 코드를 붙여넣은 뒤 저장
<!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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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과 동일)
- 스프레드시트 접근 권한 승인
- 주소(URL)를 학생에게 공유
코드를 고친 뒤에는 배포 관리 → 수정 → 새 버전으로 다시 배포합니다.
공유 설정 확인
스프레드시트
나만 접근 (일반 액세스: 제한됨)
학생
웹앱 주소로만 접속 (시트 권한 불필요)
웹앱 액세스
모든 사용자
파트 4
도전 : 나만의 웹앱 만들기
선생님의 아이디어를 웹앱으로 만들어 보세요
게임이든, 수업 도구든 무엇이든 좋아요!
- 복습 게임
- 퀴즈
- 타이머
- 모둠 뽑기
- 학급 설문
- 독서 기록장
Gemini에게 도움 요청하기
- 문장을 복사해서 [ ] 부분을 채우기
- 실습 때처럼 Code.gs와 index.html에 붙여넣기
- 배포 → 웹 앱
Google Apps Script로 웹앱을 만들고 싶어. 아이디어: [여기에 만들고 싶은 앱을 설명하기] 사용하는 사람: [학생 / 교사] 스프레드시트에 데이터를 저장하나요?: [예 / 아니요] Code.gs와 index.html 파일 2개로 만들어 줘. 휴대폰에서도 잘 보이고, 화면 글자는 한국어로 해 줘. 파일별로 코드블록을 나눠서, 설명 없이 코드만 줘.
오류가 나면 오류 메시지를 그대로 복사해서 Gemini에게 물어보세요.
게시판에 공유해 주세요
- 배포한 웹앱 주소(URL) 복사하기
- 연수 게시판에 이름 · 제목(앱 이름) · 내용(주소 + 한 줄 소개) 올리기
- 마지막에 함께 열어 보며 공유하는 시간을 가져요
액세스가 '모든 사용자'여야 다른 선생님들도 열어 볼 수 있어요.
감사합니다
우리 교실에 가장 좋은 앱은 선생님의 경험에서 나옵니다. 코드는 AI가, 아이디어는 선생님이.
오늘 웹앱 세 개를 만들었어요. 이제 우리 반에 필요한 앱을 직접 만들어 보세요.
script.google.com