T65 — 대기티켓찾기 증분갱신/캐시 설계안
개요
/Users/ef/crowny-deacon/한선씨/미션장부.한선의
대기티켓찾기(모델)이 폴링마다
티켓목록()(=
_PSV파일유니크최신(_티켓파일))을 호출해 티켓.psv
전체를 재파싱한다.
장부가 append-only로 계속 커지므로(T수 증가), 폴링 주기가 짧을수록 O(전체행수) 재스캔이
누적 CPU/토큰 비용이 된다. 코드 수정 없이 설계만 제시한다.
현재 경로 (실코드 기준)
대기티켓찾기 → 티켓목록() → _PSV파일유니크최신(_티켓파일) → _PSV읽기 →
읽기(파일) 전체 로드 +
분리(내용,"\n") 전체 파싱 + 맵 기반 last-wins 재계산(O(R)).
- 매 폴링마다: 디스크 read 전체 + 파싱 O(R) + last-wins O(R) + 대기/모델 필터 O(R).
- R(총 행수, append-only이므로 unique 티켓수보다 항상 큼: 상태전환마다 새 행 추가)이
계속 증가 → 폴링 비용이 시간에 따라 우상향.
함정: 한선씨 장수서버 배열힙 무회수 OOM
- 메모리 인용(
feedback_모니터판_OOM_디스크폭주.md, feedback_crownyc_힙144MB_장기구동_누적OOM.md):
장수 구동 crownyc 프로세스는 힙 캡(144MB)이 있고, 배열/버퍼를 append만 하고 회수하지
않으면 장기 폴링 누적으로 OOM에 도달한다("프레시 실행은 정상, 장기구동만 실패").
- 함의: 캐시 설계에서 "폴링마다 조금씩 growing하는 전역 배열/맵"을 무제한 유지하면
안 된다 — 캐시 자체가 새로운 누적 OOM 벡터가 된다. 캐시는
크기 상한 + 교체 정책이
반드시 있어야 하고, 장기구동 데몬(deacon 서버)에는 별도 재기동 정책(KeepAlive 등)이
이미 있으므로 캐시는 "재기동 시 자연 소멸 + 재구축"을 전제로 설계한다.
참조 패턴: 인메모리캐시 부팅1회
- 메모리 인용 "라이브 서비스군C" 등에서 반복되는 패턴: 부팅 시 1회 전체 로드 →
이후엔 요청마다
파일 변경분만 반영(변경 없으면 캐시 그대로 반환).
- 이 패턴을 미션장부에 이식하되, 장부는 append-only이므로 "변경분"은 항상
파일 끝에 붙은 새 바이트/새 행으로 한정할 수 있다(과거 행 수정 없음 — 스펙 보장).
설계안 A — 파일 오프셋 기반 증분 파싱 (권장, 최소 침습)
자료구조 (모듈 전역, 함수 스코프 밖에 1세트만 유지)
_캐시_오프셋 // 마지막으로 읽은 파일 바이트 오프셋 (숫자)
_캐시_행들 // 지금까지 파싱된 전체 행 배열 (append-only 그대로 growing)
_캐시_최신맵 // id → 최신행 맵 (_PSV파일유니크최신의 결과, 이미 O(R) 스캔 완료본)
_캐시_대기인덱스 // 모델 → [대기 상태인 티켓ID 배열] 맵 (대기티켓찾기 전용 파생인덱스)
갱신 절차 (매 폴링 시)
- 파일 크기 확인 (
stat 또는 읽기 후 글자수 — VM에 파일 크기만 얻는 API 없으면
전체 읽기가 불가피하므로, 최소한 "새 오프셋부터 끝까지"만 다시
분리하도록
읽기 자체를 오프셋 파라미터화하는 것이 이상적이나 현재
읽기()가 전체파일만
지원하면 대안 B로 폴백).
- 새로 추가된 행만 파싱해
_캐시_행들에 append, _캐시_최신맵을 해당 id만 갱신
(append-only 스펙 덕에 과거 id 재확인 불필요 — 최신행은 반드시 새로 추가된 쪽).
- 상태전환 행(같은 티켓ID가 새 상태로 재기록)이 오면
_캐시_대기인덱스에서
해당 티켓ID를 이전 상태 리스트에서 제거하고 새 상태가 "대기"면 추가.
대기티켓찾기(모델)은 _캐시_대기인덱스[모델]에서 번호 최소값만 선형탐색
(대기 티켓 수는 전체 R보다 훨씬 작음 — O(대기중인 티켓수)로 축소).
무회수 OOM 방지책
_캐시_행들은 원본 파일과 1:1 growing이라 회수 대상이 아님(파일 자체가 append-only
이므로 캐시도 append-only인 게 정합적) — 대신
주기적 장부 압축(회전)이 근본 해법
(메모리 인용 "장부 압축·회전 필수" 패턴 재사용): 완료/만료된 오래된 미션의 티켓 행을
별도 아카이브 파일로 이관하고
_티켓파일을 재기록하면
_캐시_오프셋도 0으로 리셋.
_캐시_대기인덱스는 파생 인덱스이므로 상시 작게 유지됨(대기 상태 티켓 수만큼) —
이 자체가 무제한 growing 위험이 없는 부분.
- 캐시 전체를 감싸는 크기 가드:
_캐시_행들 길이가 임계치(예: 5000행) 초과 시
경고 로그 + 압축 트리거 신호(별도 배치가 처리) — 폴링 경로 자체는 판단만 하고
압축은 비동기/별도 실행으로 분리(폴링 핫패스에 회전 로직을 넣지 않는다 — 지연 유발).
설계안 B — 읽기() 전체 로드 유지 + "변경없음 조기반환"만 캐시 (현재 VM 제약 하 현실적 대안)
설계안 A는
읽기()가 오프셋 기반 부분읽기를 지원해야 진짜 증분이 되는데, VM 함정
목록상 파일 API가 전체 로드 위주로 보인다(
파일존재()도 always -1 반환하는 등
제약이 많음). 이런 VM 제약 하에서는:
- 매 폴링마다 파일을 여전히 전체 읽되, 읽은 내용의 글자수(또는 마지막 N바이트
해시)를 이전 폴링과 비교.
- 동일하면(장부에 새 append 없음)
_캐시_대기인덱스를 그대로 재사용 — 파싱/맵갱신
스킵. 이 경우도 디스크 read는 매번 발생하지만(글자수 확인 위해 최소 필요),
분리()·맵넣어()·last-wins 재계산이라는 진짜 비싼 부분을 스킵할 수 있어
폴링이 잦고(초 단위) 티켓 생성/전환이 드문(분 단위) 워크로드에서 큰 절감.
- 변경 있으면 설계안 A의 "새 오프셋부터"를
부분(내용, 이전글자수, 새글자수)로
문자열 슬라이스해 시뮬레이션 가능 —
부분()이 O(n) 스캔이라도 신규분만 스캔하면
전체 재파싱보다 저렴.
권장 결론
- 1단계(즉시 적용 가능, 저위험): 설계안 B — 글자수 비교 기반 "변경없음 스킵".
코드 변경 최소, VM 제약과 무관, 기존
_필드에서 핫패스 최적화 기조와 일관.
- 2단계(성능이 실제 병목으로 실측되면): 설계안 A —
부분(내용, 이전오프셋, 끝)
슬라이스 기반 신규행만 파싱 +
_캐시_대기인덱스 파생 인덱스 유지.
- 두 단계 모두 캐시는 append-only 성격에 맞춰 growing 허용하되, 대기인덱스처럼
작게 유지되는 파생 구조로 실제 조회 비용을 낮추고, 원본 growing 방지는 캐시가 아닌
장부 압축/회전 배치로 별도 처리한다(캐시 안에 회수 로직을 넣지 않는다 —
무회수 OOM 함정은 "캐시가 스스로 비대해지는 것"이 아니라 "폴링 핫패스에서
회수/압축 같은 무거운 로직을 안 하던 걸 새로 넣어 오히려 지연·메모리를 더 쓰는 것"도
포함하므로, 회전은 반드시 저빈도 배치로 분리).
잔여 이슈
읽기()가 오프셋/부분읽기를 지원하는지 VM 레벨 확인 필요(현재 문서상 전체로드만
확인됨) — 지원 안 하면 설계안 A는 "문자열 슬라이스"로 흉내내는 수준에 그침.
- 장부 압축/회전 배치의 트리거 조건(행수 임계치·주기)은 실측(현재 R 증가 속도) 후 확정.
- 다중 프로세스(여러 deacon 세션)가 동시에 캐시를 갖는 경우 캐시 일관성은 각자
자기 프로세스 내 폴링 오프셋만 추적하면 되므로 별도 락 불필요(읽기 전용 캐시).