메모리를 줄이고 싶어도 작성 중인 탭부터 닫아서는 안 된다. 읽어 둔 자료를 끝없이 모아 두는 것도 다른 종류의 낭비다. 북극성에서 다뤘던 탭 보호와 읽기 캐시의 경계를, 실제 브라우저를 건드리지 않고 시험할 수 있는 두 가지 합성 정책으로 만들었다.

휴면 후보와 실제 닫기를 분리한다

sleep_decision은 TabState로 전달한 상태에서 후보 여부와 막힌 이유 목록을 계산한다. 현재·참고·고정 탭, 시크릿·에이전트 소유, 로딩·전송·재생·캡처·개발 도구, 입력 흔적과 초안 등을 보호 조건으로 본다. HTTPS 읽기와 모든 프레임의 안전, unload 허용도 명시돼야 한다.

이 값들은 호출자의 선언이다. 예제가 실제 프레임을 조사하거나 beforeunload를 실행하지 않는다. candidate가 True여도 renderer_released와 live_properties_verified는 항상 False다. 원래 호스트는 비동기 프레임 검사 뒤 문맥을 다시 확인하고 실제 닫기를 시도한다. 그 책임까지 작은 정책 함수가 수행한 것처럼 표시하지 않았다.

시간도 직접 들어온 값으로만 판단한다

기본 idle 기준은 5분이다. 마지막 활동이 미래이면 오류로 보고, 보호 유예 시각 이전에는 후보가 되지 않는다. boolean을 요구하는 자리에 문자열을 넣어 참처럼 해석하는 입력도 거절한다. 안전 확인이 빠진 상태를 편리한 기본값으로 허용하지 않으려는 예제의 선택이다.

실제 시계나 주기 실행은 없다. 상태가 바뀌어도 호출이 없으면 스스로 다시 검사하지 않는다. 캐시 시간도 같은 방식으로 비음수 정수 시각을 받으며 뒤로 간 값은 거절한다. 실제 운영에서는 신뢰할 시계와 현재 상태를 공급하고, 검사 뒤 동작 전까지 변하지 않았는지 확인하는 연결이 더 필요하다.

캐시는 조작할 수 없는 제목과 본문만 받는다

PassiveCache는 title과 text 문자열만 받는다. 원래 PageView보다 좁은 계약이며 추가 ref나 중첩 객체를 받아 나중에 클릭 자료로 쓰지 않는다. 입력과 반환은 사본으로 다뤄 외부에서 결과를 수정해도 저장된 읽기 자료가 바뀌지 않게 한다.

Context에는 프로필·프로젝트·탭·문서·epoch·revision이 있다. 같은 탭의 문서나 세대·revision이 달라지면 이전 항목은 지우고 miss로 돌려준다. 이 값 역시 선언 자료이며 실제 사이트를 읽어 인증한 revision은 아니다. action_authority와 live_revision_authenticated는 항상 False다. 캐시 hit로 새 클릭 권한을 만들지 않는 경계다.

코드와 구현 살펴보기

독립 합성 전체 구현

"""Independent declared-state policy. No renderer, browser, filesystem or timer."""
from dataclasses import dataclass, fields
from copy import deepcopy
import json


def integer(value, minimum=0):
    if type(value) is not int or value < minimum:
        raise ValueError('INTEGER_REQUIRED')
    return value


@dataclass(frozen=True)
class TabState:
    last_active: int
    blocked_until: int = 0
    active: bool = False
    reference: bool = False
    pinned: bool = False
    incognito: bool = False
    agent_owned: bool = False
    loading: bool = False
    transfer: bool = False
    audible: bool = False
    captured: bool = False
    devtools: bool = False
    navigation_pending: bool = False
    interacted: bool = False
    draft: bool = False
    auth: bool = False
    https_read: bool = False
    all_frames_safe: bool = False
    unload_allowed: bool = False
    dormant: bool = False
    releasing: bool = False

    def __post_init__(self):
        integer(self.last_active);integer(self.blocked_until)
        for field in fields(self):
            if field.name not in ('last_active','blocked_until') and type(getattr(self,field.name)) is not bool:
                raise ValueError('EXPLICIT_BOOLEAN_REQUIRED')


def sleep_decision(state, now, idle_ms=300000):
    if type(state) is not TabState:
        raise ValueError('TAB_STATE_REQUIRED')
    integer(now);integer(idle_ms,1)
    if now < state.last_active:
        raise ValueError('FUTURE_ACTIVITY')
    blocked=[f.name for f in fields(state) if f.name not in ('last_active','blocked_until','https_read','all_frames_safe','unload_allowed') and getattr(state,f.name)]
    blocked += [key for key in ('https_read','all_frames_safe','unload_allowed') if not getattr(state,key)]
    if now-state.last_active < idle_ms:blocked.append('not_idle')
    if now < state.blocked_until:blocked.append('blocked_until')
    return {'candidate':not blocked,'blocked_by':blocked,'live_properties_verified':False,'renderer_released':False}


@dataclass(frozen=True)
class Context:
    profile: str
    project: str
    tab: str
    document: str
    epoch: int
    revision: str

    def __post_init__(self):
        for key in ('profile','project','tab','document','revision'):
            v=getattr(self,key)
            if type(v) is not str or not v.strip() or len(v)>200:
                raise ValueError('CONTEXT_STRING_REQUIRED')
        integer(self.epoch,1)


class PassiveCache:
    def __init__(self,max_entries=4,max_bytes=4194304,max_entry_bytes=1048576,ttl_ms=300000):
        for v in (max_entries,max_bytes,max_entry_bytes,ttl_ms):integer(v,1)
        self.limits=(max_entries,max_bytes,max_entry_bytes,ttl_ms)
        self._entries={};self._clock=-1;self._sequence=0

    def _time(self,now):
        integer(now)
        if now<self._clock:raise ValueError('CLOCK_MOVED_BACKWARDS')
        self._clock=now

    @staticmethod
    def _key(context):
        if type(context) is not Context:raise ValueError('CONTEXT_REQUIRED')
        return (context.profile,context.project,context.tab)

    def _prune(self,now):
        for k,e in list(self._entries.items()):
            if now-e['touched']>=self.limits[3]:del self._entries[k]
        while len(self._entries)>self.limits[0] or sum(e['bytes'] for e in self._entries.values())>self.limits[1]:
            k=min(self._entries,key=lambda k:(self._entries[k]['touched'],self._entries[k]['sequence']))
            del self._entries[k]

    def put(self,context,view,now):
        key=self._key(context)
        if type(view) is not dict or set(view)!={'title','text'} or any(type(v) is not str for v in view.values()):
            raise ValueError('PASSIVE_TITLE_TEXT_ONLY')
        b=len(json.dumps(view,ensure_ascii=False,separators=(',',':')).encode('utf-8'))
        self._time(now);self._prune(now)
        if b>min(self.limits[1],self.limits[2]):
            self._entries.pop(key,None)
            return {'cached':False,'reason':'oversized','bytes':b,'action_authority':False}
        self._sequence+=1
        self._entries[key]={'context':context,'view':deepcopy(view),'bytes':b,'touched':now,'sequence':self._sequence}
        self._prune(now)
        return {'cached':key in self._entries,'bytes':b,'action_authority':False}

    def get(self,context,now):
        key=self._key(context);self._time(now);self._prune(now)
        entry=self._entries.get(key)
        if entry is None:return None
        if entry['context']!=context:
            del self._entries[key];return None
        entry['touched']=now
        return {'view':deepcopy(entry['view']),'action_authority':False,'live_revision_authenticated':False}

    def stats(self,now):
        self._time(now);self._prune(now)
        return {'entries':len(self._entries),'bytes':sum(e['bytes'] for e in self._entries.values()),'renderer_released':False}

개수와 총량, 한 항목과 수명을 각각 제한한다

기본값은 네 항목, 총 4MiB, 한 항목 1MiB, 미사용 5분이다. 예제의 크기는 압축 없는 짧은 UTF-8 JSON의 바이트이며 Python 메모리 전체나 Electron RAM을 측정한 값이 아니다. 한글과 ASCII가 같은 글자 수여도 바이트 수가 다른 시험을 포함했다.

읽은 항목은 touched가 갱신되고 한도를 넘으면 오래 안 읽은 것부터 제거한다. 같은 시각에는 입력 순서로 순위를 정한다. age가 TTL 이상이면 만료이며 큰 항목으로 교체하려다 거절한 경우 옛 항목도 남겨 두지 않는다. 오래된 자료를 새 자료인 것처럼 반환하는 대신 miss의 의미를 분명히 했다.

서른 개 시험은 보호와 제거를 함께 본다

현재·참고·작업 중 탭, 확인하지 않은 프레임과 unload 거절, 유예 시각의 경계를 시험한다. 캐시에서는 다른 범위의 miss, 문서·세대·revision 변경, TTL 경계와 hit의 수명 연장, LRU·총바이트·큰 항목을 구분한다. 입력 사본과 결과 사본의 변경도 원본을 건드리지 않아야 한다.

독립 원본 30개 시험과 ZIP 추출본의 같은 30개 시험을 실행한다. 다섯 기록은 실제 로컬 함수 호출 결과이며 아래 선택기는 그 결과만 읽는다. 원래 브라우저 renderer를 해제하거나 운영 계정의 메모리를 측정한 검수가 아니다. 원래 제품 시험의 수치를 이 새 예제의 실행 수로 합치지도 않았다.

합성 로컬 기록의 읽기 뷰어

운영에 연결할 책임은 코드 밖에 남겼다

이 예제에는 비동기 읽기 병합, 관찰 중 세대 변경 차단, 실제 프레임 문맥 map 해제, 페이지 읽기 fallback, 자동 UI 안내가 없다. 원래 제품은 각 기능을 별도로 연결한다. 작은 캐시의 통계가 줄었다고 실제 프로세스 RAM이 같은 양만큼 줄었다고 해석하면 안 된다.

코드·시험·기록 생성기와 설명은 수정하고 재사용할 수 있다. 보호 조건을 새 사이트에 맞춰 조정하더라도 현재 사용자의 작업을 지키는 확인을 먼저 유지해야 한다. 나는 무엇을 재울 수 있는지와 무엇을 보관할 것인지를 한 개의 메모리 절감 버튼으로 감추지 않고 각각 검토할 수 있는 출발점으로 공유한다.

코드·시험·실행 기록 ZIP 받기
Python 표준 라이브러리 · 합성 자료 · 수정과 재사용 가능