ghlee.dev

my profile picture

JSON-RPC와 이더리움 네트워크

2023. 9. 7.

웹 프론트엔드 개발자로 블록체인 도메인에서 개발을 하게 되면 REST보다 많이 접하는 통신 인터페이스는 RPC이다. 그중에서도 JSON-RPC 명세를 많이 사용하게 되는데 이는 평소 REST에만 익숙했던 사람이라면 당황할 수 있다.

업계에서 가장 영향력 있는 이더리움(Ethereum) 네트워크의 클라이언트는 JSON-RPC를 통신 표준으로 사용한다. 그래서 블록체인 도메인에서 깊이 있는 개발을 위해서는 JSON-RPC에 대한 이해는 필수이다.

이 글에서는 JSON-RPC 명세에 대해 알아본다. 그리고 블록체인에서 JavaScript 환경의 프론트엔드 개발자가 주로 사용하는 라이브러리 내부를 일부 파헤쳐 볼 것이다. 블록체인 애플리케이션(DApp) 개발을 위해서 블록체인 클라이언트와 어떻게 통신하게 되는지 어느정도 파악할 수 있을 것이다.

앞으로 블록체인 네트워크는 이더리움 네트워크를 예시로 들겠다.

JSON-RPC 명세

JSON-RPC는 상태 비저장, 경량 원격 프로시저 호출(RPC) 프로토콜입니다. 여러 데이터 구조와 처리에 대한 규칙을 정의합니다.

이는 JSON-RPC의 명세가 정의된 문서 개요의 첫 문단이다. 상태 비저장이란 이전 호출과 현재 호출은 서로 연관 없이 독립적이라는 의미이다. 이벤트 기반의 호출로도 표현할 수 있지 않을까?

경량은 말 그대로 가볍다는 의미이며, 추상적인 의미가 아닌 실제로 전달하는 데이터의 크기가 상대적으로 가볍다. 원격 프로시저는 클라이언트에서 서버에 정의된 함수를 직접 호출한다고 생각하면 된다.

REST에서는 리소스(Resource)를 주고받는 행위에 핵심을 두고 있지만 JSON-RPC에서는 함수 호출이 목적이다. 쉽게 하면 함수 이름과 함수에 필요한 매개변수를 준비한 후 클라이언트가 서버에게 해당 함수를 실행하도록 요청한다고 보면 된다.

웹 프론트엔드 개발자라면 웹뷰(Web View) 작업을 해본 사람은 브릿지(Bridge)라 부르는 인터페이스(Interface) 혹은 레이어(Layer)를 통해 모바일 네이티브와 통신할 때 어떻게 하는지 떠올리면 유사하다고 생각해 볼 수 있을 거다. 사실 네트워크 통신을 기반으로 두었을 뿐 그저 함수를 호출하는 행위이므로 어디에서나 비슷한 패턴을 볼 수 있다.

JSON-RPC 구현

JSON-RPC는 주로 HTTP 프로토콜 위에서 구현되지만 WebSocket을 통해서 구현될 수도 있으며 오로지 TCP 기반으로만 구현도 가능하다. 물론 TCP는 그저 바이트 스트림을 주고 받을 뿐이므로 JSON을 위해서 별도의 직렬화와 역직렬화를 직접 해주어야 한다.

어찌됐든 특정 프로토콜에서만 구현되어야 하는 강제 사항은 없다. JSON-RPC 명세에는 소켓을 이용한 네트워크 통신이면서 클라이언트와 서버가 동일한 프로토콜을 사용하라고만 정의되어 있다.

이는 다른 통신 프로토콜인 REST, GraphQL도 유사하다. 그저 데이터를 주고받기 위한 인터페이스 정의일 뿐이다.

여담으로 React의 Gatsby 프레임워크에서는 빌드 타임에 페이지를 구성할 때 GraphQL 인터페이스를 활용한다. 이처럼 일반적으로 생각하는 클라이언트-서버 통신이 아닌 곳에서도 활용할 수 있다.

Request Object

아래는 이더리움 클라이언트로 보내는 JSON-RPC 요청(Request) 객체 예시이다.

JSON
// Request 객체
{
  "jsonrpc": "2.0",
  "method": "eth_getBalance",
  "params": ["0x407d73d8a49eeb85d32cf465507dd71d507100c1", "latest"],
  "id": 83
}

jsonrpc 속성은 명세의 버전(Version)을 의미한다. 오늘날 사용하는 JSON-RPC 버전은 2.0이며 이를 생략하면 1.0으로 구현된 서버에 대한 요청으로 이해하면 된다. 정확하게 2.0을 문자열(String)로 작성해주면 된다.

method에는 호출할 함수 이름을 작성해주면 된다. 주의할 것은 RPC 내부 함수 혹은 확장용으로 예약되어 있는 이름이 있으니 이는 피해서 작성하면 된다. 예약된 함수 이름은 RPC로 시작하고 뒤에 마침표 문자(유니코드 U+002E)가 오는 함수 이름이다.

params는 매개변수를 전달하는데 구조화된 Object 혹은 Array를 사용하며 호출 시 보낼 매개변수가 없다면 생략도 가능하다.

id는 요청 간 구분을 위해 사용하는 식별자이며 명세에서는 StringNumber, Null이 가능하다고 언급되어 있다. 참고로 이제까지 실무에서는 정수로 된 Number만 보았다.

필요하다면 id를 생략해도 되는데 이는 Notification 요청이 되며 서버는 클라이언트에게 응답 객체을 보내주지 않아도 된다. 클라이언트가 서버에게 일방적으로 무언가 하도록 요청하는 행위이며 혹여 서버가 오류가 나더라도 이를 클라이언트에게 알려줄 의무는 없다.

Response Object

다음으로 아래는 JSON-RPC 응답(Response) 객체 예시이다.

JSON
// Response 객체
{
  "id": 83,
  "jsonrpc": "2.0",
  "result": "0x0234c8a3397aab58"
  // 서버 오류 시 result 대신 error 객체 반환
  // "error": {"code": -32601, "message": "Method not found"}
}

요청 객체와 다르게 응답 객체는 result값 혹은 error 객체를 포함한다. result값의 타입은 스펙 상에 따로 정의되어있지 않은데 JSON에서 가능한 타입은 모두 가능할 것으로 추론해볼 수 있다. error 객체는 상황별 코드와 메시지가 정의되어 있고 REST 명세에 비하면 귀여운 수준으로 오류 정의가 되어있으니 문서를 직접 확인해보는 것이 좋을듯 하다.

JavaScript Example

이제까지 설명한 명세를 이용하여 JavaScript 환경에서 fetch 함수로 코드를 구현해보면 다음과 같다.

JavaScript
fetch(URL, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    jsonrpc: '2.0',
    method: 'eth_getBalance',
    // 테스트해보고 싶다면 Array의 첫번째 인자로 이더리움 계정 주소를 넣어주면 된다.
    params: ['0xf2E9832D350697643D4Eeb1E4d60ba24A8862a97', 'latest'],
    id: 1,
  }),
})
  .then((res) => res.json())
  .them((data) => console.log(data));
 
// 실제 응답 데이터
// { jsonrpc: '2.0', id: 1, result: '0x4b41359274a0' }

실제 이더리움 클라이언트로 요청을 보내는 코드 예제이다. URL은 이더리움 클라이언트의 엔드포인트(Endpoint)이며 실제로 테스트해보고 싶다면 ChainList라고 하는 사이트에 가서 적당한 RPC Server Address를 복사해오면 된다.

참고로 웹 프론트엔드의 입장에서 이더리움 클라이언트는 서버 역할이 된다. 혹시나 클라이언트와 서버라는 용어 때문에 헷갈리면 안된다.

HTTP 프로토콜 위에서 구현 시 일반적으로 POST만 사용하여 요청을 보낸다. 요청 객체는 Body 항목을 통해서 전달한다. HTTP 요청 헤더에서 Content-TypeAcceptapplication/json으로 설정해야한다. HTTP 응답 헤더에서 Content-Typeapplication/json으로 동일하게 설정한다.

Batch Spec

JSON-RPCBatch 요청이 정의되어 있다. 여러 요청 객체를 동시에 보내기 위해 배열에 묶어서 단일 요청으로 전달할 수 있다. 이를 통해 한번 통신하는데 드는 오버헤드를 효과적으로 줄일 수 있다.

아래는 Batch 명세를 활용한 예시이다.

JSON
[
  {
    "jsonrpc": "2.0",
    "method": "eth_getBalance",
    "params": ["0xf2E9832D350697643D4Eeb1E4d60ba24A8862a97", "latest"],
    "id": 1
  },
  { "jsonrpc": "2.0", "method": "net_version", "params": [], "id": 67 },
  { "jsonrpc": "2.0", "method": "net_peerCount", "params": [], "id": 74 }
]

여담이지만 실제 DApp 개발을 할 때 이 Batch 명세는 상당히 중요하다. 실무에서 블록체인은 전통적인 관계형 데이터베이스와 달리 쿼리(Query)에 드는 비용이 상당하다. 그 비용을 줄이기 위해 Batch는 상당히 유용한 기능이다.

예를 들어 판매 상품이 ERC-721 명세의 토큰(NFT)으로 되어있다고 가정하자. 전통적인 관계형 데이터베이스에서는 SQL 쿼리문 하나로 ERC-721 명세와 유사한 데이터 구조를 한번에 몇 천개, 몇 만개씩 가져올 수 있지만 블록체인에서는 수 많은 토큰의 메타데이터를 각각 조회해주어야 한다. 이를 위해 Batch 기능을 활용하여 각각의 조회를 배열에 묶어서 처리한다. 이 명세를 모른다면 처음 DApp 개발을 할 때 당황할 수 밖에 없다.

관계형 데이터베이스 시스템에서는 다량의 데이터를 조회하기 위해 다양한 알고리듬(BTree 등)이 존재하며 이에 따른 최적화가 있어서 블록체인과 비교하면 훨씬 빠르고 안정적이라고 생각한다. 실제로 실무에서는 이더리움 클라이언트를 통해 블록에 직접 접근하여 조회하는 것보다 사전에 데이터베이스에 필요한 블록 데이터를 쌓아둔 뒤 그 데이터를 활용하기도 한다. 예를 들어 가장 유명한 NFT 마켓플레이스인 OpenSea는 이 방법으로 NFT 정보를 제공하고 있다.

또한 실무에서는 아까 보여준 예제처럼 fetch 함수를 이용하여 직접 구현하기보다는 잘 만들어진 라이브러리를 사용하게 된다. 주로 ethers.js와 web3.js를 사용하는데, 두 라이브러리는 JSON-RPC로 이더리움 클라이언트와 손쉽게 통신할 수 있도록 다양한 기능을 제공한다.

ethers.js

언급된 김에 ethers.js의 내부를 살펴보고 가자. ethers.js는 이더리움 가상 머신(EVM)이 구현된 클라이언트와 통신하는데 유용하며 JavaScript 환경에서 일하는 블록체인 영역의 개발자라면 모를 수 없는 라이브러리 중 하나이다. ethers.js를 통해 이더리움 클라이언트와의 통신뿐 아니라 유저 클라이언트 환경(예를 들면 크롬 확장 프로그램)에서 작동하는 암호화폐 지갑 애플리케이션도 구축해 볼 수 있다.

ethers.js에서는 JSON-RPC 통신을 위해 JsonRpcProvider라고 하는 인터페이스를 제공하고 있으며 이를 통해 JSON-RPC의 Batch 명세를 손쉽게 사용할 수 있다.

우선 Provider를 초기화할 때 받는 옵션을 살펴보자.

TypeScript
export type JsonRpcApiProviderOptions = {
  polling?: boolean;
  staticNetwork?: null | boolean | Network;
 
  batchStallTime?: number;
  batchMaxSize?: number;
  batchMaxCount?: number;
 
  cacheTimeout?: number;
  pollingInterval?: number;
};

타입을 보면 batch와 관련된 세 가지 옵션을 확인할 수 있는데 각각 batchStallTimebatchMaxSize, batchMaxCount가 있다.

batchStallTimeProvider를 통해 들어온 요청을 얼마만큼의 시간 동안 모아서 한 번에 처리할 것인지 정하는 옵션이다. 값으로 1000을 넣어주면 1000ms이며 1초 동안의 요청을 한 번에 모아서 처리한다. 기본 값으로는 10ms로 되어있다.

batchMaxSize는 처리할 요청의 데이터 크기를 제한할 수 있는 옵션이다. 기본 값으로 1MB로 되어있다.

batchMaxCount는 처리할 요청의 개수를 제한할 수 있는 옵션이다. 기본 값으로 최대 100개의 요청을 처리할 수 있다.

다음으로 ethers.js에는 JsonRpcProvider의 조부모 격인 JsonRpcApiProvider라고 하는 추상 클래스가 있다.

TypeScript
export abstract class JsonRpcApiProvider extends AbstractProvider {...}

'...'은 생략한 코드를 의미한다.

이 클래스에 Private인 #scheduleDrain 메서드가 있는데 이곳에서 batch 관련 옵션을 사용한다. 다음은 #scheduleDrainsend 메서드의 코드 일부이다.

TypeScript
export abstract class JsonRpcApiProvider extends AbstractProvider {
 
	(...)
 
	#scheduleDrain(): void {
		// #drainTimer가 이미 생성되어 있는지 확인한다.
		if (this.#drainTimer) {
			return;
		}
 
		const stallTime = (this._getOption("batchMaxCount") === 1) ? 0: this._getOption("batchStallTime");
 
		this.#drainTimer = setTimeout(() => {
			// 이전에 생성되었던 #drainTimer을 제거한다.
			this.#drainTimer = null;
 
			const payloads = this.#payloads;
			this.#payloads = [ ];
 
			while (payloads.length) {
				const batch = [ <Payload>(payloads.shift()) ];
				while (payloads.length) {
					if (batch.length === this.#options.batchMaxCount) {
						break;
					}
					batch.push(<Payload>(payloads.shift()));
					const bytes = JSON.stringify(batch.map((p) => p.payload));
					if (bytes.length > this.#options.batchMaxSize) {
						payloads.unshift(<Payload>(batch.pop()));
						break;
					}
				}
 
				(...)
 
			}
		}, stallTime);
	}
 
	send(method: string, params: Array<any> | Record<string, any>): Promise<any> {
 
		(...)
 
		const id = this.#nextId++;
		const promise = new Promise((resolve, reject) => {
			// 들어오는 요청을 #payloads에 보관한다.
			this.#payloads.push({
				resolve, reject,
				payload: { method, params, id, jsonrpc: "2.0" }
			});
		});
 
		this.#scheduleDrain();
 
		return <Promise<JsonRpcResult>>promise;
	}
}

ethers.js는 setTimeout을 이용해서 JavaScript의 이벤트 루프를 적극 활용한다. send 메서드에서 #scheduleDrain 메서드를 호출하면서 #drainTimer가 생성되는데, #drainTimer가 생성되어 유효한 동안 send 메서드를 통해 들어온 요청은 #payloads 배열에 차곡차곡 쌓이게 된다.

Payload는 요청 객체(Request Object)로 생각하면 된다.

send메서드에서는 #scheduleDrain 메서드를 매 요청마다 계속 호출한다. 하지만 #drainTimer은 이미 하나 생성되어 있으므로 setTimeout의 콜백(Callback)이 실행되어 #drainTimer가 명시적으로 제거될 때까지 추가적인 생성은 없을 것이다.

정해진 stallTime이 지나서 setTimeout의 콜백이 JavaScript의 큐와 이벤트 루프를 거쳐 실행되면서 #drainTimer는 삭제되며, 그간 #payloads 배열에 모아놓은 요청들을 batchMaxCountbatchMaxSize의 제한 사항에 유효한지 검사한다.

이제 #scheduleDrain 메서드의 생략된 나머지 코드도 보자.

TypeScript
#scheduleDrain(): void {
	if (this.#drainTimer) {
		return;
	}
 
	const stallTime = (this._getOption("batchMaxCount") === 1) ? 0: this._getOption("batchStallTime");
 
	this.#drainTimer = setTimeout(() => {
		this.#drainTimer = null;
 
		const payloads = this.#payloads;
		this.#payloads = [];
 
		while (payloads.length) {
 
			(...)
 
			// 이전에 생략되었던 코드이다.
			(async () => {
				const payload = ((batch.length === 1) ? batch[0].payload: batch.map((p) => p.payload));
				try {
					const result = await this._send(payload);
 
					for (const { resolve, reject, payload } of batch) {...}
				} catch (error: any) {
					for (const { reject } of batch) {
						reject(error);
					}
				}
			})();
		}
	}, stallTime);
}

여기서 볼 것은 await this._send(payload) 이 코드면 충분하다. 검사를 통과한 payload는 즉시 실행 비동기 함수 내부에서 _send메서드를 통해 사용된다. 다만 JsonRpcApiProvider 클래스 안에서는 _send 메서드는 함수 시그니처만 선언되어 있으며 실제 구현은 JsonRpcProvider 클래스에 있다.

TypeScript
export class JsonRpcProvider extends JsonRpcApiPollingProvider {
 
  (...)
 
  async _send(payload: JsonRpcPayload | Array<JsonRpcPayload>): Promise<Array<JsonRpcResult>> {
		const request = this._getConnection();
		request.body = JSON.stringify(payload);
		request.setHeader("content-type", "application/json");
 
		const response = await request.send();
 
		response.assertOk();
 
		let resp = response.bodyJson;
 
		if (!Array.isArray(resp)) {
			resp = [ resp ];
		}
 
		return resp;
	}
}

payload는 파라미터를 통해 단일로 받거나 배열을 통해 받을 수 있으며, 특별히 구분하지 않고 JSON.stringify를 하여 body에 건네주고 있다. 만약 배열을 통해서 받았다면 자연스럽게 Batch 명세로 처리되어진다.

지금까지 살펴본 코드는 이 링크를 통해 확인해 볼 수 있다.

ethers.js 사용자는 JSON-RPC의 Batch 명세를 사용하기 위해 별도의 처리를 할 필요가 없다. 그저 JsonRpcProvider 클래스를 이용해서 요청 객체를 각각 호출하면 지금까지 보았던 내부적인 처리를 통해 알아서 Batch 명세로 처리해 준다.

JSON-RPC와 이더리움 네트워크

이더리움 재단에서는 클라이언트 설계 정의를 문서로 정리하여 배포하는데, 개인이나 단체에서 이를 참고하여 클라이언트를 개발한다. 실제로 다양한 클라이언트가 존재하는데 모두 JSON-RPC 요청을 받을 수 있도록 설계되어 있다.

이더리움 생태계에서 가장 유명한 클라이언트는 Geth이다. 이 링크를 통해 이더리움 재단이 검증한 다양한 클라이언트를 볼 수 있다.

블록체인 애플리케이션과 이더리움 네트워크 사이의 통신하는 과정을 정리해 보면 다음과 같다.

  • 블록체인 애플리케이션은 이더리움 클라이언트의 엔드 포인트로 JSON-RPC 명세에 맞춰서 요청을 보낸다.
  • 이더리움 클라이언트는 받은 JSON-RPC 요청을 Method를 기반으로 해석하여 처리한다.

이더리움 클라이언트는 이더리움 가상 머신(EVM)에서 처리할 트랜잭션(Transaction)과 가상 머신 바깥에서 처리할 요청을 Method, 즉 함수 기반으로 구분한다.

예를 들어 Method 중에 eth_sendTransactioneth_sendRawTransaction은 매개변수로 받은 데이터를 가상 머신을 통해 바이트 코드로 변환하여 평가(Evaluate) 한다.

다른 예로 net_version은 가상 머신과 상관없이 클라이언트가 실행하고 있는 블록체인 네트워크의 버전을 확인하고 그 값을 반환한다. 예를 들어 이더리움 메인 넷(Ethereum Mainnet)이라면 1을 반환할 것이다.

이더리움 가상 머신은 ALU, 스택 메모리 등을 가지고 opcode를 실행하는 가상의 기계이다. Java 혹은 C#의 경험이 있다면 JVM 혹은 CLR과 역할이 유사하다고 생각하면 된다. JavaScript 엔진 중에 가장 유명한 V8에도 Ignition이라는 이름의 바이트 코드를 평가하는 가상 머신이 존재한다.

다음은 eth_sendTransaction을 이용한 요청 객체 예시이다.

JSON
{
  "jsonrpc": "2.0",
  "method": "eth_sendTransaction",
  "params": [
    {
      "from": "0xb60e8dd61c5d32be8058bb8eb970870f07233155",
      "to": "0xd46e8dd67c5d32be8058bb8eb970870f07244567",
      "gas": "0x76c0",
      "gasPrice": "0x9184e72a000",
      "value": "0x9184e72a",
      "input": "0xd46e8dd67c5d32be8d46e8dd67c5d32be8058bb8eb970870f072445675058bb8eb970870f072445675"
    }
  ],
  "id": 1
}

Rust로 작성된 이더리움 클라이언트인 Reth에서는 jsonrpsee 크레이트(Crate)를 사용하여 JSON-RPC 기반의 서버를 구축하고 있으며, 이 크레이트에서 제공하는 매크로를 통해 핸들러를 생성하고 있다. 핸들러를 통해 분기된 eth_sendTransaction은 결과적으로 아래 함수를 실행시킨다.

Rust
// https://github.com/paradigmxyz/reth/blob/main/crates/rpc/rpc-eth-api/src/core.rs#L763
async fn send_transaction(&self, request: TransactionRequest) -> RpcResult<B256> {
		trace!(target: "rpc::eth", ?request, "Serving eth_sendTransaction");
		Ok(EthTransactions::send_transaction(self, request).await?)
}
Rust
// https://github.com/paradigmxyz/reth/blob/main/crates/rpc/rpc-eth-api/src/helpers/transaction.rs#L55
pub trait EthTransactions: LoadTransaction {
 
  (...)
 
  fn send_transaction(
        &self,
        mut request: TransactionRequest,
    ) -> impl Future<Output = Result<B256, Self::Error>> + Send {
 
      (...)
 
      let transaction = match transaction {
        Some(TypedTransactionRequest::Legacy(mut req)) => {...}
        Some(TypedTransactionRequest::EIP2930(mut req)) => {...}
        Some(TypedTransactionRequest::EIP1559(mut req)) => {...}
        Some(TypedTransactionRequest::EIP4844(mut req)) => {...}
        None => return Err(EthApiError::ConflictingFeeFieldsInRequest.into()),
      }
 
      (...)
    }
}

EthTransactions::send_transaction의 세부 내용을 다루는 것은 이 글의 범위를 많이 벗어나므로 일부 코드만 가져왔다. 이전에 JSON-RPC 요청 객체의 params 배열에 담아 보냈던 값들은 EIP2930EIP1559, EIP4844 같은 트랜잭션 관련 명세에 따라서 분기 처리하게 된다.

EIP는 이더리움 개선 제안(Ethereum Improvement Proposals)이라 부르는 이더리움 네트워크의 명세이다.

마치며

이제까지 JSON-RPC의 명세와 블록체인 네트워크와 JavaScript 환경에서의 사용에 대해 알아보았다. 이더리움 네트워크에는 JSON-RPC Batch 명세와 비슷한 역할을 하는 Multicall Contract도 존재하며 ethers.js에서는 Multicall Contract 기반의 Provider를 확장 라이브러리로 제공한다. 이 글에서는 다루지 않았으니 궁금하다면 batch request and multicall contract 키워드로 검색해 보길 바란다.