시작하며: 매월 1일의 귀찮음, 자동화 써볼까?
나는 평소 주 6일 운동을 하며 가슴 -> 등 -> 하체 -> 어깨 순의 4분할 루틴을 돌린다. 벌써 8년째다. 일요일은 쉬는데, 홀수 번째는 휴관일이고 짝수 번째는 개인적인 휴식일이다. 하체를 주 2회 할 때는 ‘전면/후면’으로 나기도 한다.
혼자 운동하기도 하지만 보통 파트너들과 같이 운동을 하다 보니 매월 1일이 되면 이번 달 스케줄을 일일이 작성해야 했다. 너무 귀찮다.
그래서 결심했다. 서버에 몇 달째 잠자고 리소스만 차지하는 n8n을 사용하기로 했다. 매월 1일 자정이 되면, Google Calendar의 WorkoutRoutine 확인하고 이번 달 루틴을 쫙 채워주는 자동화를 직접 만들어보기로 했다. 요즘 우리 생활 속에서 한 자리를 차지한 LLM을 사용하는 사치를 부리기엔 너무 작아 공부 겸 수동 작업 해보기로 했다.
1단계: 복잡한 루틴, ‘마스터 테이프’로 풀어내기
처음에는 코드에 if-else 문을 도배하려 했으나 로직이 너무 지저분해졌다. 그래서 내 4주 치 루틴을 나열해 규칙을 찾아보니 규칙은 있는데 그냥 내가 작성한 루틴이 규칙인걸 알았다. 24번의 운동 루틴이 하나의 사이클(Macro Cycle)로 돌아간다.
-
1~2주 차: 상체는
[가슴 -> 등]순서 -
3~4주 차: 상체는
[등 -> 가슴]순서로 스위치
나는 이 24칸짜리 배열을 ‘마스터 테이프’ 라고 불렀다. 마스터 테이프는 녹음, 믹싱 및 마스터링 과정을 거쳐 만들어진 최종 오디오 소스라고 한다. 자동화 봇은 이 전 달의 루틴을 보고 이 테이프의 몇 번째 칸에 있는지 알아낸 뒤, 테이프를 순서대로 재생하며 달력 빈칸에 일정만 찍으면 된다.

2단계: 워크플로우
로직이 섰으니 n8n에 노드를 추가할 차례다. 초기 워크플로우는 4개의 핵심 노드(Trigger → GET → Code → POST)로 구성했다.
⏰ 1. Schedule Trigger 노드
매월 1일 자정, 자동화를 시작 조건을 정의하는 노드이다.
-
Trigger Interval:
Custom (Cron) -
Cron Expression:
0 0 0 1 * *(매월 1일 0시 0분 0초 를 의미한다)
좀 더 직관적으로 설정하고 싶으면 아래 트리거를 사용해도 된다.
- Trigger Interval:
Months
🔭 2. Google Calendar 노드 [GET]
로봇이 지난달의 마지막 운동을 파악하기 위해 Google Calendar를 읽어오는 과정이다. n8n은 이런 api들이 잘 되어있어서 편하다. 하지만 이런 노드를 쓰려면 Self-hosted로 n8n을 사용하는 환경에선 GCPGoogle Cloud Platform을 사용해서 OAuth2 권한을 받아야 한다.
여기서 한 가지 주의할 점이 있다. 구글은 보안상 공인된 https:// 도메인이 아니면(또는 로컬 PC의 localhost가 아니면) 인증을 거부한다. 서버의 사설 IP(192.168.x.x)로는 당연히 등록이 불가능하므로, 반드시 리버스 프록시를 통해 공인 도메인을 연결해 둔 상태 또는 localhost 여야 한다.
다만 보통 서버는 서버에서 바로 세팅하는 경우는 드물다. 보통 remote 상태에서 사용을 하기 때문에 localhost로 docker-compose에 환경 세팅을 해둔다면 GCP에서 OAuth2 설정 간 사용한 도메인이 localhost라서 결국 현재 remote 환경이 아닌 현재 PC localhost로 이동되기 때문에 외부로 열어줘야 한다.
-
API 활성화: 구글 클라우드 콘솔에 접속해 새 프로젝트를 생성한다. 좌측 햄버거 메뉴에서
API 및 서비스>라이브러리로 들어가 Google Calendar API를 검색하고 ‘사용’ 버튼을 누른다. -
OAuth 동의 화면 구성:
API 및 서비스>OAuth 동의 화면탭으로 이동한다. User Type을 ‘외부(External)’ 로 선택하고 만들기 버튼을 누른다.- ⚠️ 앱을 출시하지 않고 테스트 상태로 둘 것이므로, 설정 마지막 단계인 ‘테스트 사용자’ 항목에 반드시 내 구글 계정 이메일을 추가해 주어야 한다.
-
클라이언트 ID 발급:
API 및 서비스>사용자 인증 정보탭으로 이동해, 상단의 [+ 사용자 인증 정보 만들기] > [OAuth 클라이언트 ID] 를 선택한다. 애플리케이션 유형은 ‘웹 애플리케이션’ 으로 지정한다. -
리다이렉트 URI 입력 (가장 중요): 하단의 ‘승인된 리디렉션 URI’ 항목에 내 공인 도메인이 포함된 n8n의 OAuth Callback 주소를 정확히 넣어야 한다.
-
(예:
https://내 n8n 도메인.com/rest/oauth2-credential/callback) -
(해프닝: n8n 화면에서 콜백 주소가 자꾸
localhost:5678로 뜬다면, 도커 환경변수에WEBHOOK_URL=https://내 n8n 도메인.com을 명시해주지 않았기 때문이다.)
-
-
n8n 연동: 생성 버튼을 누르면
클라이언트 ID와보안 비밀(Secret)이 뜬다. 이를 n8n의 [Credentials] 메뉴에서Google Calendar OAuth2 API에 붙여넣고 [Sign in with Google] 을 눌러 로그인하면 연동이 완료된다!
이제 2번째 노드의 세팅 완료하면 된다.
-
Credential to connect with: 방금 발급 받은 자격을 선택
-
Resource:
Event/ Operation:Get Many -
Calendar: 내 헬스 전용 캘린더 선택
-
Options:
Time Min:{{ new Date(new Date().setDate(new Date().getDate() - 14)).toISOString() }}- 최근 14일 전부터
Time Max:{{ new Date().toISOString() }}- 오늘까지의 데이터를 가져온다
3. Code 노드: Javascript 알고리즘
가장 핵심이 되는 Code 노드의 스크립트다. 구글 API로 받아온 데이터에서 ‘진짜 운동’ 키워드만 돋보기처럼 찾아내어 마스터 테이프와 대조한다. 여기서 ‘진짜 운동’은 달력이다 보니 일정에 루틴 이외에 ‘원정 운동’ 같은 다른 일정들이 추가되는 경우들이 있어, 판별하여 사용하고자 한다.
주 언어가 Java라 함수를 찾느라 좀 애먹었다. 코드를 작성하고 테스트를 돌렸는데 분명 2월 말 마지막 운동이 ‘등’이었는데, 3월 2일 첫 운동이 ‘가슴’부터 시작되는 오류가 발생했다.
원인은 로봇이 ‘미래의 일정’ 을 봤기 때문이었다. 테스트를 3월이 지난 시점에 돌리다 보니, GET 노드를 통해 ‘3월 초’의 기존 테스트 데이터까지 몽땅 읽어버린 것이다.
이를 해결하기 위해 Code 노드에 new Date(e.date) < firstDayOfThisMonth 라는 필터를 걸었다. 캘린더를 짤 때는 무조건 ‘지난달 말일’까지만 보도록 제한하여 해결했다.
// 24일 주기의 루틴 마스터 테이프
const macroCycle = [
"가슴", "등", "하체", "어깨", "가슴", "등",
"하체_전면", "어깨", "가슴", "등", "하체_후면", "어깨",
"등", "가슴", "하체", "어깨", "등", "가슴",
"하체_전면", "어깨", "등", "가슴", "하체_후면", "어깨"
];
// 달이 넘어가며 테이프가 리셋되는 것을 대비해 2장을 이어 붙임
const doubleCycle = macroCycle.concat(macroCycle);
const validWorkoutNames = ["하체_전면", "하체_후면", "하체", "가슴", "등", "어깨"];
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const firstDayOfThisMonth = new Date(year, month, 1);
// 과거 루틴 파싱 및 필터링
let pastEvents = $input.all().map(item => {
let eventDate = item.json.start?.date || item.json.start?.dateTime || "";
return {
title: item.json.summary || item.json.name || "",
date: eventDate
};
}).filter(e => {
// 당월 데이터가 읽혀 스텝이 꼬이는 것을 방지
return e.date !== "" && new Date(e.date) < firstDayOfThisMonth;
});
pastEvents.sort((a, b) => new Date(a.date) - new Date(b.date));
let recentWorkouts = [];
for (let event of pastEvents) {
let found = validWorkoutNames.find(w => event.title.includes(w));
if (found) recentWorkouts.push(found);
}
// 3. 패턴 매칭 (마스터 테이프에서 현재 위치 찾기)
let nextWorkoutIndex = 0;
const maxSearchLength = Math.min(recentWorkouts.length, 6);
let isMatched = false;
for (let matchLen = maxSearchLength; matchLen > 0; matchLen--) {
let targetSeq = recentWorkouts.slice(-matchLen);
let targetStr = JSON.stringify(targetSeq);
for (let i = 0; i < 24; i++) {
let sliceStr = JSON.stringify(doubleCycle.slice(i, i + matchLen));
if (targetStr === sliceStr) {
nextWorkoutIndex = (i + matchLen) % 24;
isMatched = true;
break;
}
}
if (isMatched) break;
}
// 달력에 입력할 일정 작성
const lastDayOfMonth = new Date(year, month + 1, 0).getDate();
const newMonthEvents = [];
for (let day = 1; day <= lastDayOfMonth; day++) {
let currentDate = new Date(year, month, day);
let dayOfWeek = currentDate.getDay();
let todaysWorkout = "";
if (dayOfWeek === 0) {
let nthSunday = Math.ceil(day / 7);
todaysWorkout = (nthSunday % 2 !== 0) ? "휴관일" : "휴식일";
} else {
todaysWorkout = macroCycle[nextWorkoutIndex];
nextWorkoutIndex = (nextWorkoutIndex + 1) % 24;
}
let dateString = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
// 구글 All Day 규칙을 위한 종료일(+1일) 세팅
let nextDay = new Date(year, month, day + 1);
let endDateString = `${nextDay.getFullYear()}-${String(nextDay.getMonth() + 1).padStart(2, '0')}-${String(nextDay.getDate()).padStart(2, '0')}`;
newMonthEvents.push({
json: {
title: todaysWorkout,
date: dateString,
endDate: endDateString,
yearMonth: `${year}년 ${month + 1}월`
}
});
}
return newMonthEvents;4. Google Calendar 노드 [POST]
Code 노드에서 만들어진 한 달 치의 데이터가 노드에 도착하면, 이 노드가 일정 수 만큼 돌면서 달력에 일정을 써준다.
-
Resource:
Event/ Operation:Create -
Calendar: 내 헬스 전용 캘린더 선택
-
Start & End:
{{ $json.date }}(시작과 끝을 같게 설정) -
All Day: 활성화 (
true) -
Summary:
{{ $json.title }}
3단계: 테스트
설정된 Trigger 일정에 맞춰 돌아가지만, 우선 작동하는지 확인을 한다. 사실 각 노드별로 생성 후 Excute step을 통해 유닛 테스트를 마쳤지만 또 인티그레이션 테스트를 해봐야 하지 않겠는가?
노드 하단에 Execute workflow를 눌러 캘린더에 일정 데이터가 잘 적재되는지 확인해보자.

다행히 자동화는 잘 적재된 것을 확인할 수 있다.

마무리
이제 귀찮게 매 월 1일 스케줄링을 할 필요가 없어졌다. 하지만 걱정(?)되는 것이 있다. 이 n8n 컨테이너는 혼자 쓰는데 이걸 외부로 열어놓을 필요가 있을까 싶다. 사실 리버스 프록시를 해두고 웹소켓 설정까지 했는데 그럴 필요가 당장은 없을 것 같다는 생각이 자꾸 들었다.
오직 구글 OAuth 하나를 위해 열어두어야 했던 리버스 프록시와 공인 도메인 문제를 근본적으로 해결해보려고 한다.