ghlee.dev
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) 객체 예시이다.
jsonrpc
속성은 명세의 버전(Version)을 의미한다. 오늘날 사용하는 JSON-RPC
버전은 2.0
이며 이를 생략하면 1.0
으로 구현된 서버에 대한 요청으로 이해하면 된다. 정확하게 2.0
을 문자열(String)로 작성해주면 된다.
method
에는 호출할 함수 이름을 작성해주면 된다. 주의할 것은 RPC 내부 함수 혹은 확장용으로 예약되어 있는 이름이 있으니 이는 피해서 작성하면 된다. 예약된 함수 이름은 RPC로 시작하고 뒤에 마침표 문자(유니코드 U+002E)가 오는 함수 이름이다.
params
는 매개변수를 전달하는데 구조화된 Object
혹은 Array
를 사용하며 호출 시 보낼 매개변수가 없다면 생략도 가능하다.
id
는 요청 간 구분을 위해 사용하는 식별자이며 명세에서는 String
과 Number
, Null
이 가능하다고 언급되어 있다. 참고로 이제까지 실무에서는 정수로 된 Number
만 보았다.
필요하다면 id
를 생략해도 되는데 이는 Notification
요청이 되며 서버는 클라이언트에게 응답 객체을 보내주지 않아도 된다. 클라이언트가 서버에게 일방적으로 무언가 하도록 요청하는 행위이며 혹여 서버가 오류가 나더라도 이를 클라이언트에게 알려줄 의무는 없다.
Response Object
다음으로 아래는 JSON-RPC
응답(Response) 객체 예시이다.
요청 객체와 다르게 응답 객체는 result
값 혹은 error
객체를 포함한다. result
값의 타입은 스펙 상에 따로 정의되어있지 않은데 JSON에서 가능한 타입은 모두 가능할 것으로 추론해볼 수 있다. error
객체는 상황별 코드와 메시지가 정의되어 있고 REST 명세에 비하면 귀여운 수준으로 오류 정의가 되어있으니 문서를 직접 확인해보는 것이 좋을듯 하다.
JavaScript Example
이제까지 설명한 명세를 이용하여 JavaScript 환경에서 fetch
함수로 코드를 구현해보면 다음과 같다.
실제 이더리움 클라이언트로 요청을 보내는 코드 예제이다. URL
은 이더리움 클라이언트의 엔드포인트(Endpoint)이며 실제로 테스트해보고 싶다면 ChainList라고 하는 사이트에 가서 적당한 RPC Server Address
를 복사해오면 된다.
참고로 웹 프론트엔드의 입장에서 이더리움 클라이언트는 서버 역할이 된다. 혹시나 클라이언트와 서버라는 용어 때문에 헷갈리면 안된다.
HTTP 프로토콜 위에서 구현 시 일반적으로 POST
만 사용하여 요청을 보낸다. 요청 객체는 Body 항목을 통해서 전달한다. HTTP 요청 헤더에서 Content-Type
과 Accept
는 application/json
으로 설정해야한다. HTTP 응답 헤더에서 Content-Type
도 application/json
으로 동일하게 설정한다.
Batch Spec
JSON-RPC
는 Batch
요청이 정의되어 있다. 여러 요청 객체를 동시에 보내기 위해 배열에 묶어서 단일 요청으로 전달할 수 있다. 이를 통해 한번 통신하는데 드는 오버헤드를 효과적으로 줄일 수 있다.
아래는 Batch
명세를 활용한 예시이다.
여담이지만 실제 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
를 초기화할 때 받는 옵션을 살펴보자.
타입을 보면 batch
와 관련된 세 가지 옵션을 확인할 수 있는데 각각 batchStallTime
과 batchMaxSize
, batchMaxCount
가 있다.
batchStallTime
은 Provider
를 통해 들어온 요청을 얼마만큼의 시간 동안 모아서 한 번에 처리할 것인지 정하는 옵션이다. 값으로 1000
을 넣어주면 1000ms
이며 1초 동안의 요청을 한 번에 모아서 처리한다. 기본 값으로는 10ms
로 되어있다.
batchMaxSize
는 처리할 요청의 데이터 크기를 제한할 수 있는 옵션이다. 기본 값으로 1MB
로 되어있다.
batchMaxCount
는 처리할 요청의 개수를 제한할 수 있는 옵션이다. 기본 값으로 최대 100
개의 요청을 처리할 수 있다.
다음으로 ethers.js에는 JsonRpcProvider
의 조부모 격인 JsonRpcApiProvider
라고 하는 추상 클래스가 있다.
'...'은 생략한 코드를 의미한다.
이 클래스에 Private인 #scheduleDrain
메서드가 있는데 이곳에서 batch
관련 옵션을 사용한다. 다음은 #scheduleDrain
과 send
메서드의 코드 일부이다.
ethers.js는 setTimeout
을 이용해서 JavaScript의 이벤트 루프를 적극 활용한다. send
메서드에서 #scheduleDrain
메서드를 호출하면서 #drainTimer
가 생성되는데, #drainTimer
가 생성되어 유효한 동안 send
메서드를 통해 들어온 요청은 #payloads
배열에 차곡차곡 쌓이게 된다.
Payload
는 요청 객체(Request Object)로 생각하면 된다.
send
메서드에서는 #scheduleDrain
메서드를 매 요청마다 계속 호출한다. 하지만 #drainTimer
은 이미 하나 생성되어 있으므로 setTimeout
의 콜백(Callback)이 실행되어 #drainTimer
가 명시적으로 제거될 때까지 추가적인 생성은 없을 것이다.
정해진 stallTime
이 지나서 setTimeout
의 콜백이 JavaScript의 큐와 이벤트 루프를 거쳐 실행되면서 #drainTimer
는 삭제되며, 그간 #payloads
배열에 모아놓은 요청들을 batchMaxCount
와 batchMaxSize
의 제한 사항에 유효한지 검사한다.
이제 #scheduleDrain
메서드의 생략된 나머지 코드도 보자.
여기서 볼 것은 await this._send(payload)
이 코드면 충분하다. 검사를 통과한 payload
는 즉시 실행 비동기 함수 내부에서 _send
메서드를 통해 사용된다. 다만 JsonRpcApiProvider
클래스 안에서는 _send
메서드는 함수 시그니처만 선언되어 있으며 실제 구현은 JsonRpcProvider
클래스에 있다.
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_sendTransaction
과 eth_sendRawTransaction
은 매개변수로 받은 데이터를 가상 머신을 통해 바이트 코드로 변환하여 평가(Evaluate) 한다.
다른 예로 net_version
은 가상 머신과 상관없이 클라이언트가 실행하고 있는 블록체인 네트워크의 버전을 확인하고 그 값을 반환한다. 예를 들어 이더리움 메인 넷(Ethereum Mainnet)이라면 1
을 반환할 것이다.
이더리움 가상 머신은 ALU, 스택 메모리 등을 가지고 opcode를 실행하는 가상의 기계이다. Java 혹은 C#의 경험이 있다면 JVM 혹은 CLR과 역할이 유사하다고 생각하면 된다. JavaScript 엔진 중에 가장 유명한 V8에도 Ignition이라는 이름의 바이트 코드를 평가하는 가상 머신이 존재한다.
다음은 eth_sendTransaction
을 이용한 요청 객체 예시이다.
Rust로 작성된 이더리움 클라이언트인 Reth
에서는 jsonrpsee
크레이트(Crate)를 사용하여 JSON-RPC
기반의 서버를 구축하고 있으며, 이 크레이트에서 제공하는 매크로를 통해 핸들러를 생성하고 있다. 핸들러를 통해 분기된 eth_sendTransaction
은 결과적으로 아래 함수를 실행시킨다.
EthTransactions::send_transaction
의 세부 내용을 다루는 것은 이 글의 범위를 많이 벗어나므로 일부 코드만 가져왔다. 이전에 JSON-RPC 요청 객체의 params
배열에 담아 보냈던 값들은 EIP2930
과 EIP1559
, EIP4844
같은 트랜잭션 관련 명세에 따라서 분기 처리하게 된다.
EIP는 이더리움 개선 제안(Ethereum Improvement Proposals)이라 부르는 이더리움 네트워크의 명세이다.
마치며
이제까지 JSON-RPC
의 명세와 블록체인 네트워크와 JavaScript 환경에서의 사용에 대해 알아보았다. 이더리움 네트워크에는 JSON-RPC Batch
명세와 비슷한 역할을 하는 Multicall Contract
도 존재하며 ethers.js에서는 Multicall Contract 기반의 Provider를 확장 라이브러리로 제공한다. 이 글에서는 다루지 않았으니 궁금하다면 batch request and multicall contract
키워드로 검색해 보길 바란다.