000%

수정

NOTES · SEO / GEO / AEO17 · 2026.10

관측을 자동화하고 다음 수정으로 연결하기

응답 저장과 판정을 구분해 관측 작업을 실행한다. 오류·미확인을 보존하고 원인별로 다음 수정 위치를 정한 뒤 같은 URL과 질문으로 결과를 다시 비교한다.

§ 01SEO / GEO / AEO

관측 도구의 오판정을 막는다

사이트의 원고와 외부 검색·답변은 서로 다른 때에 바뀐다. 한 번의 수동 확인만으로는 게시 뒤 새 자료가 반영됐는지, 나중에 옛 자료가 다시 쓰였는지 추적하기 어렵다. 같은 URL·질문을 정한 주기로 관측하면 변경 시점과 결과를 비교할 수 있다. 이때 자동 수집이 다른 답변이나 이전 화면을 저장하면 잘못된 원인을 고치게 되므로, 원문과 판정 대상을 함께 보존한다.

자동화한 색인 검사는 입력칸에 넣은 URL과 결과 화면이 판정한 URL을 비교한다. 이전 URL의 결과가 남아 있거나 응답이 아직 끝나지 않았다면 미확인으로 저장한다. “색인 완료되지 않음” 안에 “색인 완료”가 들어 있다는 이유로 성공 처리하지 않도록 부정문·대기·알 수 없음 상태를 먼저 구분한다. 화면 언어와 문구가 바뀌면 원본 결과를 보존하고 판정 규칙을 다시 확인한다.

AI 인용 추출은 현재 질문에 대한 완료된 답변과 그 답변의 출처 영역으로 범위를 제한한다. 사용자가 입력한 URL, 사이드바의 이전 대화, 모델이 아직 작성 중인 문장, 단순 이름 언급은 인용 링크와 구분한다. 사이트 전체 인용과 목표 상세 페이지 인용도 다른 값으로 저장한다.

출처 목록이 저장 한도를 넘거나 응답 일부가 잘렸다면 잘린 범위를 남긴다. 전체를 확인하지 못한 상태에서 “인용 없음”으로 확정하지 않는다. 접근 실패, 로그인 필요, 서비스 제한, 미완성 응답, 해석할 수 없는 결과는 각각 사유가 있는 미확인 값으로 처리한다. 과거 기록에 정확성·최신성 평가가 없었다면 이후 필드 추가 때 과거 자료를 정답으로 채우지 않는다.

Google URL Inspection API는 색인에 저장된 버전의 상태를 반환한다. 응답의 최근 수집일과 선택 대표 주소를 현재 페이지와 비교한다. Google Indexing API는 JobPosting과 VideoObject 안의 BroadcastEvent 페이지의 갱신·삭제를 통지할 때 사용한다. 일반 안내문은 사이트맵과 내부 링크로 수집 경로를 제공한다. URL Inspection · Indexing API 대상

§ 02SEO / GEO / AEO

예약 관측의 요청량과 완료 범위를 관리한다

자동 관측을 만들기 전에 대상 URL 수, 검색 서비스 수, 질문 수, AI 서비스 수, 반복 횟수를 곱해 요청량을 계산한다. 예를 들어 색인 검사를 하는 URL이 U개이고 엔진이 E개, AI 질문이 Q개이고 서비스가 A개, 반복 횟수가 R회라면 한 회차의 기본 요청 수는 U×E + Q×A×R이다. 실제로는 재시도와 응답 대기 요청도 추가된다.

게시할 때의 캐시 갱신과 외부 검색·AI 관측 실행을 분리한다. 게시마다 모든 질문을 재실행하면 짧은 시간에 같은 요청이 반복될 수 있다. 정해진 주기에 대상 목록을 갱신하고, 변경된 URL을 식별해 필요한 관측을 수행한다. 주기는 운영자가 정하며 신규 페이지가 늘어도 자동으로 더 잦은 실행으로 바뀌지 않게 한다.

작업에는 서비스·URL·질문 버전·관측 회차를 식별하는 키를 둔다. 여러 실행기가 같은 일을 동시에 가져가지 않도록 점유 기한과 완료 기록을 관리한다. 재시도 횟수·간격·중단 조건을 정하고, 제한 응답을 받으면 해당 서비스의 다음 실행 시점을 늦춘다. 정한 대기 시간이 지나면 미완료 상태와 다음 재시도 시각을 저장한다.

관리 화면에는 등록된 작업 수, 실제 시도 수, 정상 응답 수, 실패·미확인 수, 마지막 완료 시각을 각각 표시한다. 완료 시각은 응답 원문과 판정 결과가 모두 저장된 뒤 갱신한다. 실행→원문 저장→판정→관리 화면 재조회까지 확인하고, 일부 서비스가 실패하면 그 범위를 분리해 보고한다.

§ 03SEO / GEO / AEO

관측 결과로 다음 수정 위치를 정한다

색인되지 않은 URL은 접근·robots·대표 주소·중복·본문 품질과 검색 도구의 사유를 대조한다. 색인은 됐는데 관련 질문에서 발견되지 않으면 질문과 본문의 대응, 내부 링크, 근거의 충분성을 살핀다. 인용은 되는데 답이 틀리면 혼동을 만든 날짜·표현·단계·과거 자료의 연결을 고친다.

답이 정확하지만 질문에 맞지 않으면 페이지가 다루는 대상과 조건을 제목·첫 문단·소제목에서 분명하게 쓴다. 답이 불충분하면 누락된 신청 방법·예외·비용처럼 질문에 필요한 내용을 실제 근거로 보강한다. 수정한 문장과 질문 ID를 연결해 다음 회차에 같은 조건으로 다시 관측한다.

운영 기록에는 현재 관측값, 직전 대비 변화, 가능한 원인, 다음 수정, 재관측 시점을 남긴다. 일요일 질문에 평일 시간표를 인용한 응답이 있다면 해당 인용 URL을 기록한다. 그 페이지에 주말 운영 조건을 보충한 뒤 다음 관측에서 일요일 시간표와 휴관 예외가 답에 반영되는지 확인한다.

예를 들어 새 HTML에는 휴관 조건이 있고 검색의 수집일도 갱신됐는데 AI가 2025년 기록만 인용했다면 현재·과거 문서의 제목·기간·연결을 조사한다. 현재 문서를 인용하면서 예외를 생략했다면 해당 구절과 질문 조건을 대조한다. 문서 내용이 이미 명확한 경우에는 응답 변동도 확인한다. 원문과 인용 URL 없이 “인용률 감소”만 알면 이 선택을 할 수 없다.

자동 검사에서 이미지 바이트가 갑자기 커지면 해당 CMS 경로의 currentSrc·폭·형식을 저장하고 원본 URL로 바뀌었는지 조사한다. 변환 횟수만 늘었다면 저장소 키와 재사용 경로를 본다. 철회 뒤 200이 나오면 크기 개선보다 공개 판정과 CDN 잔존 파일 처리를 먼저 고친다. 서로 다른 실패가 같은 “SEO 오류” 한 항목에 묶이지 않게 결과 필드를 나눈다.

RSS 검사는 공개 항목 ID와 링크·최초 게시일을 비교한다. 제목 수정으로 guid가 바뀌면 구독기에 중복 글이 생길 수 있고, 철회 뒤 항목이 남으면 공개 목록 갱신을 고쳐야 한다. 영상 검사는 구간 주소와 미디어 접근을 정기적으로 확인하고, 내용이 바뀌면 전사·요약·구간 시간을 다시 검토한다. 검색 노출과 AI 답변은 이 기술 검사 뒤 별도의 날짜·질문 조건으로 관측한다.

§ 04SEO / GEO / AEO

진단에서 시작한 세 문제를 끝까지 대조한다

가상 도서관의 처음 세 문제를 다시 확인한다. 운영 시간 본문이 응답 HTML에 들어오고, 상세 canonical이 해당 글을 가리키고, 과거 안내에 적용 기간과 현재 안내 링크가 있으면 사이트에서 바꾼 결과를 확인한 것이다. 공개·초안·없는 문서의 상태와 목록·사이트맵도 함께 대조한다.

다음으로 검색 도구가 해당 URL을 언제 수집했고 어떤 대표 주소를 선택했는지 기록한다. AI에는 같은 일요일 질문을 같은 조건으로 다시 실행한다. 기술 수정 뒤에도 과거 시간표를 사용하면 실제 인용 URL과 원문을 읽고 오래된 자료의 연결·시점 설명을 다시 조사한다.

인용이 생겼지만 답의 조건이 틀리면 콘텐츠와 근거의 대응을 고친다. 접근 자체가 실패하면 서버·WAF·캐시를 고친다. 기술 결과가 정상인데 아직 외부 수집 기록이 바뀌지 않았다면 재수집 상태를 관측한다. 원인에 따라 다음 조치를 선택하고 그 조치가 바꿀 기대값을 적는다.

진단에서 시작한 세 문제를 끝까지 대조한다
최초 HTML의 본문
관측할 내용
일요일 시간과 휴관 예외가 본문 요소에 있음
다음 조치
서버 본문 검사
상세 대표 주소
관측할 내용
상세·내부 링크·사이트맵이 같은 URL 사용
다음 조치
응답과 공개 목록 대조
과거 안내
관측할 내용
유효 기간과 현재 안내가 연결됨
다음 조치
본문과 근거 대조
검색 처리
관측할 내용
실제 색인·선택 대표·최근 수집일 기록
다음 조치
검색 도구 관측
AI 답변
관측할 내용
근거 URL과 일요일 조건 사용 여부 기록
다음 조치
질문 반복·원문 평가
§ 05SEO / GEO / AEO

원문 저장부터 다음 수정까지 한 기록으로 연결한다

작업 키에 질문 ID·질문 버전·서비스·실행 회차를 넣고 완료 응답 원문과 출처를 먼저 보존한다. 판정은 그 원문을 참조하는 별도 기록으로 둔다. 평가 기준을 고쳐도 원문을 덮어쓰지 않고 판정 버전을 올릴 수 있다. 재시도는 새 시도로 기록해 처음 실패한 요청을 지우지 않는다.

집계 입력에는 응답 상태, 여섯 판정, 주장별 근거와 이유, 원문 경로가 있어야 한다. 평가 코드는 필드가 빠진 완료 행을 거절한다. 아래 실습은 원문 저장, 판정 파일 읽기, 조건별 집계, 다음 수정 기록까지 실행한다.

§ 06SEO / GEO / AEO

URL 검사 요청과 원문 저장을 실행한다

observe.mjs를 metrics.mjs·assessment-fixture.mjs와 같은 폴더에 저장한다. node observe.mjs는 외부 요청 없이 가상 자료로 실행한다. observations/run-… 폴더에 raw/index.json, 응답 원문, assessments-v1.json, report.json을 만든다. 같은 작업을 다시 실행해도 새 폴더를 만들어 이전 원문을 덮지 않는다.

// Node.js 22. 기본 실행은 fixture, --live는 URL Inspection 요청. import { mkdir, mkdtemp, writeFile, readFile } from 'node:fs/promises'; import { resolve, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { grouped } from './metrics.mjs'; import { examples } from './assessment-fixture.mjs'; export async function inspectIndex({ inspectionUrl, siteUrl, token, request = fetch }) { if (!token) throw new Error('OAuth access token required'); const queriedAt = new Date().toISOString(); try { const response = await request('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ inspectionUrl, siteUrl, languageCode: 'ko-KR' }), signal: AbortSignal.timeout(10000), }); const raw = await response.text(); const base = { inspectionUrl, siteUrl, queriedAt, httpStatus: response.status, raw }; if (!response.ok) return { ...base, status: 'failed', reason: 'HTTP ' + response.status, retryable: response.status === 429 || response.status >= 500, retryAfter: response.headers.get('retry-after') }; let data; try { data = JSON.parse(raw); } catch { return { ...base, status: 'unknown', reason: 'Invalid JSON' }; } const result = data.inspectionResult?.indexStatusResult; if (!result?.verdict) return { ...base, status: 'unknown', reason: 'Missing index verdict' }; return { ...base, status: 'complete', verdict: result.verdict, lastCrawlTime: result.lastCrawlTime ?? null, googleCanonical: result.googleCanonical ?? null, canonicalMatches: result.googleCanonical ? result.googleCanonical === inspectionUrl : null, coverageState: result.coverageState ?? null }; } catch (error) { return { inspectionUrl, siteUrl, queriedAt, status: 'failed', reason: error.name === 'TimeoutError' ? 'Timeout' : 'Network failure', retryable: true }; } } export async function runObservation(out, indexRecord) { await mkdir(out, { recursive: true }); const run = await mkdtemp(join(resolve(out), 'run-')); await mkdir(join(run, 'raw')); await writeFile(join(run, 'raw/index.json'), JSON.stringify(indexRecord, null, 2), { flag: 'wx' }); for (const row of examples) { await writeFile(join(run, row.rawPath), JSON.stringify({ fixture: true, question: row.question, answer: row.answer, reference: row.reference, status: row.status, failure: row.failure, model: row.model, sources: row.claims?.filter(c => c.sourceUrl).map(c => ({ url: c.sourceUrl, quote: c.quote })), }, null, 2), { flag: 'wx' }); } await writeFile(join(run, 'assessments-v1.json'), JSON.stringify(examples, null, 2), { flag: 'wx' }); // 저장한 판정 파일을 읽어 조건별로 집계한다. const rows = JSON.parse(await readFile(join(run, 'assessments-v1.json'), 'utf8')); const report = { index: indexRecord.status, groups: grouped(rows), next: rows.filter(r => r.status === 'complete' && (r.complete === false || r.timely === false)) .map(r => ({ questionId: r.questionId, rawPath: r.rawPath, action: r.timely === false ? '과거 안내의 적용 기간과 현재 안내 링크를 대조한다.' : '현재 안내의 첫째 일요일 휴관 문구와 답변의 누락을 대조한다.' })) }; await writeFile(join(run, 'report.json'), JSON.stringify(report, null, 2), { flag: 'wx' }); return { run, report }; } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { const record = process.argv.includes('--live') ? await inspectIndex({ inspectionUrl: process.env.INSPECTION_URL, siteUrl: process.env.SEARCH_CONSOLE_SITE, token: process.env.SEARCH_CONSOLE_TOKEN }) : { status: 'complete', verdict: 'PASS', googleCanonical: 'https://example.com/guides/opening-hours', fixture: true, queriedAt: '2026-10-24T00:00:00Z' }; console.log(JSON.stringify(await runObservation(process.env.OBSERVATION_DIR || './observations', record), null, 2)); }

inspectIndex는 inspectionUrl과 siteUrl을 POST 본문에 넣고 OAuth 액세스 토큰을 Authorization 헤더로 보낸다. Search Console에 등록한 속성에 접근할 수 있는 계정과 webmasters.readonly 또는 webmasters 범위가 필요하다. URL-prefix 속성은 끝의 /를 포함하고 도메인 속성은 sc-domain:example.com 형태로 쓴다. 토큰은 환경변수에서 읽으며 저장하는 관측 기록에 넣지 않는다. 요청·인증 규약

INSPECTION_URL·SEARCH_CONSOLE_SITE·SEARCH_CONSOLE_TOKEN을 환경변수로 제공하고 node observe.mjs --live를 실행하면 URL Inspection 요청을 보낸다. AI 평가 입력은 assessment-fixture.mjs에서 읽는다. 관측한 답변을 평가할 때는 이 입력을 해당 서비스의 응답 원문·출처·평가값으로 바꾼다.

HTTP 401·403은 인증과 속성 권한을 고친 뒤 다시 시도한다. 429와 5xx는 실패 원문·retryAfter·retryable을 남기고 이번 실행을 끝낸다. 스케줄러는 Retry-After 또는 정한 지연을 적용한 새 시도로 기록하며 즉시 무한 재시도하지 않는다. 200이어도 verdict가 없거나 JSON이 깨지면 unknown이다. PASS만 읽지 말고 lastCrawlTime과 googleCanonical을 요청 주소와 비교한다.

URL Inspection 할당량은 속성당 하루 2,000회·분당 600회, 프로젝트당 하루 1,000만 회·분당 15,000회다. 여러 실행기가 같은 속성을 검사한다면 사용량을 공용 저장소에서 합산한다. 다음 요청 가능 시각을 큐에 저장하면 429 응답 후 요청을 몰아서 다시 보내는 일을 줄일 수 있다. 공식 할당량

runObservation은 원문을 먼저 저장하고 판정 파일을 다시 읽어 grouped로 집계한다. report의 next에는 휴관 예외 누락과 과거 자료 사용의 근거 원문 경로가 남는다. 다음 작업자는 해당 원문과 페이지를 대조한 뒤 수정 문장·문서 revision·재관측 질문을 기록한다. 실패 요청과 판정 불가능한 응답도 집계에서 사라지지 않는다.

이 코드에서 원문을 먼저 저장하는 순서는 판단을 다시 확인할 수 있게 한다. 검색 도구의 판정 규칙이나 AI 평가 기준이 바뀌어도 당시의 응답을 다시 읽을 수 있다. report.next의 원문 경로에서 잘못 사용한 기간이나 빠진 조건을 찾고, 바꾼 문장과 다음 관측을 연결하면 자동화가 실제 콘텐츠 수정으로 이어진다.

— FIN —