한 줄 요약: 봇이 매일 위키에 쌓아주는 용어 사전의 “근거 기사” 링크를 눌러봤더니, LFP 배터리 항목에서 한화에어로스페이스 K9 자주포 기사가 열렸다. 원인은 AI에게 130줄짜리 목록에서 URL을 받아 적게 시킨 구조였다. 링크를 프로그램이 붙이도록 바꾸고, 이미 쌓인 링크 88개를 전부 열어 대조해 27개를 정정했다.
이런 분께 도움이 돼요: LLM에게 목록에서 뭔가를 “그대로 옮겨 적게” 시키고 있는 분, 자동화 결과물을 한 번도 검증해본 적 없는 분, 그리고 에러는 안 나는데 결과만 조용히 틀리는 자동화를 굴리고 있는 분.
Before — 초록불인데 내용이 틀렸다
봇은 8월 24일부터 열흘 넘게 아무 문제 없이 돌고 있었다. 하루 3번 텔레그램이 오고, 위키에 용어가 쌓이고, GitHub에 커밋이 올라갔다. n8n 화면은 전부 초록색이었다.
그런데 위키를 읽다가 이상한 걸 발견했다.
## LFP 배터리
LFP 배터리는 리튬, 철, 인산을 사용해 만든 배터리로...
**최근 동향** (2026-08-31 기준, 언급 3회): 삼성SDI가 글로벌 톱티어 ESS 기업에
LFP 배터리를 공급한다고 밝혀...
<details><summary>근거 기사</summary>
- 2026-08-31: [기사 보기](https://www.thelec.kr/news/articleView.html?idxno=61528)
</details>
이 링크를 눌러보니 「한화에어로스페이스, 스페인에 K9 자주포 수출」 이 나왔다. 배터리와 아무 상관이 없었다.
몇 개 더 열어봤다.
| 위키 항목 | 링크를 열면 나오는 진짜 기사 |
|---|---|
| 레이저 건조 기술 | EU PFAS 규제…LGD, 올해 소형부터 퇴출 |
| LFP 배터리 6C 고출력 | 삼성전자, 2027년형 오디세이 게이밍 모니터 공개 |
| 첨단 패키징 국가전략기술 | KT&G, 3초 예열 전자담배 출시 |
| SKIET 흡수합병 | 컴투스 ‘제우스’, 앱스토어 사전다운 1위 |
설명과 최근 동향은 멀쩡한데 링크만 딴 데를 가리키고 있었다. 포트폴리오로 보여줄 사이트인데, 링크를 눌러본 사람이 전자담배 기사를 보게 되는 상황이었다.
원인 추적 — “랜덤이 아니다”
처음엔 AI가 URL을 통째로 지어냈다고(환각) 생각했다. 그런데 열어본 기사들이 전부 실제로 존재하는, 그것도 같은 날 수집된 기사였다. 지어낸 게 아니라 다른 기사의 링크를 가져온 것이었다.
결정적인 단서는 이거였다.
| 항목 | 붙어 있던 링크의 실제 내용 |
|---|---|
| HBC (용어 사전) | 메모리 가격 급등, 엔비디아 마진 하향 |
| 메모리 가격 폭등 (이슈 노트) | 퀄컴 “삼성·SK, HBC 개발 참여” |
두 항목의 링크가 서로 맞바뀌어 있었다. 랜덤이 아니라 뒤섞임이었다.
여기서 워크플로 구조를 다시 봤다.
Aggregate (title · link 두 필드를 배열로 묶기)
↓
LLM 프롬프트:
기사 목록:
{{ $json.title.map((t, i) => `${i}. ${t} | ${$json.link[i]}`).join('\n') }}
...
원문: (고른 기사의 링크를 그대로)
링크를 프로그램이 이어붙이는 게 아니라, AI가 130줄짜리 목록을 보고 손으로 받아 적고 있었다. 사람으로 치면 전화번호부 130줄을 보면서 번호를 눈으로 옮겨 적는 일이다. 한 줄만 시선이 밀려도 틀린다.
가설을 데이터로 확인하기
“긴 목록에서 잘못 베낀 것”이라면, URL이 헷갈리게 생긴 매체일수록 많이 틀렸어야 한다. 링크를 전부 열어 매체별로 세어봤다.
| 매체 | URL 생김새 | 정확 | 오배정 | 오배정률 |
|---|---|---|---|---|
| ZDNet | ?no=20260903112619 (날짜+시각 14자리) | 39 | 1 | 2.5% |
| 한국경제 | /article/202609031504i (불투명) | 9 | 5 | 36% |
| 디일렉 | ?idxno=61717 (숫자 5자리) | 13 | 21 | 62% |
가설대로였다. URL에 날짜가 통째로 박힌 ZDNet은 거의 안 틀렸고, 다섯 자리 숫자만 다른 디일렉이 압도적으로 많이 틀렸다.
디일렉 링크는 이렇게 생겼다. 61407 61416 61419 61428… 목록에서 한 줄만 밀려도 그대로 틀린다. 반면 ZDNet URL에는 20260903처럼 날짜가 들어 있어서, AI가 “8월 30일 기사”라는 맥락으로 스스로 검산할 수 있었던 것으로 보인다.
💡 여기서 배운 것: LLM에게 의미 없는 짧은 ID를 긴 목록에서 옮겨 적게 하면 안 된다. 사람도 못 하는 일을 시킨 셈이다.
해결 — AI는 번호만 고르고, 링크는 프로그램이 붙인다
원리는 간단하다. AI에게 URL을 아예 안 보여준다.
1. 프롬프트에서 링크를 뺀다
Before
기사 목록:
{{ $json.title.map((t, i) => `${i}. ${t} | ${$json.link[i]}`).join('\n') }}
- 원문: 그 기사의 링크
After
아래는 오늘 수집된 기사 목록입니다. 각 줄 맨 앞 [숫자]가 그 기사의 번호입니다.
{{ $json.title.map((t, i) => '[' + i + '] ' + t).join('\n') }}
- 기사번호: 위 목록에서 그 기사 앞에 붙은 번호 (숫자만)
- 기사제목: 그 기사의 제목을 위 목록에 있는 그대로 복사
중요: URL(링크)은 절대 쓰지 마세요. 목록에 없는 번호를 지어내지 마세요.
기사제목도 같이 받는 게 핵심이다. 번호가 틀려도 제목으로 다시 찾을 수 있는 안전망이 된다.
2. Code 노드가 진짜 링크를 꺼낸다
// Aggregate가 내보낸 원본 (title[i]와 link[i]가 같은 기사)
const agg = $('Aggregate').first().json;
const articles = (agg.title || []).map((t, i) => ({ title: t, link: agg.link[i] }));
// 문장부호·공백·대소문자를 전부 무시하고 글자만 남겨서 비교
const norm = s => (s || '').toLowerCase().replace(/[^0-9a-z가-힣]/g, '');
function 링크찾기(d) {
const i = Number(d.기사번호);
const want = norm(d.기사제목);
// 1순위: 제목이 정확히 맞는 기사
if (want) {
const exact = articles.find(a => norm(a.title) === want);
if (exact) return exact.link;
}
// 2순위: 번호가 가리키는 기사 (제목이 서로 품고 있으면 인정)
if (Number.isInteger(i) && i >= 0 && i < articles.length) {
const byNum = articles[i];
const t = norm(byNum.title);
if (!want || t.includes(want) || want.includes(t)) return byNum.link;
}
// 3순위: 제목이 부분적으로 겹치는 기사 (AI가 제목을 줄여 쓴 경우)
if (want) {
const partial = articles.find(a => {
const t = norm(a.title);
return t && (t.includes(want) || want.includes(t));
});
if (partial) return partial.link;
}
return null; // 못 찾으면 저장하지 않는다
}
셋 다 실패하면 null을 돌려주고, 그 섹터는 그날 저장하지 않는다. 틀린 링크를 남기느니 하루 건너뛰는 쪽을 택했다.
3. 덤 — LLM 노드를 하나로 합쳤다
고치다 보니 텔레그램용·위키용 LLM 노드가 같은 기사 목록을 두 번 물어보고 있다는 걸 알게 됐다. 하나로 합쳤다.
Aggregate → Wiki_LLM ─┬→ Code(섹터분리+링크조회) → GitHub 저장
└→ Code(문장조립+링크조회) → 텔레그램
- Claude 호출 하루 6번 → 3번 (비용 절반)
- 텔레그램과 위키가 항상 같은 키워드를 말하게 됨
막힘 → 해결: 하루 동안 밟은 지뢰 5개
지뢰 1 — 노드 이름이 다 똑같다
n8n이 자동으로 붙여준 이름이 Code in JavaScript, Code in JavaScript1, Code in JavaScript2였다. 텔레그램용 코드를 섹터분리 노드에 붙여넣었고, 그걸 모른 채 한참을 헤맸다.
💡 구분법은 마지막 줄:
return results;=섹터분리 /return out;=병합 /return [{json:{text}}];=텔레그램
지뢰 2 — n8n이 선을 자기 마음대로 잇는다
두 가지를 몰랐다.
- 중간 노드를 지우면 양옆을 자동으로 이어버린다. 텔레그램 LLM을 지웠더니
Aggregate → 텔레그램이 저절로 생겼다. - 노드를 기존 연결선 위에 떨어뜨리면 그 사이에 끼워 넣어진다. 텔레그램 노드가 위키 가지 한복판에 끼어들어, 섹터분리 노드가 텔레그램 전송 응답을 입력으로 받고 있었다.
💡 노드를 지우거나 추가한 뒤엔 선을 눈으로 다시 확인할 것.
지뢰 3 — Received undefined라는 알 수 없는 에러
병합 노드가 이렇게 죽었다.
The first argument must be of type string or an instance of Buffer,
ArrayBuffer, or Array or an Array-like Object. Received undefined
편집기에 보이던 줄이 Buffer.from(newContent, ...)라서 그 줄을 의심했는데, 화면에 보이는 줄은 그냥 스크롤 위치일 뿐 에러 난 줄이 아니었다.
그래서 코드가 스스로 원인을 말하게 만들었다.
if (metas.length !== gets.length) {
throw new Error(`개수 불일치: Code in JavaScript는 ${metas.length}개, GET 결과는 ${gets.length}개`);
}
if (!github.content) {
throw new Error(`[${i}번째, ${meta && meta.path}] GET 응답에 content가 없음. 실제로 받은 값: ${JSON.stringify(github).slice(0, 300)}`);
}
다시 돌리니 이렇게 나왔다.
Code in JavaScript는 1개, GET 결과는 12개
5분 만에 원인을 찾았다. 진단 코드는 지우지 않고 그대로 뒀다.
지뢰 4 — GET이 12건이면 경로가 빈 것이다
12개라는 숫자가 힌트였다. 저장소 최상위를 세어보니 정확히 12개였다.
.claude .gitignore AGENTS.md CLAUDE.md README.md SOUL.md
USER.md astro.config.mjs package-lock.json package.json src tsconfig.json
GitHub Contents API는 주소가 contents/까지만 오면 폴더 목록을 돌려준다. 즉 path가 비어 있었다는 뜻이고, 그 원인은 지뢰 2(배선 오류)였다.
💡 판별법: GET 결과에
type: dir이 보이면 파일이 아니라 폴더를 읽은 것이다.
지뢰 5 — 말줄임표 하나에 섹터가 통째로 사라졌다
고치고 돌렸더니 3개 나와야 할 항목이 2개만 나왔다. 제목 대조 함수가 공백만 무시하고 문장부호는 안 무시한 게 문제였다.
심텍, 청주에 4000억원 투자…AI 기판 생산능력 확대
↑ 이 말줄임표(U+2026)를 AI가 마침표 세 개로 옮겨 적으면 대조 실패
norm()을 글자와 숫자만 남기도록 고쳐서 해결했다.
const norm = s => (s || '').toLowerCase().replace(/[^0-9a-z가-힣]/g, '');
보너스 — AI에게 도피로를 주지 말 것
프롬프트에 이렇게 적어뒀었다.
해당 섹터에 기사가 없으면 용어에 "없음"으로 답하세요.
130건 중 디일렉만 50건인데도 배터리 섹터가 “없음”으로 왔다. 도피로가 너무 쉬웠던 것이다. 이렇게 바꿨다.
세 섹터 모두 반드시 하나씩 골라야 합니다. 완벽히 들어맞는 기사가 없더라도
가장 가까운 기사를 고르세요. 각 섹터에 해당하는 주제는 이렇게 넓게 봅니다.
- 배터리: 이차전지·양극재·음극재·전해질·분리막·ESS·전기차 배터리·리튬 등 원자재·배터리 장비
...
정말로 조금이라도 관련된 기사가 단 하나도 없을 때만 "없음"으로 답하세요.
바로 세 섹터가 다 채워졌다.
과거에 쌓인 것 정리 — 88개 전수 대조
물을 잠갔으니 바닥을 닦을 차례였다. 위키에 이미 쌓인 링크 88개를 전부 열어 항목 내용과 대조했다.
- 오배정 27개 (31%)
- 교체 6건 — 다른 항목에 잘못 붙어 있던 링크의 원래 주인을 찾아 되돌림
| 항목 | 되찾은 근거 기사 | 어디에 잘못 가 있었나 |
|---|---|---|
| HBC | 퀄컴, 삼성·SK HBC 개발 참여 | 메모리 가격 폭등 |
| 메모리 가격 폭등 | 메모리 가격 급등, 엔비디아 마진 하향 | HBC |
| ZHBM | 삼성, ZHBM 2029년 이후 출시 | 모두의 AI 사업 |
| 엔비디아 2분기 실적 | 엔비디아 2분기 매출 초과 | HBM |
| 국산 보안 AI | K-미토스 국산 보안 AI 경쟁 | 온디바이스 AI |
| K-온디바이스 AI 홈 | 코아시아세미 K-온디바이스 AI 홈 참여 | (KT 챗봇 기사였음) |
- 삭제 21건 — 원문을 특정할 수 없어 링크만 빼고 이렇게 표시했다.
*(근거 기사 링크는 봇의 링크 오배정 버그로 유실되어 제거했습니다 — 2026-09-03 정정)*
용어 설명과 최근 동향은 기사 제목에서 나온 내용이라 사실관계가 맞았다. 그래서 본문은 살리고 링크만 정리했다.
💡 틀린 걸 지우는 것보다, 틀렸다고 적어두는 게 낫다. 빈칸으로 두면 나중의 내가 “원래 없었나?” 하고 헷갈린다.
After — 결과
| Before | After | |
|---|---|---|
| 링크 정확도 | 61/88 (69%) | 65/65 (100%) — 전부 대조 확인 |
| 디일렉 링크 | 13/34 (38%) | 구조적으로 틀릴 수 없음 |
| 링크를 만드는 주체 | AI가 URL을 받아 적음 | 프로그램이 원본 배열에서 조회 |
| Claude 호출 | 하루 6번 | 하루 3번 |
| 텔레그램·위키 키워드 | 따로 물어서 다를 수 있음 | 항상 동일 |
| 기사 없는 날 | 아무것도 안 옴 | ”오늘은 새 소식 없음” 발송 |
| 에러 메시지 | Received undefined | ”1개 대 12개, 개수 불일치” |
수정 후 첫 실행에서 나온 링크 5개를 전부 열어 확인했다.
💾 반도체 키워드: 반도체 표적 관세
원문: thelec.kr/...?idxno=61788
→ 「미, 반도체 '표적' 관세 예고…靑 "韓 기업 불이익 없도록 협의"」 ✅
🔋 배터리 키워드: 국산 배터리 탑재 드론
원문: thelec.kr/...?idxno=61763
→ 「육군 '50만 드론전사' 사업, 中 밀어내고 국산 배터리 탑재」 ✅
가져가실 것
체크리스트 — LLM에게 시키면 안 되는 일
- 긴 목록에서 짧은 ID·URL·코드를 옮겨 적게 하기 ← 오늘의 범인. 번호로 고르게 하고 값은 프로그램이 조회하자.
- 정확히 맞아야 하는 값을 자유 텍스트로 출력하게 하기 — 숫자 하나만 틀려도 조용히 잘못된 결과가 된다.
- “없으면 없다고 해”라는 쉬운 도피로 주기 — 판단을 회피할 구멍이 있으면 쓴다. 기준을 구체적으로 적어주자.
자동화 결과물 검증 습관
- 초록불은 “실행됐다”는 뜻이지 “맞다”는 뜻이 아니다. 결과물을 열어서 눈으로 봐야 한다.
- 검증할 땐 표본이 아니라 전수로. 27개 중 상당수는 스무 개쯤 확인한 뒤에야 나왔다.
- 오류율을 유형별로 쪼개보면 원인이 보인다. 매체별로 나눠 세지 않았다면 “URL 생김새” 가설에 도달하지 못했을 것이다.
에러가 불친절할 때
원래 코드는 이렇게만 말했다. Received undefined
여기에 사람 말로 설명하는 검사 두 줄을 넣었더니 원인이 바로 나왔다. 자동화가 조용히 실패하는 지점마다 이런 걸 하나씩 심어두면, 다음에 막혔을 때 나를 구해준다.
if (기대한_조건이_아니면) {
throw new Error(`무엇이 어떻게 다른지 + 실제로 받은 값: ${JSON.stringify(값).slice(0, 300)}`);
}
솔직한 후기
제일 서늘했던 건 열흘 동안 아무도 몰랐다는 것이다. 에러도 안 났고, 텔레그램도 매일 왔고, 커밋도 쌓였다. 우연히 링크를 눌러보지 않았으면 지금도 모르고 있었을 것이다.
작업 자체도 매끄럽지 않았다. 코드를 엉뚱한 노드에 붙여넣고, n8n이 자기 마음대로 그은 선을 못 보고, Received undefined 앞에서 한참 멍하니 있었다. 저녁에 마무리 짓자고 미뤘다가 다시 붙잡았다.
그래도 오늘 제일 크게 남은 건 이거다. “AI에게 시키면 안 되는 일이 있다.” AI는 판단은 잘하지만 정확한 옮겨 적기는 못 한다. 그건 프로그램의 일이다. 이 경계를 오늘 하루 종일 몸으로 배웠다.
그리고 하나 더 — 자동화를 만든 사람의 진짜 일은 만드는 게 아니라 계속 확인하는 것이라는 것도.
이 봇이 어떻게 굴러가는지는 프로젝트 소개에, 처음 만들던 기록은 위키 자동 저장 사례에 있습니다.