ERC-7813 테이블 기반 온체인 스토리지 (Store, Table-Based Introspectable Storage)

Final · 기타 · 2024-11-08 작성

스마트 컨트랙트의 데이터를 데이터베이스처럼 테이블 단위로 저장하고 바뀔 때마다 표준 이벤트를 내보내 체인 밖 서비스가 상태를 자동으로 복제할 수 있게 하는 저장 방식 표준입니다.

왜 필요한가

스마트 컨트랙트는 보통 저장 변수를 내부에 고정된 형태로 두고 외부에서 읽으려면 읽기 함수를 일일이 만들어 둬야 합니다. 그래서 컨트랙트마다 데이터를 읽는 방법이 제각각이고 블록체인 밖에서 데이터를 모아 정리해 주는 서비스(인덱서)는 컨트랙트 하나하나에 맞춘 코드를 따로 짜야 합니다. 또 저장 구조가 코드에 고정돼 있어서 데이터 구성을 바꾸거나 새 항목을 추가하면 기존 연동 서비스가 깨지기 쉽고 고정 저장 레이아웃 때문에 컨트랙트 로직 업그레이드도 위험해집니다. 이 표준은 데이터를 데이터베이스처럼 정해진 틀(테이블)에 담고 데이터가 바뀔 때마다 통일된 형식의 알림(이벤트)을 내보내게 해서 이런 문제를 줄입니다. 효율적인 데이터 압축 방식을 함께 정의해 저장과 이벤트 발행에 드는 수수료(가스)도 아낍니다.

구현해야 하는 것

심층 설명

테이블과 ResourceId

모든 테이블은 32바이트 ResourceId로 구분합니다. 앞 2바이트는 테이블 종류를 나타내며 0x7462("tb")는 온체인 테이블, 0x6f74("ot")는 오프체인 테이블입니다. 나머지 30바이트는 고유 식별자입니다. 온체인 테이블은 상태를 체인에 저장하면서 이벤트도 내보냅니다. 오프체인 테이블은 상태를 체인에 저장하지 않고 인덱서를 위한 이벤트만 내보냅니다. 레코드는 하나 이상의 키로 이루어진 keyTuple로 지정합니다.

Schema, FieldLayout, EncodedLengths

각 테이블은 키 스키마와 값 스키마를 가집니다. 키 스키마는 고정 길이 타입만 허용하고 값 스키마는 고정·가변 길이 타입을 섞을 수 있습니다. Schema는 32바이트 하나에 담기며 0-1바이트는 고정 필드 전체 바이트 길이, 2번 바이트는 고정 필드 수, 3번 바이트는 가변 필드 수, 4-31바이트는 필드별 SchemaType입니다. 가변 길이 타입은 반드시 고정 길이 타입 뒤에 와야 합니다. FieldLayout은 각 고정 필드의 바이트 길이를 미리 계산해 담아 두는 최적화용 타입으로, 실행 중에 스키마에서 길이를 반복 계산하는 비용을 없앱니다. EncodedLengths는 한 레코드의 가변 필드 길이들을 32바이트 하나에 담습니다. 전체 길이는 uint56, 다섯 개 필드별 길이는 각각 uint40으로 인코딩합니다.

필드 한도와 패킹 인코딩

한 레코드는 고정·가변 필드를 합쳐 최대 28개 필드를 가질 수 있고 가변 필드는 최대 5개입니다. 고정 필드 최대 수는 28에서 가변 필드 수를 뺀 값입니다. 인코딩은 패딩 없이 꽉 채우는 방식입니다. 고정 필드는 abi.encodePacked처럼 스키마 순서대로 이어 붙이고 배열 요소도 패딩 없이 붙입니다. 가변 필드들은 길이 정보 없이 이어 붙이고 길이는 EncodedLengths 한 워드에 모아 둡니다. 레코드 전체는 (staticData, encodedLengths, dynamicData) 튜플로 표현되며 이 방식이 저장과 이벤트 발행의 가스 비용을 줄입니다.

IStore 읽기 인터페이스

스토어는 세 개의 읽기 함수를 반드시 구현합니다. getRecord는 테이블 ID와 키 튜플로 레코드 전체를 인코딩된 튜플로 돌려주고 getField는 fieldIndex로 지정한 필드 하나를 그 필드만 따로 인코딩한 형태로, getFieldLength는 해당 필드의 바이트 길이를 돌려줍니다. 쓰기 함수는 표준이 정하지 않으며 각 구현이 용도에 맞게 설계합니다.

네 가지 이벤트와 일관성 요구

상태 변경마다 정해진 이벤트를 반드시 내보냅니다. Store_SetRecord는 레코드 전체를 덮어쓸 때, Store_SpliceStaticData는 고정 데이터의 start 위치부터 바이트를 교체할 때(고정 데이터 전체 길이는 값 스키마가 정하므로 변하지 않음), Store_SpliceDynamicData는 가변 데이터에서 바이트를 삭제·교체·삽입할 때(dynamicFieldIndex, start, deleteCount, 결과 encodedLengths 포함), Store_DeleteRecord는 레코드를 지울 때 발생합니다. 온체인 테이블은 어느 블록에서든 읽기 함수로 얻는 데이터가 그 블록까지의 이벤트를 순서대로 적용한 결과와 반드시 일치해야 합니다. 이 요구 덕분에 인덱서가 이벤트만으로 상태를 정확히 복원할 수 있습니다.

Splice 이벤트를 두는 이유와 가변 타입 배열 금지

Store_SetRecord만으로도 추적은 가능하지만 레코드 일부만 바뀔 때 전체 데이터를 저장소에서 읽어 다시 내보내는 것은 비효율적입니다. Splice 이벤트는 바뀐 최소한의 데이터만 내보내므로 가스가 절약되고 가변 필드가 커도 수정 비용이 필드 크기에 비례해 늘지 않습니다. 한편 string[]이나 bytes[] 같은 가변 타입의 배열은 일부러 지원하지 않습니다. 평평한 스키마를 강제해 구현을 단순하고 효율적으로 만들기 위해서이며 필요하면 { index: uint256, data: bytes } 같은 스키마의 별도 테이블을 만들어 배열 요소를 레코드 하나씩 담으면 됩니다.

자기 자신도 담는 Tables 테이블

스토어는 모든 테이블의 메타데이터를 담는 특별한 온체인 테이블인 Tables 테이블을 반드시 포함합니다. 키는 tableId 하나이고 값은 fieldLayout, keySchema, valueSchema, abiEncodedKeyNames, abiEncodedFieldNames 다섯 필드입니다. 테이블이 등록될 때마다 Store_SetRecord 이벤트를 한 번 내보내야 하고 등록된 레코드는 불변으로 취급해 그 뒤로 다른 Store 이벤트를 내보내지 않아야 합니다. 또 다른 테이블이 등록되기 전에 Tables 테이블 자신을 설명하는 레코드를 미리 정해진 tableId(0x746273746f72650000000000000000005461626c657300000000000000000000)로 먼저 저장해야 합니다. ResourceId와 스키마가 미리 정해져 있어 인덱서가 모든 테이블의 스키마를 해석할 수 있고 SQL 비슷한 질의 같은 스키마 인식 API를 만들 수 있습니다.

쓸 때 주의할 점

ERC 원문 · ← ERCs