시작하며: 4월 1일 자정, 자동화 에러 알람
지난 6편에서 자동화 봇이 에러가 발생했을 때 즉시 텔레그램으로 전달해주는 ‘긴급 알림’ 을 구축했다. 그리고 한 달이 지나, 처음 진짜 자동화가 도는 4월 1일 자정이 되었다.
기분 좋은 성공 알림을 기대하며 스마트폰을 열었는데, 다음과 같은 메시지가 도착했다.

‘엥? 세너티를 돌렸을 때 잘 됐고, 분명 3월 말까지 운동을 채워 넣었고 _CYCLE_STATE_도 있는데 과거 데이터를 읽을 수 없다니?’ 텔레그램이 있어서 일단 빠르게 에러가 난 사실은 알 수 있었다.
봇이 보낸 에러 로그를 바탕으로 코드 노드를 읽어봤다. 그리고 이 에러는 두 가지 불운이 겹쳐서 발생한 ‘타임존(Timezone)‘이 원인 이었음을 알아냈다.
🕵️♂️ 로봇이 멈춘 2가지 이유
1. 첫 번째 불운: 텅 빈 비밀 쪽지 (Empty Note)
에러 메시지를 보면 “상태 이벤트도 없고”라는 말이 있다.
로그를 확인해 보니 구글 달력에 _CYCLE_STATE_ 가 분명히 존재하긴 했다. 하지만 자세히 들여다보니 메모(description)가 아예 없는 빈 껍데기였다. 과거에 테스트를 하다가 내용물을 날려 먹은 모양이다.
로봇은 인덱스를 보고는 *“어라? 메모가 없네. 어쩔 수 없지. 플랜 B(과거 달력 뒤져서 패턴 추리하기)를 가동하자!”*라고 판단했다. 여기까지는 5편에서 아주 잘 설계해 둔 폴백(Fallback) 방어 로직이 완벽하게 작동한 것이다. 진짜 문제는 다음이었다.
2. 두 번째 불운: 영국에 살고 있는 비서 (Timezone 버그)
플랜 B가 발동해서 로봇이 3월 달력을 훑어보았는데, 조건에 맞는 운동 기록이 단 0개가 나왔다. 왜 로봇은 0개라고 뱉고 뻗어 버렸을까? 이 현상을 이해하려면 내가 디버깅 과정에서 품었던 3가지 깊은 의문을 순차적으로 짚어보아야 한다.
의문 ①: 왜 2월 데이터조차 0개로 나왔을까?
처음 로그를 보았을 때 가장 이상했던 점은 2월 기록조차 읽히지 않았다는 것이다. 이 현상은 2번 GET 노드 와 3번 Code 노드 의 지시가 최악의 형태로 엇갈렸기 때문에 발생했다.
-
GET 노드의 행동: 메모리 절약을 위해 “오늘 기준으로 딱 14일 전까지만 가져와!”라고 설정해 두었다. 4월 1일에 시스템이 돌았으므로, 구글 캘린더에서 [3월 18일 ~ 4월 1일] 사이의 서류만 싹 긁어 코드 노드로 넘겨줬다. (2월 서류는 가져오지도 않았다.)
-
Code 노드의 착각: 서류를 검사할 봇은 내부 시계가 영국 시간(UTC) 으로 맞춰져 있었다. 한국은 4월 1일 자정이었지만, 영국은 3월 31일 오후 3시였다. 탐정은 시계를 보고 “아하, 이번 달은 3월이네! 그럼 전달인 3월 1일 이전(2월) 서류만 찾아야지!”라고 엉뚱한 필터링 조건을 걸어버렸다.
결과는 참담했다. 제공한 서류는 [3월 18일 ~ 4월 1일] 이 있는데, 봇은 [3월 1일 이전] 서류를 찾고 있으니 교집합이 0개가 될 수밖에 없었던 것이다.
의문 ②: 도커 설정도 완벽한데 왜 영국 시간이었을까? (소름 돋는 반전)
나는 분명히 내 서버의 docker-compose.yml 파일에 다음과 같이 타임존을 설정해 두었다.
environment:
- GENERIC_TIMEZONE=Asia/Seoul
- TZ=Asia/Seoul
게다가 n8n 워크플로우 설정 창의 Timezone 옵션도 Asia/Seoul로 정확히 맞춰져 있었다. 인프라와 워크플로우 세팅을 했는데도 Code 노드가 기어코 영국 시간(UTC)을 뿜어내며 뻗어 버린 것이다.
이 귀신이 곡할 노릇 같은 상황의 진짜 범인은 바로 n8n의 보안 아키텍처(Sandbox 밀실) 때문이라고 한다.
-
방음 밀실(Sandbox)의 원칙: n8n은 사용자가 작성한 자바스크립트 코드가 해킹에 쓰이는 것을 막기 위해, 코드를 실행할 때 ‘외부와 완벽히 단절된 방음 밀실(Sandbox)’ 을 만들어 그 안에서만 실행시킨다.
-
압수당한 손목시계: 외부 사무실(Docker)의 벽시계도 한국, 내 책상(Workflow)의 달력도 한국이다. 그런데 봇(Code 노드)이 코드를 실행하러 이 특수 밀실에 들어가는 순간, 보안 요원이 “외부 물건 반입 금지입니다!”라며 외부 환경변수(
TZ)가 적용된 시계를 압수해 버린다. 빈 방에는 오직 공장 초기화된 기본 벽시계(UTC) 하나만 걸려있을 뿐이다. 그래서 밀실 안에서new Date()를 외치면 무조건 UTC 시간을 대답해 버린다.
그렇다면 여기서 마지막 의문이 남는다. 밀실 밖의 다른 노드들은 왜 정상 작동했을까? 1번 스케줄 알람도 영국 시간에 울려야 하고, 2번 구글 데이터도 엉뚱하게 가져왔어야 하는 것 아닌가? 왜 유독 Code 노드만 바보가 된 걸까? 이것은 노드들이 시간을 계산하는 방식, 즉 “절대 시간” 와 “상대 시간” 의 차이 때문이었다.
-
스케줄 노드: n8n의 스케줄러 노드는 Workflow 설정 창 내부에
Timezone이라는 별도의 옵션을 품고 있다. 워크플로우를 생성할 때 내 브라우저를 인식해 자동으로Asia/Seoul로 세팅해 둔 덕분에, 혼자만 온전히 한국 시계를 보고 4월 1일 자정에 로봇을 깨울 수 있었다.
-
GET 노드: 구글에 요청을 보낼 때
toISOString()을 썼다. 이 함수는 UTC 도장을 찍어 보내는 함수다. 영국인 직원은 “지금 이 순간(영국 3월 31일 15시)부터 정확히 336시간(14일) 전의 서류를 줘!”라고 요청했다. 하지만 ‘지금부터 14일 전’이라는 것은 우주적 관점의 절대 시간이다. 한국에서 잰 14일 전이나 영국에서 잰 14일 전이나 똑같은 과거의 그 순간을 가리킨다. 그래서 타임존이 달라도 우연히 올바른 기간의 서류 뭉치를 가져올 수 있었다.-
❓ 구글 API 요청은 왜 놔뒀을까? Workflow 타임존은 한국이니, 구글 API(GET 노드)에 날짜를 요청할 때 쓰던
toISOString()도 그냥toString()으로 바꾸면 되지 않을까? 정답은 “절대 안 된다” 이다.-
toString():Wed Apr 01 2026 00:00:00 GMT+0900 (Korean Standard Time) -
toISOString():2026-03-31T15:00:00.000ZGoogle Calendar API는 전 세계의 데이터를 처리하기 때문에, 서류를 접수할 때 반드시 ‘국제 표준 규격(ISO 8601)‘으로 작성된 문서만 받겠다는 룰이 있다. 우리가toString()으로 사람이 읽기 편하게 서류를 내밀면, Google Calendar API는 “규격에 맞지 않는 문서입니다!” 라며400 Bad Request에러를 뱉는다. 즉,toISOString()은 시간을 영국으로 되돌리기 위함이 아니라, 구글이 요구하는 ‘공식 문서 양식’을 맞추기 위한 필수 작업이었다.
-
-
-
Code 노드 (벽걸이 달력의 비극): 반면 Code 노드에서는
new Date().getMonth()를 썼다. 즉, “지금 방에 걸린 달력을 보고, 이번 달이 몇 월인지 알아내!” 라고 상대적인 시간을 물어본 것이다. 밀실 안의 영국인 직원은 방에 걸린 달력이 아직 3월 31일이었으므로 “이번 달은 3월이다!”라고 엉뚱한 기준선을 그어버린 것이다.
💡해결책: 방어적 프로그래밍
도커 세팅이 완벽했는데도 샌드박스 보안 때문에 UTC가 튀어나온다면, 결국 믿을 수 있는 건 ‘코드 자체의 방어력’뿐이다.
나는 Code 노드의 알고리즘을 두 곳을 수정했다. 어떤 서버 환경, 어떤 샌드박스 보안이 봇의 시계를 흔들어도 원하는 값을 받을 수 있게 방어적 프로그래밍(Defensive Programming) 이다.
1단계: 코드 내에서 강제로 KST(한국 시간)로 명시하기
const now = new Date();
// Sandbox 시계가 강제로 UTC로 멈춰있으므로, 수학적으로 +9시간을 더해 한국 시간으로 바꾼다.
const kstTime = new Date(now.getTime() + (9 * 60 * 60 * 1000));
// 이제 연도와 월을 뽑을 때 무조건 한국 시간이 기준이 된다.
const year = kstTime.getUTCFullYear();
const month = kstTime.getUTCMonth();
// 이번 달 1일 문자열 (예: "2026-04-01")을 안전하게 생성
const firstDayStr = `${year}-${String(month + 1).padStart(2, '0')}-01`;이제 매월 1일 자정에 알람이 울리면, 봇은 샌드박스 시계가 고장 났더라도 스스로 yyyy-MM-01이라는 정확한 기준 날짜를 만들어 낸다.
2단계: 복잡한 시계 대신, 라벨지 글자만 보고 비교하기
이전에는 과거 달력을 필터링할 때 new Date(d) < firstDayOfThisMonth 처럼 날짜 객체로 변환하여 비교했다. 하지만 날짜 객체는 앞서 본 것처럼 샌드박스 환경에서 또 UTC를 바라볼 위험이 높다.
그래서 날짜 객체 비교를 버리고, 단순한 텍스트(문자열) 비교로 교체했다. 마치 시계를 들여다보는 대신, 서류 봉투에 적힌 라벨지 글자만 보고 가나다라 순서대로 분류하는 것과 같다.
// 단순 문자열 비교 (예: "2026-03-31" < "2026-04-01")
return isNotStateEvent
&& d !== ""
&& d < firstDayStr
&& (manualOnly ? isManual : true);
자바스크립트에서 "2026-03-31"과 "2026-04-01"을 부등호(<)로 비교하면, 문자(알파벳) 순서상 무조건 앞의 것이 작다고 판별해 준다. 타임존이 끼어들 틈이 전혀 없는 방식이다. 정렬(sort) 역시 localeCompare를 사용하여 텍스트 기준으로 안전하게 줄을 세웠다.
마치며: 예방 주사
코드를 다듬고, Docker 세팅을 마친 뒤 다시 실행 버튼을 눌렀다. 로봇은 텅 빈 비밀 쪽지를 보고 당황하지 않고 플랜 B를 발동했다. 그리고 정확하게 교정된 ‘한국 시계’를 바탕으로 3월 말의 크로스핏(하체) 기록들을 스캔해 냈다.
또 어떤 문제가 발생해서 나를 성장시켜줄지 기대된다.