하나의 원장, 세 가지 거래 실행 방식
A가 가진 자산 100 중 20을 B에게 보내려고 한다. 거래가 끝나면 A에게 80이 남고 B는 20을 갖게 된다. 목표는 간단하지만, 원장에 어떤 요청을 보내야 하는지는 실행 방식에 따라 달라진다.
“이 기록을 소비하고 A의 80과 B의 20을 만들어 달라”고 변경할 결과를 적을 수도 있다. “이 컨트랙트의 이전 함수를 실행해 달라”고 요청하고, 결과는 코드가 계산하게 할 수도 있다. 다른 사람이 A의 자산을 대신 사용한다면 그 권한과 조건을 검사하는 방법도 필요하다.
니고는 이 차이를 Direct Cell, Native Program, EVM이라는 세 가지 실행 방식으로 다룬다. 이번 글에서는 하나의 이전 예제를 따라, 각 요청에 무엇을 담고 누가 다음 상태를 만들며 그 결과가 어떻게 같은 원장에 반영되는지 살펴본다.
같은 기록 형식 위에 실행 방식을 나눈 이유
니고는 자산과 컨트랙트의 상태를 StateCell이라는 작은 기록 단위로 표현한다. 자산의 소유자와 수량을 담은 셀도 있고, 컨트랙트가 관리하는 저장값을 담은 셀도 있다. UTXO에서 StateCell까지에서는 이처럼 서로 다른 상태를 공통 형식으로 담으려는 이유를 설명했다.
기록 형식을 통일한 뒤에도 남는 선택이 있다. 현재 기록을 다음 기록으로 바꾸는 방법이다. 소유자가 직접 보내는 거래에는 정해진 소유권과 수량 규칙만으로 충분할 수 있다. 승인받은 사용자의 거래에는 개별 사용 조건이 필요하고, 컨트랙트 함수 호출에서는 코드가 저장된 값을 읽으며 변경 결과를 계산한다.
니고는 상태를 담는 단위는 공통으로 두면서, 상태 변경을 요청하고 판단하는 책임은 나누었다. 이 책임의 차이를 세 장면으로 보자. 모두 A=100, B=0에서 시작해 A=80, B=20으로 끝나는 비교 예제다. 각 장면은 독립적이며, 앞의 송금 뒤에 20을 더 보내는 이야기는 아니다. 수수료 금액은 0으로 가정하고, 추가 발행·소각 없이 대상 자산의 변화만 표시한다.
Direct Cell: 원장 규칙으로 확인할 수 있는 이전
먼저 A가 직접 서명해 보낸다. 자산의 종류와 수량을 원장이 직접 관리하는 Native Asset이고, 그 자산의 정책이 소유자의 직접 이전을 허용한다고 가정하자. A의 100이 하나의 셀에 담겨 있다면 거래의 주요 내용은 다음과 같다.
서명자: A
소비할 기록
A의 자산 100
생성할 기록
A의 자산 80
B의 자산 20
이 요청은 어떤 기록을 사용하고 무엇을 남길지 이미 정하고 있다. 니고의 공통 원장 규칙을 집행하는 Core는 입력이 아직 사용 가능한지, 서명자가 입력의 소유자인지, 이 자산에 직접 이전이 허용되는지 확인한다. 출력이 올바른 새 기록인지와 자산별 수량이 보존되는지도 검사한다. 예제에서는 입력 100과 출력 80+20이 같아야 한다.
검사를 통과하면 기존 100의 셀을 소비하고 80과 20을 담은 새 셀을 만든다. 기존 셀의 수량을 80으로 고치는 대신, 사용할 기록과 새로 생길 기록을 거래가 명시하는 방식이다. 니고에서는 이를 Direct Cell이라고 부른다.
이 정도의 이전을 위해 사용자가 별도의 컨트랙트를 배포하거나 노드가 프로그램을 실행할 필요는 없다. 지갑이나 거래 구성 도구가 입력과 출력을 준비하고, Core가 원장의 규칙으로 판단한다. 거래 크기와 처리량에 대한 제한, 실행량 계량도 이 경로에 적용된다.
이 경로가 맡을 범위는 분명하다. A가 직접 보내는 일은 처리할 수 있지만, S의 서명만으로 A의 입력을 소비하도록 허용하지는 않는다. A가 S에게 사용을 승인한 경우라면, 그 승인을 판단할 규칙이 필요하다.
Native Program: 제안된 변경에 사용 조건을 더한다
이번에는 A가 S에게 자신의 자산을 30까지 사용할 수 있도록 미리 승인했다고 하자. 자산은 여전히 A의 100이고, S가 받은 것은 그중 일부를 사용할 권한이다. S는 그 권한으로 20을 B에게 보내려 한다.
거래를 구성하는 쪽에서는 자산의 이전과 사용 한도의 감소를 함께 제안한다.
서명자: S
검증 프로그램: 이 자산의 사용 승인 규칙을 담당하는 프로그램
제안할 변경
자산: A의 100 → A의 80, B의 20
승인: A가 S에게 허용한 사용 한도 30 → 10
Core는 요청이 가리키는 실제 기록과 정책, 서명자 정보 등 검증에 필요한 맥락을 구성한다. 프로그램은 이 자료를 받아 S에게 유효한 승인이 있는지, 사용량 20이 한도 30 안에 있는지, 사용 후 한도가 10으로 줄어드는지를 검사한다. 이렇게 거래가 제시한 변경안을 검증하는 프로그램이 Native Program이다.
프로그램의 승인 뒤에도 Core의 검사는 남는다. 검증한 기록과 실제로 바꿀 기록이 일치해야 하고, 허용된 상태 영역과 자산 보존 규칙을 지켜야 한다. 자산 100이 80과 25로 늘어나는 변경은 개별 프로그램이 허용했다는 이유만으로 원장에 반영할 수 없다.
이 역할 분리는 사용 조건을 확장할 자리를 만든다. 승인 한도나 사용 조건을 구현하는 코드는 프로그램에 두고, 자산별 회계와 상태 접근 같은 공통 규칙은 Core에 둔다. StateCell의 소유자에서 살펴본 소유와 변경 권한의 구분이 실행 구조로 이어지는 지점이다.
여기서 프로그램은 전달받은 자료 안에서 변경안을 검사한다. 검증 도중 원장의 임의 상태를 찾아다니거나 다른 컨트랙트를 호출해 새로운 업무 상태를 만드는 역할은 맡지 않는다. 따라서 거래를 구성하는 쪽은 사용할 입력과 변경 후 출력을 미리 준비해야 한다. 지갑·SDK·노드의 요청 구성 도구가 이 작업을 도울 수 있지만, 서명할 거래에는 구체적인 변경안이 담긴다.
Native Program은 Solidity로 작성할 수 있고, 실행에는 컨트랙트 코드를 실행하는 가상 머신인 EVM의 제한된 환경을 재사용한다. 이때 Solidity와 EVM은 변경안을 검사하는 코드를 작성하고 실행하는 도구로 쓰인다. 일반적인 컨트랙트처럼 저장소를 읽고 쓰는 방식과는 실행에서 맡는 역할이 다르다.
EVM: 함수를 실행하면서 다음 상태를 만든다
같은 업무를 토큰 컨트랙트 T로 표현해보자. T의 저장소에는 A의 잔액 100, B의 잔액 0, A가 S에게 승인한 사용 한도 30이 이미 기록돼 있다고 가정한다. 앞의 Native Asset을 옮겨 담는 과정이 아니라, 같은 이전 업무를 컨트랙트의 데이터로 표현한 별도의 경우다.
S가 준비하는 요청은 다음처럼 달라진다.
서명자: S
호출 대상: 토큰 컨트랙트 T
호출 내용: transferFrom(A, B, 20)
여기서 transferFrom은 승인받은 사용자가 다른 사람의 토큰을 이전하는 함수다.
S는 보낼 사람과 받을 사람, 수량을 전달한다. 요청에 변경 후 잔액 80과 20, 남은 한도 10을
출력 셀로 미리 적을 필요는 없다.
노드의 EVM이 T의 코드를 실행하면, T의 함수는 저장소에서 A의 잔액과 S의 한도를 읽고, 이전 조건을 검사한 뒤 새 잔액과 한도를 계산해 저장한다. 예제의 함수가 정한 결과는 A=80, B=20, 사용 한도=10이다.
일반 EVM 컨트랙트는 저장소 조회와 다른 컨트랙트 호출을 조합해 업무를 표현할 수 있다. 호출하는 쪽은 함수의 입력을 구성하고, 컨트랙트 개발자는 그 함수를 실행했을 때 어떤 상태를 읽고 어떻게 바꿀지 구현한다. 함수 실행을 중심으로 애플리케이션을 만드는 개발 방식이다.
니고는 이 컨트랙트의 저장값도 StateCell로 표현한다. 다만 T의 저장소에 있는 숫자가 잔액인지 한도인지, 어떤 조건에서 줄이고 늘려야 하는지는 T의 코드가 정한다. Core가 이 숫자들을 모두 Native Asset의 수량으로 해석해 보존 여부를 검사하는 것은 아니다. EVM 실행 규칙과 프로토콜의 가치 이전·수수료 규칙은 원장이 집행하고, T의 토큰 규칙은 T가 맡는다.
변경안을 보낼까, 실행할 함수를 지정할까
세 방식의 차이는 요청에 담는 내용과 다음 상태를 만드는 위치에서 드러난다. 아래 표는 서명이나 수수료를 위한 부가 정보를 생략하고, 업무 상태를 다루는 부분만 비교한 것이다.
| 실행 방식 | 요청에 담는 업무 내용 | 다음 상태를 만드는 방식 |
|---|---|---|
| Direct Cell | 소비할 입력과 생성할 출력 | 거래 구성 단계에서 정한 변경안을 Core가 검사 |
| Native Program | 입력·출력과 프로그램 호출 정보 | 거래 구성 단계에서 정한 변경안을 프로그램과 Core가 검사 |
| EVM | 호출 대상과 함수 입력 | 노드에서 컨트랙트를 실행하며 변경 결과를 계산 |
Direct Cell은 원장의 기본 규칙으로 처리할 수 있는 이전을 간결하게 표현한다. Native Program은 명시적인 변경안을 유지하면서 개별 업무 조건을 코드로 확장한다. EVM은 저장소와 함수 호출을 중심으로 다음 상태를 계산하는 개발 방식을 제공한다.
이 선택에는 작업을 어디에 둘 것인가라는 차이가 있다. Direct Cell과 Native Program에서는 거래를 구성할 때 입력과 출력을 맞춰야 한다. 그만큼 어떤 상태를 사용하고 무엇을 남기려는지가 거래에 드러난다. EVM에서는 호출하는 쪽이 함수 입력을 준비하고, 상태 조회와 결과 계산을 컨트랙트 실행에 맡긴다.
니고가 세 방식을 함께 둔 이유는 이 책임 배치를 하나로 고정하지 않기 위해서다. 기본 이전에는 Core의 규칙을 적용하고, 명시한 변경안에 업무 조건을 더할 때는 Native Program을, 함수 실행으로 상태를 처리할 때는 EVM을 사용할 수 있게 한다. 실제 사용 가능한 경로는 그 자산의 정책과 상태를 관리하는 규칙에 따라 정해진다. 예를 들어 Direct가 허용된 Native Asset이라도, S가 A의 승인 한도를 사용하는 거래에는 그 승인을 검사하는 프로그램이 필요하다.
여기서 말하는 세 가지는 실행 방식의 분류다. 프로토콜의 세부 거래 형식은 EVM 계열의 서명된 요청 형식 등을 더 구분하므로, 실행 방식의 수와 같지는 않다.
세 경로가 같은 원장으로 합류한다
노드에 들어온 요청은 블록에 넣기 위한 선행 검사를 거친다. 실제로 블록을 실행할 때는 그 시점의 상태를 기준으로 다시 검사한다. 거래를 준비할 때 있던 입력이 그사이 사용됐을 수 있기 때문이다. 요청의 수납과 원장의 확정은 서로 다른 단계다.
실행 단계에서는 거래 종류에 맞는 경로를 선택한다. Direct 거래가 Native Program을 거쳐 일반 EVM 컨트랙트 호출로 올라가는 식의 단계별 구조가 아니라, 다음과 같은 분기다.
유효한 거래를 처리하는 흐름을 단순화한 그림이다. 세 경로의 차이는 변경을 만드는 방법에 있고, 그 변경을 블록에 반영하고 확정하는 과정은 공통으로 이어진다.
거래를 처리하는 동안 상태 변경은 거래별 임시 작업 공간에 모인다. 성공한 예제에서는 A의 자산 80과 B의 자산 20이 함께 반영되고, 승인 사용이라면 한도 10도 함께 남는다. 원장이 자산을 직접 다루는 경우에는 셀의 소비와 생성으로, EVM 저장소에서는 해당 위치의 값을 교체하는 셀 변경으로 표현할 수 있다.
프로그램 실행이 실패하면 그 업무 변경은 취소된다. 실행 결과 기록과 수수료 등 거래 처리에 필요한 효과는 각 경로의 별도 규칙을 따른다. 서명이나 입력 자체가 유효하지 않아 거부되는 거래와, 유효한 거래의 프로그램 실행이 실패하는 경우도 구분한다.
실행 경로는 상태 변경과 성공·실패 등의 결과를 담은 receipt를 만든다. 블록 실행 계층은 정해진 거래 순서로 결과를 모아 블록 상태를 구성한다. 합의로 블록이 확정되면 각 노드의 저장 계층이 상태와 거래·실행 결과를 원자적으로 저장한다. Core의 거래 처리와 블록 실행, 합의, 영속 저장이 각각 맡은 책임을 이어가는 구조다.
따라서 세 경로가 공유하는 것은 StateCell이라는 기록 형식뿐만이 아니다. 거래별 변경을 묶고, 그 결과를 일정한 순서로 원장에 반영하는 과정도 공유한다. 공통 원장 위에서 요청과 실행의 방식은 달리할 수 있도록 한 것이다.
Native Program 쪽에는 한 단계 더 살펴볼 질문이 남는다. 프로그램이 승인한 변경안과 원장이 실제 반영하는 변경이 같다는 것은 어떻게 확인할까? Native Program은 거래의 변경안을 어떻게 검증할까?에서는 앞의 승인 사용 예제를 이어, 검증에 전달하는 자료와 반환 결과를 Core가 어떻게 맞춰보는지 살펴본다.