000%

수정

NOTES · SEO / GEO / AEO07 · 2026.10

공개 원고와 데이터를 관리하는 서버

서버는 공개 상태·게시 시각·원고 버전을 확인한 뒤 자료를 반환한다. 본문·메타데이터·검색 자료가 같은 게시본을 사용하도록 조회와 게시 처리를 연결한다.

§ 01SEO / GEO / AEO

서버·DB·CMS에 공개 원고를 저장하고 조회한다

신청 기한을 11월 5일에서 6일로 고쳤는데 본문만 바뀌고 검색용 요약에는 5일이 남았다고 하자. 검색기와 답변 도구가 어느 부분을 읽느냐에 따라 서로 다른 기한을 얻는다. 공개 원고를 한 판본으로 확정하고 본문·요약·날짜를 함께 만드는 이유는 이런 충돌을 줄이기 위해서다. CMS의 저장과 게시 구조가 외부에 전달할 사실을 결정한다.

서버(server)는 네트워크 요청을 받아 응답하는 프로그램과 그 실행 장비를 가리킨다. 웹 서버는 파일을 전달하거나 요청을 애플리케이션에 넘기고, 애플리케이션 서버는 데이터 조회와 HTML 생성 같은 서비스 로직을 실행한다. 관리형 호스팅에서는 두 역할을 플랫폼이 함께 제공할 수 있다.

DB(Database)는 글·공개 상태·날짜·관계를 저장하는 데이터베이스다. CMS(Content Management System)는 이 자료를 편집하고 게시하는 관리 기능이다. DB의 스키마(schema)는 필드와 자료형·관계를 정한 구조다. SQL은 관계형 DB를 조회·변경하는 언어이고, ORM은 코드의 객체와 DB 자료를 연결하는 도구다. 어떤 도구를 쓰든 공개 상태와 게시 시각을 조회 조건에 포함한다.

DB 인덱스(index)는 필요한 행을 빨리 찾는 자료 구조다. 검색엔진의 색인과 역할이 다르다. 글의 slug나 공개 상태·게시일로 조회할 때 실제 실행 계획을 보고 인덱스를 정한다. 페이지마다 불필요한 전체 조회를 반복하면 SSR 응답 시간이 길어지고 DB 부하가 늘어난다.

트랜잭션(transaction)은 여러 DB 변경을 함께 성공시키거나 취소하는 처리 단위다. 리비전(revision)은 원고의 버전이다. 게시 리비전을 바꿀 때 본문·제목·날짜를 함께 전환하면 화면과 메타데이터가 다른 원고를 읽는 문제를 줄일 수 있다. 외부 검색 저장소에 자료를 보내는 일은 별도 작업으로 기록하고 실패 시 재시도한다.

인증(authentication)은 누구인지 확인하고, 권한 검사(authorization)는 그 사람이 어떤 자료를 사용할 수 있는지 확인한다. 세션(session)은 로그인 상태를 유지하는 정보이며 쿠키(cookie)는 브라우저가 서버에 다시 보낼 수 있도록 저장한 값이다. 공개 글은 로그인 없이 읽게 하고, 초안·관리자 조회는 서버에서 인증과 권한을 검사한다. 같은 URL에서 사용자별 내용이 바뀐다면 공용 캐시에 섞이지 않게 설정한다.

§ 02SEO / GEO / AEO

공개 판정을 한곳에서 정의한다

공개 조건은 서버 조회 계층에서 적용한다. 아래 TypeScript 코드는 다음 문서와 시간에 실린 identity-time.mjs의 isPublic을 가져온다. 그 파일을 lib/identity-time.mjs에 저장하고 사용한다. Date.parse는 2월 30일을 다른 날짜로 보정할 수 있으므로 숫자 변환 성공만으로 달력 날짜를 검증하지 않는다.

import { isPublic } from './identity-time.mjs' type Post = { status: 'draft' | 'published' | 'withdrawn' publishedAt: string | null // 알려진 최초 게시일: Date 또는 DateTime publicFrom: string | null // 이 서버에서 공개를 시작하는 정확한 시각 deletedAt: string | null } export function publicPosts(posts: Post[], now = Date.now()) { return posts.filter(post => isPublic(post, now)) }

type Post는 입력 필드와 자료형을 정의한다. publishedAt은 최초 게시일이며 날짜만 알거나 모를 수도 있다. publicFrom은 서버가 공개를 시작하는 시각이다. 이 둘을 분리하면 옛 자료를 오늘 옮겨도 옛 게시일을 보존할 수 있다. isPublic은 날짜 형식을 검증한 뒤 published 상태, 삭제되지 않음, publicFrom 도달 여부를 판정한다. 잘못된 입력은 오류로 보고하고 정상 초안은 목록에서 제외한다.

예를 들어 src/lib/public-content.ts에 이 조건을 정의하고 목록·상세 조회, 사이트맵, 공개 API가 같은 의미의 조건을 사용하게 만든다. DB에서 필터링할 때도 같은 조건을 SQL에 적용한다. 전체 자료를 클라이언트로 내려 필터링하지 않는다. 재게시·철회·예약 공개가 있다면 상태 전환 후 목록과 상세가 함께 바뀌도록 캐시 갱신 범위도 묶는다.

공개 여부를 판정한 뒤 검색 포함 정책을 적용한다. 공개 글을 검색 대상으로 삼는 사이트에서는 목록·상세·사이트맵이 같은 게시본을 읽게 한다. 관리자 화면과 초안은 서버에서 로그인 상태와 열람 권한을 검사한다.

같은 공개 조건을 쓰면 검색기에 알린 주소와 실제로 열리는 글을 맞출 수 있다. 목록은 공개라고 판단해 링크를 만들고 상세는 비공개라고 판단해 404를 반환하면, 발견한 링크가 읽을 문서로 이어지지 않는다. 예약 전 글이 사이트맵에 먼저 들어가는 경우도 같은 문제다. isPublic은 각 경로가 같은 시각과 상태를 기준으로 답하도록 하는 부분이다.

§ 03SEO / GEO / AEO

게시본에서 본문과 파생 데이터를 함께 만든다

CMS가 있는 사이트에서는 편집 중인 원고와 현재 공개 원고를 분리한다. 공개 상세·메타데이터·사이트맵은 현재 게시본을 읽고, 관리자 미리보기는 작업본을 읽는다. 같은 URL을 요청할 때마다 작업본 변경이 공개 화면으로 새어 나오지 않게 한다.

예를 들어 content_id는 문서를 식별하고 revision은 내용 버전을 식별한다. 게시할 revision에서 본문·제목·요약·출처·공유 이미지·검색 데이터를 만든 뒤 활성 게시본을 전환한다. 같은 DB 안의 갱신은 트랜잭션으로 묶을 수 있다. 외부 검색 저장소가 있으면 발행 작업과 함께 동기화 작업을 기록하고, 적용한 revision·실패·재시도 상태를 따로 저장한다.

자체 검색이나 사이트 챗봇에는 게시본의 본문과 출처를 보낸다. 게시 직후 같은 문장을 검색해 새 내용이 반환되는지 확인한다. 철회한 글은 검색 결과와 챗봇이 참조할 자료에서도 빠져야 한다.

철회할 때는 상세 원문, 목록, 사이트맵, 관련 글, 자체 검색·챗봇의 사용 가능 상태를 함께 바꾸고 관련 캐시를 무효화한다. 복원할 때는 이전 게시본이 사용한 출처와 미디어가 지금도 공개 가능한지 확인한다. 반려·차단한 원문이 과거 게시본 복원으로 다시 노출되지 않게 한다.

같은 게시 요청의 재시도를 구분하려면 요청 ID와 기대 revision을 저장한다. 예를 들어 publish-42가 revision 7을 8로 전환한 뒤 응답 전송만 실패했다면, 다시 온 publish-42에는 이미 만든 revision 8을 돌려준다. 다른 요청이 revision 8을 이미 수정했는데 기대값 7로 저장하려 하면 충돌을 반환한다. 이 방식은 네트워크 재시도가 동일한 게시를 두 번 만들거나 다른 편집을 덮는 일을 막는다.

자체 RAG가 이전 게시본을 계속 검색하면 화면을 고친 뒤에도 챗봇은 이전 기한을 답할 수 있다. 게시 revision을 검색 자료와 함께 저장하면 어떤 원고가 검색됐는지 추적할 수 있다. 검색된 조각의 revision과 공개 페이지의 revision을 비교하고, 다르면 동기화 작업을 고친다. 외부 검색·AI의 저장 자료는 해당 서비스의 재수집과 처리 결과에서 확인한다.

§ 04SEO / GEO / AEO

공개 전환과 외부 갱신 사이의 실패를 처리한다

DB의 게시 버전 전환과 외부 CDN 갱신은 서로 다른 시스템에서 일어난다. DB만 바뀌고 갱신 요청이 실패하면 일부 경로에 이전 내용이 남는다. 게시 트랜잭션에서 갱신할 문서 ID·버전·대상 경로를 작업으로 함께 저장하고, 처리기가 성공 여부와 재시도를 기록하게 할 수 있다.

같은 게시 작업이 다시 들어오면 문서 ID와 기대 버전으로 이미 적용한 작업인지 확인한다. 수정은 새 게시본으로 전환하고, 철회는 모든 조회 경로에서 더 이상 반환하지 않도록 처리한다. 캐시를 사용하는 경우에는 원본 조회 전에 저장된 응답이 반환되는 경로도 조사한다.

동시에 조회되는 본문과 메타데이터에는 같은 요청에서 얻은 공개 원고를 전달한다. 같은 함수 이름을 두 번 호출해도 매번 DB를 읽으면 두 조회 사이에 게시 버전이 바뀔 수 있다. 다음 구현 예제는 요청 안에서 조회 결과를 재사용하고, 장기 캐시는 별도로 선택한다.

§ 05SEO / GEO / AEO

같은 문서의 판본과 사실의 기간을 함께 보존한다

콘텐츠 ID는 문서의 정체성, revision은 수정 판본, publishedAt은 최초 공개 시점, contentModifiedAt은 실질 수정 시점이다. 게시본을 조회할 때 어떤 revision이 활성인지 확정한 뒤 본문·metadata·목록에 같은 자료를 전달한다. 작업본을 저장한 updatedAt으로 게시본의 수정일을 덮지 않는다.

2025년 기록의 오자를 2026년에 고치면 revision은 올라가지만 적용 기간은 2025년으로 남는다. 문서와 시간의 모델은 이 차이를 공개 상태와 적용 기간으로 나누어 저장한다.

§ 06SEO / GEO / AEO

게시·수정·철회·복원을 작은 CMS에서 실행한다

lifecycle.mjs와 lifecycle.test.mjs를 identity-time.mjs 옆에 저장하고 node --test lifecycle.test.mjs로 실행한다. 자료는 메모리에 저장하며 로컬 HTTP 서버가 JSON을 반환한다. /list는 목록, /sitemap은 사이트맵에 넣을 주소, /guides/opening-hours는 상세 데이터다.

// Node.js 22. 독립 실습용 메모리 CMS이며 종료하면 자료가 사라진다. import { isPublic, describeDocument } from './identity-time.mjs'; export function createLibrary() { let draft = { id: 'hours-current', path: '/guides/opening-hours', revision: 0, title: '도서관 운영 시간', text: '일요일 10–17시. 첫째 일요일과 공휴일 휴관.', status: 'draft', deletedAt: null, publishedAt: null, publicFrom: null, contentModifiedAt: null, eventDate: null, validFrom: '2026-10-01T00:00:00+09:00', validUntil: null }; let active = structuredClone(draft); const history = [], cache = new Map(); function saveDraft(text) { draft = { ...draft, text }; } function change(action, at, { purge = true, revision } = {}) { if (!['publish', 'withdraw', 'restore'].includes(action)) throw new Error('Invalid action'); const selected = action === 'restore' ? history.find(x => x.revision === revision) : draft; if (!selected) throw new Error('Unknown revision'); const next = action === 'withdraw' ? { ...active, status: 'withdrawn' } : { ...selected, status: 'published', publishedAt: active.publishedAt ?? at, publicFrom: at, contentModifiedAt: active.publishedAt === null ? null : at }; next.revision = active.revision + 1; describeDocument(next, at); // 전체 입력 검증 후 한 번에 전환한다. active = next; history.push(structuredClone(next)); draft = structuredClone(next); if (purge) cache.clear(); return next.revision; } function read(path, at, { bypass = false } = {}) { // 이 모델의 철회는 상세 요청에서 현재 상태를 다시 검사한다. if (path === active.path && !isPublic(active, Date.parse(at))) return { status: 404, body: null }; if (!bypass && cache.has(path)) return structuredClone(cache.get(path)); const info = describeDocument(active, at); const body = path === '/list' ? (info ? [{ id: active.id, title: active.title, revision: active.revision }] : []) : path === '/sitemap' ? (info ? [info.canonical] : []) : path === active.path && info ? { ...active, ...info } : null; const result = { status: body === null ? 404 : 200, body }; if (!bypass) cache.set(path, result); return structuredClone(result); } return { saveDraft, change, read, clearCache: () => cache.clear() }; }

saveDraft는 작업본만 바꾼다. change는 게시·철회·복원을 선택하고 전체 날짜 검증을 통과한 자료를 active에 한 번에 대입한다. history는 이전 게시본을 복사해 보존한다. 복원은 과거 본문을 새 revision으로 게시하며 최초 게시일을 유지한다. 복원으로 바뀐 공개 내용에는 현재 수정 시각을 남긴다.

read는 같은 active에서 상세·목록·사이트맵을 만든다. 상세 요청은 캐시를 읽기 전에 현재 공개 여부를 다시 검사한다. change의 기본 동작은 모든 실습 캐시를 비우는 것이다. 실제 DB와 CDN은 한 메모리에서 원자적으로 바뀌지 않으므로 운영에서는 캐시 갱신의 실패 기록과 재시도가 필요하다.

import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { createLibrary } from './lifecycle.mjs'; const at = '2026-10-24T09:00:00+09:00'; const later = '2026-10-24T10:00:00+09:00'; test('HTTP 조회로 초안→게시→수정→철회→복원을 대조한다', async () => { const cms = createLibrary(); let clock = at; const server = createServer((req, res) => { const result = cms.read(req.url, clock); res.writeHead(result.status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result.body)); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${server.address().port}`; const rows = []; async function inspect(state, revision) { const responses = await Promise.all(['/guides/opening-hours', '/list', '/sitemap'].map(async path => { const r = await fetch(origin + path); return { path, status: r.status, body: await r.json() }; })); const [detail, list, sitemap] = responses; assert.equal(detail.status, revision === null ? 404 : 200); assert.equal(list.body.length, revision === null ? 0 : 1); assert.equal(sitemap.body.length, revision === null ? 0 : 1); if (revision !== null) { assert.equal(detail.body.revision, revision); assert.equal(list.body[0].revision, revision); assert.equal(sitemap.body[0], detail.body.canonical); } rows.push({ state, responses }); return detail.body; } try { await inspect('draft', null); cms.change('publish', at); const first = await inspect('published', 1); cms.saveDraft('일요일 10–16시. 첫째 일요일과 공휴일 휴관.'); assert.equal((await inspect('draft edit remains private', 1)).text, first.text); clock = later; cms.change('publish', later); const revised = await inspect('revised', 2); assert.match(revised.text, /10–16/); assert.equal(revised.publishedAt, at); cms.change('withdraw', later); await inspect('withdrawn', null); cms.change('restore', later, { revision: 1 }); const restored = await inspect('restored', 4); assert.equal(restored.text, first.text); assert.equal(restored.publishedAt, at); assert.equal(restored.contentModifiedAt, later); console.log(JSON.stringify(rows)); } finally { await new Promise(resolve => server.close(resolve)); } }); test('무효화를 생략한 수정·철회 실패를 재현한 뒤 모든 캐시를 비운다', () => { const cms = createLibrary(); cms.change('publish', at); for (const path of ['/guides/opening-hours', '/list', '/sitemap']) cms.read(path, at); cms.saveDraft('새 운영 시간'); cms.change('publish', later, { purge: false }); assert.notEqual(cms.read('/guides/opening-hours', later).body.text, cms.read('/guides/opening-hours', later, { bypass: true }).body.text); cms.change('withdraw', later, { purge: false }); assert.equal(cms.read('/guides/opening-hours', later).status, 404); assert.equal(cms.read('/list', later).body.length, 1); // 잘못 남은 목록 assert.equal(cms.read('/sitemap', later).body.length, 1); cms.clearCache(); assert.deepEqual(cms.read('/list', later).body, []); assert.deepEqual(cms.read('/sitemap', later).body, []); });

첫 테스트는 빈 목록과 상세 404에서 시작해 게시 후 200, 작업본 수정의 비노출, 새 게시본의 본문, 철회 후 404·빈 목록·빈 사이트맵, 과거 본문 복원을 확인한다. 두 번째 테스트는 purge: false로 장애를 주입한다. 상세는 철회되지만 목록·사이트맵은 예전 캐시를 돌려주는 실패를 재현한다. clearCache 뒤 두 경로가 비는지 확인해 수정 범위를 검증한다.

이 실습에서 철회 후에도 목록 캐시가 링크를 반환하는 실패는 검색기가 더 이상 제공하지 않는 글을 계속 발견하는 상황에 해당한다. 캐시를 비운 뒤 목록과 사이트맵에서 주소가 빠지면 새로 제공하는 발견 경로가 정리된다. 이미 외부 검색에 저장된 자료의 상태는 이후 수집 기록에서 확인한다. 코드의 공개 판정과 외부의 자료 갱신은 이렇게 서로 다른 위치에서 일어난다.

§ 07SEO / GEO / AEO

공개 상태를 확인한 뒤 이미지 변환 결과를 재사용한다

CMS의 사진 ID는 파일 경로와 공개 여부를 찾아가는 키다. 실제 CMS에서는 그 사진을 현재 공개 글이 참조하는지, 공개 시작 시각이 지났는지, 철회됐는지를 조회한다. 본문만 철회하고 원본 파일 주소를 계속 열어 두면 검색·공유·직접 요청은 그 파일에 도달할 수 있다. 원본과 파생본을 제공하는 경로 모두 같은 공개 판정을 거쳐야 한다.

다음 media-store.mjs는 사진 한 장과 공개 상태를 메모리에 보관한다. 공개 상태를 확인한 뒤 원본 해시·폭·WebP 설정으로 변환 결과를 찾는다. 같은 폭의 요청이 동시에 오면 Map에 넣은 Promise를 함께 기다린다. 변환 완료 후 공개 상태와 세대 번호를 다시 검사하므로 변환을 기다리는 동안 철회된 요청도 차단한다.

import sharp from 'sharp'; import { createHash } from 'node:crypto'; // 한 장을 담는 실습 저장소. 원본과 변환 결과는 public 디렉터리에 쓰지 않는다. export async function createMediaStore(input) { const source = Buffer.from(input); if (source.length > 20 * 1024 * 1024) throw new Error('Image exceeds 20 MiB'); const options = { limitInputPixels: 40_000_000 }; const meta = await sharp(source, options).metadata(); if (!['jpeg', 'png', 'webp'].includes(meta.format) || (meta.pages ?? 1) !== 1) throw new Error('Single JPEG, PNG or WebP required'); const hash = createHash('sha256').update(source).digest('hex'); const variants = new Map(); let published = true; let generation = 0; let transforms = 0; const headers = { 'Cache-Control': 'private, no-store, max-age=0' }; const missing = () => ({ status: 404, headers, body: Buffer.from('Not found') }); return { setPublished(value) { if (typeof value !== 'boolean') throw new Error('Boolean required'); published = value; generation++; }, stats: () => ({ transforms, variants: variants.size }), async read(width = null) { if (!published) return missing(); const observedGeneration = generation; if (width === null) return { status: 200, headers: { ...headers, 'Content-Type': 'image/' + meta.format }, body: source, }; if (![320, 640, 1280].includes(width)) return { status: 400, headers, body: Buffer.from('Unsupported width') }; const key = `${hash}:${width}:webp-q78-v1`; if (!variants.has(key)) { transforms++; const pending = sharp(source, options).rotate() .resize({ width, withoutEnlargement: true }).webp({ quality: 78 }) .toBuffer({ resolveWithObject: true }); variants.set(key, pending); pending.catch(() => variants.delete(key)); } const result = await variants.get(key); // 변환을 기다리는 동안 철회·재게시가 일어난 요청도 중단한다. if (!published || generation !== observedGeneration) return missing(); return { status: 200, headers: { ...headers, 'Content-Type': 'image/webp' }, body: result.data, width: result.info.width, height: result.info.height }; }, }; }

Buffer.from은 입력 바이트를 복사하고 SHA-256 해시는 원본의 내용을 식별한다. rotate는 사진의 회전 정보를 적용한다. resize의 withoutEnlargement는 작은 원본을 억지로 확대하지 않게 한다. toBuffer의 info에서 실제 출력 폭·높이를 얻는다. 이 값을 srcset에 써야 작은 원본에 1280w를 잘못 붙이지 않는다. sharp 크기 변환

setPublished(false)는 이 실습의 철회다. 서버를 다시 시작하면 원본과 파생본이 모두 사라진다. CMS에 연결할 때는 published 변수를 게시본 조회로 바꾸고 파생 파일은 외부에서 직접 접근할 수 없는 저장소에 둔다. 여러 서버가 실행된다면 모든 요청이 같은 공개 상태를 읽게 해야 한다. 입력 용량·픽셀 수·형식 검사는 이 코드에 있으며, 처리 시간 제한과 작업 동시성 제한은 업로드·변환 작업을 실행하는 환경에서 추가한다.

이 예제는 원본 주소도 공개하므로 원본에 포함된 위치·촬영 정보까지 제공할지 먼저 결정해야 한다. 공개용으로 메타데이터를 제거한 파생본만 제공하는 서비스라면 원본 경로는 관리자 권한으로 제한한다. 한번 전달된 파일이나 이미 전송 중인 응답은 이후 철회로 회수할 수 없다. 새 요청에 적용할 접근 조건과 이미 배포된 자료의 처리 방법을 구분한다.

이 파일은 이미지 실습의 서버와 함께 실행한다. 검증 편의 검사에서는 변환 도중 철회와 저장된 파생본 재요청을 재현한다.

— FIN —