o2o-site-AEO/solution/site/scripts/watch-payloads.mjs
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +09:00

263 lines
12 KiB
JavaScript

/**
* payload 감시 → 자동 프리렌더.
*
* ★ 왜 필요한가
* 발행 잡은 payload JSON 까지만 만든다(backend/services/site_payload). 그 뒤 HTML 로 굽는
* 단계가 수동이라, 사장님이 [발행]을 눌러도 사이트가 없었다 — [사이트 열기] 가 404 였다.
* 검색 → 크롤링 → 발행 → **사이트 이동** 이 끊기는 유일한 자리가 여기다.
*
* ★ 왜 백엔드 워커가 직접 안 굽나
* 굽는 데 Node 와 이 프로젝트의 의존성이 필요하다. 파이썬 컨테이너에 Node 를 넣으면
* 백엔드 이미지가 프론트 빌드 도구를 떠안는다. 대신 payload 디렉토리를 사이에 두고
* 따로 도는 프로세스가 읽는다 — 백엔드는 파일만 쓰고, 여기는 파일만 본다.
*
* ★ 바뀐 사이트만 굽는다
* 예전에는 payload 디렉토리를 통째로 넘겨서, 한 명이 발행하면 발행된 사이트 전부를
* 다시 구웠다(게다가 매번 vite 클라이언트 번들까지 새로 만들었다). 사이트가 늘면
* 그대로 못 쓴다. 지금은 mtime 이 바뀐 payload 만 골라 넘기고, 번들은 기동 때 한 번만 만든다.
*
* ★ 실패를 삼키지 않는다
* 프리렌더가 깨지면 DB 에는 "발행됨"인데 페이지는 없는 상태가 된다. 실패한 payload 는
* 백오프를 두고 다시 시도하고, 소진되면 경고로 남긴다. 결과 보고서는 프리렌더가
* payloads/.status/<slug>.json 에 쓴다(백엔드가 그걸 읽는다).
*
* 실행: npm run watch (개발)
* node scripts/watch-payloads.mjs --once (한 번만)
*/
import {spawn} from 'node:child_process';
import {existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync} from 'node:fs';
import {dirname, join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const PAYLOAD_DIR = join(ROOT, 'payloads');
const PRERENDER_JS = join(ROOT, 'dist', 'prerender', 'prerender.js');
const ONCE = process.argv.includes('--once');
/** 폴링 간격. 발행은 분 단위 작업이라 2초 지연은 문제가 되지 않는다. */
const POLL_MS = 2000;
/** 실패한 payload 재시도 횟수. 소진되면 경고만 남기고 다음 변경을 기다린다. */
const MAX_ATTEMPTS = 3;
/** 재시도 백오프(회차별 ms). 디스크 순단·부분 기록 같은 일시 실패를 흡수한다. */
const BACKOFF_MS = [5000, 20000];
function log(msg) {
console.log(`[watch ${new Date().toTimeString().slice(0, 8)}] ${msg}`);
}
function warn(msg) {
console.error(`[watch ${new Date().toTimeString().slice(0, 8)}] ${msg}`);
}
/**
* 자식 프로세스 하나를 돌리고 `{code, stderr}` 를 돌려준다.
*
* ★ stderr 를 흘려보내면서 동시에 모은다. 화면에는 지금까지처럼 그대로 나가야 하고
* (개발자가 보는 것), 실패 보고서에는 사유가 실려야 한다(백엔드가 읽는 것).
*/
function run(command, args, label) {
return new Promise((done) => {
log(`${label} 시작`);
const child = spawn(command, args, {cwd: ROOT, stdio: ['inherit', 'inherit', 'pipe'], shell: false});
let stderr = '';
child.stderr.on('data', (chunk) => {
process.stderr.write(chunk);
// 사유는 앞부분에 나온다. 스택 전체를 들고 있을 이유가 없다.
if (stderr.length < 4000) stderr += chunk.toString();
});
child.on('close', (code) => {
if (code === 0) log(`${label} 완료`);
else warn(`${label} 실패(exit ${code})`);
done({code: code ?? 1, stderr});
});
child.on('error', (ex) => {
warn(`${label} 실행 불가: ${ex.message}`);
done({code: 1, stderr: ex.message});
});
});
}
/**
* 프리렌더가 **자기 보고서를 쓰지도 못하고 죽었을 때** 대신 실패를 남긴다.
*
* ★ 왜 필요한가
* 프리렌더는 사이트별 실패를 스스로 `.status/<slug>.json` 에 적는다. 그런데 프로세스가
* 사이트를 하나도 돌기 전에 죽으면(의존성 누락·번들 오류) 보고서가 아예 안 생긴다.
* 그러면 백엔드 BUILD 잡은 180초를 꼬박 기다린 뒤 "결과를 못 받았다"로만 실패한다 —
* 진짜 사유(예: Cannot find package 'embla-carousel-react')는 컨테이너 로그에만 남고
* 사장님 화면에도, 발행 기록에도 안 나타난다. 실제로 그 상태로 47분간 모든 사이트의
* 재발행이 조용히 죽어 있었다.
*
* ★ 재시도가 소진된 뒤에만 쓴다. 첫 실패에 바로 쓰면 백엔드가 그 보고서를 집어가서
* 재시도가 성공해도 이미 늦는다 — 백오프(5s+20s)는 180초 안에 끝나므로 여유가 있다.
*
* ★ siteVersion 을 payload 에서 읽어 싣는다. 백엔드는 **버전이 맞는 보고서만** 받으므로
* (backend/services/render_report.wait_for) 버전이 없으면 이 보고서는 무시된다.
*/
function writeFailureReport(file, error) {
let payload;
try {
payload = JSON.parse(readFileSync(file, 'utf-8'));
} catch (ex) {
warn(`실패 보고서를 쓰지 못했다(payload 를 읽을 수 없다) ${basenameOf(file)}: ${ex.message}`);
return;
}
const site = payload?.site ?? {};
const slug = site.slug || basenameOf(file).replace(/\.json$/, '');
const report = {
schemaVersion: 1,
slug,
siteId: site.siteId ?? '',
placeId: site.placeId ?? '',
siteVersion: site.version ?? 0,
ok: false,
renderedAt: new Date().toISOString(),
error: (error || '프리렌더가 보고서를 남기지 못하고 종료했다').slice(0, 1000),
};
try {
const dir = join(PAYLOAD_DIR, '.status');
mkdirSync(dir, {recursive: true});
const tmp = join(dir, `.${slug}.json.tmp`);
writeFileSync(tmp, JSON.stringify(report, null, 2), 'utf-8');
renameSync(tmp, join(dir, `${slug}.json`));
warn(`실패 보고서 기록 — .status/${slug}.json (백엔드가 이 사유로 발행을 중단한다)`);
} catch (ex) {
warn(`실패 보고서를 쓰지 못했다 ${slug}: ${ex.message}`);
}
}
/**
* 클라이언트 번들 + 프리렌더 번들을 만든다. **기동 때 한 번만** 부른다.
*
* ★ payload 가 바뀌었다고 번들을 다시 만들 이유가 없다. 데이터만 바뀌었고 코드는 그대로다.
* 코드가 바뀌면 컨테이너가 다시 뜨고, 그때 여기를 지난다.
*/
/**
* 클라이언트 번들 → 프리렌더 번들. 앞이 실패하면 뒤는 돌리지 않는다.
*
* ★ run() 은 `{code, stderr}` 를 돌려준다. 예전에는 이 값을 숫자로 알고 `code === 0` 으로
* 비교했는데, 객체는 0 과 절대 같지 않아서 **프리렌더 번들이 한 번도 실행되지 않았다.**
* 기동 때마다 "번들 빌드 실패" 로 끝났고, 그래서 DEPLOY.md 가 약속한 '기동 시 전체
* 재굽기'가 실제로는 일어나지 않았다.
*/
async function buildBundles() {
const client = await run('npm', ['run', 'build:client'], '클라이언트 번들');
if (client.code !== 0) return client;
return run('npm', ['run', 'build:prerender'], '프리렌더 번들');
}
/** payload 파일들을 굽는다. 빈 배열이면 아무것도 하지 않는다.
* ★ 호출부가 `{code, stderr}` 를 구조분해하므로 빈 경우에도 같은 모양을 돌려준다 —
* 숫자 0 을 돌려주면 code 가 undefined 가 되어 성공이 실패로 읽힌다. */
function prerender(files, reason) {
if (files.length === 0) return Promise.resolve({code: 0, stderr: ''});
const label = `프리렌더 ${files.length}개 — ${reason}`;
return run('node', [PRERENDER_JS, ...files.map((file) => `--payload=${file}`)], label);
}
// ── 큐 ────────────────────────────────────────────────────────────────────
/** 굽기를 기다리는 payload 경로. 굽는 동안 들어온 변경은 여기에 쌓였다가 이어서 돈다. */
const pending = new Set();
/** payload 경로 → 지금까지 실패한 횟수. 성공하면 지운다. */
const attempts = new Map();
let running = false;
function enqueue(file) {
pending.add(file);
void drain();
}
async function drain() {
if (running || pending.size === 0) return;
running = true;
try {
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const {code, stderr} = await prerender(batch, batch.map((f) => basenameOf(f)).join(', '));
if (code === 0) {
batch.forEach((file) => attempts.delete(file));
continue;
}
// ★ 배치가 실패하면 어느 사이트가 깨졌는지는 보고서(.status/<slug>.json)에 남는다.
// 여기서는 배치 전체를 재시도한다 — 프리렌더는 사이트별로 실패를 격리하므로
// 이미 성공한 사이트를 다시 구워도 결과는 같다(멱등).
for (const file of batch) {
const tried = (attempts.get(file) ?? 0) + 1;
attempts.set(file, tried);
if (tried >= MAX_ATTEMPTS) {
warn(`${basenameOf(file)} — ${tried}회 실패, 재시도를 멈춥니다.`);
// 프리렌더가 자기 보고서를 못 남기고 죽었을 수 있다 — 그러면 백엔드가 180초를
// 헛기다린 뒤 사유 없이 실패한다. 여기서 사유를 실어 남긴다(writeFailureReport 주석 참조).
writeFailureReport(file, stderr.trim());
continue;
}
const delay = BACKOFF_MS[Math.min(tried - 1, BACKOFF_MS.length - 1)];
warn(`${basenameOf(file)} — 실패(${tried}/${MAX_ATTEMPTS}), ${delay / 1000}초 후 재시도`);
setTimeout(() => enqueue(file), delay);
}
}
} finally {
running = false;
}
}
function basenameOf(file) {
return file.slice(file.lastIndexOf('/') + 1);
}
/** payload 디렉토리의 *.json 목록. 백엔드가 rename 전에 쓰는 임시파일(.tmp)은 건너뛴다. */
function listPayloads() {
if (!existsSync(PAYLOAD_DIR)) return [];
return readdirSync(PAYLOAD_DIR)
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
.map((name) => join(PAYLOAD_DIR, name));
}
// ── 기동 ──────────────────────────────────────────────────────────────────
async function main() {
mkdirSync(PAYLOAD_DIR, {recursive: true});
const {code} = await buildBundles();
if (code !== 0) {
warn('번들 빌드 실패 — 프리렌더를 할 수 없습니다. 컨테이너를 다시 띄우세요.');
process.exitCode = 1;
return;
}
// 기동 시 한 번은 전부 굽는다. 감시가 꺼져 있는 동안 발행된 것도 사이트가 있어야 하고,
// 코드가 바뀌었다면 번들이 새로 나왔으므로 기존 사이트도 다시 구워야 맞다.
const all = listPayloads();
const seen = new Map(all.map((file) => [file, statSync(file).mtimeMs]));
await prerender(all, '기동');
if (ONCE) return;
log(`감시 중: ${PAYLOAD_DIR} (바뀐 payload 만 굽습니다)`);
/**
* ★ fs.watch 를 쓰지 않는다. Docker 볼륨(bind mount)을 통해 들어온 변경은 macOS 에서
* inotify/FSEvents 이벤트가 오지 않는 경우가 있다 — 발행해도 아무 일이 안 일어난다.
* 폴링은 느리지만 확실하다.
*/
setInterval(() => {
for (const file of listPayloads()) {
let mtime;
try {
mtime = statSync(file).mtimeMs;
} catch {
continue; // 폴링과 rename 이 겹친 순간. 다음 틱에 다시 본다.
}
if (seen.get(file) === mtime) continue;
seen.set(file, mtime);
log(`${basenameOf(file)} 변경 감지`);
enqueue(file);
}
}, POLL_MS);
}
void main();