공개 본문·메타데이터·구조화 데이터를 함께 구현하기
공개 조회 함수 하나에서 얻은 원고로 본문·제목·대표 주소·구조화 데이터를 만든다. 요청별 SSR 페이지와 빌드 때 만드는 보존 페이지를 같은 예제에서 비교한다.
설정할 위치를 정하고 웹·서버·인프라를 연결한다
구현의 목표는 운영 안내 한 건을 제목·본문·주소·날짜·근거가 일치하는 공개 문서로 전달하는 것이다. 검색기는 그 문서를 수집해 주제와 대표 주소를 처리하고, 웹 자료를 쓰는 답변은 그 안의 사실과 조건을 참고할 수 있다. 본문은 최신 시간인데 요약·JSON-LD는 옛 시간이라면 어떤 값이 맞는지 충돌하므로, 아래 코드는 같은 게시본에서 각 출력을 만든다.
처음 구성할 때는 공개 페이지가 바뀌는 시점부터 정한다. 변경이 적은 안내는 빌드 때 HTML을 만들고, 자주 바뀌는 글은 서버에서 현재 게시본을 읽거나 게시 이벤트로 캐시를 갱신한다. 공개 페이지와 관리자 기능이 같은 애플리케이션에 있어도 조회 권한과 캐시 조건은 각각 둔다.
다음은 CMS가 있는 Next.js 사이트의 파일 배치 예다. 저장소가 src 디렉터리를 사용하면 app과 lib가 src 아래에 들어간다. getPublicGuide 같은 함수 이름은 해당 프로젝트에 맞춰 정한다.
app/
layout.tsx 공통 화면·기본 메타데이터
guides/[slug]/page.tsx 공개 글 조회·본문·상세 메타데이터
robots.ts 수집 경로 안내
sitemap.ts 공개 대표 URL 목록
lib/
guides.ts 공개 조건과 자료 조회
identity-time.mjs 날짜·공개 상태·대표 주소 판정
site.ts 운영 도메인 설정
next.config.ts 프레임워크·배포 관련 설정
package.json 설치 패키지와 build·start 명령
빌드 명령은 package.json의 scripts에서 확인한다. Node.js 서버로 직접 운영하는 구성이라면 next build로 결과물을 만들고 next start로 실행할 수 있다. 정적 내보내기라면 생성한 파일을 정적 호스팅에 올린다. 관리형 플랫폼은 프로젝트의 빌드·실행 설정을 사용하므로 산출물과 지원하는 서버 기능을 확인한다.
DNS와 인증서로 운영 도메인을 연결한 뒤 CDN·방화벽에서 공개 경로를 통과시킨다. 서버에서 공개 글을 조회하고 HTML·메타데이터·사이트맵을 제공한다. 마지막으로 게시 이벤트가 각 캐시를 갱신하는지 확인한다. 주소 연결부터 본문 전달까지 같은 URL로 요청하면 어느 설정에서 결과가 달라지는지 찾기 쉽다.
실행할 버전과 예제 자료를 고정한다
예제는 Next.js 16.3.8, React 19.2.5, TypeScript 5.9.3과 Node.js 22에서 실행하는 App Router 구성이다. next.config.ts에서 cacheComponents를 false로 지정해 아래 dynamic 설정을 사용하는 방식을 선택한다. Cache Components를 켜는 구성은 use cache·cacheLife 등 해당 모델의 설정으로 따로 구성해야 한다.
자료는 가상 도서관의 공개 안내 한 개와 초안 한 개다. 네트워크나 실제 DB 없이 조회와 출력 관계를 따라갈 수 있도록 고정 배열을 사용한다. 운영에서는 배열을 읽는 부분을 서버 DB 조회로 바꾸고 공개 조건을 같은 쿼리에 적용한다.
빈 디렉터리에 파일명에 맞춰 코드를 저장한다. 이 장의 페이지·조회 파일과 뒤의 robots·사이트맵 파일까지 만든 다음 아래 설치·빌드 명령을 실행한다. TypeScript 설정은 Next.js가 초기 실행 시 생성할 수 있다. 기존 프로젝트에 적용할 때는 설치 버전과 설정을 먼저 대조한다.
아래 세 블록은 순서대로 package.json, next.config.ts, 터미널 명령이다. Node.js 22를 사용하고, 설치가 끝나면 package-lock.json을 보존한다. 같은 실행 환경을 재현하려면 명시한 버전과 잠금 파일을 함께 사용한다.
{
"name": "seo-guide-sample",
"private": true,
"scripts": {
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.8",
"react": "19.2.5",
"react-dom": "19.2.5"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19"
}
}
import type { NextConfig } from 'next'
const config: NextConfig = { cacheComponents: false }
export default config
npm install
npm run build
npm run start -- --port 3027
npm install은 선언한 패키지를 설치하고 build는 운영 결과물을 만든다. start는 그 결과물을 실행한다. 실습 주소는 http://localhost:3027이며 코드의 example.com은 배포할 대표 주소의 예시다. 운영에서는 SITE_ORIGIN을 확정한 도메인으로 바꾼다.
공개 조회와 요청 안의 결과 재사용을 구현한다
lib/site.ts에 대표 도메인을 두고 lib/guides.ts에 공개 조건과 조회 함수를 둔다. React cache는 같은 서버 렌더링 요청에서 같은 인자로 호출한 함수의 결과를 재사용한다. 여러 방문 사이에 보관하는 CDN·데이터 캐시와 수명이 다르다.
export const SITE_ORIGIN = 'https://example.com'
import { cache } from 'react'
import { describeDocument } from './identity-time.mjs'
import { SITE_ORIGIN } from './site'
export type Guide = {
id: string
previousSlugs: string[]
publicFrom: string | null
slug: string
title: string
summary: string
paragraphs: string[]
sourceUrl: string | null
validFrom: string | null
validUntil: string | null
status: 'draft' | 'published' | 'withdrawn'
publishedAt: string | null
contentModifiedAt: string | null
deletedAt: string | null
revision: number
}
// 실습용 고정 자료. 운영에서는 이 자료를 읽는 부분을 DB 조회로 바꾼다.
const guides: Guide[] = [
{
id: 'hours-current', previousSlugs: ['library-hours'],
publicFrom: '2026-10-01T00:00:00+09:00',
slug: 'opening-hours', title: '도서관 운영 시간',
summary: '일요일 운영 시간과 휴관일을 안내합니다.',
paragraphs: [
'일요일에는 오전 10시부터 오후 5시까지 운영합니다.',
'매월 첫째 일요일과 공휴일은 쉽니다. 이 시간표는 2026년 10월부터 적용합니다.',
],
sourceUrl: '/policies/opening-hours',
validFrom: '2026-10-01T00:00:00+09:00', validUntil: null,
status: 'published', publishedAt: '2026-10-01',
contentModifiedAt: '2026-10-23T09:00:00+09:00',
deletedAt: null, revision: 2,
},
{
id: 'draft-example', previousSlugs: [], publicFrom: null,
slug: 'draft-guide', title: '미공개 안내', summary: '초안',
paragraphs: ['공개 응답에 들어가면 안 되는 실습용 초안입니다.'],
sourceUrl: null, validFrom: null, validUntil: null,
status: 'draft', publishedAt: null, contentModifiedAt: null,
deletedAt: null, revision: 1,
},
]
function toPublic(g: Guide, asOf: string) {
const info = describeDocument({ ...g, path: `/guides/${g.slug}`, eventDate: null }, asOf, SITE_ORIGIN)
return info ? { ...g, info } : null
}
// 한 요청의 본문·metadata가 같은 시각과 같은 게시본을 읽는다.
const snapshot = cache(async () => {
const asOf = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
return guides.map(g => toPublic(g, asOf)).filter(g => g !== null)
})
export const getPublicGuide = cache(async (slug: string) => {
return (await snapshot()).find(g => g.slug === slug || g.previousSlugs.includes(slug)) ?? null
})
export const listPublicGuides = snapshot
lib/guides.ts는 고정 자료를 읽고 describeDocument로 ID·대표 주소·날짜·공개 상태·적용 상태를 계산한다. snapshot에 적용한 React cache는 한 서버 요청에서 계산한 결과를 재사용한다. getPublicGuide와 listPublicGuides가 이 결과를 공유한다. 장기간 저장하는 DB·CDN 캐시와는 범위가 다르다. React cache
sourceUrl은 실제로 열 수 있는 근거 경로다. 이 실습에서는 아래에 만들 /policies/opening-hours를 가리킨다. validFrom·validUntil은 시간표가 적용되는 기간이며, 서버 공개 시작을 정하는 publicFrom과 독립적이다.
먼저 문서와 시간의 identity-time.mjs를 lib/identity-time.mjs에 저장한다. guides.ts는 describeDocument를 실제로 호출한다. snapshot은 요청 시각을 한 번 정하고 공개 원고와 그 설명을 함께 반환한다. 이전 slug인 library-hours도 같은 문서를 조회하지만 page와 generateMetadata에서 permanentRedirect를 호출해 opening-hours로 308 이동한다. @id는 hours-current라는 문서 ID에서 만들므로 주소가 바뀌어도 유지된다.
SITE_ORIGIN은 describeDocument의 세 번째 인자로도 전달한다. 대표 주소와 안정된 문서 @id가 같은 설정에서 만들어지므로 운영 도메인을 바꿀 때 날짜 모듈 안의 기본 예시 주소를 따로 고칠 필요가 없다.
여기서 snapshot을 공유하는 이유는 중복 조회 비용과 내용 불일치를 함께 줄이기 위해서다. 예를 들어 제목을 읽은 직후 게시본이 바뀌고 본문을 다시 읽으면 한 응답에 서로 다른 원고가 섞일 수 있다. 이 예제는 요청 안에서 정한 공개 목록을 재사용하므로 title·본문·날짜가 같은 자료를 설명한다. 검색기에 전달하는 한 문서의 의미를 맞추는 처리다.
SSR 상세 페이지에서 같은 게시본을 출력한다
app/layout.tsx는 문서 언어와 기본 URL을 지정한다. app/guides/[slug]/page.tsx는 slug에 해당하는 공개 자료를 조회한다. dynamic의 force-dynamic은 이 예제의 Cache Components 비활성 구성에서 요청 시 렌더링을 선택한다.
import type { Metadata } from 'next'
import type { ReactNode } from 'react'
import { SITE_ORIGIN } from '../lib/site'
export const metadata: Metadata = { metadataBase: new URL(SITE_ORIGIN) }
export default function Layout({ children }: { children: ReactNode }) {
return <html lang="ko"><body>{children}</body></html>
}
import type { Metadata } from 'next'
import { notFound, permanentRedirect } from 'next/navigation'
import { getPublicGuide } from '../../../lib/guides'
import { SITE_ORIGIN } from '../../../lib/site'
import { formatContentDate } from '../../../lib/identity-time.mjs'
export const dynamic = 'force-dynamic'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const guide = await getPublicGuide(slug)
if (!guide) notFound()
if (slug !== guide.slug) permanentRedirect(`/guides/${guide.slug}`)
const canonical = guide.info.canonical
return {
title: guide.title, description: guide.summary,
alternates: { canonical },
robots: { index: true, follow: true },
openGraph: { title: guide.title, description: guide.summary, url: canonical },
}
}
export default async function Page({ params }: Props) {
const { slug } = await params
const guide = await getPublicGuide(slug)
if (!guide) notFound()
if (slug !== guide.slug) permanentRedirect(`/guides/${guide.slug}`)
const canonical = guide.info.canonical
const structured = {
'@context': 'https://schema.org', '@type': 'WebPage',
'@id': guide.info.id, url: canonical,
name: guide.title, description: guide.summary,
...(guide.info.datePublished ? { datePublished: guide.info.datePublished } : {}),
...(guide.sourceUrl ? { citation: new URL(guide.sourceUrl, SITE_ORIGIN).href } : {}),
...(guide.info.dateModified ? { dateModified: guide.info.dateModified } : {}),
}
const format = formatContentDate
return <main><article data-revision={guide.revision}>
<h1>{guide.title}</h1><p>{guide.summary}</p>
{guide.publishedAt && <p data-date="datePublished"><span data-label>게시일</span> <time dateTime={guide.publishedAt}>
{format(guide.publishedAt)}</time></p>}
{guide.contentModifiedAt && <p data-date="dateModified"><span data-label>수정일</span> <time dateTime={guide.contentModifiedAt}>
{format(guide.contentModifiedAt)}</time></p>}
<p>적용 상태: {({ current: "현재", historical: "과거", scheduled: "적용 예정", unknown: "시작일 미확인" })[guide.info.applicability]}</p>
{guide.validFrom && <p>적용 시작 <time dateTime={guide.validFrom}>
{format(guide.validFrom)}</time></p>}
{guide.validUntil && <p>적용 종료 시각 <time dateTime={guide.validUntil}>
{guide.validUntil}</time> (이 시각 미포함)</p>}
{guide.paragraphs.map((text, i) => <p key={i}>{text}</p>)}
{guide.sourceUrl && <p>근거: <a href={guide.sourceUrl}>도서관 운영 시간 안내 원문</a></p>}
<script type="application/ld+json" dangerouslySetInnerHTML={{
__html: JSON.stringify(structured).replace(/</g, '\\u003c'),
}} />
</article></main>
}
generateMetadata가 제목·설명·대표 주소를 반환하고 Page가 같은 자료의 단락을 출력한다. data-revision은 실습에서 출력 버전을 대조할 표식이다. 안내 문서에는 WebPage를 사용했다. 기사를 제공하는 서비스는 Article 등 실제 자료에 맞는 유형과 작성자·날짜·이미지를 구성한다.
공개 자료가 없으면 notFound를 호출한다. 로딩 경계나 스트리밍이 추가되면 상태 코드가 달라질 수 있으므로 실제 응답의 404·noindex와 본문을 확인한다. 원문 조회 예외는 없는 글과 구분해 서버 오류로 추적한다.
첫 블록은 app/layout.tsx, 두 번째는 app/guides/[slug]/page.tsx다. 요청의 slug를 조회에 넘기고 같은 guide에서 canonical·제목·본문·날짜·근거 링크를 만든다. format은 확인한 날짜를 한국어로 표시한다. dateTime에는 날짜만 아는 값 또는 시간대가 포함된 시각 값을 원래 정밀도대로 유지한다. contentModifiedAt을 바꾸면 가시 수정일과 JSON-LD의 dateModified가 함께 바뀐다.
실행 후 /guides/opening-hours는 운영 시간·첫째 일요일 휴관·게시일·수정일·근거 링크를 보여야 한다. 날짜를 찾을 수 없으면 guide의 null 값과 time 분기를 확인한다. JSON-LD가 있어도 본문에 조건이 빠지면 해당 paragraphs를 고친다. unknown slug와 draft-guide는 404·noindex로 확인한다.
게시일과 수정일 행에는 data-date를 붙이고 각 라벨은 data-label로 감쌌다. 검사는 행의 의미, time의 datetime, 화면에 읽히는 날짜를 각각 대조한다. info.applicability도 본문에 표시해 새 revision의 과거 기록을 현재 안내로 오해하지 않게 한다. 날짜만 있는 publishedAt은 formatContentDate가 시간대 이동 없이 표시한다.
이 Page에서 p 요소를 만드는 map은 도서관의 시간을 가시 HTML로 바꾼다. generateMetadata는 그 문서의 주제와 대표 주소를 별도로 제공한다. 두 출력이 역할을 나누므로 title에 시간과 예외를 전부 넣을 필요가 없다. 질문에 필요한 답은 본문에 완결되게 쓰고 제목과 설명은 그 내용을 정확히 요약한다. 수정 후에는 HTML 본문과 head의 값이 같은 안내를 가리키는지 읽는다.
공개 목록에서 상세 글을 발견하게 한다
목록도 같은 공개 조건을 사용한다. app/page.tsx의 Link는 실제 상세 주소를 가진 링크를 만든다. 초안은 목록에 없고 직접 주소로 요청해도 공개 본문을 반환하지 않아야 한다.
import Link from 'next/link'
import { listPublicGuides } from '../lib/guides'
export const dynamic = 'force-dynamic'
export default async function Home() {
const guides = await listPublicGuides()
return <main><h1>도서관 안내</h1><ul>
{guides.map(g => <li key={g.slug}>
<Link href={`/guides/${g.slug}`}>{g.title}</Link>
</li>)}
</ul></main>
}
입력인 guides 배열을 map으로 순회해 실제 href를 가진 Link를 만든다. app/page.tsx로 저장한 뒤 홈에서 “도서관 운영 시간”을 눌러 상세로 이동한다. 미공개 안내가 나타나면 listPublicGuides의 공개 조건을 확인한다. 목록 링크와 직접 주소 접근을 둘 다 검사해야 초안을 화면에서만 숨기는 오류를 찾을 수 있다.
홈의 Link가 실제 href를 출력하면 수집기는 홈에서 상세 주소를 발견할 수 있다. 글을 검색창의 입력이나 클릭 이벤트로만 여는 구성에서는 그 동작을 수행하지 않는 수집 경로에 주소가 드러나지 않을 수 있다. 목록 링크와 사이트맵을 함께 제공하면 새 글이 어디에 있는지 알리는 경로를 만들 수 있다. 수집 가능한 링크
고정된 과거 기록을 정적 페이지로 만든다
보존 자료가 한 개인 실습에서는 app/archive/opening-2025/page.tsx라는 고정 경로를 만든다. 요청별 조회 없이 파일의 고정 내용을 출력하므로 빌드 결과에서 정적 페이지로 확인할 수 있다. 없는 /archive/missing은 라우트 자체가 없으므로 기본 404 경로로 처리한다.
import type { Metadata } from 'next'
import { SITE_ORIGIN } from '../../../lib/site'
import { describeDocument, example } from '../../../lib/identity-time.mjs'
// 보존본의 최초 게시·수정일을 모르는 경우다. 확인되지 않은 날짜는 출력하지 않는다.
const info = describeDocument({ ...example, publishedAt: null, contentModifiedAt: null },
'2026-10-24T09:00:00+09:00', SITE_ORIGIN)!
export const metadata: Metadata = {
title: '2025년 도서관 운영 시간 기록',
alternates: { canonical: info.canonical },
}
export default function Archive() {
const structured = { '@context': 'https://schema.org', '@type': 'WebPage',
'@id': info.id, url: info.canonical, name: '2025년 도서관 운영 시간 기록' }
return <main><article>
<h1>2025년 도서관 운영 시간 기록</h1>
<p>2025년 1월 1일부터 12월 31일까지 적용한 가상 시간표입니다.</p>
<p>당시 일요일에는 오전 11시부터 오후 4시까지 운영했습니다.</p>
<p>적용 상태: {info.applicability === 'historical' ? '과거' : '적용 기간 확인 필요'}</p>
<p>이 자료의 최초 게시일과 수정일은 확인되지 않았습니다.</p>
<a href="/guides/opening-hours">현재 운영 시간과 휴관일</a>
<script type="application/ld+json" dangerouslySetInnerHTML={{
__html: JSON.stringify(structured).replace(/</g, '\\u003c'),
}} />
</article></main>
}
이 파일의 경로가 과거 기록의 URL을 정하고 metadata가 자기 canonical을 만든다. 본문에는 2025년 적용 기간을 남기고 현재 안내로 가는 링크를 제공한다. publishedAt과 contentModifiedAt은 null이므로 게시·수정 날짜를 출력하는 분기를 건너뛴다.
빌드에서 /archive/opening-2025는 정적, /guides/[slug]는 요청 시 생성으로 표시되는지 확인한다. 고정 파일을 수정하면 재빌드해야 한다. 보존 문서가 많으면 generateStaticParams로 경로 목록을 만들 수 있다. 그 구성에서는 미등록 slug·신규 문서·철회와 재생성 동작을 별도로 검사한다. 정적 경로 생성
이 보존본은 문서와 시간의 example에 게시일·수정일 미확인 값을 넣어 describeDocument를 호출한다. 실제로 모르는 날짜는 본문과 JSON-LD에서 함께 생략한다. 적용 종료는 알려져 있으므로 과거 상태와 자기 주소를 유지한다. 고정 기준일은 이미 종료된 기록을 정적 생성하는 이 실습에 사용했다. 아직 적용 기간이 바뀔 수 있는 페이지는 요청 시 판정하거나 경계 시각에 재생성한다.
근거 링크가 가리키는 문서도 실제로 제공한다
아래 코드를 app/policies/opening-hours/page.tsx로 저장한다. 운영 시간과 휴관일, 적용 시작일을 제공하는 페이지다. 상세 안내의 sourceUrl이 이 주소를 가리킨다.
import type { Metadata } from 'next'
import { SITE_ORIGIN } from '../../../lib/site'
export const metadata: Metadata = {
title: '도서관 운영 시간 안내 원문 — 실습 자료',
alternates: { canonical: new URL('/policies/opening-hours', SITE_ORIGIN).href },
}
export default function Policy() {
return <main><article><h1>도서관 운영 시간 안내 원문 — 실습 자료</h1>
<p>가상 도서관의 2026년 10월 1일 이후 운영 시간 안내입니다.</p>
<p>일요일 10:00~17:00. 매월 첫째 일요일과 공휴일은 휴관합니다.</p>
<p>공고일 <time dateTime="2026-09-30">2026년 9월 30일</time></p>
<a href="/guides/opening-hours">이용 안내로 돌아가기</a>
</article></main>
}
상세의 sourceUrl이 이 경로로 연결된다. 클릭 후 같은 운영 시간·예외·적용일이 나오는지 읽는다. 링크가 200이어도 다른 연도나 다른 지점의 자료라면 근거가 맞지 않는다. 근거 문서에서도 자기 주소와 안내로 돌아가는 링크를 제공한다.
메타데이터 스트리밍 결과를 읽는다
상세 문서의 title·description·canonical은 본문과 같은 데이터를 사용한다. 제목에는 문서의 주제, 요약에는 대상과 제공하는 정보를 넣는다. Next.js의 메타데이터 스트리밍을 사용하는 응답에서는 데이터 조회가 끝난 뒤 head 정보가 추가될 수 있다. 전체 응답을 받은 뒤 문서별 값을 대조한다. Next.js 메타데이터
본문 배열·Markdown·미디어를 처리한다
guide.paragraphs는 본문 단락의 배열이다. map은 각 단락을 p 요소로 바꾸고 key는 React가 목록 항목을 구분하는 값이다. 중괄호 안의 일반 문자열은 React가 텍스트로 처리한다. sourceUrl에는 검증한 근거 문서 주소를 넣고 링크를 직접 열어 같은 자료인지 확인한다.
Markdown은 제목·목록·링크를 간단한 기호로 적는 문서 형식이다. HTML·Markdown CMS는 저장된 원고를 HTML로 변환하는 렌더러를 사용한다. 외부 입력에는 허용할 태그·속성·URL 규칙을 적용해 스크립트 주입을 막는다. 무한 스크롤 목록은 추가 글로 이동할 고유 URL과 링크를 제공한다. “더 보기” 클릭만으로 존재하는 자료는 크롤러가 발견할 경로가 있는지 확인한다.
이미지에는 본문과 관련된 설명과 적절한 대체 텍스트를 넣는다. 글을 이해하는 데 필요한 시간표·요금표를 이미지로만 제공하지 말고 읽을 수 있는 텍스트나 표도 둔다. 화면별 이미지 크기와 폭·높이를 지정해 과도한 전송과 레이아웃 이동을 줄인다. 실제 영상이 있는 페이지에만 영상 메타데이터를 붙이고 재생 주소·썸네일·접근 가능성을 확인한다.
구조화 데이터를 본문에서 파생한다
“김민수, 10월 24일”이라는 문자열만으로는 사람이 작성자인지 소개 대상인지, 날짜가 게시일인지 행사일인지 분명하지 않을 수 있다. 구조화 데이터는 author·about·datePublished처럼 관계의 이름을 붙인다. Google은 이를 페이지 내용을 이해하는 데 사용하며, 지원하는 유형에서는 추가 검색 표시에도 사용할 수 있다. 구조화 데이터의 역할
본문의 “첫째 일요일 휴관”은 질문에 답하는 사실이고, JSON-LD의 publisher와 dateModified는 발행 주체와 수정 시점을 설명한다. 이 둘을 같은 원고에서 만들면 누가 언제 제공한 어떤 안내인지 함께 읽을 수 있다. 이용 안내에 WebPage를 붙이는 것만으로 휴관 조건이 생기지는 않으므로, 조건 문장은 p 요소에 그대로 출력한다.
기사에는 Article, 이용 안내에는 WebPage처럼 콘텐츠에 맞는 유형을 선택한다. 아래 article-jsonld.mjs는 기사 데이터 하나에서 가시 제목·게시일·수정일·본문 이미지와 JSON-LD를 만든다. 앞 편의 identity-time.mjs와 같은 폴더에 저장한다. node article-jsonld.mjs로 JSON을 출력하고, 이미지 실습 서버의 /news/new-hours에서 완성된 HTML을 읽는다.
import { pathToFileURL } from 'node:url';
import { describeDocument, formatContentDate } from './identity-time.mjs';
export const article = {
id: 'new-hours', path: '/news/new-hours', status: 'published', deletedAt: null,
title: '도서관 일요일 운영 시간 변경',
summary: '2026년 10월부터 일요일은 10~17시에 엽니다. 첫째 일요일과 공휴일은 쉽니다.',
publishedAt: '2026-09-30T09:00:00+09:00',
publicFrom: '2026-09-30T09:00:00+09:00',
contentModifiedAt: '2026-10-01T09:00:00+09:00',
eventDate: null, validFrom: '2026-10-01T00:00:00+09:00', validUntil: null,
};
export const escapeHtml = value => String(value).replace(/[&<>"']/g,
c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
export function renderArticle(origin, image, now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')) {
const identity = describeDocument(article, now, origin);
if (!identity) throw new Error('Article is not public');
// image에는 같은 공개 이미지의 URL과 실제 변환 폭·높이를 전달한다.
const imageUrl = new URL(image.path, origin).href;
const structured = {
'@context': 'https://schema.org', '@type': 'Article', '@id': identity.id,
headline: article.title, mainEntityOfPage: identity.canonical,
datePublished: identity.datePublished, dateModified: identity.dateModified,
image: [imageUrl],
author: { '@type': 'Organization', name: '가상 도서관 편집팀' },
publisher: { '@type': 'Organization', name: '가상 도서관' },
};
const h = escapeHtml;
const json = JSON.stringify(structured).replace(/</g, '\\u003c');
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${h(article.title)}</title><link rel="canonical" href="${h(identity.canonical)}">
<meta property="og:image" content="${h(imageUrl)}">
<script type="application/ld+json">${json}</script></head>
<body><article><h1>${h(article.title)}</h1>
<p>게시 <time datetime="${h(identity.datePublished)}">${formatContentDate(identity.datePublished)}</time>
· 수정 <time datetime="${h(identity.dateModified)}">${formatContentDate(identity.dateModified)}</time></p>
<p>${h(article.summary)}</p><figure>
<img src="${h(imageUrl)}" width="${image.width}" height="${image.height}"
style="max-width:100%;height:auto" alt="도서관 안내에 사용하는 실습 이미지">
<figcaption>이미지 전송 경로를 비교하기 위한 예제입니다.</figcaption>
</figure><p>가상 도서관 편집팀</p></article></body></html>`;
return { structured, html };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
console.log(JSON.stringify(renderArticle('https://example.com', {
path: '/media/photo?w=1280', width: 1280, height: 800,
}).structured, null, 2));
}
@context는 사용할 어휘의 주소, @type은 문서 유형, headline은 글 제목, mainEntityOfPage는 이 글을 대표하는 페이지다. datePublished는 게시 시각, dateModified는 내용이 바뀐 시각이다. Article은 기사, NewsArticle은 뉴스 기사, SocialMediaPosting은 소셜 게시물, VideoObject는 영상 자료를 표현하는 유형이다. 실제 콘텐츠 형식에 맞는 유형과 속성을 선택한다.
JSON-LD는 application/ld+json 스크립트로 출력한다. 사용자 입력이 포함되면 안전하게 직렬화하고 script 종료 문자열을 주입할 수 없게 처리한다. Next.js JSON-LD 예시는 직렬화한 문자열의 < 문자를 유니코드 이스케이프로 바꾸는 방법을 제시한다. 출력 뒤에는 JSON 파싱과 본문 대조를 모두 수행한다.
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(structured).replace(/</g, '\\u003c')
}}
/>
출력한 JSON-LD의 유형과 속성을 검사하고 제목·작성자·날짜를 본문과 대조한다. 검색 도구가 읽은 구조화 데이터에서 오류가 보고되면 해당 속성의 값과 자료형을 고친다. 구조화 데이터 요구사항
JSON.stringify는 객체를 JSON 문자열로 바꾼다. replace는 문자열 안의 문자를 치환한다. 예제의 유니코드 이스케이프 \u003c는 < 문자를 JSON 안에서 표현한다. dangerouslySetInnerHTML은 문자열을 HTML로 직접 넣는 React 기능이므로, 여기에는 안전하게 직렬화한 구조화 데이터만 전달한다.
renderArticle은 공개 상태와 날짜를 describeDocument로 확인하고 이미지의 절대 주소를 만든다. 그 주소를 본문 img, og:image, Article.image에 함께 넣는다. escapeHtml은 HTML 속성과 본문의 특수문자를 처리하고 JSON의 < 치환은 script 종료 문자열이 HTML로 해석되는 것을 막는다. 뒤의 JSX 블록은 같은 직렬화 방법을 React 페이지에 적용하는 형태다.
이미지 서버가 반환한 실제 폭·높이를 img에 전달하므로 파일이 도착하기 전에도 자리를 확보할 수 있다. 본문에는 게시·수정 라벨을 따로 출력하고 적용 시점은 기사 문장에 적었다. 검색 시스템에는 사진과 날짜의 관계를 명시하고 독자에게는 변경 내용을 확인할 자료를 제공한다. Google의 Article 안내는 기사와 관련된 수집 가능한 대표 이미지를 권장한다.
화면의 작은 카드에는 320px 이미지가 적합해도 검색용 대표 사진은 세부 내용을 읽을 수 있는 더 큰 파일이 필요할 수 있다. 이 실습은 같은 1280px 파생본으로 관계를 보여준다. 실제 기사에서는 사진 내용과 자르기를 검토하고 필요하면 1:1·4:3·16:9 등 용도별 이미지를 준비한다. 본문에 없는 장면이나 다른 기사의 사진을 기본값으로 연결하지 않는다.