본문으로 건너뛰기
취준생을 위한 n8n 실전 워크플로우 레시피 · 14/44시리즈 보기 →

매일 아침 대통령 일정을 긁어 구글시트에 쌓는 n8n 워크플로우 — 최초 적재와 매일 증분을 나누는 이유

· 약 11분
Datapopcorn CEO / AI automation educator
이 글과 연결된 글 보기
7편 · 20개 연결
100%
매일 아침 대통령 일정을 긁어 구글시트에 쌓는 n8n 워크플로우 — 최초 적재와 매일 증분을 나누는 이유n8n Data Table 처음 써보기 — 트렌드 3개를 합쳐 매일 아침 분석 주제 뽑는 워크플로우n8n으로 정적 웹서버 운영하기 — 수료증 발급부터 진위 확인까지, 멱등 처리로 안전하게벡터DB 없이 실시간으로 사이트를 읽는 챗봇 — 경복대학교 안내 챗봇 해부n8n으로 반복업무 자동화 포트폴리오 만들기구글시트에 쌓인 데이터를 인스타그램 카드뉴스로 — n8n 시트 인스타그램 자동발행서브워크플로우를 Tool로 감싸 AI Agent에게 서버 점검시키기 — 그리고 시스템 프롬프트만으로는 부족한 이유

드래그로 이동 · 휠/두 손가락으로 확대 · 방향키로 글 선택 · 0으로 맞춤. 실선: 본문 링크, 점선: 시리즈·주제의 대표 연결.

표시된 글 7편 목록
전체 글 연결 탐색

대통령실 홈페이지의 월별 일정 페이지를 n8n HTML 노드로 파싱하면, 구글시트에 날짜·내용·장소가 한 행씩 쌓이는 일정 데이터베이스가 코드 몇 줄로 만들어집니다. 핵심은 한 캔버스에 흐름을 두 개 두는 것입니다. 지난 몇 달치를 한 번에 넣는 수동 흐름과, 매일 아침 이번 달 페이지를 다시 읽어 아이디가 같은 행은 덮어쓰고 없는 행만 추가하는 스케줄 흐름입니다. 이 구조를 한 번 만들어두면 채용공고, 지자체 행사, 공연 일정처럼 "날짜가 붙은 목록 페이지"는 전부 같은 방식으로 쌓을 수 있습니다.

이 워크플로우 하나로 보여줄 수 있는 것​

HTML 노드 편에서 베스트셀러 목록을 한 번 긁어봤다면, 이번 편은 그다음 질문에 답합니다. "그걸 매일 돌리면 시트에 중복이 쌓이지 않나요?" 이 워크플로우는 그 문제를 두 흐름 분리와 아이디 하나로 풀고, 그 과정에서 네 가지 역량이 드러납니다.

  • 페이지 구조 읽기 — 달력 페이지를 표, 날짜 칸, 일정 줄의 3단으로 나눠 CSS 셀렉터 세 개로 뽑아낸 경험
  • 백필과 증분 분리 — 과거 데이터를 한 번에 채우는 흐름과 매일 새 데이터를 더하는 흐름을 따로 설계한 경험
  • 고유 키로 중복 방지 — 날짜와 순서로 아이디를 만들어 Append or Update의 매칭 컬럼으로 쓴 경험
  • 스케줄 운영 — 매일 아침 알아서 돌고 슬랙에 흔적을 남기는 자동화를 Active로 켜둔 경험

세 번째가 핵심입니다. "어제까지 쌓인 것과 비교해서 새 것만 골라낸다"는 로직을 짜는 대신, 매번 전체를 다시 읽되 아이디가 같으면 덮어쓰게 하면 비교 코드가 통째로 사라집니다.

배경 — 한 번 긁는 것과 계속 쌓는 것은 다른 문제​

HTML 노드 웹 스크래핑 편은 페이지를 한 번 읽어 시트에 넣는 데서 끝났고, 403 가격 추적 편은 막히는 사이트를 우회하는 데 집중했습니다. 둘 다 "오늘 한 번"의 이야기입니다. 그런데 스크래핑에 가치가 생기는 건 대개 시간이 쌓였을 때입니다. 일정이 몇 달치 모여야 "이번 달엔 지방 방문이 늘었네" 같은 말을 할 수 있습니다.

이 워크플로우는 데이터팝콘 n8n 인스턴스에 바이브코딩 태그로 만들어 둔 것으로, 대통령실 홈페이지의 공개 일정 페이지(https://www.president.go.kr/president/calendar?year=YYYY&month=MM)를 읽습니다. 정부가 공개하는 일정이라 스크래핑 대상으로 부담이 적고, 날짜별 일정이 "내용 / 장소" 형식으로 정리돼 있어 표 구조를 읽는 연습에 알맞습니다. 캔버스에는 최초 1회 적재 코드와 매일 9시 스케줄 적재 코드라는 스티키 노트 제목이 붙어 있고, export 시점에 워크플로우는 Active 상태였습니다. 설치나 기본 노드 사용법은 n8n으로 반복업무 자동화 포트폴리오 만들기에 있으니 여기서는 두 흐름의 설계와 파싱 단계만 다룹니다.

구조 — Trigger / Task / Target​

한 캔버스에 트리거가 두 개입니다. 파싱 구간(HTTP Request → HTML 세 번 → Code)은 두 흐름이 거의 같고, 앞의 입력과 뒤의 시트 쓰기 방식만 다릅니다.

  • Trigger — 백필은 수동 실행 한 번, 증분은 매일 아침 스케줄
  • Task — 월별 페이지 요청 → 달력 tbody → 날짜 칸 → 일정 줄 순서로 HTML 추출 → Code로 아이디·날짜·내용·장소 정리
  • Target — 백필은 Append, 증분은 아이디 기준 Append or Update 후 슬랙 미리보기

만들기​

0. 페이지 구조 먼저 보기 — 셀렉터 세 개면 됩니다​

일정 페이지는 월 단위 달력입니다. 개발자 도구로 열어보면 달력 본체가 table > tbody 안에 있고, 날짜 하나가 div.box.check 블록 하나이며, 그 안에 날짜 숫자가 span.num, 그날의 일정 줄들이 ul.txtList.dot li로 들어 있습니다. 일정 줄 텍스트는 "국무회의 / 대통령실"처럼 슬래시로 내용과 장소가 나뉩니다.

원본에서 달력 본체를 잡는 셀렉터는 #contents > article > div > div > div > div.calender > div > table > tbody입니다. 개발자 도구의 Copy selector가 만들어주는 형태 그대로이고, calender라는 철자는 사이트가 쓰는 클래스명이라 그대로 둬야 합니다. "표 → 칸 → 줄" 3단이 보이면 HTML 노드도 세 번 쓰겠다는 계획이 섭니다.

1. 최초 1회 적재 — 연·월 URL 목록을 만들고 한 달씩 돕니다​

Manual Trigger 뒤에 Code 노드로 읽을 달의 URL 목록을 만듭니다. 원본은 2025년 6월부터 12월까지 일곱 달입니다.

const months = [];

for (let month = 6; month <= 12; month++) {
months.push({
year: 2025,
month: month,
url: `https://www.president.go.kr/president/calendar?year=2025&month=${month.toString().padStart(2, '0')}`
});
}

return months;

이 일곱 아이템을 Loop Over Items(Split In Batches) 노드에 넣고, loop 출력에 HTTP Request 노드(URL: {{ $json.url }})를 연결합니다. 마지막 Google Sheets 노드의 출력을 다시 Loop Over Items로 돌려보내면 한 달치가 시트에 들어간 뒤 다음 달을 요청하는 구조가 됩니다. 일곱 페이지를 한꺼번에 요청하지 않고 한 달씩 처리하는 게 서버에도, 디버깅에도 낫습니다.

2. HTML 노드 세 번 — 달력에서 날짜로, 날짜에서 일정으로​

HTML 노드는 한 번에 한 층만 뽑습니다. 그래서 세 번 이어 붙입니다.

  1. HTML — Operation: Extract HTML Content. Key table, CSS Selector에 위의 tbody 셀렉터, Return Value: HTML, Return Array 켬
  2. HTML1 — Source Data를 JSON, JSON Property를 table로 두고 Key d, CSS Selector div.box.check, Return Value: HTML, Return Array 켬. 날짜 칸 HTML이 배열 d가 됩니다
  3. Split Out — Field To Split Out을 d로. 날짜 하나가 아이템 하나가 됩니다
  4. HTML2 — JSON Property d에서 Key day는 span.num, Key contents는 ul.txtList.dot li에 Return Array 켬

두 번째와 네 번째 HTML 노드에서 JSON Property를 지정하는 게 포인트입니다. 앞 노드가 뽑은 HTML을 원본으로 삼아 다시 파고드는 방식이라, 셀렉터 하나를 길게 쓰는 것보다 어느 층에서 0건이 나왔는지 바로 보입니다.

3. Code — 일정 한 줄을 시트 한 행으로, 아이디까지 붙입니다​

날짜 아이템 하나에 일정이 여러 줄 들어 있으니 이걸 풀어 행으로 만듭니다. 원본 Code 노드의 핵심만 옮기면 다음과 같습니다.

const result = [];

for (const item of $input.all()) {
const day = item.json.day;
const contents = item.json.contents || [];

// Loop Over Items가 현재 처리 중인 월의 데이터
const loopItem = $('Loop Over Items').item.json;
const year = loopItem.year || '';
const month = loopItem.month || '';

const dateStr = `${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}`;
const fullDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
let index = 1;

for (const content of contents) {
const parts = content.split('/').map(p => p.trim());
const id = `${dateStr}_${String(index).padStart(2, '0')}`;

result.push({
아이디: id,
날짜: fullDate,
내용: parts[0] || '',
장소: parts[1] || ''
});
index++;
}
}

return result;

연·월은 페이지 안에 없고 URL에만 있으니 $('Loop Over Items').item.json으로 지금 도는 달의 값을 가져옵니다. 아이디는 20250701_02처럼 "날짜_그날의 순서"이고, 이 아이디가 뒤에서 중복 방지의 열쇠가 됩니다.

4. Google Sheets Append — 그리고 Loop로 복귀​

Google Sheets 노드(Operation: Append)에서 Mapping Column Mode를 Map Automatically로 두면 아이디, 날짜, 내용, 장소 네 필드가 같은 이름의 시트 헤더에 들어갑니다. 시트에는 이 네 헤더만 미리 적어두면 됩니다. 이 노드의 출력을 Loop Over Items로 연결하는 걸 잊지 마세요. 연결이 없으면 6월 한 달만 들어가고 멈춥니다.

5. 매일 스케줄 흐름 — 어제 날짜의 연·월로 같은 페이지를 다시 읽습니다​

두 번째 흐름은 Schedule Trigger로 시작합니다. 스티키 노트에는 "매일 9시"라고 적혀 있지만 export된 노드는 08시 10분으로 설정돼 있습니다. 어느 쪽이든 "전날 일정이 다 올라온 뒤 아침에 한 번"입니다. 그다음 Set 노드(원본 이름 Set yesterday)에서 연·월을 만듭니다.

  • year: {{ $today.minus({ days: 1 }).toFormat('yyyy') }}
  • month: {{ $today.minus({ days: 1 }).toFormat('MM') }}

오늘이 아니라 어제 기준인 것이 작지만 중요한 선택입니다. 매월 1일 아침에 실행되면 어제는 지난달 마지막 날이므로 지난달 페이지를 한 번 더 읽게 되고, 월말에 추가된 일정을 놓치지 않습니다. HTTP Request 노드의 URL은 https://www.president.go.kr/president/calendar?year={{ $json.year }}&month={{ $json.month }}이고, 그 뒤의 HTML 세 개와 Split Out은 1번 흐름과 같습니다.

Code 노드는 백필 쪽과 거의 같지만 연·월을 $('Set yesterday').first().json에서 가져오고, 장소를 마지막 슬래시 뒤로 잡습니다. 다른 부분만 발췌하면 이렇습니다.

const results = [];
const dateParams = $('Set yesterday').first().json;
const targetYear = dateParams.year.toString();
const targetMonth = dateParams.month.toString().padStart(2, '0');

for (const item of items) {
const dayStr = parseInt(item.json.day, 10).toString().padStart(2, '0');

(item.json.contents || []).forEach((contentStr, index) => {
let content = contentStr;
let location = "";
if (contentStr.includes(" / ")) {
const parts = contentStr.split(" / ");
location = parts.pop();
content = parts.join(" / ");
}
const uniqueId = `${targetYear}${targetMonth}${dayStr}_${(index + 1).toString().padStart(2, '0')}`;

results.push({ json: {
unique_id: uniqueId,
date: `${targetYear}-${targetMonth}-${dayStr}`,
content: content.trim(),
location: location.trim()
}});
});
}

원본은 items로 입력을 받는데 이건 예전 문법이라, 지금 새로 만든다면 $input.all()을 쓰세요. 아이디 규칙이 백필 쪽과 같은 YYYYMMDD_NN이라는 점이 중요합니다. 규칙이 다르면 두 흐름이 같은 일정을 다른 행으로 봅니다.

6. Append or Update — 아이디가 같으면 덮어쓰고 없으면 추가​

여기가 중복 방지의 전부입니다. Google Sheets 노드에서 Operation을 Append or Update로 두고 Column to Match On을 아이디로 지정합니다. 컬럼 매핑은 Map Each Column Manually로 아이디에 {{ $json.unique_id }}, 날짜에 {{ $json.date }}, 내용에 {{ $json.content }}, 장소에 {{ $json.location }}입니다.

이 노드는 아이템마다 시트에서 아이디가 같은 행을 찾아 있으면 갱신하고 없으면 새 행을 추가합니다. 그래서 매일 아침 이번 달 페이지 전체를 다시 읽어도, 이미 들어간 일정은 같은 값으로 덮어써질 뿐 행이 늘지 않고 새로 올라온 일정만 추가됩니다. "어제까지 쌓인 것과 비교한다"는 코드가 어디에도 없는 이유입니다. 비교를 시트 노드의 매칭 규칙에 맡겼습니다.

마지막 Slack 노드는 $input.all().at(-1)로 시트 노드가 돌려준 마지막 아이템 한 건을 골라 "가장 최근 항목 1건 미리보기"로 보냅니다. 새 일정이 몇 건인지 세는 게 아니라 오늘도 돌았다는 흔적을 남기는 용도이고, Include Link to Workflow를 켜두면 메시지에서 바로 캔버스로 갈 수 있습니다.

원본 캔버스에 남아 있는 흔적​

실행 이력은 없지만 캔버스에는 만든 과정이 꽤 남아 있습니다. 스티키 노트는 두 흐름의 제목 외에 대통령실 주소를 적어둔 메모가 하나 더 떠 있는데, 노드 어디에서도 쓰이지 않습니다.

연결되지 않은 Code 노드도 하나 있습니다. 3번 Code와 거의 같은데 아이디 없이 일자, 내용, 장소만 만듭니다. 아이디가 없으면 Append or Update가 무엇을 기준으로 맞출지 정할 수 없으니, 두 번째 흐름을 붙이면서 아이디가 생긴 순서로 읽힙니다. 처음부터 upsert를 생각한 게 아니라 "매일 돌리니 중복이 생기네"에서 출발했다는 흔적이라 오히려 참고가 됩니다.

백필 흐름의 HTTP Request 바로 뒤에는 응답의 url에서 연·월을 뽑으려는 Code 노드가 하나 더 있습니다. HTTP Request 응답 본문에는 요청 URL이 들어 있지 않아 이 노드는 기본값으로 떨어지고, 실제 연·월은 3번 Code가 Loop Over Items에서 가져오니 지워도 동작에 차이가 없습니다. 스케줄 쪽 Code가 만드는 collected_at(KST 수집 시각)도 시트 매핑에 빠져 있어 기록되지 않습니다. 남기고 싶으면 시트에 헤더를 추가하고 매핑 한 줄만 더 넣으면 됩니다.

한계​

  • 순서 기반 아이디 — 아이디가 "날짜_그날의 순서"라, 사이트가 어느 날의 목록 중간에 항목을 끼워 넣으면 뒤 순서의 아이디가 밀려 내용이 덮어써집니다. 삭제된 일정도 시트에 그대로 남습니다. 날짜와 내용 두 컬럼을 매칭 키로 쓰면 줄일 수 있습니다.
  • 셀렉터 의존 — 페이지 구조가 바뀌면 에러가 아니라 0건이 조용히 흘러갑니다. HTML 노드 뒤에 If 노드로 아이템 수가 0이면 슬랙에 경고를 보내는 분기를 두는 게 운영에서는 필수입니다.
  • 백필 재실행 — 연도와 달 범위가 Code에 박혀 있고, 백필은 Append라 두 번 실행하면 그대로 두 배가 됩니다. 백필도 Append or Update로 바꾸면 재실행이 안전해집니다.
  • 두 Code의 장소 규칙 차이 — 백필은 split('/')의 두 번째 조각, 증분은 " / " 기준 마지막 조각을 장소로 봅니다. 내용에 슬래시가 섞인 일정은 다르게 잘릴 수 있으니 한쪽으로 통일하세요.

포트폴리오·면접 포인트​

이 워크플로우는 대상 페이지를 바꾸는 것만으로 다른 프로젝트가 됩니다. 관심 기업의 채용공고 목록, 지자체 행사 캘린더, 공연장 월간 일정처럼 "날짜가 있고 매일 조금씩 늘어나는 공개 페이지"를 하나 고르고, 셀렉터 세 개와 아이디 규칙만 새로 정하면 됩니다. 포트폴리오에는 캔버스 스크린샷과 함께 몇 주 동안 실제로 쌓인 시트 캡처를 넣으세요. 쌓인 행 수 자체가 "매일 돌았다"는 증거입니다.

면접에서는 "왜 새 일정만 골라내지 않고 매일 한 달치를 다시 읽나요?"라는 질문이 올 수 있습니다. 페이지에 "새 글" 표시가 없고, 고유 아이디로 Append or Update를 하면 다시 읽는 것이 곧 중복 방지이기 때문입니다. 비교 로직이 없어 코드가 줄고, 어느 날 실행이 실패해도 다음 날 실행이 빠진 날을 메웁니다. 여기에 백필과 증분을 나눈 이유까지 이어 답하면 좋습니다. 둘은 입력(달 범위 목록과 어제 기준 한 달)과 쓰기 방식(Append와 Upsert)이 달라 한 흐름으로 합치면 분기가 늘어나는데, 파싱 구간은 같으니 한 캔버스에 나란히 두는 게 유지보수가 가장 쉽다는 설명입니다.

다음 액션​

시트에 일정이 쌓이기 시작하면 다음 질문은 "이걸로 뭘 볼 수 있나"입니다. 다음 편은 포켓몬 API를 재료로 수집, 정제, AI 분석까지 한 바퀴를 도는 워크플로우입니다. 이 편이 "쌓기"에서 끝났다면 그 편은 쌓인 데이터를 읽어 의미를 뽑는 쪽이고, HTTP Request 뒤에 정제 단계가 붙는 자리는 이 글의 HTML 세 단계와 같습니다.