사내 휴가자 및 일정 알림 봇 만들기 feat. Slack & MatterMost
📅 오늘의 일정·휴가자 알림이 봇을 만들게 된 계기
이전 회사에서는 사내 개발 프로세스 효율화를 위해, 깃허브 브랜치 점유자 정보를 실시간으로 슬랙에 알림으로 전달하는 봇을 직접 개발·운영한 경험이 있습니다.
이 경험을 통해, 개발 브랜치 점유자를 확인하여 구성원 모두가 빠르게 현황을 파악하고, 협업 효율이 크게 올라가는 효과를 체감할 수 있었습니다.
이번에도 비슷한 맥락에서, 사내 구성원들이 매일 아침 “오늘의 휴가자”와 “주요 일정”을 별도로 찾아보는 번거로움을 덜고, 중요한 정보가 모두에게 빠짐없이 전달될 수 있도록 구글 캘린더 정보를 자동으로 수집하여 메신저(슬랙·Mattermost 등)에 알림을 전송하는 오늘의 일정·휴가자 알림이 봇을 제작하게 되었습니다.
1. Webhook은 무엇인가요?
웹페이지 or 웹앱에서 발생하는 특정 행동(이벤트)들을 커스텀 Callback으로 변환해주는 방법으로 보통 REST API로 구축된 웹 서비스는 하나의 요청에 따라 하나의 응답을 제공합니다. 이러한 구조로 인해 특정 이벤트가 발생했는지 조회하려면 서버로의 요청이 선행되어야 합니다.
즉, 일반적인 API(Polling)는 클라이언트가 서버를 호출하는 방식입니다. 하지만, 웹훅은 서버에서 특정 이벤트가 발생했을 때, 클라이언트를 호출하는 방식으로써 라고도 불립니다.
이렇게 서버측에서 클라이언트의 어떤 URL로 데이터를 보낼지 정해놓은 주소를 바로 Callback URL이라고 부릅니다.
2. 어떻게 Webhook을 발생 시킬 수 있나요?
- Google Apps Script + Javascript 활용
AWS의Lambda로 구현이 가능하지만 비용 절감 이슈로 인해 최대한 무료로 사용할 수 있는 범위의 기술 스택을 찾아보게 되었으며,Google Apps Script를 활용하여 구글 캘린더의 일정을 가져와 휴가자와 오늘의 회의 일정을 가져오며 아래와 같은Workflow흐름을 참고하시면 됩니다.

3. 메신저 별 Incomeing Webhook 등록은 어떻게 하나요?
(1) MatterMost (현재는 슬랙으로 메신저를 이동하면서 컨테이너를 내려 다른 대체 이미지를 사용했습니다)
- Channels > Integrations 통해 Incoming Webhooks 후 적용하고자 하는 채널을 선택합니다.




(2) Slack
- https://api.slack.com/quickstart 링크를 통해 이동하면
Go to Your Apps버튼을 누릅니다. - 미리 만들어 둔 App 봇이 있지만 신규 생성 시
Create New App을 통해 생성 후Activate Incoming Webhooks를 활성화 합니다.



4. 구글 캘린더는 어떻게 활용해야 하나요?
- 사내 회의 일정 및 휴가자 관리를
Google Calendar관리를 하고 있기 때문에 해당 API의 기능을 가져다 활용하였습니다. 캘린더 ID를 활용해서 슬랙 및 메타모스트 알림을 전달 할 수 있습니다.


5. 완성 기능 및 소스 코드
(1) 주중 알림 봇 기능
- 주중 매일 오전 8시 30분 캘린더를 확인 할 필요 없이 오늘의 회의 일정과 휴가자를 Bot을 통하여 알람을 전달을 해줍니다.


(1) /today 기능
- 수시로 캘린더의 일정을 받아 보고 싶은 경우가 있습니다.
Slash Command로 간단하게 Bot이 캘린더의 일정을 다시 전달 해 줍니다.



- 소스 코드
- 참고만 하세요!
const GOOGLE_CALENDAR_BOT_NAME = '오늘의 일정 봇';
const GOOGLE_CALENDAR_VACATION_BOT_NAME = '휴가자 알림이 봇';
const GOOGLE_CALENDAR_ID = "";
const WEBHOOK_ID = "";
function sendToMattermost(message) {
const payload = {
text: message
};
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(WEBHOOK_ID, options);
console.log("Response: ", response.getContentText()); // 응답 로그 출력
return response;
}
function scheduleEventTriggers() {
// const calendar = CalendarApp.getDefaultCalendar();
const calendar = CalendarApp.getCalendarById(GOOGLE_CALENDAR_ID);
const now = new Date();
const timeFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24시간 후까지의 이벤트를 조회
const events = calendar.getEvents(now, timeFromNow);
for (let i = 0; i < events.length; i++) {
const event = events[i];
const eventStartTime = event.getStartTime();
const timeBeforeEvent = new Date(eventStartTime.getTime() - 30 * 60 * 1000); // 이벤트 시작 30분 전
console.log('뭐지', timeBeforeEvent)
if (timeBeforeEvent > now) {
ScriptApp.newTrigger('notifyEvent')
.timeBased()
.at(timeBeforeEvent)
.create();
}
}
}
function notifyEvent() {
// const calendar = CalendarApp.getDefaultCalendar();
const calendar = CalendarApp.getCalendarById(GOOGLE_CALENDAR_ID);
const now = new Date();
const events = calendar.getEvents(now, new Date(now.getTime() + 60 * 1000)); // 현재 시간부터 1분 이내의 이벤트
console.log(events)
for (let i = 0; i < events.length; i++) {
const event = events[i];
const eventStartTime = event.getStartTime();
const eventTitle = event.getTitle();
const eventDescription = event.getDescription();
const message = `Upcoming Event: ${eventTitle}\nDescription: ${eventDescription}\nStarts at: ${eventStartTime}`;
sendToMattermost(message);
}
}
/**
* 오전 7시 ~ 8시 사이 트리거를 만듬
* 8시 30분에 todayEvent를 실행 시켜 휴가자 및 일정 조회를 실행 시킨다.
*/
const setTriggerTodayEvent = () => {
const day = new Date();
day.setHours(8);
day.setMinutes(30);
ScriptApp.newTrigger('todayEvent').timeBased().at(day).create();
}
const todayEvent = () => {
const id = getGoogleCalendarID();
todayGoogleCalendarBacationEvents(id); // 휴가자
todayGoogleCalendarEvent(id); // 일정 조회
};
/**
* 오후 11시 ~ 오전 12시 사이 트리거 생성
* 24시가 되면 deleteTodayEvent를 실행 시켜 트리거를 전부 삭제한다.
*/
const setDeleteTriggerEvent = () => {
const day = new Date();
day.setHours(24);
day.setMinutes(00);
ScriptApp.newTrigger('deleteTodayEvent').timeBased().at(day).create();
}
const deleteTodayEvent = () => {
const triggers = ScriptApp.getProjectTriggers();
const keepFunctions = [
'setTriggerTodayEvent',
'setDeleteTriggerEvent',
'setTriggerbegin30minAfterEvent',
'scheduleMeetingReminders',
'createTrigger'
];
// 누적
let functionCounts = {
'setTriggerTodayEvent': 0,
'setDeleteTriggerEvent': 0,
// 'setTriggerbegin30minAfterEvent': 0
'scheduleMeetingReminders': 0,
'createTrigger': 0
};
for (let i = 0; i < triggers.length; i++) {
const functionName = triggers[i].getHandlerFunction();
if (keepFunctions.includes(functionName)) {
functionCounts[functionName]++;
if (functionCounts[functionName] > 1) {
Logger.log('functionCounts가 두개 이상 있다면 하나는 삭제 ' + functionName);
ScriptApp.deleteTrigger(triggers[i]);
} else {
Logger.log('keepFunctions 유지 시킬거...' + functionName);
}
} else {
Logger.log('나머지 트리거는 삭제 : ' + functionName);
ScriptApp.deleteTrigger(triggers[i]);
}
}
}
const setTriggerbegin30minAfterEvent = () => {
const day = new Date();
day.setHours(8);
day.setMinutes(30);
ScriptApp.newTrigger('todayLoopEvent').timeBased().at(day).create();
}
const todayGoogleCalendarBacationEvents = (googleCalendarId) => {
const morning4Half = googleCalendarId.getEventsForDay(new Date(), { search: "오전 반차" });
const morning2Half = googleCalendarId.getEventsForDay(new Date(), { search: "오전 반반차" });
const afternoon4Half = googleCalendarId.getEventsForDay(new Date(), { search: "오후 반차" });
const afternoon2Half = googleCalendarId.getEventsForDay(new Date(), { search: "오후 반반차" });
const reserveForces = googleCalendarId.getEventsForDay(new Date(), { search: "예비군" });
const health = googleCalendarId.getEventsForDay(new Date(), { search: "건강 검진" });
const petition = googleCalendarId.getEventsForDay(new Date(), { search: "청원 휴가" });
const allDay = googleCalendarId.getEventsForDay(new Date(), { search: "휴가" });
const summary = morning4Half.length + morning2Half.length + afternoon4Half.length + afternoon2Half.length + reserveForces.length + health.length + petition.length + allDay.length;
let attachments = [];
if (summary < 1) {
// 휴가자가 없는 경우
let text = `#### ✈️ 오늘의 휴가자는 없습니다. 🙂\n`;
attachments = [
{
text: text,
fields: [
{
short: true,
title: ':sadmove: :sadmove: :sadmove: :sadmove: :sadmove: :sadmove: ',
value: '휴가 가고 싶드아................ :sadmove: ',
},
],
},
];
}else {
// 휴가자가 있을 경우
let text = `#### ✈️오늘의 휴가자는 ${summary}명입니다. 🏖️\n`;
attachments = [
{
// color 는 Default 값을 사용
text: text,
fields: [
{
short: true,
title: `오전반차 (${morning4Half.length})`,
value: getText(morning4Half, "[오전 반차] ") || "",
},
{
short: true,
title: `오전반반차 (${morning2Half.length})`,
value: getText(morning2Half, "[오전 반반차] ") || "",
},
{
short: true,
title: `오후반차 (${afternoon4Half.length})`,
value: getText(afternoon4Half, "[오후 반차] ") || "",
},
{
short: true,
title: `오후반반차 (${afternoon2Half.length})`,
value: getText(afternoon2Half, "[오후 반반차] ") || "",
},
{
short: true,
title: `예비군 (${reserveForces.length})`,
value: getText(reserveForces, "[예비군] ") || "",
},
{
short: true,
title: `청원휴가 (${petition.length})`,
value: getText(petition, "[청원휴가] ") || "",
},
{
short: true,
title: `건강검진 (${health.length})`,
value: getText(health, "[건강검진] ") || "",
},
{
short: true,
title: `하루종일 (${allDay.length})`,
value: getText(allDay, "[휴가] ") || "",
},
],
},
];
}
postMattermostWebhook("", `${GOOGLE_CALENDAR_VACATION_BOT_NAME}`, attachments);
}
const todayGoogleCalendarEvent = (googleCalendarId) => {
const events = googleCalendarId.getEventsForDay(new Date());
let text = "#### 안녕? 👋 좋은 아침이에요! ";
let msg = "";
// 이벤트가 없을 때
if (events.length < 1) {
text += "오늘은 특별한 일정이 없네요 🙂";
} else {
text += "오늘의 일정을 간략하게 알려드릴게요.";
msg = "| 시간 | 제목 | 회의실 |\n|:------|:-------| :----------|\n";
for (let i = 0; i < events.length; i++) {
// 회의 시작 시간
const startTime = Utilities.formatDate(
events[i].getStartTime(),
"GMT+0900",
"HH시 mm분"
);
// 회의 종료 시간
const endTime = Utilities.formatDate(
events[i].getEndTime(),
"GMT+0900",
"HH시 mm분"
);
// 회의실
const location = events[i].getLocation();
// 이벤트 제목
const title = events[i].getTitle().trim();
// 휴가와 생일을 제외한 나머지 이벤트들만 가져옵니다.
if (
title.indexOf("휴가") === -1 &&
title.indexOf("오전 반차") === -1 &&
title.indexOf("오후 반차") === -1 &&
title.indexOf("생일") === -1 &&
title.indexOf("건강검진") === -1 &&
title.indexOf("오전 반차") === -1 &&
title.indexOf("오전 반반차") === -1 &&
title.indexOf("오후 반차") === -1 &&
title.indexOf("오후 반반차") === -1
) {
msg += `|${startTime} ~ ${endTime}|${title}|${location}|\n`;
}
}
}
const attachments = [
{
color: "#cc101f",
text: text,
fields: [
{
short: true,
// 이벤트가 있을 경우 오늘 날짜를 타이틀로 보여줍니다.
title: events.length < 1 ? "" : getToday(),
value: msg, // 이벤트 정보
},
],
// attachments 의 footer 정보
footer: "전 좀 쉬어야겠어요. 그럼 이만!",
footer_icon: "https://img.icons8.com/color/420/cocktail.png",
},
];
postMattermostWebhook("", `${GOOGLE_CALENDAR_BOT_NAME}`, attachments);
}
/**
* 메타 모스트로 메세지를 전달 하는 역할을 합니다.
*/
const postMattermostWebhook = (msgBody, userName, attachments = []) => {
const icon = userName === `${GOOGLE_CALENDAR_BOT_NAME}` ? "https://img.icons8.com/color/420/iron-man.png" : 'https://img.icons8.com/color/420/sunbathe.png';
const payload = {
text: msgBody,
icon_url: icon,
username: userName || "TODAY EVENTS",
attachments: attachments,
};
const response = UrlFetchApp.fetch(WEBHOOK_ID, {
method: "POST",
contentType: "application/json",
payload: JSON.stringify(payload),
});
response.getContentText("UTF-8");
}
/**
* 이벤트 제목에 말머리가 있을 경우 이를 파싱해 말머리를 제외한 제목을 리턴해주는 함수
*/
function getText(events, seperator) {
let text = [];
for (let i=0; i<events.length; i++) {
const title = events[i].getTitle().split(seperator.trim())[1];
if (title === undefined) continue;
text.push(title);
}
return text.length > 1 ? text.join(", ") : text.join("");
}
/**
* 현재 날짜를 가져온다.
*/
const getToday = () => {
return Utilities.formatDate(new Date(), "GMT+0900", "YYYY년 MM월 dd일");
}
const getGoogleCalendarID = () => {
const week = ['일', '월', '화', '수', '목', '금', '토'];
const GOOGLE_CALENDARID = CalendarApp.getCalendarById(GOOGLE_CALENDAR_ID);
const day = new Date();
if (day.getDay() === week.indexOf('토') || day.getDay() === week.indexOf('일')) return;
else return GOOGLE_CALENDARID;
}
/**
* description
* - 메타모스트에서 `/today` 슬래시 명령어를 입력 했을때 구글 신기술사업부 캘린더를 가져옵니다.
* - 현재 휴가자와 회의 일정을 가져옵니다.
*/
const doPost = (e) => {
// Mattermost에서 전송된 파라미터 가져오기
const params = e.parameter || {};
// 디버깅 정보 구성
let debugInfo = "Debug Info:\n";
debugInfo += "Full request: " + JSON.stringify(e) + "\n";
debugInfo += "Parameters: " + JSON.stringify(params) + "\n";
// 명령어와 토큰 추출
const command = params.command || '';
const token = params.token || '';
const expectedToken = "";
debugInfo += "Command: " + command + "\n";
debugInfo += "Token: " + token + "\n";
debugInfo += "Expected Token: " + expectedToken + "\n";
// 토큰 검증
if (token !== expectedToken) {
debugInfo += "Token mismatch detected!\n";
return ContentService.createTextOutput(
JSON.stringify({ text: "Unauthorized: 잘못된 토큰입니다.\n" + debugInfo })
).setMimeType(ContentService.MimeType.JSON);
}
// /today 명령어 처리
if (command === '/today') {
todayEvent();
return ContentService.createTextOutput(
JSON.stringify({ text: "오늘의 일정 및 휴가자를 Slack에 게시했습니다!\n" })
).setMimeType(ContentService.MimeType.JSON);
}
// 기본 응답
return ContentService.createTextOutput(
JSON.stringify({ text: "잘못된 명령어입니다.\n" + debugInfo })
).setMimeType(ContentService.MimeType.JSON);
}
// 트리거 생성 및 관리
function createTrigger() {
Logger.log("createTrigger 실행");
const existingTriggers = ScriptApp.getProjectTriggers();
let triggerExists = false;
for (let i = 0; i < existingTriggers.length; i++) {
if (existingTriggers[i].getHandlerFunction() === 'scheduleMeetingReminders') {
triggerExists = true;
Logger.log("scheduleMeetingReminders 트리거 이미 존재");
break;
}
}
if (!triggerExists) {
ScriptApp.newTrigger('scheduleMeetingReminders')
.timeBased()
.everyMinutes(1)
.create();
Logger.log("scheduleMeetingReminders 트리거 생성 완료");
}
}6. 마무리
- 이번 ‘오늘의 일정·휴가자 알림이 봇’ 프로젝트는 별도의 인프라 비용 없이 Google Apps Script를 활용해 사내 알림 시스템을 구축했다는 점에서 실용적인 의미가 큽니다.
(1) 비용 및 사용 한도
- Google Apps Script의 가장 큰 장점은 무료로 사용할 수 있다는 점입니다.
- 별도의 서버, 클라우드 인프라 없이 구글 계정만 있으면 바로 스크립트를 작성·실행할 수 있습니다.
- 2025년 기준, 무료/기본 할당량(Quotas)은 다음과 같습니다:
- URL 가져오기(UrlFetchApp) 호출: 20,000회/일 (Google Workspace 계정은 100,000회/일)
- 생성된 캘린더 일정 수: 5,000개/일 (Workspace는 10,000개/일)
- 총 트리거 런타임: 90분/일 (Workspace는 6시간/일)
- 트리거 개수: 사용자/스크립트당 20개
- 스크립트 실행당 런타임: 6분
- 속성 읽기/쓰기: 50,000회/일 (Workspace는 500,000회/일)
- 웬만한 소규모 사내 자동화, 일정 알림, 슬랙/메타모스트 연동 봇은 월 0원으로 충분히 운영 가능합니다.
- 추가 비용 없이 운영하다가 할당량 초과가 잦아지면, Google Workspace 유료 플랜(기업 계정)에서 더 높은 한도를 이용할 수 있지만 그래야 할 마땅한 이유를 찾지 못했습니다.
(2) 한계와 단점
- 정밀한 트리거 제어의 한계
- Apps Script 트리거는 기본적으로 “특정 시간에 1회 실행” 혹은 “N분마다 반복 실행”만 지원합니다.
- 즉, 회의 30분 전처럼, 이벤트 발생 시각을 기준으로 “상대적 시점”에 맞춰 트리거를 등록하거나 정밀하게 알림을 보내는 것이 내장된 기능만으로는 어렵습니다.
- 실제로 위 예제처럼 반복적으로 1분 간격으로 실행하여, “지금으로부터 30분 후에 시작하는 이벤트”를 찾아 알림을 보내는 식의 우회 로직이 필요합니다.
- 트리거 개수가 많아질 경우, Google에서 제공하는 트리거 최대 개수(일반적으로 20개 내외) 제한에도 걸릴 수 있습니다.
- API Rate Limit
- 무료 계정 기준 외부 서비스 호출(UrlFetchApp) API 20,000회/일 한도가 존재합니다.
- 실시간 대량 알림, 여러 캘린더 연동, 여러 채널로 동시에 보내야 하는 케이스라면 할당량에 주의해야 합니다.
- 대부분의 사내 일정, 휴가 봇은 1일 10~100회 수준의 호출이면 충분하므로 현실적으로 큰 제한은 없음.
정리
Google Apps Script를 활용하면 인프라/비용 부담 없이 사내 업무 효율화 자동화가 가능합니다.
다만, “정밀한 상대 시간 알림” 또는 “대량 이벤트/알림 처리”에는 구조적 한계가 존재하므로, 더 복잡한 니즈에는 AWS Lambda, GCP Functions 같은 별도 서버리스 인프라로 확장하는 것을 권장합니다.