본문으로 건너뛰기
전체 글

StateCell의 스키마 해시: 값의 구조를 표현하는 방법

· 약 10분
Bankware Global Engineering

주소 하나와 숫자 30을 바이트열로 저장했다고 하자. 읽는 쪽은 어디까지가 주소이고 어디부터가 정수인지 알아야 한다. 필드가 더 늘어나거나 같은 기록이 배열로 반복된다면 그 조합을 해석할 약속도 필요하다.

1편에서는 자산 출력과 스마트컨트랙트의 상태를 StateCell이라는 공통 단위로 표현하려는 이유를 설명했다. 공통 단위를 정하고 나니 그 안에 담을 값의 구조를 어떻게 나타낼지가 다음 질문이 되었다. 정수 하나뿐 아니라 구조체, 배열, 매핑도 다룰 수 있어야 하기 때문이다.

니고프로토콜은 값과 값의 구조를 분리하고, 그 구조를 스키마 해시로 식별한다. 이번 글에서는 작은 사용 한도 기록에서 시작해, 기본 자료형으로 복합 구조를 만들고 이를 셀 단위의 상태로 연결하는 설계를 설명한다.

값이 바뀌어도 구조는 남는다

A가 사용자 S에게 자산을 최대 30까지 대신 사용할 권한을 주었다고 하자. 우선 이 기록을 두 필드로 줄여서 생각해보자.

사용자: S의 주소
한도: 30

첫 필드는 주소인 ADDRESS, 두 번째 필드는 부호 없는 256비트 정수인 UINT256으로 표현한다. 이렇게 값의 타입과 조합 방법을 정한 정의를 **스키마(schema)**라고 부른다.

구조: STRUCT(ADDRESS, UINT256)
값: (S의 주소, 30)

이 글의 STRUCT(...) 같은 표기는 구조를 읽기 쉽게 나타낸 의사 표기다. 사용자와 한도라는 필드 이름은 설명을 위해 붙였다. 구조의 조합에 집중하기 위해 사용자와 한도만 남긴 예제다.

S가 20을 사용하면 남은 한도는 10이다. 기록은 (S의 주소, 10)으로 바뀌지만, STRUCT(ADDRESS, UINT256)이라는 구조는 그대로다. 다른 사용자 U의 한도 20을 기록해도 같은 구조를 사용할 수 있다. 여기서 한도는 사용 권한의 크기이며 별도로 예치된 자산 잔액이 아니다.

이 차이를 저장 표현에도 반영하고 싶었다. 내용이 바뀔 때마다 타입 정의까지 새로 만들 필요는 없다. 스키마 해시는 값 30이나 10을 해시한 결과가 아니라, 그 값을 해석할 구조에서 계산하는 식별자다.

기본 자료형에서 복합 구조로

출발점은 더 작은 필드로 분해하지 않는 기본 자료형, 즉 primitive다. 정수, 주소, 참·거짓, 바이트열과 문자열이 그 재료가 된다.

종류자료형
부호 없는 정수UINT8, UINT64, UINT256
부호 있는 정수INT64, INT256
주소와 참·거짓ADDRESS, BOOL
고정 길이 바이트열BYTES4, BYTES32
가변 길이 바이트열과 문자열BYTES, STRING

ADDRESSUINT256은 각각 하나의 스키마다. 구조체는 이런 스키마를 순서 있는 필드 목록으로 묶는다. 앞의 한도 기록은 첫 필드가 주소이고 두 번째 필드가 정수인 구조체다.

필드에는 다른 구조체나 배열도 넣을 수 있다. 따라서 복합 구조마다 새로운 기본 타입을 계속 추가하기보다, 작은 스키마를 조합해 더 큰 구조를 표현할 수 있다.

이제 사용 전의 S=30, U=20 기록을 여러 구조로 표현해보자. 아래의 예제는 같은 내용을 담는 방식을 비교한다. 배열은 같은 구조의 값이 반복되는 경우다. 앞의 한도 기록을 Grant라고 부르면 다음처럼 나타낼 수 있다.

Grant = STRUCT(ADDRESS, UINT256)

구조: ARRAY(Grant, 길이 2)
값: [(S의 주소, 30), (U의 주소, 20)]

여기서 Grant는 설명용 이름이다. 배열이 실제로 참조하는 것은 그 이름이 아니라 STRUCT(ADDRESS, UINT256)이라는 요소 스키마다.

고정 배열은 길이도 타입의 일부다. 길이 2인 배열과 길이 3인 배열은 다른 스키마다. 반면 동적 배열은 스키마에 ‘동적 길이’라는 표시를 넣고, 현재 원소 수는 상태 값으로 별도 관리한다. 동적 배열의 원소 수가 2에서 3으로 늘어나는 것은 값의 변화이지 스키마의 변화가 아니다.

순서로 찾을 것인가, 키로 찾을 것인가

앞의 배열에서 S의 한도를 찾으려면 S가 들어 있는 위치를 알아내야 한다. 프로그램이 주로 ‘이 주소의 한도는 얼마인가’를 묻는다면 주소를 키로 사용하는 매핑이 더 직접적인 표현일 수 있다. 매핑(mapping)은 키와 값의 대응을 나타내는 구조다.

구조: MAPPING(ADDRESS → UINT256)

S의 주소 → 30
U의 주소 → 20

같은 두 사용자의 한도를 표현하지만 배열과 매핑은 다른 약속이다. 배열은 순서와 원소 수가 있고, 매핑은 특정 키에 대응하는 값을 찾는다. 주소를 값 안에 반복해서 저장하는 대신 키로 사용한다. 매핑 스키마 자체에 현재 등록된 S와 U의 목록이 들어가는 것은 아니다.

설계에서 매핑의 값 자리는 또 하나의 스키마다. 사용자별로 한도와 활성 여부를 함께 기록하고 싶다면, 두 값을 구조체로 묶어 매핑의 값으로 두면 된다.

Policy = STRUCT(UINT256, BOOL)
구조: MAPPING(ADDRESS → Policy)

S의 주소 → (30, true)
U의 주소 → (20, true)

Policy의 두 필드는 순서대로 한도와 활성 여부다. 사용자 주소는 키가 되고, 그 키 아래의 값은 구조체가 된다. 승인자별로 이런 목록을 구분한다면 바깥에 매핑을 한 겹 더 두는 식으로 설계를 확장할 수 있다.

승인자 주소 → (사용자 주소 → Policy)

여기서 핵심은 복잡한 경우마다 새로운 종류의 매핑을 정의하지 않는다는 점이다. 구조체는 필드 스키마들을, 배열은 요소 스키마를, 매핑은 키 타입과 값 스키마를 조합한다. 같은 조합 규칙을 반복해서 적용하는 재귀적 구조로 복잡한 상태를 표현하려는 것이다.

스키마의 구조에서 해시를 계산한다

프로그램마다 타입에 붙인 이름은 달라도 같은 구조를 사용할 수 있다. 구조 자체에서 식별자를 계산하면 서로 다른 프로그램도 같은 정의를 같은 기준으로 참조할 수 있다. 긴 구조 정의를 매번 기록하는 대신 고정 길이 식별자를 남기고, 필요한 정의는 그 식별자로 찾아오는 것이다.

이 식별자로 사용하는 것이 스키마 해시다. SHA-256이라는 해시 함수로 구조를 32바이트의 값으로 계산한다. 각 구조의 종류를 구분하는 표식과, 그 구조를 결정하는 정보를 입력으로 쓴다.

스키마 종류해시 계산에 포함하는 정보
기본 자료형PRIMITIVE: 표식과 타입 이름
구조체STRUCT: 표식과 필드 순서대로 나열한 하위 스키마 해시
배열ARRAY: 표식, 고정 길이 또는 동적 길이 표시, 요소 스키마 해시
매핑MAPPING: 표식, 키 타입 이름, 값 스키마 해시

앞의 구조체는 먼저 ADDRESSUINT256의 해시를 각각 계산한 뒤, 그 두 해시를 순서대로 결합해 자신의 해시를 만든다. 구조체의 배열은 그 구조체 해시를 다시 요소로 참조한다. 작은 구조의 식별자가 더 큰 구조의 식별자를 만드는 재료가 되는 셈이다.

계산 규칙을 짧게 적으면 다음과 같다. H는 SHA-256, ||는 바이트를 순서대로 이어 붙이는 연산이다. 따옴표 안의 문자열은 UTF-8 바이트이며, addressHashamountHash는 16진수 문자열이 아니라 각각 32바이트 해시다.

addressHash =
H("PRIMITIVE:ADDRESS")
amountHash =
H("PRIMITIVE:UINT256")
grantHash = H(
"STRUCT:"
|| addressHash
|| amountHash
)

같은 규칙에 같은 구조를 넣으면 같은 해시가 나온다. 값이 30에서 10으로 바뀌거나 프로그램의 클래스명이 바뀌는 것은 이 계산에 영향을 주지 않는다. 구조를 정의한 프로그램의 이름보다 타입과 조합 관계를 공통 기준으로 삼는 것이다.

이 선택에는 분명한 경계가 있다. STRUCT(ADDRESS, UINT256)STRUCT(UINT256, ADDRESS)로 바꾸면 해시 입력이 달라진다. 그러나 같은 UINT256 필드 두 개의 업무상 이름을 서로 바꾸어도 타입의 순서가 그대로라면 같은 해시가 나온다.

따라서 스키마 해시는 구조를 식별하는 기준이다. ‘이 정수는 한도이고 저 정수는 잔액’이라는 업무 의미나 호환성까지 판정하는 기준으로 사용할 수는 없다.

해시와 스키마 정의를 함께 사용한다

셀에 32바이트 해시를 기록했다고 해서 그 안에서 구조체 정의를 꺼낼 수 있는 것은 아니다. 해시를 거꾸로 풀어 필드 목록을 복원하는 방식이 아니기 때문이다. 읽는 쪽에는 그 해시에 대응하는 스키마 정의가 있어야 한다.

스키마 정의를 해시로 등록해 두면, 읽는 프로그램은 그 정의에서 필드의 순서와 타입, 배열의 요소 구조 등을 확인할 수 있다. 복합 스키마가 참조하는 하위 정의도 같은 방식으로 찾는다.

스키마를 알고 값을 읽을 때는 실제 값이 그 정의에 맞는지도 검사한다. 예를 들어 한도 필드에 UINT256을 기대했는데 문자열이 들어 있거나, 두 필드 구조체에 세 개의 값을 전달했다면 구조에 맞지 않는 데이터다. 말단 셀을 읽는 검증에서는 기대한 스키마 해시와 셀의 해시, 실제 primitive 타입과 값을 함께 확인한다.

다만 이것이 한도 30을 승인할 권리가 A에게 있는지까지 확인해주지는 않는다. 숫자가 타입에 맞는지와 그 숫자를 기록할 권한이 있는지는 다른 질문이다. 스키마는 전자를 위한 공통 표현을 제공하고, 후자는 상태를 다루는 프로그램과 원장 규칙이 맡는다.

복합 구조를 셀 단위의 상태로 연결하기

여기까지는 논리적인 구조 이야기였다. StateCell 하나에는 id, owner, schemaHash, value가 들어가며, value는 primitive 표현을 사용한다. 그렇다면 앞의 두 필드 구조체는 어디에 저장할까.

복합 구조를 셀에 나누어 담는 방법은 구조를 따라 내려가며 말단 값마다 셀을 두는 것이다. 앞의 Grant 구조를 쓰는 (S의 주소, 30) 기록 하나를 생각해보자. 이 기록을 구분하는 기준 ID를 R이라고 하겠다. 읽는 프로그램은 어느 기록인지 나타내는 R과, 그 기록의 전체 구조인 Grant를 알고 출발한다.

이 경우 R은 전체 구조체를 담은 추가 셀이 아니라 두 필드의 위치를 찾아가는 기준이다. 각 필드는 다음과 같이 배치한다.

논리적 위치셀의 schemaHash셀의 value
R의 필드 0ADDRESS의 해시S의 주소
R의 필드 1UINT256의 해시30

첫 필드를 읽을 때는 R과 Grant의 스키마 해시, 필드 번호 0을 결합해 그 셀의 ID를 계산한다. Grant의 정의에 따르면 첫 필드는 주소여야 한다. 따라서 찾은 셀의 스키마 해시가 ADDRESS에 해당하는지, 값이 주소 타입인지 확인한다. 두 번째 필드도 번호 1로 위치를 찾고 UINT256 값 30을 확인한다. 이렇게 읽은 두 값이 (S의 주소, 30)이라는 기록을 이룬다.

전체 구조의 해시는 필드의 위치를 찾는 규칙에, 말단 셀의 스키마 해시는 그 위치의 값을 검사하는 데 쓰인다. 같은 Grant 구조를 사용해도 기준 ID가 R인 기록과 Q인 기록은 서로 다른 상태다. 스키마 해시는 ‘어떤 구조인가’를 구분하고, 기준 ID와 경로는 ‘그 구조를 쓰는 어느 상태인가’를 구분한다.

배열도 같은 방식으로 원소 인덱스를 사용해 위치를 찾는다. Grant 두 개를 담은 고정 배열을 분해하면 각 원소의 두 필드, 총 네 개의 말단 셀이 생긴다. 같은 내용을 동적 배열에 담으면 현재 길이 2를 기록하는 UINT64 셀이 하나 더 생긴다. 동적 배열의 길이 셀은 요소 셀과 구분되는 종류의 ID를 사용한다.

매핑 항목의 ID는 매핑 인스턴스 ID와 스키마, 키의 타입·값으로 파생한다. 따라서 S의 한도를 찾을 때 전체 항목을 한 덩어리로 읽을 필요 없이 S의 키로 항목 위치를 계산할 수 있다. 복합 값을 담는 매핑도 같은 발상으로 생각할 수 있다. 키로 찾은 위치에서 값의 스키마를 따라가며 필드나 배열 원소의 경로를 이어가는 것이다. 앞의 중첩 매핑에서 A가 S에게 허용한 한도는 다음 경로로 표현할 수 있다.

승인 목록의 기준 ID
→ 승인자 A의 키
→ 사용자 S의 키
→ Policy의 한도 필드
→ 값 30

매핑은 키로 다음 위치를 정하고, 구조체는 필드 번호로 다음 위치를 정한다. 중첩 구조는 이 규칙들을 차례로 적용한 경로다.

이렇게 하면 구조를 이해하는 방법과 상태를 찾아가는 방법이 연결된다. 프로그램이 사용하는 복합 데이터와 원장이 다루는 작은 상태 단위 사이에 공통 규칙이 생긴다.

여러 필드를 한 셀에 담는 선택

같은 Grant 기록을 하나의 셀에 담는 방법도 있다. 주소와 한도를 정해진 형식으로 인코딩해 하나의 BYTES 값으로 묶는 것이다.

여기서 BYTES는 셀에 담는 물리적 값의 표현이다. 그 바이트열 안에는 주소와 한도라는 두 개의 논리 필드가 들어 있다. 셀의 valueBYTES이고, schemaHash는 그 바이트열을 해석할 Grant 구조체의 정의를 가리킨다. 바이트열 하나를 저장하면서 복합 구조를 표현할 수 있는 이유다.

같은 (S의 주소, 30)을 담는 두 배치를 비교하면 다음과 같다.

배치셀의 스키마 해시셀의 갱신 단위
두 필드로 분해ADDRESS, UINT256 각각의 해시주소 또는 한도
BYTES 하나로 묶음Grant 전체의 해시기록 전체

어느 배치를 사용할지는 해당 상태를 다루는 프로그램과 저장 규약이 정한다. 읽는 쪽도 그 규약에 따라 셀을 찾아 값을 해석한다. 스키마는 무엇을 담는지 설명하고, 배치와 인코딩 규약은 그것을 어떤 셀에 어떻게 담을지 정한다.

개별 필드를 따로 바꾸는 일이 많으면 분해한 셀이 유용하다. 대부분 함께 읽고 바꾸는 값이면 하나의 셀로 묶는 선택을 할 수 있다. 여러 셀로 나눈 상태도 한 거래에서 함께 검증하고 변경할 수 있다. 함께 성공해야 할 변경을 묶는 원자성은 3편에서 살펴본다.

EVM 저장소 역시 배치 규칙이 따로 있다. Solidity의 구조체·배열·매핑은 컴파일러와 EVM의 저장 규칙에 따라 최종 슬롯으로 표현된다. 니고는 그 슬롯을 다시 Solidity 필드별로 분해하지 않고, 슬롯 하나를 UINT256 값을 담은 StateCell로 저장한다. 같은 셀 기반을 사용하면서 기존 EVM의 저장 의미를 유지하는 것이다.

구조를 식별한 다음에 남는 일

스키마 해시를 두면서 원장이 다루는 공통 표현은 특정 프로그램의 클래스명에서 떨어져 나왔다. 기본 타입과 조합 규칙으로 구조를 식별하고, 같은 구조를 여러 상태에서 재사용하며, 기대하는 타입과 실제 저장된 값이 맞는지 검사할 수 있다.

구조를 바꾸는 일도 분명해진다. 앞의 한도 기록에 BOOL 필드를 추가하면 새로운 구조의 스키마 해시를 계산하게 된다. 하지만 새 해시가 이전 데이터를 자동으로 옮기거나 기본값을 채워주지는 않는다. 어떤 이전 상태를 읽어 무엇을 새로 기록할지, 프로그램이 어떤 버전을 받아들일지는 별도의 전이 규칙으로 정해야 한다.

그 다음 질문은 변경 권한이다. 한도 30을 구조에 맞게 만들었다고 해도 아무나 다른 사람의 자산에 대한 사용 권한을 만들 수 있어서는 안 된다. 반대로 자산의 소유자가 직접 서명하지 않아도 승인받은 사용자가 정해진 범위에서 사용할 수는 있어야 한다.

3편에서는 이 관계를 다룬다. StateCell의 owner가 뜻하는 것, 소유 정보와 변경 권한을 연결하는 규칙, 그리고 프로토콜 코어와 Native Program·EVM 컨트랙트가 나누어 맡는 책임을 사용 승인과 이전 예제로 살펴보려 한다.


StateCell 설계 시리즈 2편. 사용 한도 기록은 스키마의 조합과 상태 배치를 설명하기 위한 예제다.