자동화의 지름길이자 만병통치약인 "자동화설계법"으로 n8n workflow 복기해 보기

지난주 작성했던 사례를 "자동화설계법"에 비추어, 복기해 본 사례입니다.

만들어 보고 싶었던 거

옵시디언에 작성한 글들을
에빙하우스의 망각곡선 주요 주기에 따라
복습할 내용을 텔레그램으로 전송받고,
전송 받은 문제를 텔레그램을 통해 제출하면,
AI가 채점하고
채점 결과를 텔레그램에 확인하는
간단한(?) 자동화

에빙하우스 망각곡선 주기별 동작은 확실한 테스트를 진행하지 못함. 토요일에~
자동화 설계법 위주로 봐 주세요.

사전 설정

  • 옵시디언 설치 및 구글드라이브 연동

  • n8n (아무거나 상관없음.)

    • 참고) 전 self hosting, cloudflared 이용한 터널링

      -> Qdrant(VectorDB) 이용한 RAG, Ollama(local llm) 맛볼수 있음.

      단, PC사양이 좋아야하고, 그래픽카드까지 있으면 금상첨화

  • PC 및 핸드폰에 텔레그램 설치

  • n8n에 Google Cloud OAuth 설정(Google Drive API, Google Sheet API)

  • n8n에 OpenAI API Credential 설정

!! 자동화 설계법 !!

이전 사례는 해봤다에만 의미를 둔 야메였다면, 이번 사례글을 정석대로...


step1. 자동화 목적 정의

옵시디언에 뭔가를 기록하고, 수집은 하고 있으나, 머리속에 남는게 없다.
주기적으로 이전에 작성한 내용을 복습하는 강제화가 필요하다.

step2. 문제 분해

전반적인 workflw를 LLM을 통해 만들었더니, 뭔가 어색한 부분이 있으나,
일단 따라해 봄

  • 옵시디언 기록, 수집 (이 단계는 트리거는 아님)

  • 주기적 스케쥴러 돌기 (트리거)

  • 옵시디언에서 복습할 문서 찾기

  • 사용자에게 복습할 문서목록 전송

  • 사용자는 텔레그램에서 복습할 문서 선택

  • AI가 해당 문서에 대한 문제 제출해서 텔레그램으로 전송

  • 사용자는 텔레그램에서 문제풀이 후 제출

  • AI가 채점 후 결과를 텔레그램으로 전송

step3. 단계별 규칙 정의

  • 단계별 규칙을 정리할 때 단게별 도구 및 조건/규칙까지 다 나열한다는 건 사실 너무 어려운 작업임

  • 그래서, 문제 분해 단계에서 llm의 도움을 받기는 했음(맘에 들지는 않음)

  • 가장 좋은건, 일단 큰 뼈대를 정리해 두고, workflow에 노드 붙여나갈때마다, 함께 정리해 나가는게 좋을 듯함.

단계

역할

도구

조건/규칙

Trigger

매일 지정 시간에 실행

스케쥴 Trigger

최소 일 단위 실행 (에빙하우스 망각곡선 주기를 따름)

1

수정된 문서 찾기

HttpRequest --> Google Apps Script

FireTime 기준 망각곡선 주기에 부합하는 문서 검색.
GAS를 Web으로 서비스해야 함.

2

문서 목록 전달

HttpRequest -> Telegram

감지된 문서 목록을 사용자에게 전송
reply_markup 요청위해 HttpRequest 이용함.

3

사용자 문서 선택

Telegram

사용자가 문서 선택. Callback 요청

4-1

사용자 메시지 수신

Telegram Trigger

사용자 선택 기반

4-1-1

문서 내용 가져오기

Google Drive / Obsidian

선택된 문서에서 본문 추출

4-1-2

Quiz Prompt 가져오기

Obsidian (템플릿)

프롬프트 템플릿 참조

4-1-3

AI Quiz 생성

AI Agent Node

문서 내용 및 Prompt 기반 질문/답변 생성

4-1-4

Quiz를 사용자에게 전달

Telegram Node

사용자에게 퀴즈 전송. 응답 요청

4-1-5

사용자 Action: 퀴즈 풀기

Telegram

답변 입력

4-2

사용자 메시지 수신

Telegram Trigger

제출된 답변 수집

4-2-1

답변 추출

Code node

사용자 입력 텍스트 파싱

4-2-2

문서 내용 가져오기

HttpRequest -> Google Drive / Obsidian

채점을 위해 원문 불러오기

4-2-3

문제 채점용 Prompt 가져오기

Google Sheets Node

채점 기준 포함된 프롬프트

4-2-4

AI 채점

AI Agent Node

답변과 원문 비교 후 채점

4-2-5

옵시디언 노트 메타데이터 생성

Code Node

학습 진행 기록 생성

4-2-6

옵시디언 노트 메타데이터 반영

HttpRequest --> Google Apps Script

해당 문서에 메타데이터 업데이트

4-2-7

채점 결과를 사용자에게 전송

Telegram Node

채점 결과 보고

4-2-8

사용자 확인

Telegram

결과 확인

step4. 데이터 구조 분석

필요한 데이터의 구조를 잘 분석해 두어야, 이후 workflow 작업 시 AI에게 전달할 프롬프트를 좀 더 정교하게 짤수 있음!!

Obsidian Metadata

옵시디언 복습에 필요한 데이터들을 어디다(구글스프레드시트, 에어테이블, 로컬DB, etc) 정리할까 고민하다가,
옵시디언 문서 자체의 메타데이터(Properties)를 이용하기로 함.

옵시디언노트에서 Properties 활성화 방법 :
- Command + ; --> 수동으로 추가
- Command + p ==> Templates: Insert template ==> 템플리 문서 선택 --> 템플릿 파일을 통해 묶음 추가

Properties 정보는 md 파일의 가장 상단에 저장됨

테스트 속성 화면의 스크린 샷

property 설명

  • created_at : 문서 생성일. 문서 생성시 사용자가 입력

  • reviewable : 복습 대상 여부. 체크되어 있어야만 복습 대상으로 간주

  • review_count: 복습 횟수. 복습하면 카운트 1씩 올라감

  • reivewed_at : 마지막 복습한 일자. 복습하면 자동으로 업데이트 됨

  • next_review_date : 다음 복습할 일자. 복습하면 자동으로 업데이트 됨.

  • properties : md 파일 제일위에 yml 규칙으로 기록됨.

    테스트 MD의 스크린 샷

에빙하우스 망각곡선 복습 주기

  • 에빙하우스 망각곡선 권장 복습 주기

    • 20~30분, 1일(24시간), 3일, 7일, 14일, 30일

    • 20~30분 마다 복습하는 건 현실적으로 어려움 - 일단위로 할 예정, 단 테스트는 분단위

  • 구글 시트에 아래와 같이 정의함.

    • review_count : 복습 횟수,

    • interval_minutes : 복습 이후 next_review_date를 계산할 때 사용

    • days와 minutes : 상호 배타적 입력. 테스트 목적으로 minutes를 둔 거임.

  • 제대로 동작하는지 검증 안됨.. 제대로 검증하려면, obsidian의 properties에서 날
    짜 관련 필드들을 날짜/시간 포맷으로 변경하고 해야 함.--> 토요일

    Google 시트의 Ossidian 검토 설정

AI가 사용할 프롬프트

  • 문제 제출 & 문제 채점

    한국어 텍스트가 포함 된 Google 문서의 스크린 샷

텔레그램 메시지 타입 : 이번 workflow에서는 사용자가 입력한 메시지는 Callback과 Reply만 사용

1. 일반 메시지

텍스트 메시지: 그냥 채팅창에 입력하는 글.
미디어 메시지: 사진, 동영상, 파일, 오디오, 보이스, 스티커 등.
위치/연락처 메시지: 위치 좌표나 연락처 공유.

2. Reply 메시지

사용자가 특정 메시지를 "답장(reply)" 기능으로 보낸 메시지.
원본 메시지를 인용해서 함께 전송됨.
사용처: "이 질문에 대답해 보세요" → 학생이 reply로 답하면, 원본 메시지를 그대로 연결해서 처리 가능.

특징
원본 메시지의 message_id가 함께 따라옴.
n8n이나 봇에서는 reply_to_message 필드로 확인 가능.

3. Callback 메시지

인라인 키보드 버튼을 눌렀을 때 발생.
실제로 채팅창에 새 메시지가 생기지 않고, **봇에게만 데이터(callback_data)**가 전달됨.
사용처: OX 퀴즈, 메뉴 선택, 복습 시스템에서 "다음 문제" 버튼 등.

특징
callback_query 이벤트로 전달됨.
data 필드에 버튼을 눌렀을 때 지정한 값이 들어 있음.
채팅창은 깨끗하게 유지되지만, 봇은 사용자의 선택을 바로 인식할 수 있음.

4. Inline Query (검색형 메시지)
사용자가 @봇아이디 키워드를 입력했을 때 동작.
봇이 검색 결과를 카드 형식으로 제시 가능.
예: @dictionary_bot hello → 사전 결과 리스트 보여줌.

5. Command 메시지
/start, /help 같은 **슬래시(/)**로 시작하는 명령어.
봇 설정에서 미리 등록 가능.

자동화 구현

1. Google Apps Script 생성, 적용, 웹서비스

GAS 생성 및 적용

  • Google Apps Script가 담당할 역할

    • 오늘 복습할 노트 찾기

    • 파일 내용 조회

    • 노트 메타데이터 업데이트

  • LLM에 물어보며 만들었는데, 프롬프트를 어느 LLM을 통해 만들었었는지 못찾겠네요.
    역공학, 기존의 소스로 Claude에게 물어봅니다.

첨부한 소스가 하는 역할이 뭐야?
특히, 오늘 복습할 노트를 찾는 기준이 궁금해.

마지막으로 위 소스를 다신 짠다면, 어떻게 프롬프트를 짜면 좋을 지 알려줘.
  • 아마도 최종 소스를 Claude를 통해 만들어 달라고 했던것 같은데, 자기가 짠 소스에 문제가 있다고 알려주네요.

## 소스 코드의 역할

이 Google Apps Script는 **Obsidian 노트 기반 간격 반복 학습 시스템**의 백엔드 API 역할을 합니다.

### 주요 기능

1. **복습 대상 노트 검색** (`findReviewNotes`)
2. **파일 내용 조회** (`getFileContent`)
3. **학습 메타데이터 업데이트** (`updateNoteMetadata`)
4. **디버깅 도구들** (`debugFolder`, `debugMarkdown`)

## 현재 복습 노트 검색 기준의 문제점

**현재 기준**:

- 오늘 생성되거나 수정된 `.md` 파일 중
- `reviewable: true` 속성을 가진 파일

이 기준은 **간격 반복 학습에 적합하지 않습니다**. 매일 파일을 수정해야만 복습 대상이 되는 구조는 비효율적입니다.

## 올바른 간격 반복 시스템 기준

javascript

```javascript
// 진정한 간격 반복 기준
if (frontmatter.includes('reviewable: true') && 
    frontmatter.includes(`next_review_date: ${today}`)) {
  // 오늘 복습해야 할 노트
}
  • 제안해준 프롬프트 및 약간의 수정

### 1. 복습 대상 노트 검색 (`findReviewNotes`)

- **검색 범위**: 지정된 Google Drive 폴더 및 모든 하위 폴더
- **조건 규칙** : 아래 조건들은 모두 AND 조건임.
- **파일 조건**: `.md` 확장자 파일만
- **필터링 조건**:
    - YAML frontmatter에 `reviewable: true` 속성이 있는 파일
    - `next_review_date`가 오늘 날짜와 일치하는 파일 또는 비어있는 파일. 이 단락은 OR 조건으로 처리.
- **반환 데이터**: 파일 ID, 파일명, 경로, 복습 관련 메타데이터

==> 토요일에 해 보는거로..

GAS를 웹으로 배포

  • 상단 메뉴에서 배포 클릭

  • 유형 선택 : "웹 앱" 선택

    한국어가있는 페이지의 스크린 샷
  • 설명 항목을 꼭 넣으세요.!!

    삼성 갤럭시 탭 S3- 스크린 샷
  • 잘 배포되면, 배포 ID 및 URL 복사 하는 화면이 나옴.

  • 수정할 때마다 매번 새 배포를 하면 배포되는 URL이 변경됨

    • ==> n8n도 변경해 주어야 함.

  • 수정 시 "새배포" 말고, "배포관리"를 클릭.

  • 오른쪽 연필 모양 클릭 후, 수정할꺼 있으면 수정하고, 하단의 "배포" 클릭.

    • 기존 URL 그대로 사용가능

2. Telegram 설정

여기서부터 n8n workflow 작업입니다.
크게 2가지 workflow, 그 중 한 개는 분기 처리 (2가지 흐름)

3. 옵시디언 복습대상 문서목록을 사용자 Telegram에 보내기

Telegram Bot을 만드는 방법을 보여주는 다이어그램
  • 일단 첫번째 노드부터 잘못되어 있습니다.

    • 트리거를 Schedule Tirgger로 바꿨어야 했는데, 테스트 목적의 Manual Trigger가 남아있네요. --> 토요일 수정

3.1 Find Notes

  • GAS의 web 을 호출

  • Method: GET

  • URL : GAS 에서 배포한 URL

  • Options - Response Format : JSON

3.2 Build Keyboard

빌드 키보드를 보여주는 iPad 화면
  • JavaScript Code

const items = $json.data.notes;

if (!items || items.length === 0) {
  return [{ 
    json: { 
      text: "🎉 오늘 복습할 노트가 없습니다! 멋진 하루 보내세요.", 
      keyboard: {} 
    } 
  }];
}

const keyboard = items.map(item => {
  const fileName = item.fileName.replace('.md', '');
  const fileId = item.fileId;
  return [{ 
    text: fileName, 
    callback_data: `start_quiz_${fileId}` 
  }];
});

return [{ 
  json: {
    text: `🔔 **오늘 복습할 노트는 총 ${items.length}개입니다.**\n\n아래 목록에서 시작할 노트를 선택하세요.`,
    keyboard: { inline_keyboard: keyboard }
  }
}];
  • 일반적으로 telegram에 메시지만 전송

  • telegram 속성 중 reply_markup을 이용하면, 보낸메시지에 대한 댓글 개념의 메시지를 받을 수 있음

  • reply_markup 시 inline keyboard를 이용하면 버튼형태로 선택을 유도할 수 있음..

  • 선택할 수 있는 버튼의 갯수가 지정되어 있다면, telegram 노드에서 작업하면 되는데,
    버튼의 갯수가 지정되어 있지 않다면, httprequest 노드를 통해 telegram으로 메시지 및 inlinekeyboard 정보를 보내야 함.

  • 이 코드 노드는 http request 노드 전에 텔레그램에 보낼 데이터(키보드포함)를 설정

  • inline_keyboard에 옵시디언에서 복습할 노트들의 이름이 각각 버튼으로 제공됨

  • 응답될 때, fileId가 Callback data로 설정되게 함.

  • fileid를 다시 n8n쪽으로 보내는 이유는 문제 채점을 위해 원래 노트의 내용이 필요하기 때문임.

3.3 Send NoteList to Telegram

Telegram에 메모를 보내십시오
JQuery CSS Parser- jQuery CSS에 파서 추가
  • Header : 컨텐트 타입

  • Send Body : 활성화

  • Body Content Type : JSON

  • Specific Body : Using Json

  • JSON

{
  "chat_id": "xxxxxxxxxx",
  "text": "{{ $json.text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "")
  .replace(/\n/g, " ") }}",
  "parse_mode": "Markdown",
  "reply_markup": {{ JSON.stringify($json.keyboard) }}
}
- chat_id: 텔레그램 사용자ID
- text : 이전 노드의 결과값 중 text - {{ $json.text }} 만 보내면 되는데, 
        이 값에 특수문자 등이 들어 있으면 오류. replace 2번으로 처리
- parse_mode: Markdown : 왜 사용했는지 기억안남..ㅠㅠ
- reply_markup : {{ JSON.stringify($json.keyboard) }} --> JSON.stringify()로 감싸서 json 문자열로 변환.

참고) Telegram Node에서 버튼 달기.


Adobe Adobe Adobe Adobe에서 양식에 추가 데이터를 추가하는 방법

4. 텔렉그램에서 사용자가 복습할 노트 선택

  • 복습안내와 문서 2건에 대한 제목이 버튼으로 메시지 전송됨

  • 사용자는 telegram 또는 obsidian 주요 사용법 문서 중 1개를 선택하면 메시지가 발송됨

    컴퓨터에서 한국 채팅 앱의 스크린 샷

5. Telegram에서 사용자가 복습 하기

Telegram 방아쇠 - 전보에서 다시 전화하는 방법

5.1 Telegram Trigger

전보 트리거의 스크린 샷
  • credeintial

  • Trigger On

    • Message : 일반 메시지로 보냈을 때 ( 문제 풀이를 했을 때)

    • Callback Query : 사용자가 응답(댓글)로 보냈을 때 ( 복습할 노트를 선택했을 때)

5.2 If-IsCallBack

  • {{ $json.callback_query !== undefined }} 이면 Callback( 복습할 노트 선택)

  • 아니면 Message ( 답안 제출 )



6. 사용자 문제풀기 및 제출

6.1 Get FileID

  • 사용자가 응답한 데이터 중 FileId 추출 : 보낸 메시지에 파일ID가 'startquiz'로 시작하게 했기 때문에 데이터 파싱.

6.2 Get Contents

  • GAS에 web으로 접근해 옵시디언 노트 내용을 가져옴.

  • Method : GET

  • URL 정보는 생성된 GAS 스크립트에 따라 다를 수 있음.

  • /action=getFileContent&fileId={{ $json.fileId }}

  • Options 중 Response Format : JSON으로 설정

6.3 Get Quiz Prompt

  • Filterd에서 Data Location on Sheet, Specify Range(A1 notatiaon), Range(A,B)

  • 2번째 행만 가져오고 싶은데, 다른 옵션으로 지정해도 딱 2번째 로우만 가져오기가 안됨. --> 방법아시는분 알려주세요!!
    가장 간단하게 수정하는 방법 : sheet를 나눈다.

  • 일단 다 가져와서 다음 노드에서 한 번 더 필터링

  • 이 부분도 복습하기 전까지 설정이 잘못되어 있었네요. 저장을 제대로 안 한듯.. 습관적 Ctrl + S 중요!!

6.4 Extract Quiz Prompt
- 앞선 노드에서 불필요 Row까지 함께 조회되었기에 필요한 정보만 셋팅

6.5 Gen Quiz

  • Model : Open AI Chat Modle (gpt 4.1 mini)

  • Structured Output 사용 설정

  • prompt : 구글시트에 있는 프롬프트로, 텍스트 내용을 이용해서 문제내라..

{{ $json.prompt_template }}

---
# 텍스트 내용
{{ $("Get Contents").first().json.data.content }}
  • Structured Output Parser

{
  "quiz": [
    {"type": "ox", "question": "...", "answer": "..."}, 
    {"type": "short", "question": "...", "answer": "..."}
  ]
}


6.6 Send Quiz

  • 사용자에게 quiz 전송

  • Reply Markup : Force Rely 선택

  • Force Reply : 활성화


  • Text : quiz를 제출하고, 사용자 응답시 FileID가 응답에 포함되게 FileID를 함께 보냄(채점 시 파일내용 필요)

🧠 **{{ $('Get Contents').first().json.data.fileName.replace('.md', '') }} 퀴즈**

아래 질문에 대해 **이 메시지에 답장(Reply)**하여 답변을 제출해주세요.

1. ({{ $json.output.quiz[0].type }}) {{ $json.output.quiz[0].question }}
2. ({{ $json.output.quiz[1].type }}) {{ $json.output.quiz[1].question }}

---

FileID: [{{$('Get FileID').first().json.fileId }}]


7. 문제 수신 및 문제 풀기
- 응답으로 풀어야 하는데, 일반메시지로 보내는 경우, workflow에서 벗어남. --> 오류수정 필요, 토요일에!!
- 응답으로 풀어야 파일ID가 전송됨



8. 채점 및 메타데이터 업데이트, 사용자에게 최종 응답

8.1 Extract Q&A

  • 사용자가 제출한 답안을 추출

  • 사용자 응답으로부터 온 FileID 추출 - 채점시 필요

  • Javascript Code

const userAnswer = $input.first().json.message.text;
const originalMessage = $json.message.reply_to_message.text;
const fileIdMatch = originalMessage.match(/FileID: (.+)$/m);

if (!fileIdMatch) {
  throw new Error("FileID not found in original message");
}

return [{ 
  json: { 
    fileId: fileIdMatch[1].trim(),
    userAnswer: userAnswer,
    originalQuiz: originalMessage
  } 
}];


8.2 Get Content Again

  • 위쪽 GetContent 와 동일함

8.3 Get Schedule

  • Google Spread Sheet에 있던 반복주기 표 가져옴

  • A:B로 필터링 했기 때문에 필요한 데이터는 다 가져옴

8.4. Get Answer Prompt

  • 위 Get Quiz Prompt와 동일하기 때문에 생략

Extract Answer Prompt

  • 위 Extract Quiz Prompt와 동일하기 때문에 생략

  • javascript code : 찾아오는 대상이 달라짐. answer_evaluator, 변수명 변경 answerPrompt

const prompts = $input.all();
const answerPrompt = prompts.find(item => 
  item.json.prompt_name === 'answer_evaluator'
);

if (!answerPrompt) {
  throw new Error('Answser evaluator prompt not found');
}

return [{ 
  json: { 
    prompt_template: answerPrompt.json.prompt_template 
  } 
}];


8.5 Evaluate Answer

  • 위 Gen Quiz와 동일하기 때문에 생략

  • prompt

  • Answer Prompt를 이용해서, 원본텍스트, 퀴즈 내용, 사용자 답변을 비교 평가하는 단계임.

{{ $json.prompt_template }}

원본 텍스트:
{{ $('Get Content Again').first().json.data.content }}

퀴즈:
{{ $('Extract Q&A').first().json.originalQuiz }}

사용자 답변:
{{ $('Extract Q&A').first().json.userAnswer }}


8.6 Calculate new Metadata

  • Javascript Code

// --- Calculate New Metadata (fixed node mappings) ---

// 1) Get evaluation from previous node (Evaluate Answer)
const evalItem = $input.first();
const evaluation = (evalItem?.json?.output?.evaluation ?? evalItem?.json?.evaluation ?? '').trim();

// 2) Get original content & file info from "Get Content Again"
const contentNode = $('Get Content Again').first().json;
const fileName = (contentNode?.data?.fileName ?? '').toString();
const fileContent = contentNode?.data?.content ?? '';

if (!fileContent) {
  throw new Error('Get Content Again → data.content is empty');
}

// 3) Get schedule rows from "Get Schedule"
const scheduleRows = $('Get Schedule').all().map(i => i.json);

// 4) Parse frontmatter to read current review_count
const fmMatch = fileContent.match(/---\s*([\s\S]*?)\s*---/);
if (!fmMatch) {
  throw new Error('Frontmatter not found in file content');
}
let currentCount = 0;
const countMatch = fmMatch[1].match(/review_count:\s*(\d+)/);
if (countMatch) currentCount = parseInt(countMatch[1], 10);

// 5) Compute next schedule & feedback text
const today = new Date().toISOString().slice(0, 10);
let newCount = currentCount;
let nextDueDateStr = '';
let feedbackText = `AI 평가: **${evaluation}**\n\n`;

const baseName = fileName.replace(/\.md$/,'');


// 모든 경우에 review_count 증가시키는 로직
if (['Excellent', 'Good'].includes(evaluation)) {
  newCount = currentCount + 1;
  // 성공: 스케줄에 따른 긴 간격
  const scheduleEntry = scheduleRows.find(r => parseInt(r.review_count, 10) === currentCount);
  const interval = scheduleEntry ? parseInt(scheduleEntry.interval_minutes, 10) : 60;
  const nextDueDate = new Date();
  nextDueDate.setMinutes(nextDueDate.getMinutes() + interval);
  nextDueDateStr = nextDueDate.toISOString().slice(0, 10);
  feedbackText += `**${baseName}** 노트의 복습 횟수가 ${newCount}으로 업데이트되었습니다. 다음 복습은 ${interval}분 후입니다. 수고하셨습니다!`;
} else { // Poor
  newCount = currentCount + 1; // ← 여기가 변경점!
  // 실패: 짧은 간격 (1분)
  const nextDueDate = new Date();
  nextDueDate.setMinutes(nextDueDate.getMinutes() + 1);
  nextDueDateStr = nextDueDate.toISOString().slice(0, 10);
  feedbackText += `조금 더 학습이 필요해 보여요. **${baseName}** 노트의 복습 횟수가 ${newCount}으로 업데이트되었지만, 1분 후 다시 복습하도록 설정했습니다.`;
}

// 6) Get fileId from upstream nodes (prefer Extract Q&A, fallback Get FileID)
const fileId =
  $('Extract Q&A').first()?.json?.fileId ??
  $('Get FileID').first()?.json?.fileId ??
  null;

if (!fileId) {
  throw new Error('fileId not found (Extract Q&A / Get FileID)');
}

// 7) Return payload
return [{
  json: {
    newMetadata: {
      review_count: newCount,
      reviewed_at: today,
      next_review_date: nextDueDateStr,
    },
    feedback: feedbackText,
    fileId,
  }
}];
  • 다시 보니 너무 복잡하네요. 다시 llm에 코드내용 물어보기


이 코드는 n8n 워크플로우 내에서 **간격 반복 학습(Spaced Repetition System, SRS) 로직을 실행**하는 핵심 부분입니다. AI의 평가 결과에 따라 노트 파일의 메타데이터(metadata)를 동적으로 계산하고, 다음 복습 일정을 결정하는 역할을 수행합니다.

---

#### ### 1. 데이터 수집 및 전처리 (Data Ingestion & Pre-processing)

코드 실행의 첫 단계는 `$` 구문을 통해 이전 노드들로부터 필요한 데이터를 집계하는 것입니다.

- **AI 평가 (`evaluation`):** `Evaluate Answer` 노드의 출력에서 AI가 내린 평가 결과('Excellent', 'Good', 'Poor')를 가져옵니다.
- **노트 콘텐츠 (`fileContent`):** `Get Content Again` 노드에서 원본 마크다운 파일의 전체 텍스트를 확보합니다.
- **복습 스케줄 (`scheduleRows`):** `Get Schedule` 노드에서 복습 횟수와 그에 따른 복습 간격(분)이 정의된 데이터 배열을 가져옵니다.

이후, 가져온 `fileContent`에서 정규표현식(RegEx)을 사용해 `---`로 구분된 **프론트매터(frontmatter)** 영역을 파싱하고, 그 안에서 현재 `review_count` 값을 추출합니다. 이 값이 스케줄 계산의 기준점이 됩니다.
---

#### ### 2. 조건부 스케줄링 로직 (Conditional Scheduling Logic)
이 노드의 핵심 로직으로, AI의 `evaluation` 값에 따라 다음 복습 일정을 다르게 계산하는 분기 처리를 수행합니다.

- **학습 성공 시 (`Excellent` 또는 `Good`)** `review_count`를 1 증가시킨 후, `scheduleRows` 배열에서 **현재 `review_count`**와 일치하는 항목을 찾습니다. 해당 항목의 `interval_minutes` 값을 가져와 현재 시간에 더하여 다음 복습 날짜(`next_review_date`)를 계산합니다. 이는 성공적인 복습 주기에 따라 점진적으로 간격이 늘어나는 SRS의 기본 원리를 구현한 것입니다.
    
- **보충 학습 필요 시 (`Poor`)** 성공 시와 달리 외부 스케줄 표를 참조하지 않고, 다음 복습 간격을 **1분으로 고정(hardcoding)**하여 즉각적인 재학습을 유도합니다. 여기서 중요한 설계 포인트는, **실패했을 경우에도 `review_count`는 1 증가**한다는 점입니다. 이는 복습 시도 자체를 기록하여 전체 학습 과정을 추적하기 위함입니다.
---

#### ### 3. 결과 데이터 구성 및 반환 (Output Data Structuring & Return)

모든 계산이 완료되면, 후속 노드(예: 파일 업데이트, 알림 발송 노드)에서 사용할 수 있도록 **표준화된 JSON 객체**를 생성하여 반환합니다.
- **`newMetadata`**: 새로 계산된 `review_count`, `reviewed_at`(오늘 날짜), `next_review_date`를 포함하는 객체입니다. 파일의 프론트매터를 업데이트하는 데 사용됩니다.
- **`feedback`**: 사용자에게 보여줄 동적 텍스트입니다.
    
- **`fileId`**: 어느 파일을 업데이트해야 하는지 식별하기 위한 고유 ID입니다. 코드에서는 `Extract Q&A` 노드의 결과를 우선 사용하고, 없을 경우 `Get FileID` 노드의 결과를 사용하는 폴백(fallback) 로직이 구현되어 있습니다.


8.7 Update Note

  • GAS web에 metadata 업데이트 요청

  • Method : Post

  • Url : ?action=updateNoteMetadata

  • json

{
  "fileId": "{{ $json.fileId }}",
  "newMetadata": {{ JSON.stringify($json.newMetadata) }}
}


8.8 Send Feedback

  • Text

:✅ 채점 완료!\n\n{{ $json.feedback }}

9. Telegram 에서 결과 확인


10. 옵시디언 노트 Properties 업데이트 확인
- review_count, reviewed_at, next_review_date가 업데이트 됨을 확인. 반복주기를 분단위로 해 놓아서. 업데이트는 되었으나, 다 테스트 당일로 업데이트 됨. 날짜/시간 포팻으로 바꺼어 확인 --> 토요일



느낀점

  • workflow만 그려서는 제대로 했는지, 문제가 있는지 식별하기 어렵다.

    • ==> 설계도 없는 자동화는 모래위에 성을 쌓는 거랑 똑같음

    • ==> 자동화 설계법 먼저, 나중에라도 꼭 다시 자동화설계법으로 정리한 내용과 워크플로우 비교해 봐야한다.

    • ==> 기업이나 유료 서비스일 경우, 진짜진짜 중요!!

  • n8n은 어렵다. 다만, 많은 사례를 n8n으로 그리다 보면 99.9999% 의 완성도를 가질 수 있다.

  • 디버깅은 어렵다. 최선의 디버깅 방법 중 하나는 코딩!!

  • 복습이 필요했던 이유는 머릿속에 남는게 없어서 였는데, 머리속에 남게 하는 가장 좋은 방법은

    • 바로바로, 다른 분들께 알려주기 위해 학습하자!!

Next Todo

  • 토요일에 미진한 부분, 이상한 부분 보완하기

도움 받은 글 (옵션)

타이칸님의 0920 토요 오프모임 영수증 자동화 사례 교본 중 "자동화설계법" --> 정말 도움이 많이 되었습니다!!

3
1개의 답글

👉 이 게시글도 읽어보세요