자료를 읽는 함수와 실제로 저장하는 함수를 한 덩어리로 두면 어디서 멈춰야 하는지 설명하기 어렵다. 나는 공개 ERP 체험의 검토 장면에서 출발해 먼저 확인하고 다음 결과를 제안하는 작은 모듈을 만들었다.
입력은 이미 해석된 합성 행 객체다. 이 코드는 파일 업로더나 엑셀 파서, 운영 이관 프로그램이 아니다. 아래 계약과 실패 사유를 읽고 자신의 업무에서 연결할 부분을 정할 수 있다.
입력 계약을 먼저 읽는다
previewImport는 행 목록과 id·item·quantity에 연결할 열 이름, 기존 식별자 목록을 받는다. 세 항목은 서로 다른 열에 연결되어야 한다. 입력 행은 최대 1000개이며 식별자는 40자 이내의 영문자·숫자·밑줄·하이픈 문자열이다. 품목은 빈 문자열을 허용하지 않고 80자로 제한했다. 수량은 1~99의 문자열을 정수로 바꾼다. 식별자의 앞 0은 그대로 보존한다.
이 규칙은 임의의 회사 파일을 자동 해석하는 추정이 아니다. 소수 수량, 복합 식별자나 다른 품목 체계가 필요하면 계약을 명시적으로 확장해야 한다. 함수는 상속된 속성을 열 값으로 받지 않고 정상 후보에는 세 필드만 담는다. 나머지 열을 통째로 복사하지 않으며 원본 행도 바꾸지 않는다. 원본 자료와 반영할 자료를 분리해 읽을 수 있게 한 선택이다.
문제의 행과 정상 후보를 함께 보여 준다
결과에는 ready, candidates, issues가 있다. 중복 번호는 뒤에 나온 한 행만 표시하지 않고 모든 등장 행을 검토 대상으로 남긴다. 기존 목록에 이미 있는 번호도 반영하지 않는다. 빈 목록은 empty_batch라는 검토 사유로 구분한다. 정상 후보가 일부 있더라도 문제가 남아 있으면 ready는 false다. 표시할 수 있는 후보와 전체 반영 가능 여부는 다른 값이다.
실무에서는 건너뛰기나 병합이 필요할 수 있다. 이 예제는 그 정책을 임의로 넣지 않았다. 병합을 추가한다면 어느 필드를 누가 덮어쓸지, 기존 문서의 금액과 상태를 어떻게 유지할지 새 계약과 테스트로 정해야 한다. 조용히 중복을 없애는 처리보다 왜 멈췄는지 설명 가능한 상태를 먼저 공유하고 싶었다.
반영 직전에도 검토를 다시 수행한다
proposeImport는 기존 합성 목록, 현재 입력과 열 연결, 예상 수정 번호와 현재 수정 번호를 받는다. 번호가 다르면 revision_conflict를 돌려준다. 번호가 맞아도 입력과 기존 식별자를 다시 검사해 문제가 있으면 review_required로 멈춘다. 통과할 때만 다음 번호와 전체 후보 목록을 돌려준다. 기존 목록과 새 입력은 직접 수정하지 않는다.
이것은 데이터베이스 트랜잭션이 아니다. 현재 번호를 확인하고 후보를 만드는 계산만 맡는다. 운영 서버에서는 그 확인과 실제 쓰기를 같은 원자적 경계에 넣어야 하며 로그인 권한과 요청 중복 방지도 별도로 필요하다. 기존 자료를 포함한 결과 목록은 최대 10000개다. 작은 교육용 메모리 계약을 대용량 이관이나 잠금 구현으로 확대해 설명하지 않는다.
코드와 구현 살펴보기
독립 합성 모듈 전체 구현
// Independent synthetic staging model. No parser, database, network or client records.
const exact=(v,keys)=>v&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).sort().join(',')===keys;
const idOK=v=>typeof v==='string'&&/^[A-Za-z0-9_-]{1,40}$/.test(v);
const itemOK=v=>typeof v==='string'&&v.length>0&&v.length<=80;
const quantityOK=v=>Number.isSafeInteger(v)&&v>=1&&v<=99;
export function previewImport(rows,mapping,existingIds=[]) {
if(!Array.isArray(rows)||rows.length>1000||!Array.isArray(existingIds)||existingIds.length>10000||!existingIds.every(idOK)||new Set(existingIds).size!==existingIds.length)throw new TypeError('Invalid review input');
const mappingOK=exact(mapping,'id,item,quantity')&&Object.values(mapping).every(v=>typeof v==='string'&&/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(v))&&new Set(Object.values(mapping)).size===3;
if(!mappingOK)return {ready:false,candidates:[],issues:[{row:null,code:'mapping'}]};
if(!rows.length)return {ready:false,candidates:[],issues:[{row:null,code:'empty_batch'}]};
const counts=new Map(),existing=new Set(existingIds),issues=[],candidates=[];
for(const r of rows){const id=r&&typeof r==='object'&&Object.hasOwn(r,mapping.id)?r[mapping.id]:null;if(idOK(id))counts.set(id,(counts.get(id)||0)+1);}
rows.forEach((r,index)=>{
const codes=[];
if(!r||typeof r!=='object'||Array.isArray(r)){issues.push({row:index,code:'row_shape'});return;}
const get=key=>Object.hasOwn(r,mapping[key])?r[mapping[key]]:undefined;
const id=get('id'),item=get('item'),quantity=get('quantity');
if(!idOK(id))codes.push('id');
else{if(counts.get(id)>1)codes.push('duplicate_in_batch');if(existing.has(id))codes.push('already_exists');}
if(!itemOK(item))codes.push('item');
if(typeof quantity!=='string'||!/^[1-9]\d?$/.test(quantity))codes.push('quantity');
if(!codes.length)candidates.push({id,item,quantity:Number(quantity)});
codes.forEach(code=>issues.push({row:index,code}));
});
return {ready:rows.length>0&&!issues.length,candidates,issues};
}
export function proposeImport(existing,rows,mapping,expectedRevision,currentRevision) {
if(!Array.isArray(existing)||existing.length>10000||!existing.every(r=>exact(r,'id,item,quantity')&&idOK(r.id)&&itemOK(r.item)&&quantityOK(r.quantity))||new Set(existing.map(r=>r.id)).size!==existing.length)throw new TypeError('Invalid existing synthetic store');
if(!Number.isSafeInteger(currentRevision)||currentRevision<0||currentRevision>=Number.MAX_SAFE_INTEGER||expectedRevision!==currentRevision)return {ok:false,reason:'revision_conflict'};
const review=previewImport(rows,mapping,existing.map(r=>r.id));
if(!review.ready)return {ok:false,reason:'review_required',issues:review.issues};
if(existing.length+review.candidates.length>10000)return {ok:false,reason:'store_limit'};
return {ok:true,revision:currentRevision+1,records:[...existing.map(r=>({...r})),...review.candidates]};
}
export function syntheticImport(){return {mapping:{id:'code',item:'label',quantity:'units'},rows:[{code:'001',label:'A',units:'6'},{code:'002',label:'B',units:'4'}]};}데모는 검토와 반영을 따로 누른다
아래에서 정상 자료, 중복, 누락, 잘못된 열 연결, 기존 자료와 중복, 수정 번호 충돌을 선택할 수 있다. 조건을 바꾸면 이전 검토를 지우고 다시 검토해야 반영 버튼을 쓸 수 있다. 정상 반영도 페이지 메모리의 합성 목록만 바꾼다. 실제 회사 파일을 받지 않으며 서버나 저장소에 연결하지 않는다. 새로고침하면 예시는 다시 시작한다.
검토 뒤 수정 번호가 달라지는 조건은 후보가 정상이어도 반영 단계에서 거절되는 경우를 보여 준다. 화면에 준비됐다고 나왔다고 저장이 끝난 것은 아니라는 점을 읽을 수 있다. 한영 데모는 같은 모듈 코드를 안에 포함하며 외부 라이브러리나 네트워크 요청 없이 동작한다. 기계용 오류 코드를 실제 화면에 붙일 때는 담당자가 이해할 문장과 수정 위치로 연결하면 좋다.
파일과 확인 범위를 함께 넘긴다
ZIP에는 구현, 독립 테스트, 한영 HTML 데모, 적용 범위와 재사용 허용을 적은 문서가 있다. 압축을 풀고 node test.mjs를 실행한 뒤 데모 파일을 열어 볼 수 있다. 수정·재배포·상업적 사용이 가능하며 무보증으로 제공한다. 원래 업체 프로그램에서 가져온 패키지가 아니라 이번 설명을 위해 새로 작성한 독립 합성 예제다.
추출한 패키지의 24개 테스트 그룹과 허용 수량 99개를 대조했다. 원본 보존, 중복의 모든 등장 행, 검토 뒤 입력 변경, 잘못된 기존 목록과 수정 번호도 확인했다. 한영 데모의 이벤트 연결은 코드 대역으로 평가했으며 실제 브라우저나 운영 파일의 검수는 아니다. CSV·엑셀 파서, 문자 인코딩, 회사 코드, 실제 저장과 이관 검수는 다음 연결에서 별도로 다룬다.