시작하며: “돌아가는 쓰레기”에서 “미래의 나에게 덜 미안한 코드”로
지난 4편까지의 과정을 통해 외부망 노출 없이 안전하게 구글 캘린더에 운동 루틴을 꽂아 넣는 자동화 파이프라인을 완성했다. 노드는 내 의도대로 잘 움직였다.
하지만 개발자들의 오랜 농담 중에 “일단 돌아가는 쓰레기를 만들어라” 라는 말이 있다. 내 로직이 딱 그렇다. 지금 당장은 잘 돌아가지만, 내가 운동을 하루 빼먹거나 캘린더를 수동으로 수정하는 순간 조용히 오작동을 일으킬 결함들이 있다.
이번 글에서는, 알고리즘의 약점들을 찾아내 소프트웨어 아키텍처 패턴(상태 외부화) 을 도입하고, n8n의 TypeScript 문법 검사기를 통과할 리팩토링 기록을 작성해 본다.
1. 근본적인 한계
Edge case는 실제 데이터를 넣고 돌려보면서 발견되었다. 내가 3월 4일에 ‘가슴’을 해야 하는데 수동으로 ‘등’으로 바꿨다 하자. 다음 달 1일이 되어 로봇이 과거 달력을 읽을 때, 로봇은 내가 수동으로 수정한 순서를 기본 루틴에 대조하게 되고 결국 패턴 매칭에 실패한다.
이를 극복하기 위해 ‘상태 외부화 (Externalized State)’ 패턴을 도입했다. DB같은걸 증설해서 이런걸 캐싱하는 리소스 낭비하긴 싫고 해서 그냥 달력에 남겨봤다. 매월 과거 데이터를 뒤지며 패톤 추론을 할 필요 없이, 봇이 스스로 다음 달 달력 구석에 인덱스(_CYCLE_STATE_) 를 하나 꽂아두는 것이다.
Upsert 방식
인덱스를 매달 새로 만들면 달력엔 불필요한 인덱스가 계속 쌓인다. 이를 막기 위해 n8n 워크플로우에
If노드를 추가하여 분기 처리를 했다.중요한 점은 이 인덱스는 메모지가 아니라, ‘북마크’ 처럼 동작한다는 것이다. 기존 북마크가 존재하면, 봇은 그 북마크에 있는 기존 메모(
CYCLE_INDEX:15)를 지우고 새로 적은 뒤, 기존 날짜에서 빼들어 다음 달 1일 칸으로 옮겨 꼽는다(PATCH). 이렇게 하면 매달 새로운 인덱스가 생성되는 것이 아니라, 단 1개의 북마크가 항상 한 달 먼저 위치해 다음 달 1일 칸에서 대기하는 시스템으로 사용된다.
2. 🚨 TypeScript Linter
로직을 고도화한 후 n8n 코드 노드에 올렸더니 곳곳에서 붉은 줄이 그어졌다. 나의 빈약한 JS코드는 아마(?) 괜찮은데, n8n 최신 버전에는 TypeScript Linter가 있어 경고를 한다. lint에게 합격을 받기 위해 코드를 어떻게 수정했는지 스니펫 3개를 작성해봤다.
🛑 에러 1. 날짜끼리 빼면 안돼 (getTime())
이전 코드에서는 날짜를 정렬하기 위해 new Date(a) - new Date(b)를 사용했다. 하지만 TypeScript는 “날짜 객체끼리 무작정 빼지 말고 단위를 통일해!”라며 산술 연산 에러를 뱉어냈다.
// ❌ 기존 코드 (에러 발생)
.sort((a, b) => new Date(a.date) - new Date(b.date))
// ✅ 수정: .getTime()을 붙여서 밀리초(ms) 단위의 '숫자'로 변환
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())🛑 에러 2. 빈 배열 (never[] 타입 에러)
구글 API 응답을 평탄화하거나 원하는 배열을 뽑아낼 때 .reduce(..., [])를 사용했었다. 그런데 TypeScript는 이 초기값 [](빈 배열)을 “아무것도 들어갈 수 없는 배열(never[])” 로 인식해버려서, 그 안에 무언가를 합치려 하거나(concat) 밀어 넣으려(push) 할 때마다 에러를 냈다.
// ❌ 기존 코드 (reduce를 사용한 추출 - never[] 에러 발생)
.reduce((acc, e) => {
const found = validWorkoutNames.find(w => e.title.includes(w));
if (found) acc.push(found);
return acc;
}, []);
// ✅ 수정: reduce를 버리고 map과 filter 체이닝으로 교체
.map(e => validWorkoutNames.find(w => e.title.includes(w)))
.filter(found => found !== undefined);평탄화 작업 역시 에러를 유발하는 flatMap 대신, 투박하지만 가장 원초적이고 안전한 for 루프와 push 를 사용했다.
🛑 에러 3. 없는 데이터 요구 금지 (대괄호 표기법 우회)
인덱스(_CYCLE_STATE_)의 내용을 읽어오기 위해 stateEvent.json.description을 호출했더니 에러가 났다. 구글이 준 기본 데이터 Type에 description이라는 속성 자체가 정의되어 있지 않아, “왜 없는걸 달라해? (Property does not exist)” 라고 에러가 난 것이다..
// ❌ 기존 코드 (점 표기법 - 에러 발생)
const desc = stateEvent.json.description;
// ✅ 수정: 대괄호 표기법(['description'])으로 검사 우회
const stateJson = stateEvent.json || {};
const desc = stateJson['description'] ? String(stateJson['description']) : "";점(.) 대신 대괄호를 사용하면 “저 상자 안에 description 라벨이 있는지 직접 찾아줘”라고 지시하게 되어 에러를 우회할 수 있다.
3. 최종 완성된 무결점 알고리즘 코드
위의 TypeScript 호환성 패치와 더불어 ‘12월 연도 이월 버그’(new Date로 자동 이월), ‘구글 캘린더 HTML 태그 숨김 현상’(Regex 정규식 추출) 등 엣지 케이스를 해결하고자 한 최종 코드는 다음과 같다.
// ─── 상수 ────────────────────────────────────────────────────────
const macroCycle = [
"가슴", "등", "하체", "어깨", "가슴", "등",
"하체_전면", "어깨", "가슴", "등", "하체_후면", "어깨",
"등", "가슴", "하체", "어깨", "등", "가슴",
"하체_전면", "어깨", "등", "가슴", "하체_후면", "어깨"
];
const CYCLE_LEN = macroCycle.length;
const MAX_MATCH = 6;
const doubleCycle = macroCycle.concat(macroCycle);
const validWorkoutNames = ["하체_전면", "하체_후면", "하체", "가슴", "등", "어깨"];
// ─── 헬퍼 ────────────────────────────────────────────────────────
const toYMD = (y, m, d) => {
const date = new Date(y, m, d); // 12월 이월 버그 방지
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${mm}-${dd}`;
};
// ─── 날짜 기준 ───────────────────────────────────────────────────
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const firstDayOfThisMonth = new Date(year, month, 1);
const lastDayOfMonth = new Date(year, month + 1, 0).getDate();
// ─── 0. 구글 API 응답 평탄화 ─────────────────────────────────────
const allEvents = [];
const items = $input.all();
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.json && Array.isArray(item.json.items)) {
for (let j = 0; j < item.json.items.length; j++) {
allEvents.push({ json: item.json.items[j] });
}
} else {
allEvents.push(item);
}
}
// ─── 1. 상태 이벤트 우선 탐색 ────────────────────────────────────
const stateEvent = allEvents.find(item =>
item.json && item.json.summary === "_CYCLE_STATE_"
);
let nextWorkoutIndex = 0;
let isMatched = false;
if (stateEvent) {
const stateJson = stateEvent.json || {};
const desc = stateJson['description'] ? String(stateJson['description']) : "";
const match = desc.match(/CYCLE_INDEX:(\d+)/);
if (match) {
const saved = parseInt(match[1], 10);
if (saved >= 0 && saved < CYCLE_LEN) {
nextWorkoutIndex = saved;
isMatched = true;
}
}
}
// ─── 2. 패턴 매칭 폴백 ───────────────────────────────────────────
if (!isMatched) {
const buildWorkouts = (manualOnly) =>
allEvents
.filter(item => {
if (!item.json) return false;
const start = item.json.start || {};
const d = start.date || start.dateTime || "";
const isNotStateEvent = item.json.summary !== "_CYCLE_STATE_";
const creator = item.json.creator || {};
const isManual = creator.email === "ericna130@gmail.com";
return isNotStateEvent
&& d !== ""
&& new Date(d) < firstDayOfThisMonth
&& (manualOnly ? isManual : true);
})
.map(item => {
const start = item.json.start || {};
return {
title: item.json.summary || item.json.name || "",
date: start.date || start.dateTime || ""
};
})
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map(e => validWorkoutNames.find(w => e.title.includes(w)))
.filter(found => found !== undefined);
const tryMatch = (workouts) => {
const maxSearchLength = Math.min(workouts.length, MAX_MATCH);
for (let matchLen = maxSearchLength; matchLen > 0; matchLen--) {
const targetSeq = workouts.slice(-matchLen);
for (let i = 0; i < CYCLE_LEN; i++) {
const matched = targetSeq.every((v, k) => doubleCycle[i + k] === v);
if (matched) return (i + matchLen) % CYCLE_LEN;
}
}
return null;
};
const manualResult = tryMatch(buildWorkouts(true));
if (manualResult !== null) {
nextWorkoutIndex = manualResult;
isMatched = true;
}
if (!isMatched) {
const fullResult = tryMatch(buildWorkouts(false));
if (fullResult !== null) {
nextWorkoutIndex = fullResult;
isMatched = true;
}
}
if (!isMatched) {
throw new Error(
"운동 패턴 매칭 실패. 상태 이벤트도 없고 과거 데이터도 읽을 수 없습니다. " +
"캘린더와 2번 노드 크롤링 범위를 확인하세요."
);
}
}
// ─── 3. 달력 블록 조립 ───────────────────────────────────────────
const newMonthEvents = [];
for (let day = 1; day <= lastDayOfMonth; day++) {
const dayOfWeek = new Date(year, month, day).getDay();
let todaysWorkout;
if (dayOfWeek === 0) {
const weekOfMonth = Math.ceil(day / 7);
todaysWorkout = weekOfMonth % 2 !== 0 ? "휴관일" : "휴식일";
} else {
todaysWorkout = macroCycle[nextWorkoutIndex];
nextWorkoutIndex = (nextWorkoutIndex + 1) % CYCLE_LEN;
}
const dateString = toYMD(year, month, day);
const endDateString = day < lastDayOfMonth
? toYMD(year, month, day + 1)
: toYMD(year, month + 1, 1);
newMonthEvents.push({
json: {
title: todaysWorkout,
date: dateString,
endDate: endDateString,
yearMonth: `${year}년 ${month + 1}월`
}
});
}
// ─── 4. 상태 이벤트 저장 (Upsert용) ─────────────────────────────
const existingStateId = (stateEvent && stateEvent.json && stateEvent.json.id) ? stateEvent.json.id : null;
newMonthEvents.push({
json: {
title: "_CYCLE_STATE_",
date: toYMD(year, month + 1, 1),
endDate: toYMD(year, month + 1, 2),
description: `CYCLE_INDEX:${nextWorkoutIndex}`,
yearMonth: `${year}년 ${month + 1}월`,
stateEventId: existingStateId
}
});
return newMonthEvents;마치며
코드가 멈출 수 있는 edge case를 방어해 보았다. 하지만 세상엔 완벽한게 없고 내가 생각하지 못한 어떤 에러가 발생할지 모르는데 너무 happy case만 생각한 것 같아, 예상치 못한 크래시 발생 시 알림을 줄 수 있는 전역 에러 노드를 만드는걸 해보려고 한다.