canonical·게시일·수정일과 현재·과거를 정확히 표현하기
어떤 문서의 어느 판본이 언제 유효한지를 대표 주소, 날짜, 적용 기간과 실제 코드로 설명한다.
주소와 시간은 서로 다른 질문에 답한다
“이 내용의 원문은 어디인가”, “언제 공개됐는가”, “지금 적용되는가”는 서로 다른 질문이다. canonical은 중복되거나 매우 비슷한 문서의 대표 주소를 알리고, 게시일·수정일은 문서의 이력을 설명한다. 적용 기간은 본문에 적힌 요금·시간표·자격이 어느 시점에 유효한지 설명한다.
가상 도서관의 2025년 시간표를 2026년에 새 CMS로 옮겼다고 하자. 이관 시각을 최초 게시일로 넣고 제목을 “현재 운영 시간”으로 바꾸면 과거 자료가 최신 안내처럼 보인다. 현재 시간표에 canonical까지 지정하면 서로 다른 기간의 사실을 같은 문서로 묶는 신호를 보낸다. 독자가 과거 기준을 확인할 수 있도록 원래 기간·출처·자기 주소를 남기고 현재 안내를 연결한다.
검색 시스템은 문서를 묶고 검색 결과의 날짜를 추정한다. 생성형 답변은 질문의 시점에 맞는 자료를 골라야 한다. 주소·기간·본문이 서로 맞으면 사람과 도구가 비교할 근거가 생긴다. Search Console에서는 선택된 대표 주소와 최근 수집 시각을 읽고, AI 답변에서는 인용한 페이지와 그 문장의 적용 기간을 대조한다.
canonical이 필요한 중복과 별도 문서를 나눈다
canonicalization은 여러 후보 중 대표 URL을 정하는 과정이다. 같은 글에 추적용 UTM 매개변수만 붙었거나 출력 형태만 달라졌다면 대표 주소를 통일할 수 있다. 내부 링크와 사이트맵도 그 주소를 사용한다. Google은 redirect와 rel=canonical을 강한 신호로, 사이트맵을 더 약한 신호로 설명하며 최종 대표 주소는 검색 시스템이 선택한다. 대표 주소 지침
canonical 링크를 읽은 브라우저는 원래 URL에 머문다. 방문자를 새 주소로 보내려면 301 또는 308 리다이렉트를 사용한다. 비공개 문서는 인증과 권한 검사로 보호하고, 공개 문서를 검색에서 제외할 때는 noindex를 제공한다.
같은 안내의 기본 주소와 UTM 주소가 따로 수집되면 검색 시스템은 두 문서를 비교해 대표를 골라야 한다. canonical과 내부 링크를 맞추는 목적은 같은 내용의 신호를 원하는 주소로 모으고 결과에 사용할 주소를 일관되게 제시하는 데 있다. 과거 안내와 현재 안내는 답하는 기간이 다르므로 각각의 주소를 유지한다. AI가 과거 사실을 현재 사실로 읽는 문제는 그 문서의 기간 설명과 실제 인용 결과에서도 확인해야 한다.
작성·게시·수정·사건·적용 시간을 나눈다
createdAt은 작업본을 만든 내부 시각이다. 최초 공개일이 확인되면 publishedAt으로 저장하고 JSON-LD의 datePublished에 사용한다. contentModifiedAt은 공개 내용의 실질 변경 시각이며 dateModified의 입력이 된다. 단순 저장·배포·수집 때마다 바꾸면 어떤 사실이 언제 바뀌었는지 알 수 없어진다.
사건일은 문서가 설명하는 일이 발생한 날짜다. 운영 방침을 의결한 날, 공지를 게시한 날, 새 시간표를 적용하는 날은 다를 수 있다. 본문의 “게시”, “수정”, “적용 기간” 라벨도 구분한다. Google의 날짜 지침은 게시·실질 수정 날짜를 보이는 본문과 구조화 데이터에 일관되게 제공하고 사건일과 혼동하지 않도록 설명한다. 페이지 날짜 지침
사이트맵의 lastmod는 해당 URL의 중요한 변경을 나타낸다. 본문·주요 링크·구조화 데이터 등 실제 변경 기록에서 만든다. 정확한 기록이 없으면 생략한다. 오래된 글에 오늘 날짜만 넣는 방식은 실질적인 갱신을 설명하지 못한다. 사이트맵 지침 · 콘텐츠 갱신 지침
“10월 24일 게시, 11월 1일부터 운영 시간 변경”이라는 공지를 10월 25일에 읽었다면 아직 이전 시간이 적용된다. 게시일만 보고 최신 자료를 고르면 앞으로 적용할 시간을 오늘의 시간으로 답할 수 있다. 게시·수정 날짜는 문서가 나온 때를, 적용 기간은 그 사실을 사용할 때를 알려 준다. 그래서 본문에도 “11월 1일부터”를 쓰고 현재 안내와 예정 안내를 구분한다.
공개 상태와 현재·과거 상태를 독립적으로 표현한다
과거 기록도 공개된 문서일 수 있다. publicationStatus의 초안·공개·철회와 사실의 적용 상태를 따로 둔다. 현재 효력이 없다는 이유로 기록을 삭제하면 과거 정책·요금을 확인하려는 질문에 답할 수 없다. 보존 가치와 접근 정책을 판단한 뒤 공개 여부를 정한다.
문서 ID는 같은 글을 식별하고 revision은 수정 판본을 식별한다. slug는 주소에 사용할 이름이다. 제목·slug가 바뀌어도 같은 문서라면 안정된 ID와 수정 이력을 유지한다. 현재 안내와 과거 기록이 별도 문서라면 서로 다른 ID를 두고 “현재 안내”, “이전 안내” 링크로 관계를 설명한다. supersedes는 후속 문서가 이전 문서를 대체한다는 내부 관계로 사용할 수 있다.
validFrom·validUntil은 예제의 데이터 모델에서 적용 기간을 저장하는 필드다. WebPage에는 게시·수정 날짜를 넣고, 적용 기간은 본문의 “2025년 1월 1일~12월 31일”처럼 표시한다. Schema.org 속성은 문서 유형마다 정의돼 있다. WebPage 속성
날짜와 주소의 판정을 실행 가능한 함수로 만든다
아래 파일을 identity-time.mjs로 저장한다. Node.js 22에서 외부 패키지 없이 실행할 수 있다. dateOnly는 실제 존재하는 YYYY-MM-DD 날짜를 검증하고 그대로 반환한다. instant는 초 단위 시각과 명시된 시간대를 검증한다. 날짜만 아는 사건에 임의의 자정이나 시간대를 덧붙이지 않는다.
ISO 8601은 날짜와 시각의 표현 형식을 정하는 국제 표준이다. 이 예제는 2026-10-23T12:00:00+09:00처럼 날짜·시각·UTC 오프셋을 적는 초 단위 형식을 사용한다. +09:00은 UTC보다 9시간 빠르다는 뜻이며 같은 순간의 UTC 표기는 2026-10-23T03:00:00Z다. 실제 지역 시간대의 계절별 변경이 필요한 서비스는 지역 시간대 규칙도 관리한다.
canonicalUrl은 저장된 경로와 콘텐츠를 바꾸는 페이지 번호로 URL을 만든다. 예제의 허용 경로는 영문 소문자·숫자·하이픈으로 제한했다. 실제 서비스가 한글 slug나 다른 매개변수를 지원하면 그 데이터 계약에 맞춰 확장한다. 요청의 Host와 전체 쿼리를 그대로 복사하지 않으므로 UTM·임의 도메인이 대표 주소에 섞이지 않는다.
// Node.js 22, 외부 패키지 없음. 시각 값은 초 단위로 제한하며 게시·수정일은 날짜만 받을 수도 있다.
export function dateOnly(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
throw new Error('Invalid date');
const ms = Date.parse(value + 'T00:00:00Z');
if (!Number.isFinite(ms) || new Date(ms).toISOString().slice(0, 10) !== value)
throw new Error('Invalid date');
return value; // 사건일은 시간대로 이동시키지 않는다.
}
export function instant(value) {
const m = typeof value === 'string' && value.match(
/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(Z|[+-]\d{2}:\d{2})$/);
if (!m) throw new Error('Timezone required');
dateOnly(m[1]);
const offset = m[5] === 'Z' ? [0, 0] : m[5].slice(1).split(':').map(Number);
if (+m[2] > 23 || +m[3] > 59 || +m[4] > 59 || offset[0] > 23 || offset[1] > 59)
throw new Error('Invalid time');
const ms = Date.parse(value);
if (!Number.isFinite(ms)) throw new Error('Invalid time');
return ms;
}
export function canonicalUrl(path, page = 1, origin = 'https://example.com') {
// 요청 Host/쿼리를 복사하지 않고, 저장된 문서 경로와 의미 있는 페이지 번호만 사용한다.
if (!/^\/[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(path)) throw new Error('Invalid path');
if (!Number.isSafeInteger(page) || page < 1) throw new Error('Invalid page');
const url = new URL(path, origin);
if (page > 1) url.searchParams.set('page', String(page));
return url.href;
}
// 게시일은 Date 또는 초 단위 DateTime을 받는다. 날짜만 알면 정밀도를 유지한다.
export function contentDate(value) {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return dateOnly(value);
instant(value);
return value;
}
// 비교용 범위다. 저장하거나 출력하는 게시 시각을 만들어내지 않는다.
// 이 실습의 날짜는 도서관의 Asia/Seoul 달력 기준이다.
function bounds(value) {
contentDate(value);
if (value.length > 10) return [instant(value), instant(value)];
const start = instant(value + 'T00:00:00+09:00');
return [start, start + 86400000 - 1];
}
export function isPublic(doc, now = Date.now()) {
if (!Number.isFinite(now)) throw new Error('Invalid comparison time');
if (!['draft', 'published', 'withdrawn'].includes(doc.status)) throw new Error('Invalid status');
if (doc.publishedAt !== null) contentDate(doc.publishedAt);
if (doc.deletedAt !== null) instant(doc.deletedAt);
const release = doc.publicFrom === null ? null : instant(doc.publicFrom);
return doc.status === 'published' && doc.deletedAt === null
&& release !== null && release <= now;
}
export function documentId(id, origin = 'https://example.com') {
if (typeof id !== 'string' || !/^[a-z0-9-]+$/.test(id)) throw new Error('Invalid id');
return new URL('/#document-' + id, origin).href;
}
export function describeDocument(doc, asOf, origin = 'https://example.com') {
const now = instant(asOf);
// 저장 자료의 오류는 초안에도 보고한다. 검증을 마친 뒤 공개 여부를 판정한다.
const published = doc.publishedAt === null ? null : bounds(doc.publishedAt);
const modified = doc.contentModifiedAt === null ? null : bounds(doc.contentModifiedAt);
const from = doc.validFrom === null ? null : instant(doc.validFrom);
const until = doc.validUntil === null ? null : instant(doc.validUntil);
if (published && modified && modified[1] < published[0])
throw new Error('Modified before publication');
if (from !== null && until !== null && from >= until) throw new Error('Invalid interval');
if (doc.eventDate !== null) dateOnly(doc.eventDate);
const canonical = canonicalUrl(doc.path, 1, origin);
const id = documentId(doc.id, origin);
if (!isPublic(doc, now)) return null;
if (published && published[0] > now) throw new Error('Future publication');
if (modified && modified[0] > now) throw new Error('Future modification');
const applicability = from === null ? 'unknown'
: now < from ? 'scheduled' : until !== null && now >= until ? 'historical' : 'current';
return {
id, canonical, applicability,
...(published === null ? {} : { datePublished: doc.publishedAt }),
...(modified === null ? {} : { dateModified: doc.contentModifiedAt }),
};
}
export function formatContentDate(value) {
contentDate(value);
// 날짜만 있는 값은 다른 시간대로 변환하지 않는다.
const day = value.length === 10 ? value : new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date(value));
const [year, month, date] = day.split('-').map(Number);
return `${year}년 ${month}월 ${date}일`;
}
export const example = {
id: 'hours-2025', path: '/archive/opening-2025', revision: 2,
status: 'published', deletedAt: null,
publicFrom: '2024-12-23T09:00:00+09:00',
createdAt: '2024-12-20T09:00:00+09:00',
publishedAt: '2024-12-23T09:00:00+09:00',
contentModifiedAt: '2026-10-01T09:00:00+09:00',
eventDate: '2024-12-19',
validFrom: '2025-01-01T00:00:00+09:00',
validUntil: '2026-01-01T00:00:00+09:00',
};
describeDocument는 문서와 비교 시각 asOf를 받는다. 먼저 ID·주소·날짜·기간을 검증한다. 초안의 잘못된 날짜도 오류로 보고한다. 유효한 입력만 isPublic으로 넘기며 비공개 문서에는 null을 반환한다. 적용 기간은 시작을 포함하고 종료를 제외하는 [시작, 종료)로 비교한다. 적용 시작을 모르면 unknown이다. 반환한 id·canonical·datePublished·dateModified·applicability는 다음 구현편에서 본문과 JSON-LD에 함께 사용한다.
publicFrom은 정확한 공개 시작 시각이며 publishedAt과 독립적이다. contentDate는 Date와 DateTime의 정밀도를 보존한다. bounds는 날짜만 아는 값의 비교 범위를 이 도서관의 한국 달력 기준으로 계산하지만 그 경계를 게시 시각으로 저장하거나 출력하지 않는다. 날짜만으로 선후를 확정할 수 없는 같은 날의 수정은 허용한다. 다른 지역의 달력을 쓰는 서비스는 이 비교 정책도 명시해야 한다.
이 함수의 applicability가 historical이면 화면에 과거 기록이라는 설명과 당시 기간을 출력한다. dateModified가 오늘이어도 applicability는 과거로 남을 수 있다. 이렇게 값을 따로 계산해야 단순 오자 수정이 과거 시간표를 현재 안내로 바꾸지 않는다. canonical 계산과 날짜 계산을 한곳에서 재사용하면 제목·본문·JSON-LD에 서로 다른 주소나 날짜가 들어가는 오류도 찾기 쉬워진다.
시작·종료 경계와 잘못된 입력을 검사한다
같은 폴더에 다음 파일을 identity-time.test.mjs로 저장하고 node --test identity-time.test.mjs를 실행한다. 테스트는 공개 여부, 적용 기간의 경계, 시간대, 잘못된 날짜와 주소를 각각 확인한다.
import test from 'node:test';
import assert from 'node:assert/strict';
import { dateOnly, instant, canonicalUrl, describeDocument, example, isPublic, formatContentDate } from './identity-time.mjs';
const at = '2026-10-23T12:00:00+09:00';
const historical = describeDocument(example, at);
test('과거 기록은 공개 상태와 자기 주소를 유지한다', () => {
assert.equal(historical.applicability, 'historical');
assert.equal(historical.canonical, 'https://example.com/archive/opening-2025');
assert.equal(historical.datePublished, example.publishedAt);
});
test('적용 시작은 포함하고 종료는 제외한다', () => {
const d = { ...example, contentModifiedAt: null };
assert.equal(describeDocument(d, '2024-12-31T23:59:59+09:00').applicability, 'scheduled');
assert.equal(describeDocument(d, d.validFrom).applicability, 'current');
assert.equal(describeDocument(d, d.validUntil).applicability, 'historical');
});
test('초안·철회·삭제·예약 전은 공개하지 않는다', () => {
for (const change of [{ status: 'draft' }, { status: 'withdrawn' }, { deletedAt: at },
{ publicFrom: '2027-01-01T00:00:00Z', contentModifiedAt: null }]) {
assert.equal(describeDocument({ ...example, ...change }, at), null);
}
});
test('적용 시작을 모르면 현재라고 추정하지 않는다', () => {
assert.equal(describeDocument({ ...example, validFrom: null }, at).applicability, 'unknown');
});
test('없는 날짜와 시간대 없는 시각을 거절한다', () => {
assert.throws(() => instant('2026-02-30T12:00:00+09:00'));
assert.throws(() => instant('2026-10-23T12:00:00'));
assert.throws(() => instant('2026-10-23T25:00:00Z'));
assert.equal(dateOnly('2026-10-23'), '2026-10-23');
});
test('같은 순간의 다른 시간대 표기는 같고 사건 날짜는 그대로다', () => {
assert.equal(instant(at), instant('2026-10-23T03:00:00Z'));
assert.equal(dateOnly(example.eventDate), '2024-12-19');
});
test('역전 기간·미래 수정·게시 전 수정은 거절한다', () => {
assert.throws(() => describeDocument({ ...example, validUntil: example.validFrom }, at));
assert.throws(() => describeDocument({ ...example, contentModifiedAt: '2027-01-01T00:00:00Z' }, at));
assert.throws(() => describeDocument({ ...example, contentModifiedAt: '2020-01-01T00:00:00Z' }, at));
});
test('의미 있는 페이지 번호와 잘못된 경로를 구분한다', () => {
assert.equal(canonicalUrl('/guides', 2), 'https://example.com/guides?page=2');
assert.throws(() => canonicalUrl('//elsewhere.example/a'));
assert.throws(() => canonicalUrl('/guides/../admin'));
assert.throws(() => canonicalUrl('/guides', 0));
});
console.log(JSON.stringify(historical, null, 2));
test('공개 검사도 달력에 없는 날짜를 거절한다', () => {
assert.throws(() => isPublic({ ...example, publishedAt: '2026-02-30T09:00:00+09:00' }));
});
test('잘못된 초안 날짜는 공개 제외 전에 오류로 보고한다', () => {
assert.throws(() => describeDocument({ ...example, status: 'draft', validFrom: 'bad-date' }, at));
});
test('날짜만 아는 게시일과 모르는 게시일을 보존한다', () => {
const d = { ...example, publishedAt: '2024-12-23', contentModifiedAt: null };
assert.equal(describeDocument(d, at).datePublished, '2024-12-23');
assert.equal(formatContentDate('2024-12-23'), '2024년 12월 23일');
assert.equal(describeDocument({ ...d, publishedAt: null }, at).datePublished, undefined);
assert.equal(describeDocument({ ...d, publicFrom: null }, at), null);
});
test('같은 문서의 주소를 바꿔도 ID는 유지한다', () => {
const moved = describeDocument({ ...example, path: '/archive/old-hours' }, at);
assert.equal(moved.id, historical.id);
assert.notEqual(moved.canonical, historical.canonical);
});
출력 객체의 applicability는 historical이고 canonical은 https://example.com/archive/opening-2025다. 날짜는 입력한 최초 게시일과 실질 수정일을 유지한다. 12개 테스트가 통과해야 한다. 종료 시각에서 current가 나오면 종료 비교가 >=인지 확인한다. 2026-02-30이 통과하면 달력 날짜 검증을 확인한다. 시간대가 빠진 입력을 묵인하면 서버 지역에 따라 같은 문자열을 다른 순간으로 읽을 수 있다.
이 함수가 반환한 공개 여부와 적용 상태를 페이지 출력에 사용한다. 게시 처리는 DB의 게시본을 전환하고 관련 캐시를 갱신하도록 구성한다. 예약 시각에 정적 페이지가 자동으로 바뀌어야 한다면 해당 시각의 재생성 작업도 연결한다. 이미 캐시에 남은 게시본은 캐시 운영의 갱신 경로로 확인한다.
본문에서 현재 안내와 과거 기록을 읽을 수 있게 만든다
과거 기록의 첫 문단에는 “2025년 1월 1일부터 12월 31일까지 적용한 운영 시간표입니다. 현재 운영 시간은 다음 안내에서 확인하세요”처럼 기간과 이동 경로를 적는다. 기계가 읽는 종료값이 2026-01-01T00:00:00+09:00이면 마지막 적용 날짜는 2025년 12월 31일이다. 개발자는 경계값과 가시 문장을 함께 대조한다.
현재 안내에는 적용 시작일과 예외를 쓰고, 게시·수정 날짜를 별도 라벨로 표시한다. JSON-LD의 날짜만 정확하고 제목이 여전히 “현재 안내”인 과거 문서는 독자를 혼동시킨다. 제목·요약·표·출처·구조화 데이터가 같은 시점과 조건을 말하도록 편집한다.
재게시·이관 때는 원래 확인한 날짜와 출처를 보존한다. 최초 공개일을 모르면 새 시스템의 저장 시각으로 대신 채우지 않는다. “원문 게시일 미확인”처럼 독자의 해석에 필요한 범위에서 설명하고, 확인 가능한 수정이나 적용일만 사용한다.
날짜의 정밀도와 변경의 종류를 보존한다
옛 공지에 “2024년 12월 23일 게시”만 남았다면 publishedAt은 2024-12-23으로 저장한다. 00:00:00+09:00을 덧붙이면 확인하지 못한 게시 시각을 만들게 된다. 공개일도 모르면 null로 남기고 datePublished를 생략한다. 서버 공개 예약에는 별도로 확인한 publicFrom을 쓴다. Google의 게시 날짜 지침은 시각을 선택 사항으로 설명한다. 게시 날짜 지침
const migrated = {
publishedAt: "2024-12-23", // 원자료에서 확인한 날짜
publicFrom: "2026-10-24T09:00:00+09:00", // 이관본을 공개하는 시각
contentModifiedAt: null, // 실질 수정 기록 없음
}
// JSON-LD: "datePublished": "2024-12-23"
// 화면: 게시일 2024년 12월 23일
오자를 고친 경우와 운영 시간이 바뀐 경우는 갱신할 날짜가 다르다. 다음 표는 변경 내용에 따라 게시일·수정일·lastmod·적용 기간을 정하는 예다. 핵심 규정의 출처 링크를 바꾼 경우도 내용 변경에 포함한다.
현재 기관 소개와 과거 활동 기록이 서로 다른 시점을 말하게 한다
가상 도서관을 2025년에는 “개관 준비위원회”가 준비했고 2026년부터 “도서관 운영팀”이 운영한다고 가정하자. 지금도 홈페이지 제목이 “개관 준비위원회”이면 현재 질문에 옛 역할을 답할 자료가 남는다. 반대로 2025년 회의록의 서명을 운영팀으로 고치면 당시 기록이 바뀐다. 현재 소개에는 현재 역할을, 회의록에는 당시 역할과 기간을 표시한다.
본문 수정일은 정보가 언제 바뀌었는지 설명한다. 역할의 적용 기간은 그 정보가 어느 시점의 질문에 답할 수 있는지 설명한다. 2025년 회의록에 2026년 후속 링크를 추가해 수정일이 갱신돼도 회의의 역할과 날짜는 2025년에 남는다. “최근 수정한 자료”만 고르는 검색은 이 둘을 혼동할 수 있다.
CMS에서는 기관 ID를 유지하고 역할·적용 시작·종료·근거 문서 ID를 별도로 저장한다. 현재 헤더·소개·공유 제목은 현행 역할에서 만들고, 과거 페이지는 당시 문서에 저장된 역할을 사용한다. 자체 RAG의 검색 자료에도 문서 ID·역할·적용 기간을 넣는다. “현재” 질문은 기준일에 유효한 문서를, “2025년” 질문은 그 기간을 설명하는 문서를 찾도록 질의와 검색 조건을 연결한다.