스프레드 시트로 구글 캘린더 초대 자동화하기

Apps Script로 구글 캘린더 초대 자동하기

스프레드 시트로 구글 캘린더 초대 자동화하기
Photo by Glenn Carstens-Peters / Unsplash

회사에서 영어 동호회를 운영하고 있다. 첫주에는 사람들이 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
signups 시트
    • users: name, email (사전 등록 필요)
users 시트
  • 시트 ID는 URL에서 확인한다.
Google Sheet ID
  • 캘린더에는 규칙적인 이름의 이벤트를 미리 만들어둔다(예: "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건 신청에도 안정적으로 동작한다.