동시에 실행해도 같은 원장이 되려면
같은 원장 상태에서 실행할 거래와 그 순서가 정해졌다고 하자. 어떤 노드는 그 거래들을 하나씩 실행하고, 다른 노드는 여러 작업에 나누어 동시에 실행한다. 두 노드가 같은 원장을 만들려면 무엇이 같아야 할까?
동시에 시작한 작업은 끝나는 순서가 달라질 수 있다. 뒤에 놓인 거래가 먼저 끝났다는 이유로 원장에 먼저 반영된다면, 다른 노드와 거래 순서나 실행 결과가 달라질 수 있다. 반대로 앞 거래가 끝나야만 다음 거래를 시작한다면, 서로 무관한 계산도 함께 실행할 수 없다.
Native Program의 검증 구조에서는 거래가 사용할 상태와 제안하는 변경을 미리 드러내는 방식을 살펴봤다. 그 정보는 여러 거래 중 무엇을 함께 실행할 수 있는지 판단하는 데도 쓰인다. 이번에는 계산이 끝나는 순서가 달라도, 정해진 순서대로 하나씩 실행했을 때와 같은 결과를 만드는 방법을 보자.
자산 100을 두 갈래로 나누고 다시 합친다
A가 자산 100을 갖고 있고, B에게 20, C에게 10을 보내려 한다. 니고에서는 자산의 소유자와 수량을 StateCell이라는 기록에 담을 수 있다. 거래는 기존 기록을 소비하고 새 기록을 만드는 방식으로 이전을 표현한다.
예제에서는 소유자의 직접 이전이 허용된 Native Asset을 사용하고, 네 거래 모두 A가 서명한다고 하자. 추가 발행·소각은 없고 수수료 금액은 0이다. 네 거래를 한 블록에 담을 실행량 여유도 있다고 가정한다. 의존 관계를 보기 위해 이전을 다음 네 거래로 나누었다.
- 나누기: A의 100을 소비하고, A 소유의 왼쪽 60과 오른쪽 40을 만든다.
- B에게 보내기: 왼쪽 60을 소비해 B에게 20을 주고, A에게 40을 남긴다.
- C에게 보내기: 오른쪽 40을 소비해 C에게 10을 주고, A에게 30을 남긴다.
- 잔액 합치기: 두 송금 뒤 A에게 남은 40과 30을 소비해 A의 70을 만든다.
최종 상태는 A=70, B=20, C=10, 합계는 여전히 100이다. B의 20과 C의 10은 마지막 거래에서 소비하지 않고 그대로 남는다. ①이 만든 오른쪽 40과 ②가 만든 왼쪽 잔액 40은 금액만 같을 뿐 서로 다른 기록이다.
거래 후보의 순서를 ① → ② → ③ → ④로 고정해보자. ②와 ③은 ①이 만든 서로 다른 입력을 사용한다. 따라서 ①의 결과가 준비되면 두 송금은 함께 계산할 수 있다. ④는 두 송금이 남긴 잔액을 모두 사용하므로 양쪽 결과를 기다려야 한다.
화살표는 뒤 거래가 앞 거래의 결과를 필요로 한다는 뜻이다. ②와 ③은 함께 계산할 수 있다. B에게 보낸 20과 C에게 보낸 10은 ④로 합치지 않고 각자의 기록으로 남는다.
이 예제는 앞의 결과를 둘로 나누고 다시 합치는 의존 관계를 보여준다. 지갑이 모든 송금을 이렇게 네 거래로 나누어야 한다는 뜻은 아니다.
소유자가 같아도 독립적일 수 있다
②와 ③은 모두 A의 자산을 보낸다. 소유자가 같다는 이유만으로 같은 상태를 고치는 거래라고 판단하면, 이 둘을 함께 실행할 기회를 놓친다. 반대로 받는 사람이 B와 C로 다르다는 이유만으로 독립적이라고 판단해서도 안 된다. 두 거래가 같은 입력 기록을 소비하려 할 수도 있기 때문이다.
독립성을 판단하려면 각 거래가 어떤 기록을 읽고, 어떤 기록을 소비하거나 생성하는지 알아야 한다. 거래별 원장 규칙을 검사하는 Core는 거래의 의미를 해석해 이 접근 범위를 제공하고, 블록 실행 계층은 거래 사이의 의존 관계를 만든다. 앞 거래의 출력을 뒤 거래가 읽거나 소비한다면 앞 거래의 결과가 필요하다. 같은 기록을 바꾸려 하거나, 한쪽이 읽는 기록을 다른 쪽이 바꾼다면 순서를 지켜야 한다.
함께 읽는 것만으로는 충돌하지 않는다. ②와 ③이 같은 자산 정책을 읽더라도 둘 다 그 정책을 바꾸지 않는다면, 그 공유 조회 때문에 기다릴 필요는 없다. 이 예제의 두 갈래는 소비하는 자산 기록과 생성하는 기록도 서로 다르다. 수수료 처리에 필요한 기록을 포함해 다른 변경 대상도 공유하지 않는다고 가정한다.
만약 두 거래가 모두 왼쪽 60을 소비하도록 바뀐다면 이야기가 달라진다. 한 번 소비한 입력을 다시 사용할 수 없으므로 둘 다 성공할 수 없다. 순서를 붙인다고 두 요청이 모두 유효해지는 것도 아니다. 병렬 실행 여부와 별개로 거래 자체의 검증이 필요하다.
접근 범위에는 표면적인 송금 입출력 외의 상태도 포함된다. 자산 정책, 수수료 처리, “이 기록이 아직 없다”는 조회도 결과에 영향을 줄 수 있다. Native Program은 실행 중 거절되더라도 수수료 관련 기록을 바꿀 수 있으므로, 성공할 때와 실패할 때 접근할 수 있는 범위를 함께 고려해야 한다. 성공 경로만 보고 독립적이라고 판단해서는 안 되는 이유다.
같은 기준 상태를 보고, 변경은 따로 모은다
함께 실행할 거래를 찾았어도, 여러 작업이 같은 저장소를 직접 고치게 할 수는 없다. 한 작업이 반쯤 반영한 결과를 다른 작업이 읽으면 그때그때의 실행 속도가 거래의 입력이 되어버린다.
니고의 병렬 경로에서는 같은 묶음의 작업들이 읽을 기준 상태를 고정한다. 각 작업은 그 상태를 보면서 자신의 변경을 별도의 임시 공간에 모은다. 이처럼 기존 상태 위에 아직 확정하지 않은 변경을 얹는 공간을 overlay라고 부른다. 각 작업이 전체 원장을 복제하는 구조는 아니다.
예제에서 ①이 만든 60과 40은 먼저 병렬 시도의 임시 상태에 반영된다. 그 상태를 기준으로 ②와 ③을 함께 계산한다. ②의 작업은 왼쪽 60의 소비와 A의 40·B의 20 생성을 기록하고, ③의 작업은 오른쪽 40의 소비와 A의 30·C의 10 생성을 기록한다. 서로의 작업 공간을 수정하거나 상대 작업의 중간 상태를 읽지 않는다.
두 결과가 검증되면 다음 계산에 사용할 임시 상태로 모은다. 그제야 ④는 A에게 남은 두 기록을 입력으로 사용해 70을 만들 수 있다. 여기서 다음 거래가 앞 거래의 결과를 본다는 것과, 그 결과가 데이터베이스에 확정 저장됐다는 것은 다르다. 이 단계의 상태는 병렬 시도를 위해 모으는 잠정 결과다.
모든 작업이 거래마다 다른 환경을 보아서도 안 된다. 같은 블록을 실행하는 데 쓰는 높이와 시각, 수수료 기준, Native Program을 해석하는 기준 등도 고정한다. 거래 사이에서 바뀌는 자산·승인 상태와, 블록 실행의 공통 기준을 구분하는 것이다.
먼저 끝난 거래가 앞자리를 차지하지 않는다
③이 ②보다 먼저 끝났다고 해보자. ③의 결과는 자신에게 배정된 위치에 보관된다. 완료 순서 때문에 원래 후보 순서인 ① → ② → ③ → ④를 바꾸지는 않는다.
의존 관계를 따라 계산한 결과를 최종 블록 상태에 반영할 때는 정해진 후보 순서를 따른다. 예제의 정상 경로에서는 ①의 나누기, ②의 B 송금, ③의 C 송금, ④의 합치기가 그 순서로 반영된다. ②와 ③을 계산하는 시간이 겹칠 수 있으면서도, 원장에 남는 의미는 순차 실행과 같아지는 것이다.
그 의미는 최종 잔액이 같다는 것보다 넓다. 어떤 거래가 블록에 들어갔는지, 성공·실패를 담은 실행 결과 기록인 receipt와 이벤트가 어떤 순서로 남는지, 어떤 셀을 소비하고 생성했는지도 같아야 한다. 최종 상태를 대표하는 해시인 state root와 블록 해시도 실행 작업 수나 완료 순서에 따라 달라져서는 안 된다.
이 동일성을 확인하기 위해 니고는 같은 요청을 순차·병렬로 실행해 결과를 비교하는 검증 경로도 둔다. 이 경로에서는 비교 결과와 관계없이 순차 결과를 최종 채택한다.
실행량 제한을 생각하면 후보 순서가 필요한 이유가 더 분명해진다. 앞에서는 네 거래를 모두 담을 여유가 있다고 가정했다. 그 여유가 없어 같은 블록에 일부만 넣을 수 있다면, 병렬로 먼저 계산을 끝냈다는 이유로 먼저 포함할 수는 없다. 노드는 후보 순서에 따라 누적 실행량인 gas를 계산하고 포함·보류를 결정한다. 수수료 금액이 0이어도 실행량 제한은 별도로 존재한다.
따라서 계산을 끝낸 결과도 최종 블록에 채택되지 않을 수 있다. 임시 공간에서 계산한 변경을 곧바로 영속 저장하지 않는 이유가 여기에 있다. 채택된 블록 결과의 확정·저장은 이후의 공통 경로를 따른다.
예상한 접근과 실제 실행이 다르면 다시 판단한다
병렬 계획은 접근 범위가 정확하다는 전제 위에 선다. 만약 접근 분석이나 실행 구현에 오류가 있어, ②가 왼쪽 입력만 사용한다고 분석했는데 실제 실행에서는 오른쪽 기록까지 바꿨다면, ②와 ③을 독립적으로 계산해도 된다는 판단부터 잘못된 셈이다.
니고는 실제 실행 중 읽고 쓴 기록을 추적해 미리 선언한 범위 안에 있는지 확인한다. 읽으려 했지만 존재하지 않았던 기록도 추적 대상이다. 작업이 반환한 상태 변경이 실제 쓰기와 맞는지, 실행 결과가 올바른 거래에 속하는지, 프로그램과 블록의 실행 기준이 계획한 것과 같은지도 대조한다. 앞서 함께 실행해도 된다고 판단했다는 이유만으로 결과를 그대로 채택하지 않는다.
이 검증에 실패하거나 병렬 작업이 중단·시간 초과되는 경우에는 블록의 병렬 시도 전체를 폐기하고, 변경 전의 원본 상태와 같은 후보 목록으로 순차 실행을 다시 수행한다. 일부 작업의 결과를 이미 채택한 상태에서 나머지만 이어가면, 잘못된 분석에 기대어 만든 변경이 남을 수 있기 때문이다. 원본으로 돌아가야 기존 순차 실행을 기준으로 결과를 다시 결정할 수 있다.
이것을 모든 거래 실패의 처리 방식으로 이해하면 안 된다.
예를 들어 Native Program이 사용 조건 때문에 revert를 반환한 경우에는 업무 변경이 취소되고,
정해진 수수료 변경과 실패 receipt가 유효한 실행 결과로 남을 수 있다.
그 결과가 접근 범위와 실행 규칙을 지켰다면, 프로그램이 거절했다는 이유만으로 병렬 실행 전체가 잘못된 것은 아니다.
블록의 누적 gas 한도에 따라 뒤 거래를 보류하는 일도 정상적인 결과 선택이다.
순차 실행으로 되돌아가는 것은 노드 내부의 병렬 시도를 신뢰할 수 없을 때 적용하는 장치다. 그 사실만으로 거래나 블록이 무효가 되는 것은 아니다. 거래의 유효성과 최종 처분은 순차 경로의 규칙으로 판단한다.
순서를 지켜야 하는 경계도 있다
현재 니고는 일반 EVM 거래를 순차 실행하는 경계로 둔다. 컨트랙트가 실행 도중 어떤 저장값을 읽고 다른 컨트랙트를 호출할지는 실행 전의 명시적 셀 목록만으로 확정하기 어렵기 때문이다. 자산 등록이나 프로그램 설치·변경처럼 시스템의 기준 상태를 바꾸는 작업도 이 경계에서 처리한다.
이런 거래가 나오면 앞의 병렬 구간을 마무리하고, 갱신된 상태에서 그 거래를 순차 처리한다. 뒤 거래들은 그 결과가 반영된 상태를 기준으로 다시 묶는다. 접근 범위를 확정할 수 없는 거래를 “아무 상태도 건드리지 않는 거래”로 간주해 함께 실행하지 않는다.
앞의 그림에서 ②와 ③ 사이에 이런 순차 경계가 들어간다면, 두 송금은 같은 병렬 구간으로 묶이지 않는다. 자산 입력이 따로 있다는 사실만으로 블록 안의 모든 경계를 넘어 함께 실행할 수 있는 것은 아니다.
Native Program은 필요한 상태를 명시적으로 전달받는 구조 덕분에 병렬 여부를 분석할 기반을 갖는다. 그래도 실제 접근 범위와 프로그램·정책의 기준을 확인해야 한다. 어떤 프로그램 이름을 사용했다는 이유만으로 자동으로 병렬 실행을 허용하는 방식은 아니다.
같은 결과를 지키면서 일을 배정하려면
예제의 두 송금은 별도 입력을 사용하므로 함께 계산할 수 있고, 합치기는 양쪽 결과를 기다린다. 각 작업은 고정된 기준 상태에서 변경을 따로 모으며, 실제 접근과 결과를 검증받는다. 최종 반영은 후보 순서에 따라 이루어져 A=70, B=20, C=10과 그에 대응하는 기록을 남긴다.
이때 병렬화는 각 노드가 계산을 배치하는 방법이다. 작업 수와 완료 순서가 달라도 같은 입력 상태와 거래 순서에서는 같은 블록 결과가 나와야 한다.
그 조건을 지키는 실행 배치도 하나로 정해지지는 않는다. 빠른 거래의 결과만 있으면 시작할 수 있는 후속 거래까지, 같은 묶음의 느린 거래를 기다리게 할 필요가 있을까? 준비된 거래를 바로 배정하거나 현재 작업자가 이어서 맡는 방법도 생각할 수 있다. 같은 거래 의존성, 세 가지 병렬 실행 방식에서는 거래 사이의 기다림을 DAG로 표현하고, 같은 의존 관계를 다르게 배정하는 방법을 살펴본다.