검색 화면과 내보내기 결과가 서로 다른 자료를 설명하지 않게 하려면 무엇을 함께 묶어야 할까. 이번에는 조건을 해석하고 결과를 고정한 뒤 페이지와 요약이 그 결과를 읽는 독립 Python 모듈을 만들었다.
아래 코드는 고정 합성 행으로 실행했다. 실제 고객 주문과 회계 자료를 사용하지 않았으며, 페이지 안의 실행 기록은 로컬 테스트에서 저장한 결과를 읽는 뷰어다. 실제 서비스나 데이터베이스가 브라우저에서 동작하는 예제는 아니다.
입력 범위를 먼저 좁혔다
입력은 이미 읽어 온 최대 1000개의 합성 행과 회사·상태·검색어 조건이다. 행의 ID는 정해진 ASCII 문자와 길이만 허용하고 tick은 음수가 아닌 안전한 정수, 수량은 1부터 99까지의 정수로 제한했다. 참과 거짓도 Python에서는 정수처럼 다뤄지지만 업무 수량으로는 받지 않는다. 지원하지 않는 상태와 알 수 없는 조건 키도 오류로 멈춘다.
이 제한은 모든 ERP의 자료 형식을 대신하는 규칙이 아니다. 어떤 값을 비교하고 보관하는지 명확하게 만들기 위한 작은 실험 계약이다. 실제 서버는 인증된 문맥에서 회사와 조회 가능 필드를 정해야 한다. 이 함수에 회사 문자열을 넘길 수 있다는 이유로 사용자가 그 회사 자료를 볼 권한이 생기지는 않는다. 권한을 확인한 호출자라는 입력 전제를 함께 공개한다.
한 번 선택한 결과를 여러 기능이 읽는다
make_snapshot은 조건을 정리하고 ID의 대소문자를 구분하지 않는 검색을 적용한다. 선택된 행은 tick과 ID 순서로 정렬하고 필요한 네 필드만 불변 튜플로 복사한다. 원본에 내부 메모 같은 다른 키가 있어도 결과에 포함하지 않는다. page, export_rows와 summary는 새로 조건을 해석하지 않고 같은 Snapshot을 읽는다. 페이지를 모두 연결한 결과와 전체 출력이 정확히 같아야 한다.
summary의 합은 수량 합이며 매출이나 회계 금액이 아니다. export_rows도 Python 값의 목록을 반환할 뿐 실제 파일을 만들거나 다운로드하지 않는다. 기능 이름만 보고 화면과 파일 전송까지 완성했다고 오해하지 않도록 반환값과 적용 범위를 분명히 했다. 실제 출력 형식과 금액 계산이 필요하면 해당 업무의 단위와 허용 열을 별도 계약으로 연결해야 한다.
지문에는 조건과 선택한 내용이 같이 들어간다
revision은 호출자가 주는 자료 기준 번호다. 번호만 믿으면 다른 내용을 같은 번호로 제공하는 실수를 놓칠 수 있어서 회사·조건·번호·선택 내용을 함께 해시한다. 원본이 달라지면 같은 revision이라도 지문이 달라진다. 반대로 기존 Snapshot은 원본 배열을 바꾸거나 지워도 그대로 남는다. 예전 결과를 이어 읽기와 최신 자료를 새로 읽기가 서로 섞이지 않는다.
커서는 이 지문과 마지막 행의 tick·ID를 JSON으로 담아 base64로 표현한다. 다른 조회의 커서와 없는 위치는 거절한다. 다만 이것은 서명이나 암호화가 아니다. 같은 결과 안에서 다른 유효한 위치를 아는 사람은 커서를 만들 수 있다. 테스트에도 그 경우가 포함된다. 커서가 있다는 이유로 보안 경계가 만들어졌다고 말하지 않고 실제 서버의 권한과 필요한 서명 검증을 별도로 요구한다.
코드와 구현 살펴보기
독립 합성 전체 구현
"""Independent synthetic read snapshot. No database/auth service or signed cursors.
The company query must be chosen from trusted server context, never client authority.
"""
from dataclasses import dataclass
import json,hashlib,base64,re
def ident(v):return isinstance(v,str) and re.fullmatch('[A-Za-z0-9_-]{1,40}',v) is not None
def packed(v):return json.dumps(v,sort_keys=True,separators=(',',':'),ensure_ascii=False,allow_nan=False)
@dataclass(frozen=True)
class Snapshot:
company_id:str
status:str
query:str
revision:int
fingerprint:str
rows:tuple
def make_snapshot(records,query,revision):
if not isinstance(records,list) or len(records)>1000 or not isinstance(query,dict) or set(query)-{'company_id','status','q'} or not ident(query.get('company_id')) or type(revision) is not int or not 0<=revision<=9007199254740991:raise ValueError('Invalid snapshot input')
status=query.get('status','all');q=query.get('q','')
if status not in ['all','draft','sent'] or not isinstance(q,str) or len(q)>40:raise ValueError('Invalid query')
q=q.casefold();seen=set();selected=[]
for row in records:
if not isinstance(row,dict) or not ident(row.get('company_id')) or not ident(row.get('id')) or type(row.get('tick')) is not int or not 0<=row['tick']<=9007199254740991 or type(row.get('quantity')) is not int or not 1<=row['quantity']<=99 or row.get('status') not in ['draft','sent']:raise ValueError('Invalid synthetic record')
identity=(row['company_id'],row['id'])
if identity in seen:raise ValueError('Duplicate scoped record')
seen.add(identity)
if row['company_id']!=query['company_id'] or status!='all' and row['status']!=status or q not in row['id'].casefold():continue
selected.append((row['tick'],row['id'],row['quantity'],row['status']))
rows=tuple(sorted(selected,key=lambda r:(r[0],r[1])))
fingerprint=hashlib.sha256(packed([query['company_id'],status,q,revision,rows]).encode('utf-8')).hexdigest()
return Snapshot(query['company_id'],status,q,revision,fingerprint,rows)
def export_rows(snapshot):return [{'id':r[1],'tick':r[0],'quantity':r[2],'status':r[3]} for r in snapshot.rows]
def summary(snapshot):return {'count':len(snapshot.rows),'quantity':sum(r[2] for r in snapshot.rows),'revision':snapshot.revision,'snapshot':snapshot.fingerprint}
def page(snapshot,limit=2,cursor=None):
if type(limit) is not int or not 1<=limit<=50:raise ValueError('Page limit')
start=0
if cursor is not None:
if not isinstance(cursor,str) or len(cursor)>500:raise ValueError('Invalid cursor')
try:
value=json.loads(base64.b64decode(cursor+'='*((-len(cursor))%4),altchars=b'-_',validate=True).decode('utf-8'))
except (ValueError,UnicodeError):raise ValueError('Invalid cursor') from None
if not isinstance(value,dict) or set(value)!= {'snapshot','after'} or value['snapshot']!=snapshot.fingerprint or not isinstance(value['after'],list) or len(value['after'])!=2:raise ValueError('Cursor snapshot mismatch')
key=value['after']
if type(key[0]) is not int or not ident(key[1]):raise ValueError('Invalid cursor key')
matches=[i for i,r in enumerate(snapshot.rows) if (r[0],r[1])==tuple(key)]
if not matches:raise ValueError('Unknown cursor position')
start=matches[0]+1
chunk=snapshot.rows[start:start+limit];next_cursor=None
if chunk and start+len(chunk)<len(snapshot.rows):next_cursor=base64.urlsafe_b64encode(packed({'snapshot':snapshot.fingerprint,'after':list(chunk[-1][:2])}).encode('utf-8')).decode('ascii').rstrip('=')
return {'rows':[{'id':r[1],'tick':r[0],'quantity':r[2],'status':r[3]} for r in chunk],'next_cursor':next_cursor,'revision':snapshot.revision,'snapshot':snapshot.fingerprint}
def synthetic_records():return [{'company_id':'A','id':'S01','tick':1,'quantity':6,'status':'draft'},{'company_id':'A','id':'S02','tick':1,'quantity':4,'status':'sent'},{'company_id':'A','id':'S03','tick':2,'quantity':3,'status':'draft'},{'company_id':'B','id':'S01','tick':1,'quantity':9,'status':'draft'}]기록으로 확인한 것과 아직 연결하지 않은 것
로컬에서 14개 테스트를 실행했고 내려받기 패키지를 새 폴더에 풀어 같은 테스트를 다시 실행했다. 같은 시각의 행 순서, 모든 페이지와 출력의 일치, 숨긴 필드 제외, 빈 결과, 잘못된 입력과 커서, 원본 수정 뒤 예전 결과 보존을 확인했다. 아래 선택기는 실제 로컬 실행에서 남긴 세 가지 고정 결과를 보여 준다. 사용자의 회사 자료를 읽거나 새로운 DB 작업을 실행하지 않는다.
복사본은 이미 섞여 들어온 원본을 고칠 수 없다. 데이터베이스에서 처음 읽는 시점이 일관되어야 한다면 그 저장소의 읽기 트랜잭션을 먼저 구현해야 한다. 이번 모듈에는 지속 스냅샷 저장소, 만료 정책, 로그인, 실제 내보내기 파일과 브라우저 검수가 없다. 작은 코드의 검사 결과를 전체 서비스의 운영 보장으로 확대하지 않는 것도 공유할 노하우라고 생각했다.
재사용할 때 먼저 정할 선택
실무에 연결할 때는 최신 목록을 볼 것인지 특정 기준을 이어 읽을 것인지부터 정해야 한다. 이후 권한을 확인한 읽기 경계, 결과 보관 기간, 대량 자료 처리와 출력 형식을 결정한다. 단순히 이 코드를 복사하고 완료로 처리하면 원래 문제였던 자료 기준의 혼동을 다른 곳으로 옮길 수 있다. 입력 범위와 기능 책임을 먼저 맞추는 순서로 적용해야 한다.
ZIP에는 전체 구현, 테스트, 합성 실행기, 실행 기록과 적용 안내가 들어 있다. 실제 업체의 화면이나 자료를 복제하지 않은 독립 예제로 재사용할 수 있다. 코드를 바꾼다면 기존 페이지와 전체 결과의 동등성, 원본 변경으로 이전 결과가 바뀌지 않는 조건부터 다시 확인하면 좋다. 어떤 화면을 만들었는지보다 여러 결과가 같은 질문의 답이라는 근거를 남기는 데 초점을 맞췄다.