복원 버튼을 누르기 전에 어떤 자료가 들어오고 무엇이 달라지는지 확인할 수 있어야 한다고 생각했다. 나는 고정 합성 자료를 검증한 뒤 교체 후보를 만드는 작은 모듈을 새로 작성했다.

파일을 읽고 쓰거나 실제 업체 장부를 복구하지 않는다. JSON 묶음의 형식과 무결성을 확인하는 코드, 변경 미리보기, 독립 테스트와 한영 데모를 함께 공유한다. 해시는 작성자나 복구 권한을 증명하지 않는다.

받아들일 자료의 범위를 좁혔다

묶음은 synthetic-restore-v1 형식과 합성 자료 표시를 갖는다. 허용 파일 이름은 records.json 하나로 고정했다. 실제 경로에 압축을 풀지 않으며 다른 이름이나 경로 이동 문자열을 거절한다. 알려진 필드만 받고 행은 id와 quantity만 가진다. 식별자는 S 뒤 두 자리 숫자이고 수량은 1~99의 정수다. 같은 식별자는 중복될 수 없다.

묶음 문자열은 최대 80000자, 내용 문자열은 최대 60000자로 제한했다. 배열 상한은 1000개지만 식별자 규칙상 서로 다른 행은 최대 100개다. 이는 큰 백업 파일 처리기가 아니라 계약을 읽기 위한 작은 합성 예제다. 실무 형식과 버전 변환을 더하려면 어떤 과거 자료를 허용하고 어떻게 변환할지 새 정책과 테스트로 정해야 한다.

내용을 해석하기 전에 바이트와 해시를 맞춘다

makeSyntheticBundle은 합성 행을 JSON으로 만들고 UTF-8 바이트 수와 SHA-256을 기록한다. inspectSyntheticBundle은 그 값을 다시 계산해 일치해야 내용을 받아들인다. 문자 수와 바이트 수는 다른 개념이므로 TextEncoder의 결과를 사용한다. 정상 해시라고 해서 내용의 스키마 검사를 생략하지도 않는다. 잘못된 수량을 넣고 해시를 다시 만들어도 행 계약에서 멈춘다.

SHA-256은 실행 환경의 Web Crypto를 사용한다. 사용할 수 없으면 약한 방식으로 대신 계산하지 않고 실패한다. Node 독립 테스트에서는 별도 암호 라이브러리의 결과와 대조했다. 브라우저에서도 해당 기능을 제공하는 환경이 필요하며 실제 배치 환경에서 확인해야 한다. 이번 검증은 코드 실행과 대역 이벤트 연결이고 실브라우저 기능 검수는 아니다.

복원 후보와 실제 반영을 분리했다

rehearseSyntheticRestore는 검증된 새 행과 기존 합성 행을 비교해 추가·제거·수량 변경의 개수를 반환한다. 결과에는 candidate와 preview, integrityVerified, applied:false가 있다. 기존 배열을 고치지 않는다. 같은 개수의 자료라도 한 행이 사라지고 다른 행이 들어올 수 있으므로 총 개수만으로 복구 결과를 설명하지 않게 했다.

데모의 반영 버튼은 검토된 문자열을 다시 검증한 다음 페이지 메모리의 목록만 바꾼다. 검토 중 조건을 바꾸면 늦게 끝난 결과가 새 조건을 덮어쓰지 않도록 현재 검토 번호를 확인한다. 운영 DB를 교체하거나 파일을 복원하는 기능은 없다. 실제 복구에서는 쓰기 중단, 권한, 현재 자료 사본과 되돌릴 경로가 별도로 필요하다.

코드와 구현 살펴보기

독립 합성 모듈 전체 구현

// Fixed-path synthetic bundle; checks integrity, not author identity. Never writes files or production data.
const exact=(v,k)=>v&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).sort().join(',')===k;
function validRecords(rows){
 return Array.isArray(rows)&&rows.length<=1000&&rows.every(r=>exact(r,'id,quantity')&&typeof r.id==='string'&&/^S\d{2}$/.test(r.id)&&Number.isSafeInteger(r.quantity)&&r.quantity>=1&&r.quantity<=99)&&new Set(rows.map(r=>r.id)).size===rows.length;
}
async function digest(text){
 if(!globalThis.crypto?.subtle)throw new Error('SHA-256 unavailable');
 const data=await globalThis.crypto.subtle.digest('SHA-256',new TextEncoder().encode(text));
 return Array.from(new Uint8Array(data),n=>n.toString(16).padStart(2,'0')).join('');
}
export async function makeSyntheticBundle(rows){
 if(!validRecords(rows))throw new TypeError('Invalid synthetic records');
 const payload=JSON.stringify(rows),bytes=new TextEncoder().encode(payload).length;
 return JSON.stringify({format:'synthetic-restore-v1',synthetic:true,entry:{path:'records.json',bytes,sha256:await digest(payload)},payload});
}
export async function inspectSyntheticBundle(text){
 if(typeof text!=='string'||text.length>80000)throw new TypeError('Bundle too large or invalid');
 const b=JSON.parse(text);
 if(!exact(b,'entry,format,payload,synthetic')||b.format!=='synthetic-restore-v1'||b.synthetic!==true||!exact(b.entry,'bytes,path,sha256')||b.entry.path!=='records.json'||!Number.isSafeInteger(b.entry.bytes)||b.entry.bytes<0||typeof b.entry.sha256!=='string'||! /^[a-f0-9]{64}$/.test(b.entry.sha256)||typeof b.payload!=='string'||b.payload.length>60000)throw new TypeError('Unsupported synthetic bundle');
 if(new TextEncoder().encode(b.payload).length!==b.entry.bytes||await digest(b.payload)!==b.entry.sha256)throw new Error('Integrity mismatch');
 const rows=JSON.parse(b.payload);if(!validRecords(rows))throw new TypeError('Unsupported synthetic records');
 return {format:b.format,rows:rows.map(r=>({...r})),recordCount:rows.length,sha256:b.entry.sha256};
}
export async function rehearseSyntheticRestore(text,currentRows){
 if(!validRecords(currentRows))throw new TypeError('Invalid target example');
 const checked=await inspectSyntheticBundle(text),oldIds=new Set(currentRows.map(r=>r.id)),newIds=new Set(checked.rows.map(r=>r.id));
 return {candidate:checked.rows,preview:{before:currentRows.length,after:checked.rows.length,added:checked.rows.filter(r=>!oldIds.has(r.id)).length,removed:currentRows.filter(r=>!newIds.has(r.id)).length,changed:checked.rows.filter(r=>currentRows.some(old=>old.id===r.id&&old.quantity!==r.quantity)).length},integrityVerified:true,applied:false};
}

내용과 해시를 함께 바꿀 수 있다는 한계

누군가 정상 내용을 만들고 그 내용의 해시도 다시 만들면 이 무결성 검사를 통과할 수 있다. 검사는 함께 전달된 기록과 바이트가 같은지 확인할 뿐 그 기록을 누가 만들었는지 증명하지 않는다. 따라서 신뢰할 기준 목록의 보호, 서명이나 별도 출처 확인은 다른 책임이다. 해시 일치를 보안 승인이라는 말로 바꾸지 않은 이유다.

아래에서 정상 묶음, 내용만 변조, 다른 파일 경로, 새 정상 내용과 해시를 비교할 수 있다. 마지막 경우는 의도적으로 통과한다. 그 차이를 눈으로 확인해 보관 무결성과 출처 신뢰의 질문을 나누도록 했다. 모든 자료는 프로그램 안의 고정 합성 예시이며 회사 파일과 연락처, 인증 정보는 받거나 보내지 않는다.

독립 합성 예시 · 실제 회사 자료·서버 연결 없음

패키지와 검증 근거를 함께 받는다

ZIP에는 구현 파일, 독립 테스트, 한영 HTML 데모와 범위·재사용 허용 문서가 들어 있다. 압축을 풀고 Web Crypto를 지원하는 Node 환경에서 node test.mjs를 실행할 수 있다. 한영 데모는 같은 코드를 포함한다. 수정·재배포·상업적 사용이 가능하고 무보증으로 제공한다. 외부 라이브러리 설치나 네트워크 연결은 필요하지 않는다.

추출한 패키지의 테스트 12개 그룹은 독립 SHA-256 대조, 바이트 불일치, 잘못된 경로와 형식, 해시를 다시 만든 잘못된 행, 중복과 추가 필드, 원본 보존을 확인한다. 한영 이벤트 테스트에는 SHA-256을 쓸 수 없는 경우도 포함했다. 암호화·서명·지속 저장·실제 DB 복구와 다중 파일 일관성, 복구 시간 보장은 구현하지 않았다. 이 모듈은 확인한 범위만 맡긴 채 확장할 출발점이다.

구현·테스트·한영 데모 ZIP 받기
재사용 허용과 적용 범위 문서 포함