저장한 장부를 다시 열었을 뿐인데 합계가 달라진다면 계산식만 볼 일은 아니다. 합성 계좌의 현금 한 칸을 숫자 문자열로 바꾸어 실제 복원 경로를 확인했고, 검사에서 통과한 값과 계산이 받은 값 사이의 틈을 찾았다.

다시 열었을 때 같은 장부가 나와야 한다

업무 화면은 지금 보이는 숫자만 맞으면 끝나는 프로그램이 아니다. 창을 닫았다가 다시 열었을 때도 같은 자료를 같은 뜻으로 읽어야 한다. 저장 버튼이 성공했다는 안내와 저장한 자료를 다시 계산에 쓸 수 있다는 확인은 서로 다른 약속이었다. 이번에는 모의 계좌를 저장하는 실제 프로그램에서 그 경계를 다시 살폈다.

실제 작업 자료에는 화면이 저장 자료를 읽는 검사와 엔진이 장부를 복원하는 검사가 따로 있었다. 두 곳에 검사가 있다는 사실은 안심할 이유처럼 보이지만, 무엇을 검사하는지가 같다는 뜻은 아니었다. 나는 정상 화면을 새로 만드는 일보다 이미 있던 장부를 어떤 조건으로 받아들이는지부터 읽었다.

같은 숫자처럼 보이는 문자열을 넣어 보았다

운영 계좌나 방문자 자료를 사용하지 않고 합성 장부를 만들었다. 열 개의 계좌에 각각 숫자 100000이 들어 있으면 전체 현금은 1000000이다. 그중 한 계좌의 현금만 문자열 "100000"으로 바꾸자 엔진 검사와 화면 쪽 검사 모두 통과했다. 같은 검사를 null 현금과 필수 totals 객체를 제거한 장부에도 적용했는데 둘 다 허용됐다.

문자열 현금을 가진 장부에 합성 시세 평가를 한 번 적용하자 전체 현금은 백만 대신 약 1e59의 값으로 바뀌었다. 이 숫자는 실제 운용 결과가 아니라 잘못된 자료형을 받아들인 반례의 출력이다. 입력 원본이 변하지 않았다는 것도 따로 확인했다. 자료를 건드리지 않는 함수여도 잘못된 입력을 그대로 계산하면 잘못된 새 결과를 만들 수 있었다.

변환할 수 있음과 저장된 숫자임을 나누었다

원인은 검증 과정의 Number 변환에 있었다. 숫자 문자열은 변환할 수 있고, null과 불리언도 특정 숫자로 바뀔 수 있다. 검사에서 변환 가능하다고 통과시킨 뒤 원래 값을 그대로 반환하면 계산 쪽은 여전히 문자열이나 null을 받는다. 더하기가 문자열 연결로 바뀌는 순간도 이 사이에서 생겼다.

내가 바꾼 기준은 저장 장부의 계산 필드는 이미 유한한 숫자여야 한다는 것이었다. 현금은 0 이상, 최초 자금과 보유 단가는 양수, 수량은 양의 정수로 검사했다. 손익과 수익률은 손실을 표현할 수 있으므로 음수라는 이유만으로 거절하지 않았다. 필수 합계의 여섯 필드도 각각 유한한 숫자인지 확인했다.

입력창이나 외부 자료에서 문자열을 숫자로 읽어야 하는 일은 남아 있다. 다만 그 변환은 허용한 구분자·단위·소수 규칙을 정한 별도 입력 단계의 책임이다. 저장된 장부를 읽는 중에 조용히 다른 뜻의 값으로 보정하는 것과 합치지 않았다. 이번 수정에서 기존 시세 정규화와 매매 계산 로직은 그대로 두었다.

코드와 구현 살펴보기

실제 수정에서 사용한 숫자 조건의 발췌

const positiveNumber = (value) => Number.isFinite(value) && value > 0;
const cashIsValid = Number.isFinite(agent.cash) && agent.cash >= 0;
const totalFields = ['equity', 'cash', 'realizedPnl', 'unrealizedPnl', 'netPnl', 'returnPct'];
// Part of the validator, after requiring totals to be a record:
const totalsAreNumeric = totalFields.every((key) => Number.isFinite(value.totals[key]));

검사 두 벌을 고치는 대신 같은 함수를 연결했다

엔진과 화면의 검사에는 차이도 있었다. 엔진은 합계와 마지막 가격 맵의 필수 구조를 충분히 확인하지 않았고, 화면 쪽에는 다른 보유 가격 조건이 들어 있었다. 한쪽만 수정하면 다른 복원 경로에서는 같은 입력이 계속 통과할 수 있었다. 비슷한 조건문을 두 벌 유지하는 비용은 수정할 때 드러났다.

엔진의 공통 검사 함수를 화면 저장 로더에서도 호출하도록 연결했다. 그 함수가 저장 자료의 숫자와 필수 구조에 대한 같은 판단을 내리게 한 것이다. 마지막 가격 맵의 값과 보유 수량·단가·마크 가격도 같은 경로에서 확인한다. 저장 버전과 키를 새로 만들거나 기존 정상 장부를 다시 쓰는 변경은 하지 않았다.

공통 함수라는 이름만으로 연결이 증명되지는 않는다. 시험에서는 실제 화면 읽기 함수의 코드를 메모리 저장소 대역에 넣어 실행했다. 엔진 검사만 통과했다고 화면 복원까지 확인한 것으로 세지 않았고, 같은 반례가 두 입구에서 모두 거절되는지 대조했다.

새 버전 번호보다 정상 자료를 지키는 시험이 먼저였다

버전 2라는 표시가 같아도 내부 값은 잘못될 수 있었다. 반대로 검사 코드를 고쳤다고 정상 버전 2 장부까지 새 장부로 바꾸면 사용자가 기대한 이어 쓰기가 사라진다. 그래서 미국·한국 구분의 정상 숫자 장부와 합성 체결로 보유 종목이 생긴 장부를 JSON으로 저장했다가 읽는 경로를 확인했다.

정상 장부는 내용이 유지됐고 엔진 복원 함수는 받은 객체를 그대로 반환했다. 오류 시험에는 숫자 변환·필수 구조와 가격·보유 필드의 잘못된 입력 157개가 들어갔다. 기존 엔진 시험 10개에 새 회귀 시험 6개를 더한 16개가 통과했다. 이 개수는 시험 함수와 내부 반례의 수를 구별한 기록이다.

필수 합계가 숫자라는 검사는 합계가 실제 거래내역과 맞는다는 검증까지 하지 않는다. 이번 작업은 복원 입구의 타입과 구조를 다뤘다. 과거 거래 기록 전체의 의미, 손익 인식 기준과 장부 대사까지 마쳤다고 쓰지 않았다. 통과라는 말에는 어떤 계약을 통과했는지가 따라야 했다.

잘못된 장부를 만났을 때의 정책도 그대로 읽었다

원래 프로그램은 형식이나 시장이 맞지 않으면 새 합성 장부를 시작하는 정책이었다. 이번에는 그 정책을 바꾸지 않고 잘못된 타입이 정상 복원으로 들어오는 구멍을 막았다. 오류 자료를 별도 보관함으로 옮기거나 사용자가 변환안을 승인하게 하는 기능을 새로 만들지는 않았다. 실제 브라우저의 저장 계좌를 열거나 초기화한 작업도 아니다.

이 흐름을 회사 업무 프로그램에 그대로 옮긴다면 다른 판단이 필요하다. 잘못된 자료를 읽지 않는다는 이유로 사용자의 원본을 잃게 해서는 안 된다. 원본 사본을 보존하고 복원 보류 사유를 보여 줄지, 명시적인 변환안을 만들지, 권한을 가진 사람이 확인한 뒤 새 장부를 만들지부터 정해야 한다. 이것은 이번 앱에 설치한 기능이 아니라 다음 적용을 위한 기준이다.

자료형을 엄격히 검사하는 일과 실패 뒤에 자료를 어떻게 보존하는지는 다른 책임이다. 나는 검사를 강화했다는 말로 복구 정책까지 좋아졌다고 설명하지 않으려 했다. 정상 복원의 조건과 실패했을 때 일어나는 일을 함께 읽어야 사용자가 계속 쓰던 자료에 어떤 영향이 있는지 판단할 수 있다.

고친 코드와 실행 중인 제품을 같은 말로 부르지 않았다

소스 수정 뒤에는 고정된 의존성 목록으로 별도 폴더에서 빌드했다. 기존 실행 파일과 원래 배포 결과를 교체하지 않았다. 전후 소스와 시험에 필요한 문맥을 각각 압축하고 실제로 풀어 바이트를 대조한 뒤, 복구한 이전 소스의 시험 10개와 수정 소스의 시험 16개도 실행했다. 보호 대상 29개 파일은 해시와 수정 시각이 유지됐다.

독립 코드 검수에서도 처음 발견한 두 문제를 현재 코드로 다시 확인했다. 그 판단은 실제 시험 결과와 별도 근거로 보관했다. 메모리 저장소 대역, 격리 빌드, 복구본 시험을 실제 브라우저 화면이나 실제 시세·주문 연동 검수로 바꾸어 부르지 않았다. 이번에 만든 결과는 확인 범위가 있는 로컬 수정이다.

기존 모듈에서 다음 두 질문을 이어간다

숫자 문자열을 거절하는 작은 검증기를 하나 더 공개할 필요는 없었다. 기존 장부 검증 모듈은 거래 금액과 수금의 누락·중복·정수 범위와 합계 보류를 다룬다. 기존 합성 복원 모듈은 바이트·해시·행 구조를 확인한 뒤 교체 후보와 차이만 보여 준다. 이번 글의 저장 장부 검사와 같은 모듈은 아니지만, 타입 다음에 무엇을 확인해야 하는지 이어서 읽을 수 있다.

두 공개 ZIP을 실제로 내려받아 바이트와 해시를 확인하고 격리 폴더에서 기존 시험을 다시 실행했다. 장부 모듈은 21개 시험 그룹과 861개 조합, 복원 모듈은 12개 시험 그룹의 계약을 제공한다. 새 앱에 자동 통합하거나 실제 자료를 복구한 결과는 아니다. 코드를 받은 뒤에는 자신의 자료 단위·버전·보존 정책을 먼저 정하고, 같은 잘못된 입력을 실제 모든 입구에 넣어 보는 일부터 시작하면 좋겠다.