스프레드 시트로 구글 캘린더 초대 자동화하기
Apps Script로 구글 캘린더 초대 자동하기
회사에서 영어 동호회를 운영하고 있다. 첫주에는 사람들이 Slack 스레드에 신청하면, 직접 초대 메일을 보내야 했지만, Apps Script + Advanced API로 자동화된 파이프라인을 만들었다.
간단한 HTML 폼으로 신청을 받으면 구글 시트에 기록되고, 다음 주 캘린더 슬롯에 게스트가 자동으로 추가된다. 이로써 수작업이 크게 줄었다.
이 글에서는 시스템 구성 방법을 단계별로 설명한다. 준비 단계, 핵심 로직(코드 스니펫), 전체 흐름을 다룬다. 전체 스크립트는 단일 Apps Script 프로젝트이며, 폼 처리를 위해 웹 앱으로 배포했다. 자동화는 전부 구글 제품 내부에서 이뤄진다.
Step 1: 사전 준비 – Google API 설정하기
왜 Advanced API인가? 기본SpreadsheetApp
/CalendarApp
만으로도 가능하지만, 대량 처리와 초대 메일 억제(사일런트 추가) 같은 세부 제어를 위해 Sheets/Calendar Advanced API를 활성화했다.
Google Cloud Project 생성하기
- Google Cloud Console로 이동한다.
- 새 프로젝트를 만들거나 기존 프로젝트를 사용한다.
API 활성화하기
- Cloud Console > APIs & Services > Library로 이동한다.

- Google Sheets API 활성화 (기본 SpreadsheetApp 제한 없이 읽기/쓰기)
- Google Calendar API 활성화 (이메일 알림 없이 게스트 추가하기 가능)
구글 시트 & 캘린더 준비하기
나는 신청을 받기 위한 HTML 페이지 제작했다. (인증 없이 이름만으로 신청) 본 글에서는 스프레드 시트와 캘린더 연동을 다룬다.
- Google Sheet 생성 (2개 시트):
signups
: week, slot, name

users
: name, email (사전 등록 필요)

- 시트 ID는 URL에서 확인한다.

- 캘린더에는 규칙적인 이름의 이벤트를 미리 만들어둔다(예:
"Mon 9–10"
). 스크립트에서 제목 토큰으로 매칭한다.

- 캘린더 ID를 확인한다. (본인의 Gmail 계정이다:
jwkwak1002@gmail.com
)
- 나의 경우 타임존은
Asia/Seoul
로 설정했다.
Apps Script 설정
- Google Sheet > Extensions > Apps Script
- Services(좌측 메뉴)에서:
- Google Sheets API(v4) 추가
- Google Calendar API(v3) 추가
Triggers 설정
- Apps Script > Triggers (시계 아이콘) >
syncNextWeekToCalendar
를 매일 실행되도록 시간 기반 트리거 추가. - 이렇게 하면 수동 실행 없이 캘린더가 시트와 자동 동기화된다.

Apps Script를 수정할 때마다 새 버전으로 재배포해야 한다. Deploy > New deployment > Web app.
HTML 페이지를 만들었다면, 이 URL을 HTML 내에 업데이트 해야한다.
Step 2: 기본 설정 – Slots, Weeks, Normalizer
스크립트 상단에 CONFIG 섹션을 두었다.
/***** === CONFIG === *****/
const SPREADSHEET_ID = '<YOUR_SHEET_ID>'; // e.g., 1u1I...Zjg
const SHEET_SIGNUPS = 'signups'; // A:E = week | slot | name | email
const SHEET_USERS = 'users'; // A:B = name | email
const CALENDAR_ID = '<YOUR_CALENDAR_ID>'; // e.g., you@example.com
const TZ = 'Asia/Seoul';
// Invite behavior
const SILENT_INVITES = false; // true=Advanced Calendar API(no emails), false=CalendarApp(emails sent)
// Optional summary email after each sync
const SEND_SUMMARY = true;
const ORGANIZER_EMAIL = '<YOUR_EMAIL>'; // e.g., you@example.com
모임 슬롯을 요일·시간으로 매핑한다.
/***** === SLOTS → weekday/time (24h) === *****/
const SLOT_TIME = {
'Mon 9–10': { dow: 1, start: { h: 9, m: 0 }, end: { h: 10, m: 0 } },
'Tue 12–1': { dow: 2, start: { h: 12, m: 0 }, end: { h: 13, m: 0 } },
'Wed 12–1': { dow: 3, start: { h: 12, m: 0 }, end: { h: 13, m: 0 } },
'Thu 12–1': { dow: 4, start: { h: 12, m: 0 }, end: { h: 13, m: 0 } }
};
ISO 주차(예: 2025-W38
)로 계산해 주 단위 스케줄을 안정적으로 다룬다.
/***** === ISO WEEK HELPERS === *****/
function toISOWeek(d) {
const start = new Date(d.getFullYear(), 0, 1);
const week = Math.ceil((((d - start) / 86400000 + start.getDay() + 1) / 7));
return `${d.getFullYear()}-W${String(week).padStart(2, '0')}`;
}
function isoWeekMondayKST(iso) {
const m = /^(\d{4})-W(\d{2})$/.exec(iso);
if (!m) throw new Error('Bad ISO week: ' + iso);
const year = +m[1], week = +m[2];
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Dow = (jan4.getUTCDay() + 6) % 7; // Mon=0
const monW1 = new Date(jan4); monW1.setUTCDate(jan4.getUTCDate() - jan4Dow);
const monUTC = new Date(monW1); monUTC.setUTCDate(monW1.getUTCDate() + (week - 1) * 7);
const kst = Utilities.formatDate(monUTC, TZ, 'yyyy-MM-dd');
const [Y, M, D] = kst.split('-').map(Number);
return new Date(Y, M - 1, D);
}
function timesFor(weekISO, slotLabel) {
const key = normDash(slotLabel || '');
const def = SLOT_TIME[key];
if (!def) throw new Error('Unknown slot label: ' + slotLabel);
const mon = isoWeekMondayKST(weekISO);
const start = new Date(mon); start.setDate(mon.getDate() + (def.dow - 1)); start.setHours(def.start.h, def.start.m, 0, 0);
const end = new Date(mon); end.setDate(mon.getDate() + (def.dow - 1)); end.setHours(def.end.h, def.end.m, 0, 0);
return { start, end };
}
Step 3: 신청 처리 – Web Form → Sheet
신청 정보 제출 시 doPost
가 실행되고, LockService
로 레이스 컨디션을 막는다.
function doPost(e) {
const mode = e?.parameter?.mode || 'signup';
if (mode === 'signup') {
const week = e.parameter.week?.trim();
const slot = normDash(e.parameter.slot || '');
const name = (e.parameter.name || '').replace(/\s+/g, ' ').trim();
const email = e.parameter.email?.trim().toLowerCase();
if (!week || !slot || !name) {
return ContentService
.createTextOutput(JSON.stringify({ ok: false, error: 'missing fields' }))
.setMimeType(ContentService.MimeType.JSON);
}
const lock = LockService.getScriptLock();
lock.tryLock(5000);
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sh = ss.getSheetByName(SHEET_SIGNUPS);
sh.getRange(sh.getLastRow() + 1, 1, 1, 5)
.setValues([[new Date(), week, slot, name, email]]);
return ContentService
.createTextOutput(JSON.stringify({ ok: true }))
.setMimeType(ContentService.MimeType.JSON);
} catch (err) {
console.error('Signup failed:', err);
return ContentService
.createTextOutput(JSON.stringify({ ok: false, error: 'Failed to write to sheet' }))
.setMimeType(ContentService.MimeType.JSON);
} finally {
lock.releaseLock();
}
}
읽기 시에는 Advanced Sheets API로 특정 주의 행만 필터링한다.
function readWeekRows(weekISO) {
const res = Sheets.Spreadsheets.Values.get(SPREADSHEET_ID, SHEET_SIGNUPS + '!A2:E');
const vals = res.values || [];
return vals
.filter(r => (r[1] || '').trim() === weekISO)
.map(r => ({
week: r[1].trim(),
slot: normDash(r[2]),
name: (r[3] || '').replace(/\s+/g, ' ').trim(),
email: (r[4] || '').trim().toLowerCase()
}));
}
function readUserDirectory() {
const ss = Sheets.Spreadsheets.Values.get(SPREADSHEET_ID, SHEET_USERS + '!A2:B');
const vals = ss.values || [];
const map = new Map();
for (const r of vals) {
const name = (r[0] || '').replace(/\s+/g, ' ').trim();
const email = (r[1] || '').trim().toLowerCase();
if (name && email) map.set(name, email);
}
return map;
}
function resolveEmail(row, users) {
return row.email || users.get(row.name) || '';
}
이메일은 users
시트에서 매핑되며, 미해결 항목은 나중에 수동 처리하도록 플래그된다.
Step 4: 캘린더 동기화 – 핵심 로직
매일 트리거되는 syncNextWeekToCalendar
가 다음 주 신청을 읽고, 슬롯별로 묶어 해당 이벤트에 게스트를 추가한다.
이벤트 매칭은 ① 제목 토큰 포함(예: "Thu 12–1"
) + ② 시작 시각 일치 두 조건을 사용한다.
/***** === CORE SYNC === *****/
function syncWeekToCalendar(weekISO) {
const rows = readWeekRows(weekISO);
if (!rows.length) {
console.log('No signups for', weekISO);
return;
}
const users = readUserDirectory();
const perSlot = new Map();
const unresolved = [];
for (const r of rows) {
const email = resolveEmail(r, users);
if (!email) {
unresolved.push(`${r.name} / ${r.slot}`);
continue;
}
if (!perSlot.has(r.slot)) perSlot.set(r.slot, []);
perSlot.get(r.slot).push(email);
}
const updates = [];
for (const [slot, list] of perSlot) {
const emails = uniqueEmails(list);
if (!emails.length) continue;
const ev = findEventByTitleToken(weekISO, slot);
if (!ev) {
console.warn('No matching event for', weekISO, slot);
continue;
}
try {
if (SILENT_INVITES) addGuestsSilently(ev.getId(), emails);
else addGuestsWithEmails(ev, emails);
const line = `Synced ${emails.length} → ${slot} (${weekISO})`;
console.log(line);
updates.push(line);
} catch (e) {
console.error(`Failed add → ${slot} (${weekISO}):`, e);
}
}
if (unresolved.length) {
console.warn('Unresolved name→email:\n' + unresolved.join('\n'));
} else {
console.log('All signups resolved for ' + weekISO + ' ✅');
}
if (SEND_SUMMARY) sendSyncSummaryEmail(weekISO, updates, unresolved);
}
//“오늘+7일” → toISOWeek() → syncWeekToCalendar()를 호출
function syncNextWeekToCalendar() {
const target = new Date();
target.setDate(target.getDate() + 7);
const weekISO = toISOWeek(target);
syncWeekToCalendar(weekISO);
}
사일런트로 초대하려면 Calendar Advanced API patch
를 사용한다.
function addGuestsSilently(eventId, emails) {
if (!emails.length) return;
const ev = Calendar.Events.get(CALENDAR_ID, eventId);
const existing = new Set((ev.attendees || []).map(a => a.email.toLowerCase()));
const merged = (ev.attendees || []).slice();
emails.forEach(e => {
if (!existing.has(e)) merged.push({ email: e, responseStatus: 'needsAction' });
});
ev.attendees = merged;
Calendar.Events.patch(ev, CALENDAR_ID, eventId, { sendUpdates: 'none' });
}
결과
- 동기화 시 요약 메일이 온다(선택 사항).
- 다음 주 슬롯 이벤트에 신청자가 자동으로 게스트 추가된다(사일런트/일반 초대 선택).


마치며
Slack 스레드 → 수동 캘린더 초대라는 번거로운 흐름을 HTML Form → Google Sheet → Apps Script → Calendar 자동화로 바꾼 사례다. 실무에서 주당 20~30건 신청에도 안정적으로 동작한다.