# 실시간 read-model change journal과 revision-first 조회 설계 ## 목적 메인 화면의 실시간 갱신이 실제 화면 변화가 없는 경우에도 viewer별 PostgreSQL read model을 다시 구성한 뒤 `unchanged`를 판정하는 비용을 제거한다. 동시에 다음 운영 상한을 만족하는 구조와 재현 가능한 검증 절차를 제공한다. - 실제 사용자 최대 약 300명 - NPC 최대 약 900명 - 5분 턴 서버 - 공백지 점령기처럼 지도·기록 변화가 집중되는 구간 - 300명이 모두 로그인하고 메인 화면 자동 갱신을 켠 상태 - 현재 `dev-sam2026`과 동급인 AMD Ryzen 7 5800X 8C/16T 호스트 - runtime container 제한 4 CPU, 8 GiB, 256 PID 이 문서는 성능을 위해 전투 결과, 명령 판정·RNG 소비 순서, DB mutation 순서, 권한과 공개 SSE redaction을 바꾸는 것을 허용하지 않는다. 성능 합격은 source 추산이 아니라 아래 workload를 실제 PostgreSQL·Redis·HTTP/SSE 경계에서 실행한 결과로 판정한다. ## 현재 상태와 병목 현재 turn daemon은 `InMemoryTurnWorld`의 dirty general/city/nation 후보를 daemon 수명의 in-memory baseline과 비교한다. `databaseHooks`가 `content`, `map`, `contacts`, `frontStatus`, `lobby` canonical projection을 나누어 비교한 뒤 `RealtimeReadModelChanges`를 만든다. 이 단계는 dirty entity만 직렬화하며 일반 entity 판정을 위해 DB를 다시 읽지 않는다. DB transaction 안에서 직접 생성되는 `log_entry`는 별도다. `databaseHooks`는 transaction 진입 시 log ID floor를 잡고 flush 뒤 실제 저장된 화면 노출 row를 조회하여 개인 기록, 장수 동향과 중원 정세 flag를 보완한다. 이 조회는 direct Prisma writer와 rollback을 정확히 포괄하기 위해 유지한다. 주요 낭비는 browser가 invalidation을 받은 뒤다. ```text readModelInvalidated -> frontend가 모든 non-empty plan에 context=true를 강제 -> access-limit gate가 general/world/access-log를 조회 -> getGeneralContext()가 viewer context를 3~15 SQL로 재구성 -> canonical JSON/hash 뒤에야 unchanged 판정 -> map/records/front-status 등 선택 slice를 추가 조회 ``` 보통 소속 장수의 context-only 자동 갱신은 access gate를 포함해 약 13 SQL이고, command table까지 포함하면 약 21 SQL이다. 300 viewer가 global 변화 burst를 1초 간격으로 받으면 bundle만 이론상 약 3,900~6,300 statement/s까지 커질 수 있다. 이는 실제 운영 측정값이 아니라 현재 호출 그래프의 상한식이며, 구현 뒤 실제 statement rate로 대체한다. ## 보존할 계약 1. gameplay 계산 중 hook은 DB, Redis, network side effect를 실행하지 않는다. 2. 공개할 변화는 DB commit 성공 뒤에만 만들어진다. rollback된 변화는 보이지 않는다. 3. `log_entry` 기반 기록은 in-memory draft가 아니라 commit되는 실제 row가 기준이다. 4. Redis pub/sub은 best-effort wake-up이며 durable state source가 아니다. 5. 내부 committed change에는 entity ID를 둘 수 있지만 public SSE는 viewer별 boolean slice만 노출한다. 6. 다른 장수의 private 변화와 clock-only turn은 public SSE와 dashboard 조회를 만들지 않는다. 7. cache/revision 장애, 구버전 client와 rolling deployment는 기존 full snapshot으로 복구한다. 8. viewer-private payload는 공유 cache에 저장하지 않는다. 9. manual refresh는 최신 authoritative DB snapshot을 다시 읽는 복구 경로로 남긴다. 10. typed journal 수집은 action module order, RNG 호출과 persistence 순서를 바꾸지 않는다. ## 목표 구조 ```text engine / API / worker mutation -> transaction-local ChangeJournal에 entity 후보와 닫힌 의미 변화 기록 -> 영향받는 canonical projection만 final value와 baseline 비교 -> 같은 DB transaction에서 read_model_revision을 batch increment -> 같은 DB transaction에 compact read_model_outbox 1건 기록 -> transaction commit -> outbox dispatcher가 Redis readModelChanged를 at-least-once publish -> shared projection은 domain revision을 cache key로 사용 -> browser가 viewer-safe slice invalidation을 merge -> access-only gate + slice source revision 1회 조회 known source revision 동일: payload DB 조회 없이 unchanged 다름: 해당 viewer-private projection만 lazy 재구성 -> content hash/RFC 6902 patch/full snapshot의 기존 복구 계약 유지 ``` ### 세 단계 자료형 #### `ChangeCandidate` transaction 안에서만 쓰는 mutable collector다. gameplay action에 범용 callback을 연결하지 않고 현재 dirty state와 API/worker의 명시적 mutation 결과를 수집한다. ```ts type ChangeCandidate = { generalIds: Set; cityIds: Set; nationIds: Set; domains: Set; generalRecordIds: Set; }; ``` entity dirty는 비교 후보일 뿐 public 변화의 증거가 아니다. 예를 들어 gold 변경은 context/command에 영향을 줄 수 있지만 map에는 영향이 없다. 기존 canonical projection 비교 또는 명시적인 field dependency가 최종 domain을 결정한다. #### `CommittedReadModelInvalidation` DB transaction에서 revision과 함께 확정되는 내부 계약이다. 현재 `RealtimeReadModelChanges`의 entity ID와 domain flag를 이 이름과 역할로 정리한다. API는 이 값으로 viewer identity를 대조하지만 그대로 browser에 보내지 않는다. #### `RealtimeReadModelInvalidation` 현재와 동일하게 entity ID, wall/logical time과 global revision을 제거한 public boolean slice다. 신규 domain도 실제 화면 consumer가 있는 경우에만 boolean을 추가한다. ## durable revision ### DB schema profile마다 별도 PostgreSQL schema를 사용하므로 profile column은 두지 않는다. ```prisma model ReadModelRevision { domain String entityId Int @default(0) @map("entity_id") revision BigInt @default(0) updatedAt DateTime @updatedAt @map("updated_at") @@id([domain, entityId]) @@map("read_model_revision") } model ReadModelOutbox { id BigInt @id @default(autoincrement()) payload Json attempts Int @default(0) availableAt DateTime @default(now()) @map("available_at") lockedAt DateTime? @map("locked_at") lockOwner String? @map("lock_owner") deliveredAt DateTime? @map("delivered_at") lastError String? @map("last_error") createdAt DateTime @default(now()) @map("created_at") @@index([deliveredAt, availableAt, id]) @@map("read_model_outbox") } model ReadModelRevisionMeta { id Int @id coverageVersion Int @default(0) @map("coverage_version") @@map("read_model_revision_meta") } ``` 한 mutation에서 같은 key가 여러 번 표시돼도 collector에서 dedupe한다. commit 시 모든 key를 한 `INSERT ... ON CONFLICT ... DO UPDATE revision = revision + 1`로 올려 statement 수와 row lock 시간을 제한한다. 없는 key의 revision은 0으로 해석한다. 초기 domain은 다음과 같다. | domain | entity ID | 의미 | | --- | ---: | --- | | `general.content` | general ID | 현재 장수 context/command/board dependency | | `city.content` | city ID | 현재 도시 context/command dependency | | `nation.content` | nation ID | 현재 국가 context/command/board dependency | | `dashboard.global` | 0 | 부대·예약턴 및 전체 general/city/nation aggregate를 포괄하는 source-only dependency | | `world.content` | 0 | 연월, scenario/config/catalog 성격의 dependency | | `map.world` | 0 | shared base map projection | | `map.general` | general ID | 현재 장수 이동처럼 actor별 map wake-up에 필요한 변화 | | `records.general` | general ID | 개인 최근 기록 | | `records.global` | 0 | 장수 동향 | | `records.history` | 0 | 중원 정세 | | `front.general` | general ID | actor별 front status | | `front.nation` | nation ID | 국가 공지 등 viewer별 front status | | `front.global` | 0 | 설문과 global front status | | `access.general` | general ID | 접속 점수와 제한 상태, public fan-out 없음 | | `lobby.world` | 0 | NPC/국가 수, 공용 lobby projection | | `lobby.general` | general ID | 본인 lobby에 보이는 이름·아이콘 projection | | `contacts.world` | 0 | 장수 목록·외교 연락처 공용 projection | | `reserved.general` | general ID | 장수 예약 명령 projection | | `messages.mailbox` | mailbox ID | commit된 메시지 mailbox wake-up; public SSE에는 ID 미노출 | | `tournament` | 0 | 토너먼트 stage/state | | `betting` | 0 | 국가/토너먼트 베팅 목록·상태 | projection 세부 ID 목록은 Redis wake-up의 viewer filtering에 사용하고, DB revision row는 API가 작은 dependency vector를 읽는 authoritative 경계로 사용한다. `access.general`은 제한 gate와 현재 context의 접속 점수에만 포함하고 public SSE를 발행하지 않는다. 모든 access 기록마다 다른 viewer를 깨우지 않으면서 본인 다음 조회의 source revision은 정확히 바뀐다. ### outbox와 coverage gate revision은 `unchanged` 판정의 authority이고 outbox는 commit 뒤 browser wake-up과 shared projector 실행의 내구성 경계다. non-empty journal transaction마다 normalized payload 한 건만 넣는다. dispatcher는 `FOR UPDATE SKIP LOCKED`와 bounded lease로 row를 claim하고 Redis publish/cache 작업을 at-least-once 수행한다. revision-keyed cache와 boolean invalidation은 중복 전달에 idempotent해야 한다. - commit 뒤 publish 전 crash: pending outbox가 재시도한다. - publish 뒤 acknowledge 전 crash: 중복 publish될 수 있으나 현재 state를 다시 읽으므로 결과는 같다. - 전달 완료 row는 짧은 운영 진단 retention 뒤 batch prune하여 4 turns/s 지속 workload의 무제한 table 성장을 막는다. - outbox ID는 전달 identity일 뿐 projection revision으로 사용하지 않는다. 서로 다른 transaction의 sequence 할당 순서와 commit 순서가 다를 수 있기 때문이다. `coverageVersion=0`에서는 revision-first equality를 절대 신뢰하지 않는다. 모든 engine/API/worker writer와 dispatcher가 배포되고 reconciliation이 끝난 뒤 현재 binary가 요구하는 version으로 올린다. coverage가 없거나 낮으면 기존 content-hash full computation 경로를 사용한다. rollback은 coverage를 0으로 내리는 것만으로 fast path를 끌 수 있다. 현재 binary 요구 version은 1이다. migration은 rolling deployment 중 구버전 writer가 남을 수 있으므로 0을 seed한다. 배포 완료 뒤 아래 one-off만 실행한다. 이 명령은 advisory transaction lock을 얻고 `dashboard.global`/`map.world` head를 `ON CONFLICT DO NOTHING`으로 seed한 다음 meta를 0→1 CAS한다. concurrent writer revision을 덮어쓰지 않으며 실패·rollback은 head와 meta를 함께 되돌린다. profile/confirmation은 stdout에 비밀값을 출력하지 않는다. ```sh pnpm --filter @sammo-ts/infra build READ_MODEL_COVERAGE_PROFILE=hwe \ READ_MODEL_COVERAGE_CONFIRM=activate:hwe:coverage-v1 \ pnpm --filter @sammo-ts/infra coverage:activate:game ``` 감사·기록은 outbox payload로 재구성하지 않고 원래 transaction의 domain row와 `log_entry`를 source of truth로 유지한다. ## producer별 적용 ### game engine - dirty general/city/nation/log/reserved state는 기존 canonical projection 비교 뒤 `ChangeJournal` adapter로 변환한다. - `persistedVisibleLogs`를 합친 최종 invalidation, DB revision과 outbox는 domain mutation과 같은 transaction에 저장한다. 성공 뒤에만 dirty baseline과 committed receipt를 확정한다. - 부대/외교, 부대장 예약턴과 전체 entity aggregate는 source-only `dashboard.global`로 보수적으로 포괄한다. clock/lease-only flush는 이 key를 올리지 않는다. - 일반 turn message와 통일 경매 취소 message는 실제 insert callback의 mailbox를 같은 transaction에 기록한다. - 연·월과 response-relevant config/meta만 world canonical projection에 포함하고 clock/lease/heartbeat를 제외한다. `world.content`와 `map.world`는 state commit과 원자적이다. ### game API transaction `inputEventMiddleware`가 request-local journal을 만들고 transaction context에 전달한다. handler는 성공한 mutation의 닫힌 의미만 mark한다. middleware는 handler와 `input_event=SUCCEEDED`를 저장한 같은 transaction에서 revision을 올리고, outer transaction commit 뒤에는 dispatcher wake-up만 시도한다. 설문의 pre-commit Redis publish는 actor/global front-status journal mark로 바꿨다. 메시지도 pre-commit `messageCreated`를 제거하고 mailbox outbox 전달 뒤 viewer-safe `messagesInvalidated`만 공개한다. 국가 설정, 베팅, 외교 응답과 장수 예약명령 direct writer는 86개 mutation inventory test가 등록/명시적 비대상 분류를 고정한다. 현재 일부 `authedProcedure`/`accessAuthedProcedure` mutation은 API interactive transaction을 잡은 채 `turnDaemon.requestCommand()`의 별도 ENGINE transaction 완료를 기다린다. 300명 동시 mutation에서는 pool 고갈·timeout 위험이 있으므로 다음처럼 분류한다. - daemon만 state를 소유하는 handler: `engineAuthedProcedure` 계열로 옮겨 API outer transaction을 만들지 않는다. - API DB write와 daemon write가 섞인 handler: journal만 추가하고 원자적이라고 부르지 않는다. 한쪽 소유 transaction으로 이동하거나 durable idempotent saga를 정의한다. - API input event와 ENGINE input event의 request ID/idempotency 관계를 integration test로 고정한다. ### Redis-owned tournament state 토너먼트 state/participants/matches/bets는 현재 Redis가 원본이므로 PostgreSQL revision과 원자적으로 묶을 수 없다. `TournamentStore`가 state write와 Redis domain revision 증가를 같은 Lua script로 수행한다. source revision은 참가자·대진·베팅을 포함한 모든 projection write에서 증가하지만 메인 화면 wake-up은 Lua 안에서 이전/다음 `state.stage`를 비교해 실제 단계가 바뀔 때만 결정한다. commit 뒤 `tournamentChanged`를 공용 game event channel에 best-effort publish하며, API는 이를 식별자·revision 없는 public `tournament: true` invalidation으로 바꾼다. 참가 등록, 대진 결과, 같은 stage 안의 phase/timer/정산 flag write는 300 viewer를 깨우지 않는다. 장기 durability 요구가 생기면 tournament state 자체를 PostgreSQL 소유로 옮기는 별도 migration으로 다룬다. ### 국가 베팅과 direct writer - 국가 베팅 제출은 actor의 비용/랭킹과 `betting` domain을 같은 API transaction에서 mark한다. - engine의 베팅 open/finish와 정산 로그는 engine transaction journal이 소유한다. - diplomacy/message response처럼 city/nation을 직접 쓰는 API 경로는 해당 entity와 map/front/contacts projection을 명시적으로 mark한다. - direct writer inventory test가 알려진 mutation 파일을 journal 등록 목록과 대조한다. `TurnWorldChanges`에 있으나 현재 realtime summary가 사용하지 않는 troop, diplomacy, messages, neutral auction, nation betting과 nation reserved queue도 inventory에 포함한다. 화면 consumer가 없는 값은 무조건 event를 만들지 않고 명시적 비대상으로 기록한다. ## request 경로 ### 1단계: access-only gate `dashboard.getContextBundleDelta`의 include가 모두 false인 요청을 허용하거나 별도 `dashboard.checkRealtimeAccess`를 둔다. frontend는 모든 event에 `context=true`를 강제하지 않는다. 제한이면 후속 query를 만들지 않고 현재와 같이 EventSource를 닫는다. `getGeneralAccessState()`의 general/world/access-log 조회는 한 SQL 또는 request-local loader로 합친다. non-limited 결과는 짧은 최대 5초 cadence로만 재검사할 수 있지만, manual mutation/명시적 제한 endpoint는 기존 server-side gate를 계속 적용한다. ### 2단계: revision-first delta 각 private slice response는 기존 content `revision` 외에 opaque `sourceRevision`을 포함한다. source revision은 domain/entity/revision tuple을 canonicalize한 뒤 hash한 값이고 내부 ID vector 자체를 browser에 내보내지 않는다. ```ts type DashboardDeltaInput = { // 기존 client가 보내는 content revision 계약을 유지한다. known?: Partial>; // revision-first를 이해하는 client만 별도로 보낸다. knownSource?: Partial>; }; ``` API는 access gate에서 얻은 현재 general/city/nation identity로 slice dependency key를 만들고 `read_model_revision`을 한 번 읽는다. - client source와 같으면 `getGeneralContext()`, command table, board access와 payload canonicalization을 모두 생략하고 `unchanged`를 반환한다. - 다르면 현재 projection을 만들고 기존 content hash/Redis baseline/patch/snapshot 선택을 수행한다. - source revision row/table이 없거나 query가 실패하면 기존 full computation으로 fallback한다. - `coverageVersion`이 현재 binary 요구값보다 낮으면 source가 같아도 fast unchanged를 사용하지 않는다. - general의 소속·위치가 바뀌면 `general.content`가 먼저 mismatch되므로 새 identity로 projection과 source revision을 다시 만든다. 현재 dashboard private-slice 구현은 context에 `dashboard.global/general.content/city.content/nation.content/world.content/access.general`과 인증 token의 icon projection hash를 사용한다. command table은 `dashboard.global/general.content/city.content/nation.content/world.content`, board access는 `general.content/nation.content`를 사용한다. `dashboard.global`은 troop/leader turn, 국가 도시·장수 aggregate, top chiefs와 전체 command option 목록의 transitive dependency를 보수적으로 포괄한다. 인증 hash는 icon 목록/권한만 포함하며 session ID나 token 만료처럼 payload를 바꾸지 않는 값은 제외한다. source hash에는 dependency-vector code version을 포함하고 browser에는 22자 base64url hash만 반환한다. access gate가 확보한 general ID를 기준으로 actor city/nation, coverage와 여섯 DB head를 payload loader보다 먼저 한 SQL로 읽는다. coverage가 낮거나 meta/actor row가 없거나 query/result가 잘못되면 source hash를 authority로 사용하지 않고 기존 content 계산으로 복구한다. Migration/runtime 기본은 0이지만 writer reconciliation과 activation v1 PostgreSQL rollback test가 완료되어 post-deploy one-off로 안전하게 활성화할 수 있다. ### shared projection - world map base/public cache key는 coverage v1이 확인된 DB `map.world` revision만 사용한다. meta/head 누락, DB/Redis 오류에는 shared cache를 완전히 우회해 full compute한다. - 개인 `spyList`, `shownByGeneralList`, `myCity`, `myNation`은 request에서 계속 조합한다. - tournament는 API store, 월 자동 개막과 runtime clock shift 모두 payload와 profile source revision을 같은 Lua invocation으로 갱신한다. stage transition만 main realtime channel에 best-effort publish하고, 같은 stage의 phase/participant/match/bet write는 source revision만 진행한다. - records는 기존 `lastGeneralRecordId`/`lastWorldHistoryId` 증분 조회를 유지하되 해당 domain이 선택되지 않으면 query하지 않는다. ## frontend scheduling 같은 profile/account의 visible tab leader 하나만 SSE와 tRPC를 수행하는 현재 BroadcastChannel 계약을 유지한다. 변경 ID set/boolean은 drop하지 않고 union한다. 초기 cadence 목표는 다음과 같다. | 종류 | 최대 시작 빈도 | 이유 | | --- | ---: | --- | | 자기 context/commands/board | 1초 1회 | 자기 명령 결과의 빠른 반영 | | records/front status | 2초 1회 | global burst 합치기 | | map/lobby/tournament/betting | 5초 1회 | 300 viewer의 shared fan-out 제한 | | access-only non-limited 재확인 | 5초 1회 | 제한 DB gate fan-out 제한 | manual refresh, visible 복귀와 realtime 재활성화는 cadence를 기다리지 않고 fresh snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라 조정하며, 부하만 낮추기 위해 5초를 초과하지 않는다. 메인 dashboard의 `tournamentStage`는 store가 소유한다. visible leader 탭이 `tournament: true`를 받으면 기존 access-only gate 뒤 `tournament.getState`만 읽어 stage patch를 만들고, 같은 profile/account의 follower 탭은 BroadcastChannel patch를 적용한다. 따라서 상단 `토너먼트:` 문구, 국가 메뉴와 모바일 메뉴의 stage 강조가 한 값으로 함께 갱신된다. Redis/API 오류에는 현재 stage를 거짓 0으로 덮지 않고 다음 event, 사용자 `갱 신`, visible 복귀 snapshot으로 복구한다. ## 구현 단계와 commit 경계 ### Phase A: 저위험 read 절감 1. access-only gate/all-false bundle을 허용한다. 2. frontend의 강제 `context=true`를 제거한다. 3. request-local actor/world/access loader 또는 단일 SQL로 gate 중복을 줄인다. 4. unit + 실제 PostgreSQL query-count integration + production Chromium network trace를 통과시킨다. ### Phase B: typed journal과 durable revision 1. `ReadModelDomain`, collector와 committed invalidation type을 common에 추가한다. 2. revision/outbox/meta migration과 Prisma model을 추가한다. 3. engine transaction에서 final projection/log 결과, revision과 outbox를 원자화한다. 4. outbox dispatcher의 claim/lease/retry/prune를 구현한다. 5. API `inputEventMiddleware`에 request journal을 추가하고 pre-commit publish를 제거한다. 6. daemon-only procedure와 mixed API/ENGINE saga inventory를 분리한다. 7. survey/nation betting/message/diplomacy/direct writer를 등록한다. 8. rollback, duplicate input, direct log writer와 Redis publish 실패를 검증한다. ### Phase C: revision-first와 shared cache 1. dashboard delta contract에 source revision을 optional로 추가한다. 2. context/command/board의 dependency vector와 fast unchanged를 구현한다. 3. map cache를 durable world revision으로 전환한다. 4. tournament Redis state/revision을 원자화한다. 5. old client, missing row, Redis/DB failure와 rolling deployment fallback을 검증한다. 6. writer coverage reconciliation 뒤에만 coverage version을 활성화한다. ### Phase D: scale benchmark와 tuning 1. 5분 턴·900 NPC 및 900 NPC+300 human control engine profile을 고정 seed로 실행한다. 2. 300 SSE 연결의 idle/keepalive/memory를 측정한다. 3. 300 인증 viewer의 own-change와 global map/record burst를 실제 HTTP/PostgreSQL/Redis로 실행한다. 4. daemon 900 NPC 처리와 API mixed load를 동시에 실행한다. 5. 결과에 따라 pool, cadence, cache와 worker concurrency를 조정하고 전체 benchmark를 다시 실행한다. ### 2026-08-17 구현 상태 | Phase | 상태 | 현재 근거 | | --- | --- | --- | | A | 완료 | all-false access gate, frontend 강제 context 제거, Chromium realtime trace | | B | 완료 | typed journal, PostgreSQL revision/outbox/meta, engine/API 원자 writer, retry dispatcher, 86 mutation inventory | | C | 완료 | dashboard revision-first, auth/global dependency, durable map cache, 모든 tournament Redis writer 원자화, stage-only main realtime invalidation, coverage v1 activation/rollback integration | | D | 부분 완료 | E1 1,200장수 1개월 deterministic profile과 300 SSE/HTTP 짧은 calibration 완료. E2 actual daemon DB flush 및 30분 M1/R1은 미실행 | `부분 완료`는 capacity 합격을 뜻하지 않는다. 이 작업의 수용 추산은 아래 실제 짧은 calibration과 E1 결과를 함께 사용하되, 장기 soak/daemon flush 경계는 별도 admission run 전까지 미검증으로 남긴다. 각 phase는 독립 commit으로 유지한다. broad refactor 뒤에는 `pnpm exec turbo typecheck --force`, lint/build와 architecture check를 실행한다. ## 부하 workload와 합격 기준 ### 산술 기준 5분 턴에서 900 NPC는 평균 3 general turns/s다. 사용자 300명의 turn이 같은 주기로 분산되면 평균 1 turn/s이므로 정상 지속 입력은 약 4 general turns/s다. "NPC 포함 총 900장수"도 별도 control로 둔다. 경계 시각의 몰림과 worker catch-up을 위해 최소 3배인 12 turns/s 지속 burst와 1,200장수 동시 경계 chunk를 별도로 측정한다. 기존 880 NPC DB-free 자연 통일 profile은 계산 상한을 제공하지만 PostgreSQL, Redis, API와 process 경합을 제외하므로 production 수용 근거로 단독 사용하지 않는다. ### workload | ID | workload | 필수 관찰값 | | --- | --- | --- | | E1 | 5분 턴, 900 NPC 및 총 1,200장수 DB-free 고정 seed | turns/actions/s, command p95/p99, memory high-water, 최종 state hash | | E2 | 실제 DB flush를 포함한 12 turns/s와 1,200 동시 경계 | 계산/flush/publish, schedule lag, rows/statements, rollback 0 | | A1 | 300 SSE idle 30분 | 연결 성공/유지율, ping, runtime RSS, event-loop lag | | A2 | 300 viewer own context 변화 5분 주기 | HTTP p95/p99, fast unchanged 비율, DB statements/s | | A3 | 초당 3회 map/record 후보 10분 | coalesced request rate, shared cache hit, stale/missed revision 0 | | M1 | E2 + A1 + A2 + A3 혼합 30분 | CPU/RSS/DB/Redis/HTTP/SSE와 daemon lag 전체 | | R1 | Redis publish/cache 장애와 API/daemon restart | durable revision 수렴, full snapshot 1회, 유실/중복 표시 0 | ### provisional pass gate 현재 운영 후보 runtime limit과 한 호스트의 PostgreSQL/Redis 공유를 기준으로 한다. - runtime CPU: 30분 sustained 4 CPU의 70% 이하, 1분 burst 90% 이하 - runtime RSS: 6 GiB 이하, 종료 후 지속 증가 없음 - API HTTP: 성공률 99.9% 이상, read p95 500ms 이하, p99 1.5s 이하, mutation p95 1s 이하, p99 3s 이하 - SSE: 300 연결의 99.5% 이상 steady 유지, publish-to-client p95 250ms 이하, p99 1s 이하, reconnect storm 없음 - PostgreSQL: connection pool 고갈 0, deadlock/serialization failure 0, query p95 50ms 이하 - Redis: command error 0, blocked client 0, cache 장애 주입 후 요청 실패 대신 snapshot 복구 - daemon: 계산+flush p99 60초 이하, 관측 max 120초 이하, 5분 턴 backlog가 다음 경계까지 누적되지 않음 - correctness: 고정 seed engine state hash 동일, rollback invalidation 0, public SSE entity ID/time/revision 노출 0 - fast path: revision 동일 context/command/board 요청에서 payload projection SQL 0회 - global burst: viewer당 shared slice 시작률 5초당 1회 이하, trailing union 보존 - own commit-to-dashboard p99 2초 이하, shared map은 5초 cadence를 포함해 p99 7초 이하 이 gate는 최초 측정 전 목표값이다. 미달이면 "추산상 가능"으로 끝내지 않고 병목을 계측하여 수정하고 M1/R1을 다시 실행한다. dev host의 다른 workload가 결과에 영향을 주면 container CPU/memory limit과 host steal/load를 함께 기록한다. ## 2026-08-16 실측과 수용 판정 ### E1: 1,200장수 고정 seed 계산 profile 로컬 source-tree에서 정확히 900 NPC와 300 synthetic human 장수, 5분 turn 간격, 1개월/1,200 general turn을 두 번 실행했다. 두 실행의 최종 state SHA-256은 모두 `d14a5f451385095bb56f9459928f100bf81864178bac9861686e06001a7f02a0`이었다. | 관찰값 | 결과 | | --- | ---: | | 처리량 | 1,283.986 general turns/s | | general turn p95 / p99 | 2.793 / 3.393 ms | | month wall p95 | 934.590 ms | | max RSS | 681,316,352 bytes | 이는 평균 필요량 4 turns/s와 설계 burst 12 turns/s보다 계산량 자체가 충분히 작다는 근거다. 그러나 DB-free in-memory profile이며 local CPU는 운영 후보 host와 다르다. PostgreSQL flush, Redis/outbox, API 경합을 포함한 E2/M1의 대체 근거로 사용하지 않는다. ### 300 SSE + 실제 HTTP/PostgreSQL/Redis 짧은 calibration 같은 deterministic fixture SHA-256 `de8a5b459ca80c85c83dbdf0504203d3d3297dbd55226e57988b1c5d870d30cb`를 사용했다. PostgreSQL 18.4와 Redis 8.2.7을 loopback 전용 격리 stack에 두고 API만 CPU `0-3` affinity와 `MemoryMax=8 GiB`로 제한했다. local host CPU는 Ryzen 7 9800X3D이므로 dev-sam2026의 Ryzen 7 5800X와 동급이라고 간주하지 않는다. 각 run은 idle 5초, own/global/mixed 각 10초이며, 각 phase에서 300 SSE를 열고 닫았다. 이는 30분 A1/M1 soak가 아니다. 두 번째 run은 activation transaction으로 coverage 0→1과 `dashboard.global`/`map.world` 초기 head를 확정한 뒤 실행했다. 300 SSE는 모든 phase에서 300/300 open/close했고 failure, reconnect, privacy violation은 0이었다. HTTP 5,839건도 모두 성공했다. 3,607 dashboard 응답, 즉 10,821개 slice에서 전송한 `knownSource`가 응답 source와 같았고 전부 payload loader 이전 `unchanged`로 반환됐다. | 지표 | coverage 0 | coverage 1 | 변화 | | --- | ---: | ---: | ---: | | own dashboard 요청 | 1,347 | 2,454 | +82.2% | | mixed dashboard 요청 | 1,222 | 1,753 | +43.5% | | unchanged slice | 5,907 | 10,821 | +83.2% | | API CPU time / 약 35초 | 27.377 s | 15.505 s | -43.4% | | API MemoryPeak | 728.1 MB | 682.5 MB | -6.3% | | DB transaction delta | 52,367 | 34,237 | -34.6% | | DB tuple returned delta | 5,026,942 | 2,053,957 | -59.1% | | own mean / p50 | 1,107.4 / 1,113.0 ms | 214.2 / 4.7 ms | -80.7% / -99.6% | | own p95 / p99 | 1,460.6 / 1,564.7 ms | 1,566.1 / 1,869.2 ms | +7.2% / +19.5% | | mixed own mean / p50 | 498.0 / 493.1 ms | 76.5 / 4.5 ms | -84.6% / -99.1% | | mixed own p95 / p99 | 653.7 / 738.9 ms | 583.1 / 782.9 ms | -10.8% / +6.0% | coverage 1 run에서 API CPU는 wall time 대비 약 한 core의 44.1%, 할당 4 CPU의 11.0%였다. global-only front/lobby/records p95는 각각 6.7/6.4/5.7ms였다. source-fast-path가 반복 `unchanged`의 평균 비용과 DB read amplification을 크게 줄였다는 결론은 성립한다. 반면 own p95/p99는 각 dashboard phase 시작 때 300명이 동시에 최초 full snapshot을 계산하는 cold-start 군집 때문에 provisional 500ms/1.5s gate를 넘었다. mixed phase의 일부 global tail도 같은 군집과 경합했다. 따라서 현재 판정은 다음과 같다. - 300 SSE 연결과 정상상태 revision-equal read는 이 짧은 조건에서 충분한 여유가 있다. - 900 NPC 계산량도 E1에서는 큰 여유가 있다. - 300명 동시 최초 진입 tail은 합격하지 않았다. connection/request staggering, bounded snapshot concurrency 또는 안전한 shared subprojection 확대를 검토해야 한다. - E2 actual daemon flush, 30분 A1/M1, 실제 mutation fan-out A2/A3, Redis/API restart R1이 없으므로 “dev-sam2026 동급 운영 서버가 300명+900 NPC 혼합 부하를 수용한다”는 최종 admission은 아직 내리지 않는다. coverage 0 run의 최초 산출물은 tRPC v11 GET input을 잘못 감싼 harness 결함으로 폐기했다. 위 비교는 수정 뒤의 `calibration-result-2.json`과 coverage 활성화 뒤의 `calibration-result-3.json`만 사용한다. raw result와 token은 Git 제외 0600 파일이며, 보고서에는 aggregate만 남긴다. PostgreSQL 통계는 계측 query를 소량 포함한 `pg_stat_database` 전후 delta이고, 두 번째 run은 DB/Redis 재기동·재시드로 cold cache였으므로 `blks_read`는 직접 비교하지 않는다. ## 검증 matrix ### unit - journal merge/dedupe와 domain dependency - entity dirty지만 projection 동일한 경우 revision 증가 없음 - content-only/map-only/front-only 분리 - source revision 같을 때 loader 0회 - malformed/missing source revision fallback - public invalidation redaction ### PostgreSQL/Redis integration - migration fresh/re-run 검사 - engine commit은 state/log/revision을 함께 저장 - transaction rollback은 revision과 public event를 남기지 않음 - API mutation/input-event/revision 원자성 - direct Prisma log writer 포함 - Redis publish 실패 후 DB revision으로 최신 snapshot 수렴 - 동시 transaction의 같은 domain revision lost update 없음 - 동일 head 32개 동시 writer가 정확히 +32이고 stable key order에서 deadlock 없음 - commit 후 publish 전 crash는 dispatcher가 복구하고 publish 후 ack 전 crash는 중복에 안전함 ### Chromium - empty/clock-only event network 0건 - records/map/front-only event에서 `getGeneralContext()` 0회 - 실제 context 변화의 patch와 DOM identity 유지 - same-account visible 2 tab은 SSE/query 1세트 - sync OFF/hidden/off-route 중단 - access 제한은 gate 뒤 후속 query 0, 수동 복구 성공 ### capacity 도구 기존 저장소와 호스트에는 지속 부하 도구가 없다. tRPC/SSE/auth 의미를 그대로 쓰는 repo-local `tools/load-tests` package를 추가한다. synthetic token은 Git 제외 0600 파일에 두고 report에는 token/user ID를 기록하지 않는다. ```text tools/load-tests/ config/300-users-900-npcs-5m.json compose.capacity.yml scripts/run-capacity-api.sh src/cli.ts src/fixture.ts src/runner.ts src/sse.ts src/trpc.ts src/metrics.ts ``` load generator 자체의 CPU/event-loop lag를 결과에 포함하고 가능하면 target runtime과 다른 host/cgroup에서 실행한다. fixture는 실제 daemon fast-forward checkpoint를 전용 PostgreSQL schema와 Redis prefix로 복원하며 공유 운영 profile을 부하 대상으로 쓰지 않는다. raw JSON에는 commit/image digest, Node/PostgreSQL/Redis 버전, CPU/memory quota, fixture hash와 실패 run도 남긴다. ## rollback과 운영 관측 - schema는 additive이며 구버전 API는 revision table을 사용하지 않아도 동작한다. - source revision은 optional contract로 시작해 rolling deployment를 허용한다. - fast path를 runtime flag로 끌 수 있게 하고 disabled 시 기존 full computation으로 간다. - migration rollback이 필요하면 fast path를 먼저 끄고 code rollback 뒤 table을 보존한다. revision table 삭제는 데이터 복구에 필요하지 않으므로 즉시 수행하지 않는다. - metric label에는 profile/domain/slice만 사용하고 user/general ID, token과 payload를 넣지 않는다. - 기록할 metric: invalidation candidate/committed count, revision bump rows, fast-unchanged/full-compute/patch/snapshot, query count/time, SSE connections, coalesced event count, daemon schedule lag와 process CPU/RSS/event-loop lag. ## 완료 조건 1. Phase A~D 코드와 이 문서, 상위 mapping/report가 현재 source와 일치한다. 2. 모든 mutation producer inventory가 journal 등록 또는 명시적 비대상으로 분류된다. 3. unit/type/lint/build/architecture, 실제 PostgreSQL/Redis, production Chromium 검증이 통과한다. 4. E1~M1과 R1 raw JSON/요약/재현 명령이 남는다. 5. 300 viewer·900 NPC·5분 서버 pass gate가 실제 측정으로 확인된다. 6. 전용 branch를 최신 main에 통합한 뒤 핵심 검증을 재실행한다. 7. local main, tracking branch와 `git ls-remote` hash/ancestry를 확인하고 push한다. 2026-08-16 작업은 1~3의 Phase A~C 범위와 E1/짧은 calibration까지만 충족했다. Phase D의 E2/A1~A3/M1/R1과 위 4~5는 운영 admission 전 후속 완료 조건이다.