코드만 짜다 보니 어느 순간 내 블로그에는 오프라인 경험이 하나도 없었다. 컨퍼런스도 가고, 세미나도 듣고, 투어도 다녀왔었는데 경험 기반 블로그 글은 자동화를 어떻게 해야할지 고민이 생겼다.
오프라인 경험을 블로그에 담으려면 뭔가 자동화가 필요했다
개발 관련 내용은 그나마 코드나 커밋 기록이라도 남는데, 오프라인 경험은 기억이 휘발되는 속도가 너무 빠르다. 행사 끝나고 피곤한 상태에서 "나중에 정리해야지"를 다짐하면 99%는 그냥 묻힌다.
나는 이미 Google Calendar에 일정을 꼼꼼히 등록하는 편이었다. 그러면 일정이 끝나는 시점을 트리거로 쓸 수 있지 않을까? 별도로 "기록해야지" 라는 의식적인 행동 없이, 일정이 끝나면 알아서 디스코드 봇의 알림이 울리고 생성된 스레드에 글을 작성하면 알아서 경험 기반 데이터를 수집하는 식으로 가능하지 않을까 생각했다.
그렇게 Google Calendar → Apps Script → Discord 봇 → Supabase → 블로그 파이프라인 흐름이 만들어졌다.
전체 플로우 한눈에 보기
[Google Calendar] '개발 일정' 캘린더에 일정 등록
↓
[Apps Script] 이벤트 종료 시간에 트리거 예약
↓
[Discord Bot] 해당 시간에 스레드 자동 생성
↓
[사용자] 스레드에 후기 작성
↓
[Supabase] 메시지 저장 (experience_threads 테이블)
↓
[블로그 파이프라인] 자동 글 생성
Google OAuth refresh token 발급
Apps Script에서 Calendar API를 쓰려면 OAuth 인증이 필요하다. 처음에는 redirect URI 설정이나 서버 구동 같은 걸 다 해야 하는 줄 알았는데, 임시로 쓸 refresh token을 얻는 방법은 야매로 진행하니 단순해졌다.
- Google Cloud Console에서 OAuth 클라이언트 ID 생성
- redirect URI를
http://localhost로 설정 - 인증 URL로 접근해서 구글 로그인 완료
- 브라우저가
http://localhost/?code=XXXX로 리다이렉트되면서 "사이트에 연결할 수 없음" 표시 - 주소창의
code=XXXX값만 복사 - curl로 token endpoint에 POST 요청 → refresh token 발급 완료
curl -X POST https://oauth2.googleapis.com/token \
-d code=XXXX \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d redirect_uri=http://localhost \
-d grant_type=authorization_code
처음에 "사이트에 연결할 수 없음"이 떠도 localhost는 서버를 실제로 띄우는 게 목적이 아니라, 주소창에서 code 값을 눈으로 보기 위한 임시 목적지이기 때문에 당황하지 않고 refresh token 값만 가져오면 된다.
Apps Script로 트리거 예약하기

Google Calendar 이벤트가 등록되면 Apps Script가 이를 감지하고, 종료 시간에 정확히 맞춰 Discord 알림 함수를 실행하도록 트리거를 예약한다.
function onCalendarEventUpdated() {
const calendar = CalendarApp.getCalendarById('YOUR_CALENDAR_ID');
const now = new Date();
const events = calendar.getEvents(now, new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000));
events.forEach(event => {
const eventId = event.getId();
const endTime = event.getEndTime();
// 기존 트리거 삭제 (시간 수정 대응)
deleteTriggerForEvent(eventId);
// 종료 시간에 트리거 새로 예약
const trigger = ScriptApp.newTrigger('sendDiscordThread')
.timeBased()
.at(endTime)
.create();
// 트리거 ID와 이벤트 ID 매핑 저장
PropertiesService.getScriptProperties()
.setProperty(eventId, trigger.getUniqueId());
});
}
만약 일정 시간을 수정하면 트리거가 두 개 생겨버리는 문제가 있었는데, PropertiesService로 이벤트 ID와 트리거 ID를 매핑해두고, 새로 예약하기 전에 기존 트리거를 먼저 삭제하는 방식으로 해결했다.
종료 시간에 sendDiscordThread가 실행되면, 그 시점에 정확히 Discord에 알림이 간다.
Discord 봇이 스레드를 자동으로 만들어준다
Apps Script에서 트리거가 발동되면, Discord 봇에게 웹훅으로 신호를 보낸다. 봇은 지정된 채널에 스레드를 자동 생성하고, 사용자에게 후기 작성을 유도하는 메시지를 남긴다.
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
// experience_threads 테이블에서 해당 스레드 ID 확인
const { data } = await supabase
.from('experience_threads')
.select('*')
.eq('thread_id', message.channelId)
.single();
if (!data) return;
// 스레드 내 메시지를 Supabase에 저장
await supabase.from('experience_logs').insert({
thread_id: message.channelId,
event_title: data.event_title,
content: message.content,
created_at: new Date().toISOString(),
});
});

experience_threads 테이블은 봇이 스레드를 생성할 때 이 테이블에 해당 스레드 ID와 캘린더 이벤트 정보를 함께 저장해두면 이후 메시지가 들어올 때 "이 스레드는 수집 대상이다"라는 걸 판단한다. 봇이 모든 채널을 감시하는 게 아니라, 명시적으로 등록된 스레드만 처리하는 방식이라 깔끔하게 마음에 들었다.
Supabase에 쌓인 후기가 블로그로 이어진다
스레드에 작성된 메시지들이 Supabase에 쌓이면, 블로그 파이프라인이 주기적으로 이 데이터를 가져다가 글을 자동 생성한다.
사실 이 부분은 아직 완전히 다듬어진 건 아닌데, 기본 흐름은 이렇다.
experience_logs테이블에서 미처리 후기 조회- 해당 이벤트 제목 + 작성한 후기 내용을 LLM에 전달
- 블로그 글 초안 생성
- Notion 또는 직접 블로그 포스팅
캘린더에 일정을 등록하는 건 어차피 하는 일이고, 일정이 끝나면 Discord에서 알아서 물어봐주니까 거기에 답하기만 하면 된다. 마찰이 확실히 줄었다.
회고: 자동화의 진짜 목적은 습관을 대신하는 게 아니라, 습관을 만드는 것
이 시스템을 만들면서 느낀 건, 자동화가 "기억"을 대신해줄 수는 없다는 거다. 결국 스레드에 뭔가를 적는 건 나의 몫이다. 다만 "언제 적어야 하지?"라는 판단 비용을 없애준다는 게 핵심이었다.
앞으로는 수집된 경험 데이터의 품질을 높이는 방향으로 개선해보려 한다. 단순 후기 텍스트 외에 사진이나 링크도 함께 수집하거나, 이벤트 종류(컨퍼런스 / 스터디 / 투어)에 따라 스레드 템플릿을 다르게 만드는 것도 재미있을 것 같다.
일단 이 파이프라인이 살아있는 동안, 경험을 흘려보내지 않는 습관을 조금씩 만들어가보려 한다.