RocksDB를 붙이기 전에 저장소의 약속부터 테스트했다
여섯 개의 상태를 두 묶음으로 받아오는 중이라고 하자. 첫 묶음의 세 개는 잘 저장했다. 두 번째 묶음에는 중복 항목이 있어 저장을 거절했다. 진행률에도 여전히 세 개만 받았다고 나온다. 그런데 임시 저장 공간을 직접 열어보니 다섯 개가 들어 있다.
실패를 알리는 예외도 있었고, 진행률도 늘지 않았다. 무엇이 잘못됐을까? 실패한 묶음의 앞부분이 이미 저장됐는데, 진행률은 그 사실을 모르는 상태였다.
이것은 nigo-protocol에서 새 저장소를 준비하다가 기존의 Memory 저장소, 즉 메모리에 상태를 보관하는 구현에서 발견한 문제다. 2026년 9월 7일, RocksDB 구현에 앞서 Memory와 관계형 데이터베이스 저장소에 같은 검증을 적용했다. 새 구현이 따라야 할 기준을 정하려던 작업이, 그 기준으로 삼으려던 구현을 고치는 일로 이어졌다.
거절한 묶음에서 두 개가 남았다
노드가 다른 노드의 상태를 따라잡을 때는 특정 시점의 전체 상태를 묶은 snapshot을 받아올 수 있다. 큰 자료를 한 번에 처리하는 대신 여러 chunk, 즉 작은 묶음으로 나누어 받는다. 받는 중인 자료는 지금 사용하는 상태와 분리해 임시 공간에 쌓는다. 이 준비 단계를 staging이라고 한다.
앞의 여섯 상태를 ID 순서대로 A부터 F라고 부르자. 글자를 사용한 것은 설명을 단순화하기 위해서다. 실제 검사에서는 셀의 ID를 기준으로 순서를 비교한다. 여기서 세는 것은 잔액이 아니라 상태 셀의 개수다.
첫 묶음은 A, B, C, 다음 묶음은 D, E, F여야 한다.
그런데 두 번째가 D, E, E로 전달됐다고 하자. 중복 없이 순서대로 쌓아야 한다는 조건을 어긴 입력이다.
기존 Memory 구현은 항목 하나를 검사하고, 문제가 없으면 곧바로 임시 공간에 넣었다. D를 검사하고 넣고, E를 검사하고 넣은 뒤 마지막 E에서 중복을 발견했다. 이때 예외를 던지므로 묶음 처리는 완료되지 않았다. 진행률을 갱신하는 코드에도 도달하지 않았다. 하지만 앞서 넣은 D와 E는 사라지지 않았다.
| 확인 시점 | 임시 공간에 들어 있는 상태 | 진행률의 상태 개수 |
|---|---|---|
| 첫 묶음 수신 완료 | A, B, C | 3개 |
| 수정 전, 잘못된 둘째 묶음 거절 | A, B, C, D, E | 3개 |
| 수정 후, 같은 입력 거절 | A, B, C | 3개 |
| 수정 후, 정상 둘째 묶음 재시도 | A, B, C, D, E, F | 6개 |
현재 사용 중인 원장 상태는 staging과 분리돼 있었다. 따라서 이것을 잘못된 다섯 셀이 곧바로 활성 원장에 반영된 사고로 해석하면 안 된다. 문제는 받아오는 도중의 내부 상태가 서로 어긋났다는 데 있다. 호출자는 두 번째 묶음을 다시 보내면 된다고 생각하지만, 저장소는 이미 그 일부를 가지고 있었다.
수정은 검사와 반영의 순서를 분리하는 것이었다. 묶음 전체의 순서와 중복을 먼저 확인하고, 새 개수와 바이트 수를 계산한다. 그 과정이 모두 끝난 다음에 셀들과 진행 정보를 반영한다. 검사 중 거절된 묶음은 기존의 A, B, C와 진행률을 그대로 남기고, 정상 묶음으로 다시 시도할 수 있다.
테스트도 예외가 발생했다는 확인에서 끝내지 않았다. 실패 뒤의 실제 셀 목록, 다음에 받을 묶음 위치, 개수와 바이트 수, 현재 사용 중인 상태까지 함께 확인했다. 이후 올바른 묶음을 넣어 여섯 셀을 모두 받을 수 있는지도 검사했다. 뒤쪽 항목이 중복되는 경우뿐 아니라 순서가 거꾸로 되는 경우에도 같은 약속을 요구했다.
저장 함수를 공유한다는 것의 의미
Memory와 관계형 저장소가 모두 같은 이름의 저장 함수를 제공한다고 해서 같은 저장소처럼 쓸 수 있는 것은 아니다. 정상 입력에서 값을 넣고 다시 읽는 결과가 같아도, 실패 후 무엇이 남는지 다르면 호출자가 안전하게 재시도할 수 없다.
여기서 필요한 것이 공통 계약이다. 저장소를 사용하는 쪽에서 기대할 수 있는 동작을 정하고, 각 구현에 같은 입력을 주어 같은 결과를 확인한다. 내부에서 해시 맵을 쓰는지 SQL을 쓰는지는 달라도 된다.
snapshot의 경우 계약은 “묶음을 저장한다”보다 구체적이다. 거절된 입력이 기존 수신분을 바꾸지 않아야 하고, 진행 정보가 실제 저장 내용과 맞아야 한다. 전체 자료가 준비되기 전에는 현재 사용하는 상태를 바꾸지 않아야 한다. 성공한 요청을 다시 받았을 때도 이미 처리한 자료를 중복으로 쌓지 않아야 한다.
같은 방식으로 상태 셀의 저장과 조회, 거래 및 영수증, 거래가 확정된 위치, 상태를 증명하는 트리와 snapshot까지 공통 검증을 넓혔다. 예를 들어 조회 결과가 요청 순서를 지키는지, 저장한 거래를 다시 직렬화했을 때 바이트가 같은지, 이미 저장한 거래와 충돌하는 내용을 거절한 뒤 원래 자료가 남는지를 확인했다.
검증은 각 기능의 공통 인터페이스를 소유한 모듈에 두었다. Memory와 관계형 저장소는 자신을 준비하는 방법만 달리해서 같은 시나리오를 실행한다. SQL이나 데이터베이스 연결 방식이 공통 테스트에 들어가지 않도록 해, 뒤에 다른 저장소가 추가돼도 같은 질문을 받을 수 있게 했다.
값을 읽는 것만 같아서는 부족했다
공통 검증이 찾아낸 또 다른 차이는 상태의 이력이었다. 어떤 셀이 어느 거래에서 생겼고, 언제 내용이 바뀌었으며, 어느 거래에서 소비됐는지 연결하는 정보를 lineage라고 한다. 현재 값을 읽는 결과가 같더라도 이 분류가 다르면 과거를 설명하는 답이 달라진다.
EVM 계열 거래에서 동일한 ID의 셀이 입력과 출력에 모두 등장하면, 이력 조회에서는 그 셀의 내용이 바뀐 관계로 분류한다. 반면 입력으로 소비됐지만 같은 ID의 출력이 없다면 소멸을 구분해야 한다.
당시 Memory 구현은 일반 EVM 거래만 이 분류에 포함하고 있었다. Type-2와 Type-4 거래에서는 같은 ID의 교체를 생성과 소비로 나눠 처리했다. 반대로 일반 EVM 거래에서는 출력이 없는 소비까지 변경으로 분류하는 문제가 있었다. 관계형 저장소가 따르던 EVM 계열의 규칙과 달랐다.
수정에서는 EVM 계열의 범위를 맞추고, 변경 관계를 판별할 때 같은 ID의 출력도 있는지 확인했다. 이것은 거래의 실행 규칙이나 영수증의 원본 바이트를 바꾼 작업이 아니다. 같은 실행 결과를 저장한 뒤, 저장소에 따라 서로 다른 이력을 돌려주던 부분을 맞춘 작업이다.
새 저장소를 기존 Memory 구현과 비교하기만 했다면 잘못된 분류까지 복사했을 수 있다. 공통 계약을 세우는 과정에서는 “기존 구현과 같은가”에 앞서 “호출자가 받아야 할 답은 무엇인가”를 확인해야 했다. 기존 구현도 그 질문에 답해야 하는 대상이었다.
같은 계약과 같은 내구성은 다르다
그렇다고 모든 저장소에 같은 시험만 적용하면 충분한 것은 아니다. Memory와 디스크 기반 저장소는 실패할 수 있는 지점이 다르다.
묶음 전체를 검사한 뒤 Memory에 반영하는 방식은 잘못된 입력이 앞부분만 남기는 문제를 해결한다. 관계형 저장소에서는 입력 검사를 통과한 뒤 실제 쓰기 도중 데이터베이스 오류가 날 수도 있다. 이 경우에는 이미 실행한 삭제와 삽입이 트랜잭션 전체와 함께 취소되는지를 별도로 확인해야 한다.
그 검증을 위해 기존 셀을 삭제하고 새 셀을 넣는 중, 뒤쪽 삽입에서 실제 데이터베이스 제약 오류를 발생시켰다. 호출이 실패한 뒤에는 원래 셀이 돌아와 있고, 앞서 넣으려던 새 셀도 남지 않아야 했다. 테스트 전체를 바깥의 자동 취소 트랜잭션으로 감싸지 않고, 실제 저장 어댑터의 호출이 끝난 뒤 상태를 읽어 그 경계를 확인했다.
여러 저장 영역에 걸친 블록 반영도 따로 살폈다. Memory와 관계형 저장소에는 같은 저장 입력을 주어 성공 결과를 비교했다. 관계형 저장소에서는 반영 단계 중간에 장애를 주입하고, 이전 상태로 되돌아갔는지 확인했다. 파일을 정상적으로 닫았다 다시 연 뒤에도 그 상태가 유지되고, 새 시도로 성공할 수 있는지 이어서 검사했다.
이 결과를 Memory의 여러 저장 영역이 장애 때 함께 되돌아간다는 뜻으로 넓히지 않았다. 또 파일 재개방 검증은 프로세스 강제 종료나 전원 차단 검증과 다르다. 이 글에서 돌아본 것은 9월 7일의 Memory와 H2 기반 관계형 저장소 검증이며, 그 자체가 RocksDB나 모든 운영 데이터베이스의 내구성을 증명하지는 않는다.
저장 함수가 돌아오면 서명을 보내도 되는지 살펴본 글은 이 구분을 더 깊게 다룬다. 자체 commit, 저장 장치로의 동기화 완료, 재시작 후 복원이 서로 다른 확인 대상이라는 이야기다. 공통 계약으로 호출의 의미를 맞추는 일과, 특정 저장소에서 그 약속이 어떤 장애까지 유지되는지 입증하는 일은 함께 필요하다.
새 저장소가 들어올 자리를 먼저 만들기
RocksDB를 붙이기 전에 얻은 것은 함수 목록만 맞춘 인터페이스가 아니었다. 어떤 입력을 받아야 하는지, 거절한 뒤 무엇이 남아야 하는지, 성공한 결과와 재시도가 어떻게 보여야 하는지를 실행 가능한 질문으로 남겼다. 새 구현은 그 질문에 답하고, 자신에게만 있는 저장과 복구의 경계도 별도로 확인할 수 있다.
이 과정에서 기존 Memory 구현이 수정됐다는 점이 오히려 중요했다. 공통 계약은 새 구현을 검사하는 입구이면서, 이미 믿고 있던 구현을 다시 점검하는 기준이 됐다.
처음의 snapshot으로 돌아오면 목표는 분명하다. 진행률이 세 개라면 실제로 받아둔 것도 A, B, C 세 개여야 한다. 실패한 묶음을 다시 보낼 때는 남몰래 추가된 상태가 없어야 하고, 정상 묶음을 받은 뒤에는 실제 여섯 개와 진행률이 함께 맞아야 한다.
저장소가 이런 일관된 상태를 제공하더라도 운영 화면에는 다음 질문이 남는다. 화면이 보여주는 정상 상태는 언제 관측한 것인가? 저장된 사실의 정확성과, 그 사실을 지금도 관측하고 있다는 확신은 다음으로 구분해야 할 문제다.