수정 결과를 검증하고 배포·복구하기
수정 전후의 같은 URL을 비교하고 운영 빌드·본문·상태·캐시를 확인한다. 운영 도메인에 배포된 버전을 확인한 뒤 장애 유형에 맞춰 코드·원고·데이터를 복구한다.
수정 전후에 같은 URL을 사용한다
빌드는 코드와 배포 결과물을 만들 수 있는지 확인한다. 검색에 제공할 본문·주소·날짜가 맞는지는 실제 응답을 읽어야 알 수 있다. 예를 들어 빌드는 성공해도 운영 환경의 noindex가 남거나 CDN이 수정 전 본문을 보내면 검색·답변에 필요한 자료 전달은 달라지지 않는다. 수정 전후 같은 URL을 비교하면 바꾼 코드가 어떤 응답을 바꿨는지 확인할 수 있다.
진단 단계에서 만든 URL 목록을 검증 입력으로 사용한다. 정상 공개 상세, 목록 첫 페이지와 다음 페이지, 이미지·첨부 자료, 이전 주소, 없는 주소, 초안·철회 자료를 포함한다. 수정 전 상태 코드·최종 URL·robots·canonical·제목·본문의 핵심 문장을 저장한다. 수정 후에도 같은 값을 수집해야 무엇이 달라졌는지 비교할 수 있다.
검증표에는 대상 URL, 기대값, 실제값, 확인 시각, 배포 버전을 적는다. 공개 목록에 글이 있는지만 보지 말고 상세로 이동해 원문과 대조한다. 메타데이터만 수정했다면 원래 본문·사진·링크가 보존됐는지도 함께 본다.
변경에 맞는 코드 검사를 선택한다
공개 판정이나 URL 생성 함수를 바꿨다면 정상 공개·초안·예약 전·철회·없는 문서처럼 결과가 달라지는 입력을 검토한다. 기존 테스트가 있으면 해당 범위만 실행한다. 타입 검사와 운영 빌드로 실제 배포 가능한지 확인한다. 아래 명령은 package.json에 typecheck와 build 스크립트가 정의된 저장소의 예다. 이름이 다르면 그 저장소의 명령을 사용한다.
npm run typecheck
npm run build
타입·빌드 통과 뒤에도 실제 운영 모드로 페이지를 열어 본다. 빌드 중 쓰이는 환경과 런타임 환경에서 운영 도메인·공개 API 주소가 다르지 않은지 확인한다. 검증용 사이트의 noindex·인증 설정이 운영에 그대로 들어가지 않도록 환경별 값을 비교한다.
핵심 공개 상태가 잘못되거나, 정상 글이 오류로 바뀌거나, 본문·링크가 누락되면 해당 변경을 수정한 뒤 관련 범위를 다시 확인한다. 색인이나 외부 인용은 이 단계의 로컬 검사로 판정하지 않고 배포 후 관측표로 넘긴다.
렌더링 방식은 빌드 결과와 실제 응답으로 확인한다
package.json의 build 명령, Next.js 버전, next.config, 데이터 조회와 캐시 설정을 먼저 읽는다. 운영 빌드를 실행한 뒤 그 결과물로 페이지를 연다. 빌드 출력과 배포 설정에서 정적 생성되는 경로, 요청 시 처리되는 경로, 재검증이 적용되는 경로를 확인한다. output: export라면 출력 HTML과 호스팅의 직접 URL·404 처리를 확인한다.
파서(parser)는 문서 문자열을 구조로 읽는 프로그램이다. 공개 상세 URL을 curl로 받아 HTML 파서로 article·h1·본문 단락·a href를 추출한다. 원고의 핵심 문장이 가시 본문 요소에 있는지 비교한다. Next.js의 직렬화된 데이터나 script 문자열 안에만 문장이 있는 것은 HTML 본문과 다르다. 스트리밍 응답은 마지막까지 받아 전체 결과를 읽고, 첫 조각의 로딩 표시만 보고 본문이 없다고 판단하지 않는다.
브라우저에서는 직접 URL 진입과 목록에서 링크로 이동한 결과를 모두 확인한다. 핵심 본문·출처가 hydration 전후에 유지되는지, 클릭·스크롤을 해야만 새 자료를 조회하는지 확인한다. JavaScript를 끈 화면은 의존성을 찾는 보조 확인이며, 스크립트를 사용하는 스트리밍 배치까지 고려해 원본 응답·완료 DOM·검색 도구의 렌더 결과를 함께 읽는다.
메타데이터는 일반 요청과 대상 수집기의 실제 응답 조건을 구분한다. Next.js는 일부 요청에 메타데이터를 스트리밍하고, HTML만 읽는 것으로 분류한 봇에는 메타데이터를 기다려 제공할 수 있다. User-Agent를 바꾼 curl로 메타데이터의 응답 분기를 비교한다. 실제 봇의 방문 시각과 수집 결과는 서버 로그와 검색 도구에서 확인한다. Metadata API 안내의 스트리밍·봇 처리와 설치 버전을 대조한다.
없는 글에 notFound()를 호출했더라도 운영 HTTP 상태를 직접 읽는다. 스트리밍이 시작된 뒤에는 200으로 오류 화면이 전달될 수 있다. 실제 404가 필요한 경로는 응답 헤더를 보내기 전에 존재·공개 여부를 판정할 수 있는 위치를 설계하고 noindex도 확인한다. Next.js not-found 규칙은 스트리밍과 비스트리밍의 상태 코드 차이를 설명한다.
게시·수정·철회 뒤에는 상세·목록·사이트맵·메타데이터의 현재 값을 다시 읽는다. 정적 생성이나 캐시가 있는 페이지는 캐시가 찬 상태에서 변경한 뒤 첫 요청과 갱신 완료 후 요청을 비교한다. 기록할 값은 공개 버전, 기대 내용, 실제 내용, 응답 시각, 재검증 처리 시각이다. 이전 원고가 남으면 데이터 캐시·페이지 캐시·CDN 중 어디에서 반환됐는지 좁힌다.
응답·메타데이터·본문을 검사한다
검증 환경에서 아래 명령을 실행한다. HTML과 헤더를 서로 다른 파일에 저장하므로 헤더에 있는 X-Robots-Tag를 놓치지 않고 비교할 수 있다. 주소와 파일명은 검사할 URL마다 바꾼다.
CHECK_URL='https://example.com/guides/opening-hours'
curl -sS -D after.headers -o after.html "$CHECK_URL"
rg -n -i 'HTTP/|location:|x-robots-tag:|content-type:' after.headers
rg -n -i 'canonical|noindex|<title|application/ld\+json' after.html
공개 대표 URL은 정상 상태, 의도한 canonical, 검색을 막지 않는 robots 지시를 보여야 한다. 본문에서는 질문의 직접 답과 출처 링크가 실제로 들어 있는지 찾는다. 응답이 200인데 로그인·봇 확인·“찾을 수 없음” 안내가 보이면 접근 설정이나 오류 처리 코드를 고친다. 검색 제외를 제거할 때는 meta와 헤더 어느 쪽에도 noindex가 남지 않아야 한다.
스트리밍(streaming)은 응답을 여러 조각으로 나누어 준비되는 대로 보내는 방식이다. Next.js의 스트리밍 메타데이터나 서버 렌더링 방식에 따라 태그 위치가 달라질 수 있다. 완성된 응답과 브라우저 DOM을 함께 읽는다. 일반 사용자 에이전트와 봇 에이전트의 응답이 다르면 코드·CDN 정책을 조사한다. curl에 Googlebot 이름을 넣는 것은 응답 분기를 확인하는 재현이며, 실제 Google 수집을 증명하는 관측은 URL 검사와 서버 기록에서 얻는다.
예제의 기대 응답을 직접 비교한다
운영 모드로 실행한 실습 서버에 아래 경로를 요청한다. 공개 글은 200과 운영 시간 본문, 초안과 없는 글은 404·검색 제외, 보존 페이지는 생성된 본문을 기대한다. 사이트맵에는 공개 상세와 보존 주소가 있고 초안 주소는 없어야 한다.
curl -sS -D guide.headers -o guide.html http://localhost:3027/guides/opening-hours
curl -sS -D draft.headers -o draft.html http://localhost:3027/guides/draft-guide
curl -sS -D missing.headers -o missing.html http://localhost:3027/guides/missing
curl -sS http://localhost:3027/archive/opening-2025
curl -sS http://localhost:3027/sitemap.xml
HTML 파서로 article의 본문과 data-revision을 읽고, title·canonical·JSON-LD가 같은 원고를 사용하는지 비교한다. script 내부의 직렬화 문자열과 실제 본문 요소를 구분한다. 로컬에서 확인한 요청 결과와 운영 도메인에서 확인한 결과는 각각 기록한다.
/archive/missing과 /policies/opening-hours도 요청한다. 전자는 404·noindex이며 canonical이 없어야 하고, 후자는 200과 운영 시간 안내 본문을 보여야 한다. 응답 코드만 보지 말고 서버 로그의 예외도 확인한다. 보존 페이지의 파일 경로는 app/archive/opening-2025/page.tsx다.
HTML 파서로 본문·주소·날짜 불일치를 찾는다
check-pages.mjs와 아래 validate-dates.mjs를 실습 폴더에 저장하고, identity-time.mjs도 같은 폴더에 둔다. npm install --save-dev cheerio@1.2.0으로 HTML 파서를 설치한다. 실습 운영 서버를 시작한 뒤 node check-pages.mjs http://localhost:3027을 실행한다.
// 설치: npm install --save-dev cheerio@1.2.0
// 실행: node check-pages.mjs http://localhost:3027
import assert from 'node:assert/strict';
import { load } from 'cheerio';
import { validateDates } from './validate-dates.mjs';
const base = new URL(process.argv[2] || 'http://localhost:3027');
const origin = 'https://example.com'; // sample의 SITE_ORIGIN과 같은 값
const results = [];
for (const [path, status] of [
['/guides/opening-hours', 200], ['/guides/draft-guide', 404],
['/guides/missing', 404], ['/archive/opening-2025', 200],
['/archive/missing', 404], ['/policies/opening-hours', 200],
]) {
const response = await fetch(new URL(path, base), {
redirect: 'manual', signal: AbortSignal.timeout(10000),
});
assert.equal(response.status, status, path + ': status');
const $ = load(await response.text());
const canonicals = $('link[rel="canonical"]');
const robots = $('meta[name="robots"]').map((_, e) => $(e).attr('content')).get().join(',')
+ ',' + (response.headers.get('x-robots-tag') || '');
if (status === 404) {
assert.match(robots, /noindex/i, path + ': noindex');
assert.equal(canonicals.length, 0, path + ': inherited canonical');
assert.equal($('article[data-revision]').length, 0, path + ': leaked article');
} else {
assert.equal(canonicals.length, 1, path + ': canonical count');
assert.equal(canonicals.attr('href'), origin + path, path + ': canonical');
assert.doesNotMatch(robots, /noindex/i, path + ': index blocked');
}
if (path === '/guides/opening-hours') {
const data = JSON.parse($('script[type="application/ld+json"]').text());
assert.equal(data.url, origin + path);
assert.equal(data.name, $('article h1').text());
validateDates($, data);
const body = $('article').clone(); body.find('script').remove();
assert.match(body.text(), /첫째 일요일과 공휴일/);
assert.equal($('article a').attr('href'), '/policies/opening-hours');
}
results.push({ path, status, passed: true });
}
const xml = await fetch(new URL('/sitemap.xml', base));
assert.equal(xml.status, 200);
const $xml = load(await xml.text(), { xmlMode: true });
const urls = $xml('loc').map((_, e) => $xml(e).text()).get();
assert(urls.includes(origin + '/guides/opening-hours'));
assert(urls.includes(origin + '/archive/opening-2025'));
assert(urls.includes(origin + '/policies/opening-hours'));
assert(!urls.some(url => /draft-guide|missing/.test(url)));
const moved = await fetch(new URL('/guides/library-hours', base), { redirect: 'manual' });
assert.equal(moved.status, 308);
assert.equal(new URL(moved.headers.get('location'), base).pathname, '/guides/opening-hours');
console.log(JSON.stringify({ pages: results, sitemap: urls }, null, 2));
validateDates는 data-date로 게시일과 수정일 행을 구분한다. 각 행의 라벨, datetime, 화면 날짜를 JSON-LD와 대조한다. 날짜 값이 어디엔가 존재한다는 검사만으로는 게시일과 수정일을 뒤집거나 보이는 글자만 1999년으로 바꾼 오류를 찾지 못한다. 마지막에는 이전 slug의 308 이동과 사이트맵도 검사한다.
검사 결과는 여섯 페이지의 상태와 사이트맵 목록이다. 이전 slug의 308도 통과해야 출력된다. datePublished: label은 잘못 붙인 라벨, dateModified: visible text는 화면 날짜 불일치, inherited canonical은 오류 페이지의 대표 주소 상속을 가리킨다. 실패한 항목의 실제 HTML을 저장한 뒤 해당 출력 필드를 고친다.
import assert from 'node:assert/strict';
import { formatContentDate } from './identity-time.mjs';
export function validateDates($, data) {
for (const [key, label] of [['datePublished', '게시일'], ['dateModified', '수정일']]) {
const row = $(`article [data-date="${key}"]`);
const expected = data[key];
assert.equal(row.length, expected === undefined ? 0 : 1, key + ': row count');
if (expected === undefined) continue;
assert.equal(row.find('[data-label]').text().trim(), label, key + ': label');
assert.equal(row.find('time').length, 1, key + ': time count');
assert.equal(row.find('time').attr('datetime'), expected, key + ': datetime');
assert.equal(row.find('time').text().trim(), formatContentDate(expected), key + ': visible text');
}
}
formatContentDate는 화면과 검사에서 같은 표시 정책을 사용한다. 회귀 검사에서는 게시일·수정일 라벨을 바꾼 입력, datetime을 맞바꾼 입력, 보이는 연도만 바꾼 입력을 각각 만들어 모두 실패하는지 확인한다. 정상 입력과 실패 입력을 함께 두어 검사 자체가 오류를 놓치지 않는지 대조한다.
다음 파일을 validate-dates.test.mjs로 저장한다. 앞에서 Cheerio를 설치한 실습 폴더에서 node --test validate-dates.test.mjs를 실행하면 정상 입력 1개와 세 가지 변형 오류를 검사한다.
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from 'cheerio';
import { validateDates } from './validate-dates.mjs';
const data = { datePublished: '2026-10-01', dateModified: '2026-10-23T09:00:00+09:00' };
const html = `<article><p data-date="datePublished"><span data-label>게시일</span><time datetime="2026-10-01">2026년 10월 1일</time></p><p data-date="dateModified"><span data-label>수정일</span><time datetime="2026-10-23T09:00:00+09:00">2026년 10월 23일</time></p></article>`;
test('정상 라벨·기계 날짜·화면 날짜는 통과한다', () => validateDates(load(html), data));
test('라벨 교환·기계값 교환·보이는 연도 오기는 각각 실패한다', () => {
const labelSwap = html.replace('게시일', 'TEMP').replace('수정일', '게시일').replace('TEMP', '수정일');
const valueSwap = html.replace('datetime="2026-10-01"', 'datetime="TEMP"')
.replace('datetime="2026-10-23T09:00:00+09:00"', 'datetime="2026-10-01"')
.replace('datetime="TEMP"', 'datetime="2026-10-23T09:00:00+09:00"');
for (const bad of [labelSwap, valueSwap, html.replace('2026년 10월 1일', '1999년 1월 1일')])
assert.throws(() => validateDates(load(bad), data));
});
날짜 검사에서 보이는 라벨까지 읽는 이유는 날짜 숫자가 같아도 의미가 뒤바뀔 수 있기 때문이다. “게시일” 자리에 적용 시작일을 출력하면 미래의 운영 시간을 현재 공지로 읽게 만들 수 있다. validateDates는 게시·수정 라벨과 값의 일치를 검사하고, 적용 기간 문장과 실제 업무 사실의 일치는 원고와 근거를 대조해 확인한다.
브라우저에서 본문과 연결을 직접 사용한다
비로그인 창으로 목록을 열고 첫 글·중간 글·마지막 글을 눌러 본다. 다음 페이지와 관련 글 링크로 이동하고, 뒤로 가기와 새로고침 후 같은 콘텐츠가 유지되는지 확인한다. 본문 사진·첨부·영상은 실제로 열거나 재생한다. 출처 링크가 제목만 비슷한 다른 문서로 연결되지 않는지도 대조한다.
넓은 화면과 좁은 화면에서 제목·긴 URL·코드가 어떻게 줄바꿈되는지 읽는다. 표가 세로로 바뀌면 각 값에 열 제목이 붙는지도 본다. 테마를 바꾸어 본문과 배경의 색 대비를 비교하고, 언어를 바꾸어 문단과 링크가 유지되는지 확인한다.
브라우저 개발자 도구의 Network 탭은 요청·응답·전송 시간을 보여주고 Console 탭은 코드 실행 메시지와 오류를 보여준다. 문서·이미지·스크립트의 실패 응답을 확인하고 오류가 해당 기능에 영향을 주는지 추적한다. HTML에 보이지 않던 본문이 브라우저에만 있으면 JavaScript 실행 전후 DOM과 데이터 요청을 비교한다. Google이 처리한 결과는 배포 후 URL 검사에서 다시 확인한다.
사이트맵·링크·구조화 데이터를 대조한다
사이트맵 URL 집합과 공개 데이터의 기대 URL 집합을 비교한다. 중복, 누락, 철회 주소, 리다이렉트 주소를 각각 분류한다. 사이트맵 인덱스라면 하위 파일까지 읽는다. 전체 대상의 응답 코드를 수집할 때는 서비스 부하를 고려해 요청 동시 수와 속도를 제한하고, 오류가 난 URL과 이유를 저장한다.
정상 상세의 canonical과 사이트맵의 URL이 같은지, 내부 링크도 그 주소를 쓰는지 확인한다. 수정일이 모든 페이지에서 배포 시각으로 덮였으면 생성 코드를 고친다. hreflang이 있다면 상대 언어 페이지에서도 되돌아오는 참조와 정상 응답을 확인한다.
구조화 데이터는 페이지의 모든 application/ld+json 블록을 JSON으로 파싱한다. 문법 오류를 먼저 고치고, 제목·날짜·작성자·URL을 가시 본문과 대조한다. 리치 결과(rich result)는 일반 링크 외에 추가 정보를 보여주는 검색 표시다. Google이 지원하는 유형은 Rich Results Test로 추가 확인한다. 경고는 필수 항목 누락과 권장 항목 누락을 구분해 해당 유형의 문서와 비교한다.
게시·수정·철회·복원을 같은 경로로 대조한다
콘텐츠 기능을 바꿨다면 한 글의 수명주기를 확인한다. 초안 작성 후 공개면에 없는지 보고, 게시한 뒤 목록·상세·사이트맵·공유 정보가 같은 내용을 보여주는지 본다. 제목·날짜·근거를 수정한 뒤에는 이전 값이 캐시에 남지 않는지 확인한다. 철회 후에는 공개 링크와 자체 검색·챗봇에서 사용되지 않는지 확인한다.
복원 테스트는 시험 문서를 게시한 뒤 수정·철회하고 이전 revision을 다시 공개하는 순서로 실행한다. 복원한 본문·출처·이미지를 읽고 현재 작업본이 유지되는지도 확인한다. 날짜는 최초 게시일을 보존하고 복원으로 내용이 바뀐 시각을 수정일에 기록한다.
새 콘텐츠 유형의 목록·상세·사이트맵이 같은 공개 조건을 사용하는지 비교한다. 새 유형의 화면은 보이는데 사이트맵·관련 글·감시 대상에서 빠지면 등록 경로를 고친다. 두 데이터 원천을 합친 목록에서 한쪽 조회가 실패할 때는 빈 자료로 조용히 처리하지 않는다. 사용자 화면은 일부 실패를 알릴 수 있어도 전체 목록을 확정하는 사이트맵·감시 작업은 불완전한 결과를 구분해 저장해야 한다.
개편된 메인과 보존 페이지는 원문·사진·링크를 함께 대조한다. 자동 검사가 다루지 않는 클릭 후 영역과 영상, 브라우저별 표시도 확인 범위에 적는다. 목록에서 상세·관련 글·출처로 이동하며 각 화면의 내용과 연결 주소를 비교한다.
배포 버전과 운영 도메인을 확인한다
배포 전 변경 파일과 콘텐츠 목록을 확인하고 이전 정상 버전의 식별자를 기록한다. 코드 변경과 콘텐츠 수정이 함께 있으면 어느 조합을 배포하는지 적는다. 저장 구조를 바꾸는 작업은 기존 데이터를 읽을 수 있는 순서로 진행한다. 새 필드 추가, 기존 데이터 보완, 읽기 전환, 이전 필드 정리 순서를 나누면 중간 버전의 충돌을 줄일 수 있다.
배포 도구에서 빌드 완료 상태와 커밋 또는 릴리스 ID를 확인한다. 이어서 실제 운영 도메인이 그 배포를 가리키는지 확인한다. 미리보기 주소와 운영 도메인을 각각 요청한다. 공개 URL 목록 중 영향 경로를 다시 요청해 헤더·본문·대표 주소·사이트맵을 대조한다.
CMS와 CDN 캐시가 있으면 콘텐츠 갱신 경로의 재검증·무효화 동작을 확인한다. 글은 바뀌었는데 목록·사이트맵·공유 카드가 이전 내용을 보이면 각각 어떤 캐시와 데이터를 읽는지 찾는다. 최신 글을 수정·철회했을 때 모든 공개 경로가 같은 상태를 보여야 한다.
DNS·TLS·캐시·앞단 차단을 운영 경로에서 확인한다
DNS는 dig 명령으로 A·AAAA·CNAME을 조회한다. dig는 DNS 레코드를 질의하는 도구다. 호스팅에서 안내한 값과 실제 응답을 대조하고, 설정한 IPv4·IPv6 경로가 각각 동작하는지 확인한다. 아래 example.com과 경로를 작업할 공개 주소로 바꾼다.
dig +noall +answer example.com A
dig +noall +answer example.com AAAA
dig +noall +answer www.example.com CNAME
CHECK_URL='https://example.com/guides/opening-hours'
curl -sS --max-time 30 -D infra.headers -o infra.html \
-w 'status=%{http_code} dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}\n' \
"$CHECK_URL"
rg -n -i 'HTTP/|location:|cache-control:|age:|etag:|vary:|x-robots-tag:|content-type:' infra.headers
curl은 URL로 요청을 보내는 명령이다. -D는 응답 헤더, -o는 본문을 파일에 저장하고, -w는 지정한 측정값을 출력한다. --max-time 30은 요청 전체를 최대 30초 동안 기다린다. 출력 시간은 요청 시작부터 각 단계까지의 누적 초다. time_appconnect는 TLS 연결 완료, time_starttransfer는 첫 응답 바이트까지의 시간이다. 두 값에는 앞 단계의 시간도 포함되므로 서버 처리 시간 자체로 읽지 않는다.
TLS 인증서 확인은 curl의 기본 검증을 사용한다. 오류가 나면 도메인 이름·만료일·인증서 체인과 연결 경로를 확인한다. -k로 검증을 생략한 결과는 인증서가 정상인지를 확인하는 데 쓸 수 없다. HTTP 주소도 요청해 HTTPS 대표 주소로 이동하는지 확인한다.
같은 URL을 처음 요청한 때와 다시 요청한 때의 헤더·본문을 저장한다. Age는 캐시에 저장된 응답의 나이를 나타내며, 플랫폼별 캐시 상태 헤더도 함께 읽는다. 응답이 캐시 적중이어도 본문이 최신 게시본인지 대조한다. 검증 자료를 수정·철회한 뒤에는 상세·목록·사이트맵·이미지가 갱신되는 시점까지 확인한다.
WAF·CDN 로그에서 검사 요청의 시각과 경로를 찾고 원본 서버 로그와 연결한다. 공개 글 대신 봇 확인 화면·403·429가 나오면 일치한 규칙을 확인한다. 사람과 수집기에 전달할 핵심 본문은 같은 공개 자료를 사용한다. 검색 도구의 실제 수집 결과에서도 본문을 확인한다.
실패 유형에 따라 되돌린다
코드 배포 후 공개 상세가 5xx가 되거나 비공개 자료가 노출되면 우선 이전 정상 배포로 복구하거나 해당 경로의 공개를 차단한다. 메타데이터만 잘못됐고 본문은 정상이라면 원인이 확인된 설정을 좁게 되돌릴 수 있다. 복구 후에는 원래 실패한 URL과 인접 상태를 다시 확인한다.
코드 롤백은 CMS 본문과 DB 변경을 자동으로 되돌리지 않는다. 잘못 게시한 원고는 CMS의 변경 이력에서 복원하고, 데이터 구조 변경은 이전 코드가 현재 데이터를 읽을 수 있는지 확인한 뒤 처리한다. DB 전체 복원을 일반적인 첫 조치로 삼으면 이후 정상 입력까지 잃을 수 있다.
복구 기록에는 실패 증상, 영향 URL, 배포 ID, 복구 시각, 복구 뒤 관측값을 남긴다. 운영 응답이 정상으로 돌아온 뒤 검색 도구와 AI 관측에서 이전 내용이 얼마나 남는지 별도로 추적한다. 검색 시스템의 재수집 시점은 운영 코드 배포 시점과 다르다.
복구 뒤 외부 검색이 당장 바뀌지 않으면 최근 수집 시각을 먼저 본다. 검색 시스템에 장애 때의 응답이 남아 있는 동안에는 현재 서버가 정상이어도 예전 자료를 보여줄 수 있다. 복구 응답과 그 이후의 수집 결과를 연결하면 다시 코드를 바꿔야 하는 문제인지, 외부 자료 갱신을 확인할 단계인지 판단할 수 있다.
이미지·기사·피드의 실패를 요청으로 재현한다
이미지 최적화에서는 파일 크기·철회 후 접근·후보 폭을 함께 확인한다. 큰 원본·200px 작은 원본·변환 중 철회·같은 폭의 동시 요청을 입력으로 사용한다. 아래 media.test.mjs를 이미지 실습 폴더에 저장하고 node --test media.test.mjs로 실행한다. 서버는 임의의 로컬 포트에서 시작하고 끝나면 닫는다.
import test from 'node:test';
import assert from 'node:assert/strict';
import sharp from 'sharp';
import { createMediaStore } from './media-store.mjs';
import { createDemoServer, sampleImage } from './media-demo.mjs';
import { article, renderArticle } from './article-jsonld.mjs';
import { renderFeed, escapeXml } from './feed.mjs';
const now = '2026-10-24T09:00:00+09:00';
test('같은 폭의 동시 요청은 한 번만 변환하고 실제 픽셀 폭을 돌려준다', async () => {
const store = await createMediaStore(await sampleImage());
const [a, b] = await Promise.all([store.read(640), store.read(640)]);
assert.deepEqual(a.body, b.body); assert.equal(store.stats().transforms, 1);
assert.equal((await sharp(a.body).metadata()).width, 640);
assert.equal(a.headers['Cache-Control'], 'private, no-store, max-age=0');
assert.equal((await store.read(999)).status, 400);
});
test('변환 도중 철회하면 대기 요청도 404이며 원본·기존 파생본 모두 막힌다', async () => {
const store = await createMediaStore(await sampleImage());
const pending = store.read(640);
store.setPublished(false);
assert.equal((await pending).status, 404);
for (const width of [null, 320, 640, 1280]) assert.equal((await store.read(width)).status, 404);
store.setPublished(true);
assert.equal((await store.read(640)).status, 200);
assert.equal(store.stats().transforms, 1);
});
test('작은 원본을 확대하지 않으며 srcset에는 중복 폭을 넣지 않는다', async () => {
const source = await sharp({ create: { width: 200, height: 100, channels: 3, background: 'red' } }).jpeg().toBuffer();
const store = await createMediaStore(source);
assert.equal((await store.read(1280)).width, 200);
const server = createDemoServer(store);
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const base = `http://127.0.0.1:${server.address().port}`;
const html = await (await fetch(base)).text();
assert.equal((html.match(/ 200w/g) ?? []).length, 1);
assert(!html.includes('1280w'));
await fetch(base + '/media/photo?w=640');
store.setPublished(false);
for (const path of ['/media/photo', '/media/photo?w=640', '/news/new-hours', '/'])
assert.equal((await fetch(base + path)).status, 404);
const feed = await (await fetch(base + '/rss.xml')).text();
assert(!feed.includes('<item>'));
} finally { await new Promise(resolve => server.close(resolve)); }
});
test('기사의 가시 이미지·공유 이미지·Article.image와 날짜가 같은 자료에서 나온다', () => {
const { html, structured } = renderArticle('https://example.com', { path: '/media/photo?w=1280', width: 1280, height: 800 }, now);
assert.equal(structured.image[0], 'https://example.com/media/photo?w=1280');
assert(html.includes(`src="${structured.image[0]}"`));
assert(html.includes(`content="${structured.image[0]}"`));
assert(html.includes(`datetime="${structured.datePublished}"`));
assert(html.includes(`datetime="${structured.dateModified}"`));
assert.throws(() => renderArticle('https://example.com', { path: '/photo' }, '2026-09-01T00:00:00Z'));
});
test('피드는 초안·철회·예약을 제외하고 수정·주소 이동에도 ID와 최초 게시일을 유지한다', () => {
const changed = { ...article, path: '/news/revised-hours', summary: 'A & B < C', contentModifiedAt: now };
const xml = renderFeed([changed,
{ ...article, id: 'draft', status: 'draft' },
{ ...article, id: 'withdrawn', status: 'withdrawn' },
{ ...article, id: 'future', publicFrom: '2027-01-01T00:00:00Z' }], 'https://example.com', now);
assert.equal((xml.match(/<item>/g) ?? []).length, 1);
assert(xml.includes('A &amp; B &lt; C'));
assert(xml.includes('/#document-new-hours</guid>'));
assert(xml.includes('/news/revised-hours</link>'));
assert(xml.includes('Wed, 30 Sep 2026 00:00:00 GMT'));
assert(!renderFeed([{ ...article, status: 'withdrawn' }], 'https://example.com', now).includes('<item>'));
});
test('피드가 날짜의 정밀도를 보존하고 XML 금지 문자와 역전 날짜를 거부한다', () => {
const xml = renderFeed([{ ...article, publishedAt: '2026-09-30' }], 'https://example.com', now);
assert(!xml.includes('<pubDate>'));
assert.throws(() => escapeXml('bad\u0000'));
assert.throws(() => escapeXml('bad\uD800'));
assert.throws(() => renderFeed([{ ...article, contentModifiedAt: '2026-09-01' }], 'https://example.com', now));
});
첫 검사는 동시에 두 번 읽어도 변환 횟수가 1인지 확인한다. 둘째는 변환을 기다리는 동안 철회해 대기 요청과 이후 원본·파생본 요청을 막는지 확인한다. 셋째는 200px 원본에 1280w를 붙이지 않는지와 실제 HTTP 경로의 철회를 검사한다. 기사 검사는 날짜·이미지의 입력이 HTML과 JSON-LD에 같이 반영되는지 본다. 피드 검사는 초안·예약·철회 제외, 안정된 ID, 날짜 정밀도와 XML 특수문자를 검사한다.
브라우저에서는 기사 사진이 실제로 디코딩되는지, RSS가 XML 파서에서 오류 없이 읽히는지 추가로 확인한다. 영상 4초 링크를 누른 뒤 주소를 새 창에 붙여 넣어 같은 안내문이 보이는지도 확인한다. 이미지 전송을 바꾼 뒤에도 기존 본문·영상 구간 이동이 동작해야 독자가 근거까지 도달할 수 있다.
영상 파일을 만든 뒤 아래를 video.test.mjs로 저장해 node --test video.test.mjs를 실행한다. 파일 앞부분·끝부분·끝까지의 범위를 요청해 받은 바이트를 원본과 대조하고, 파일 밖 범위와 역전 범위는 416인지 확인한다.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createDemoServer } from './media-demo.mjs';
// node make-video-demo.mjs로 talk.mp4를 만든 뒤 실행한다.
test('영상 byte range가 요청 구간을 반환하고 파일 밖 요청을 거부한다', async () => {
const file = await readFile(new URL('./talk.mp4', import.meta.url));
const server = createDemoServer(null);
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const url = `http://127.0.0.1:${server.address().port}/talk.mp4`;
for (const [range, start, end] of [
['bytes=0-99', 0, 99], ['bytes=-100', file.length - 100, file.length - 1],
[`bytes=${file.length - 100}-`, file.length - 100, file.length - 1],
]) {
const response = await fetch(url, { headers: { Range: range } });
assert.equal(response.status, 206);
assert.equal(response.headers.get('content-range'), `bytes ${start}-${end}/${file.length}`);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), file.subarray(start, end + 1));
}
for (const range of ['bytes=999999999-', 'bytes=20-10', 'bytes=-0', 'bytes=-'])
assert.equal((await fetch(url, { headers: { Range: range } })).status, 416);
} finally { await new Promise(resolve => server.close(resolve)); }
});