ZEP과 Slack을 이용한 출석체크(feat. Firebase)
우리 스터디의 출석부!
이것을 하고싶은 분들은 게시글을 봐주세요!


- 핵심만 보고싶으시다면 Firebase 부분, 코드 부분만 빠르게 보고 스터디에 적용해봐요!
고민의 시작
데이터 흐름:
프로젝트 형식의 과외에서 ZEP과 Slack을 사용하고 있었어요
저는 의지가 약한 편이라 멘토님들에게 여쭤봤어요
‘혹시 ZEP을 운용하시는데 매일 출석하는 느낌으로 접속해도 괜찮을까요?’
멘토님들은 흔쾌히 좋다고 하시면서 ZEP을 이용하여 어떻게 학습 의지를 북돋을수 있을까 고민하고 있으시다고 해주시더라구요
저는 그날로부터 과외가 시작되기 전까지 고민을 하기 시작했어요
그러다가 발견했어요
Webhook이라는 것을..!
Webhook은 최고야!
Webhook은 서버에서 특정 이벤트가 발생했을 때 미리 등록된 URL로 HTTP POST 요청을 보내서 실시간 전달하는 자동화된 기술이에요
그때 하나의 흐름이 제 머리속을 스쳐가더라구요

이거 잘만하면 빠르게 만들 수 있겠다!
마침 ZEP과 Slack 모두 Webhook 앱을 지원하고 있는 상태기도 했고요!


ZEP 이미지에서 ‘외부 알림 연결’에 수신받을 URL을 입력해주면 끝이에요
그러면 수신받을 URL은 Slack 어디에서 얻냐?
바로 Slack 수신웹후크 앱 설정에 들어가면 수신받을 Slack URL 주소, 수신받을 채널 선택 등 설정을 해줄 수 있어요

저기 나오는 URL을 복사해서 ZEP Webhook에 연결해주면!
아래 이미지 같이 정상적으로 입/퇴장 메시지를 받아볼 수 있어요


참고로 나중에 나올 Firebase Function URL은 API로 등록하셔야해요
냉혹한 현실
하지만 세상 모든일이 그렇듯 제 생각대로만 흘러가지는 않았어요..
Webhook으로는 한계가 명확했어요
일단 메시지 커스텀이 안되고요
궁극적으로 입/퇴장 정보를 사용해서 일일/주간 접속 통계를 하는 등 데이터를 활용하는 것까지 생각하고 있었기에 더욱 한계가 명확하게 느껴졌어요
그때 ‘어차피 HTTP Post로 데이터를 전달해주는거면 중간 서버에서 캐치하면되지 않을까?’라는 생각이 머리를 스쳤어요

서버 운용의 부담.. 어떻게 해야할까?
하지만 이제 막 과외를 시작하는 입장에서 서버운용은 해본적 없고.. 배보다 배꼽이 커지는 느낌이였어요
그때 마침 제 머리속을 스쳐나가는 예전 기억
안드로이드 개발할 때 서버리스 앱을 개발하기 위해서 자주 사용했던 Firebase
마침 찾아보니 Firebase Functions 라는게 존재하더라고요!

설명만 봐도 딱 제가 원하는 ‘HTTPS 요청을 Cloud 서버에서 응답하여 백엔드 코드를 자동으로 실행할 수 있다’ 라는 조건을 충족하네요
그래서 찾아봤어요 비용은..? 괜찮을까?

다행히 월 200만 요청까지는 무료라고 하더라고요
이정도면 스터디용도로는 충분히 차고넘친다고 판단했어요
다만, 사용하려면 결제 카드 등록은 꼭 필요하니 카드를 미리 준비해두세요!
Firebase Functions
먼저 Firebase를 사용해본적이 없으신 분들은 Google Firebase에 계정을 생성해주셔야해요
아! 물론 Firebase를 꼭 사용할 필요는 없어요
자신에게 맞는 서버가 있다면 그걸 써서 데이터를 받아 커스텀하시면 돼요!
로그인하고 console로 들어가시면 아래와 같은 화면이 반겨줄거에요

새로운 Firebase 프로젝트를 생성하시면 돼요!
그러면 메인화면이 반겨줄 텐데 딱 여기만 보시면 돼요!

먼저 제일 하단에 요금제를 업그레이드 해줄 수 있는데 여기서 카드 등록이랑 업그레이드를 해주시면 돼요
업그레이드한다고 요금 결제가 되는 것도 아니고 당장에 돈이 빠져나가는 건 아니니까 안심하셔도 돼요!
아! 그리고 처음을 떠올려보면 데이터를 저장한다고 했잖아요
Firestore Database는 NoSQL 데이터베이스에요
그러니까 SQL 데이터베이스처럼 테이블 정의, 컬럼 정의를 할 필요가 없어요
문서 형식의 데이터베이스이기 때문에 아무것도 설정할게 없으니 지금은 신경안쓰셔도 돼요!
그 다음 Functions 메뉴를 들어가면 아래와 같은 화면이 반겨줄거에요

여기서 ‘안내’를 클릭하면 우리의 로컬에 Firebase 패키지 설치 명령어와 배포 명령어를 알려줄거에요
참! Firebase 패키지 설치할 때 뭘 설치할건지 선택해야하는데 여기서는
Functions 랑 Database 2개만 설치하셔도 돼요!
설치를 하면 로컬에 아래와 같이 Firebase Functions 프로젝트가 보일거에요

드디어 코드 구현..
우리가 신경써야할 파일은 단 2개에요
- index.ts
- secret.txt (패키지 포함이 아닌 생성한 파일)
먼저 index.ts 부터 설명을 할게요(전체 소스는 최하단에 첨부할게요!)
1. ZEP의 이벤트 감지 설정
import {setGlobalOptions} from "firebase-functions";
import {onRequest} from "firebase-functions/https";
import * as logger from "firebase-functions/logger";
import {defineSecret} from "firebase-functions/params";
import * as admin from "firebase-admin";
// 동시 실행 인스턴스를 10개로 제한(비용 보호)
setGlobalOptions({maxInstances: 10});
....
/**
* ZEP 이벤트 감지 함수
*/
export const zep = onRequest(
{secrets: [SLACK_WEBHOOK_URL]},
async (req, res) => {
try {
// req.body -> ZEP Event body
const payload = pickPayload(req.body);
logger.info("ZEP data", {payload});
// DB 저장 함수
await saveToDb(payload);
// secret.txt Slack URL
const webhook = SLACK_WEBHOOK_URL.value();
// slack 전송 커스텀 메시지 생성
const text = madeComment(payload);
// slack 전송
await postToSlack(webhook, text);
res.status(200).type("text/plain").send("success");
} catch (e: any) {
logger.error("zep error", {message: e?.message, stack: e?.stack});
res.status(500).type("text/plain").send("error");
}
}
);
우리는 ZEP의 Event를 onRequest 함수를 통해 받아줄거에요
소스를 뜯어보기 전에 먼저 secret.txt부터 설명하고 넘어갈게요
우리가 위에서 받은 Slack 수신 웹후크 URL은 외부에 유출되면 안되는 중요한 정보에요
이걸 Firebase Functions에서 어떻게 비밀스럽게 관리할 수 있을까 찾아보니 functions secrets라는 것을 제공해준다고 하더라고요
먼저, 프로젝트 root 경로에 secret.txt를 만들어주고 그 텍스트 내부에는 Slack URL만 입력하고 저장해주세요
꼭! gitignore에 secret.txt를 넣어주세요!
그리고 터미널에 와서 아래 명령어를 입력해주세요
type secret.txt | npx firebase functions:secrets:set SLACK_WEBHOOK_URL
이걸 우리의 index.ts 소스에서 불러오려면 이렇게 쓰면 돼요
// slack url secret 보관
const SLACK_WEBHOOK_URL = defineSecret("SLACK_WEBHOOK_URL");
자.. 그럼 다음단계로 넘어가볼까요?
2. 메시지 커스텀
/**
* ZEP payload type definition
* - date: 이벤트 발생 시간
* - mapName: 맵 이름
* - map_hashID : 맵 고유 ID
* - type : 이벤트 타입
* - nickname : 유저 닉네임
* - userId : 유저 Id
* - userKey : 유저 고유 키
*/
type ZepPayload = {
date?: string;
mapName?: string;
map_hashID?: string;
nickname?: string;
type?: string; // enter | exit | test ...
userId?: string;
userKey?: string;
};
/**
* ZEP payload parser
* ZEP에서 보내는 데이터의 경우 body.body.* 형태로 발송되어 파싱 필요
* @param {any} body
* @return {ZepPayload}
*/
function pickPayload(body: any): ZepPayload {
return (body?.body ?? body) as ZepPayload;
}
/**
* ZEP made comment builder
* type 이벤트에 따라 메시지를 구분지어 생성
* @param {ZepPayload} payload
* @return {string}
*/
function madeComment(payload: ZepPayload): string {
const type = String(payload.type ?? "");
const nickname = String(payload.nickname ?? "");
const date = String(payload.date ?? "");
const userId = String(payload.userId ?? "");
if (type === "enter") {
return `${nickname}[${userId}] 입장\n- 시간: ${date}`;
}
if (type === "exit") {
return `${nickname}[${userId}] 퇴장\n- 시간: ${date}`;
}
return `${type} 이벤트 \n- ${nickname}[${userId}]\n- 시간: ${date}`;
}
/**
* ZEP 이벤트 감지 함수
*/
export const zep = onRequest(
{secrets: [SLACK_WEBHOOK_URL]},
async (req, res) => {
try {
const payload = pickPayload(req.body);
...
const text = madeComment(payload);
...
} catch (e: any) {
...
}
}
);
ZEP에서 넘겨받은 body 데이터를 파싱해서 커스텀 메시지를 만들어줘요
ZepPayload를 사용한 이유는 ZEP에서 보내주는 데이터가 body.body.*로 감싸져서 오기 때문에 파싱을 위해 사용했어요
메시지를 수정하고 싶으신 분들은 madeComment 여기에서 입맛에 맞게 수정해주시면 돼요!
3. Slack으로 전달
/**
* post to slack
* 생성된 메시지를 슬렉으로 전송
* @param {string} webhook
* @param {string} text
*/
async function postToSlack(webhook: string, text: string) {
const resp = await fetch(webhook, {
method: "POST",
headers: {"content-type": "application/json; charset=utf-8"},
body: JSON.stringify({text}),
});
// 발송 실패의 경우 로그로 남기기 위해 throw 발생
if (!resp.ok) {
const body = await resp.text();
throw new Error(`Slack webhook failed: ${resp.status} ${body}`);
}
}
/**
* ZEP 이벤트 감지 함수
*/
export const zep = onRequest(
{secrets: [SLACK_WEBHOOK_URL]},
async (req, res) => {
try {
const payload = pickPayload(req.body);
...
const webhook = SLACK_WEBHOOK_URL.value();
const text = madeComment(payload);
await postToSlack(webhook, text);
...
} catch (e: any) {
...
}
}
Slack을 향해서 Post 전달을 하면!! 아래와 같이 Slack에서 잘 받아지는 것을 확인할 수 있어요
여기서 꼭 Firebase Functions 배포를 해주세요!(하단 배포 메뉴 참고)

4. Firestore Database
아직 한 단계가 더 남아있어요
우리는 ZEP에서 넘어온 데이터를 데이터베이스에 저장할거에요
// firebase db 연결
admin.initializeApp();
const db = admin.firestore();
/**
* ZEP 이벤트 데이터 Firestore 저장
* @param {ZepPayload} payload
*/
async function saveToDb(payload: ZepPayload) {
// events 컬렉션에 원본 저장(디버깅/추적용)
await db.collection("events").add({
receivedAtMs: Date.now(),
type: payload.type ?? null,
userId: payload.userId ?? null,
date: payload.date ?? null,
map_hashID: payload.map_hashID ?? null,
mapName: payload.mapName ?? null,
nickname: payload.nickname ?? null,
payload,
});
}
/**
* ZEP 이벤트 감지 함수
*/
export const zep = onRequest(
{secrets: [SLACK_WEBHOOK_URL]},
async (req, res) => {
try {
const payload = pickPayload(req.body);
...
await saveToDb(payload);
...
} catch (e: any) {
...
}
}
saveToDb 메소드는 ZEP에서 넘겨받은 데이터를 그대로 저장해주고 있어요
그리고 마지막으로 Firebase Functions 배포를 해주면 아래와 같이 잘 저장되는 것을 확인할 수 있어요 :)

Firebase Functions 배포
Firebase Functions 배포를 하기 위해서는 아래 명령어를 실행하면 돼요
npx firebase deploy
다만, 신경써야할 점이 있어요
Firebase Functions 배포를 하면 Firebase 사이트에서 URL을 제공해주는데 그 URL은 ZEP에서 Webhook이 아닌 API에 등록을 해주셔야해요!
도움이 되었다는 기쁨
그리고 저는 여기까지 확인한 차에 과외가 시작되어 프로젝트는 멈추게 되었어요
여기서 조금더 가다듬으면 일일/주간 통계라던지 스스로의 학습 시간들을 직접 눈으로 확인하고 하루하루 성장하는 동력에 큰 힘이 될 수 있을 것 같았는데..
너무 아쉬웠어요
그래서 멘토님에게 이렇게 ZEP과 Slack을 활용하면 어떨까 의견을 드렸어요
정말 감사하게도 멘토이신 그릿님께서 너무 좋은 아이디어라고 해주시면서 저의 흐름 베이스에서 직접 만들어주시겠다고 하셨어요
저는 제가 생각한 작은 아이디어가 다른 분들에게 도움이 될거라고 하니 너무 기뻤어요
그리고 그릿님께서는 정말 빠르게 프로토타입 배포를 시작하셨어요



제가 고민하고 해결하고자 했던 문제가 실제로 눈앞에서 해결되는 것을 보니 너무나 즐거웠어요.
그래서 더 많은 분들이 이 글을 통해 동기부여를 얻고, 직접 만든 도구로 성장의 동력을 느끼실 수 있기를 바라며 작성하게 되었어요
지금 저의 소스로는 아직 부족한 부분이 많지만 여러분들이 원하는 출석 시스템을 한번 상상해보시고 구축해보시는 건 어떨까요?
작은 아이디어 하나가 스터디 문화를 바꿀 수도 있어요, 한번 도전해보세요!
매일 조금씩 앞으로 나아가는 자신을 믿어보아요!
전체 소스
/**
* npx firebase deploy --only functions
* type secret.txt | npx firebase functions:secrets:set SLACK_WEBHOOK_URL
*
* ZEP - Firebase Functions - Slack 연동하여 이벤트 저장 및 메시지 발송
* 1. ZEP에서 외부알림연결 -> API 등록으로 firebase functions 배포하면 나오는 URL 등록
* 2. 하단 소스에서 넘겨받은 데이터를 파싱하여 Slack 으로 메시지 발송(Slack incoming-webhook 활성화시 URL 발급)
* 3. Slack 에서 incoming-webhook 을 이용하여 메시지 수신
*/
import {setGlobalOptions} from "firebase-functions";
import {onRequest} from "firebase-functions/https";
import * as logger from "firebase-functions/logger";
import {defineSecret} from "firebase-functions/params";
import * as admin from "firebase-admin";
// 동시 실행 인스턴스를 10개로 제한(비용 보호)
setGlobalOptions({maxInstances: 10});
// firebase db 연결
admin.initializeApp();
const db = admin.firestore();
// slack url secret 보관
const SLACK_WEBHOOK_URL = defineSecret("SLACK_WEBHOOK_URL");
/**
* ZEP payload type definition
* - date: 이벤트 발생 시간
* - mapName: 맵 이름
* - map_hashID : 맵 고유 ID
* - type : 이벤트 타입
* - nickname : 유저 닉네임
* - userId : 유저 Id
* - userKey : 유저 고유 키
*/
type ZepPayload = {
date?: string;
mapName?: string;
map_hashID?: string;
nickname?: string;
type?: string; // enter | exit | test ...
userId?: string;
userKey?: string;
};
/**
* ZEP payload parser
* ZEP에서 보내는 데이터의 경우 body.body.* 형태로 발송되어 파싱 필요
* @param {any} body
* @return {ZepPayload}
*/
function pickPayload(body: any): ZepPayload {
return (body?.body ?? body) as ZepPayload;
}
/**
* ZEP made comment builder
* type 이벤트에 따라 메시지를 구분지어 생성
* @param {ZepPayload} payload
* @return {string}
*/
function madeComment(payload: ZepPayload): string {
const type = String(payload.type ?? "");
const nickname = String(payload.nickname ?? "");
const date = String(payload.date ?? "");
const userId = String(payload.userId ?? "");
if (type === "enter") {
return `${nickname}[${userId}] 입장\n- 시간: ${date}`;
}
if (type === "exit") {
return `${nickname}[${userId}] 퇴장\n- 시간: ${date}`;
}
return `${type} 이벤트 \n- ${nickname}[${userId}]\n- 시간: ${date}`;
}
/**
* post to slack
* 생성된 메시지를 슬렉으로 전송
* @param {string} webhook
* @param {string} text
*/
async function postToSlack(webhook: string, text: string) {
const resp = await fetch(webhook, {
method: "POST",
headers: {"content-type": "application/json; charset=utf-8"},
body: JSON.stringify({text}),
});
// 발송 실패의 경우 로그로 남기기 위해 throw 발생
if (!resp.ok) {
const body = await resp.text();
throw new Error(`Slack webhook failed: ${resp.status} ${body}`);
}
}
/**
* ZEP 이벤트 데이터 Firestore 저장
* @param {ZepPayload} payload
*/
async function saveToDb(payload: ZepPayload) {
// events 컬렉션에 원본 저장(디버깅/추적용)
await db.collection("events").add({
receivedAtMs: Date.now(),
type: payload.type ?? null,
userId: payload.userId ?? null,
date: payload.date ?? null,
map_hashID: payload.map_hashID ?? null,
mapName: payload.mapName ?? null,
nickname: payload.nickname ?? null,
payload,
});
}
/**
* ZEP 이벤트 감지 함수
*/
export const zep = onRequest(
{secrets: [SLACK_WEBHOOK_URL]},
async (req, res) => {
try {
const payload = pickPayload(req.body);
logger.info("ZEP data", {payload});
await saveToDb(payload);
const webhook = SLACK_WEBHOOK_URL.value();
const text = madeComment(payload);
await postToSlack(webhook, text);
res.status(200).type("text/plain").send("success");
} catch (e: any) {
logger.error("zep error", {message: e?.message, stack: e?.stack});
res.status(500).type("text/plain").send("error");
}
}
);