컨트랙트 하나를 REST API로 운영하기까지
토큰 컨트랙트에는 잔액을 읽는 함수와 토큰을 보내는 함수가 이미 있다. 업무 서버가 쓰기 편하도록 두 함수를 REST API로 내놓으면 연계 작업은 끝날까?
잔액 조회 화면은 잘 열리는데 큰 수량의 끝자리가 달라질 수 있다. 전송 요청에 정상적인 HTTP 응답을 받았어도 고객이 기대한 토큰 이동은 아직 끝나지 않았을 수 있다. 함수 이름을 URL로 옮기는 일만으로는 이 차이를 설명할 수 없다.
BXB의 API Service Factory는 컨트랙트의 인터페이스를 읽어 HTTP API를 구성하고,
그 요청을 실제 조회와 거래 실행으로 연결하는 기능이다.
같은 토큰의 balanceOf와 transfer를 따라가며 생성한 API를 운영 가능한 연결로 만들려면
어떤 정보를 끝까지 보존해야 하는지 살펴보자.
같은 토큰에서 잔액을 읽고 10개를 보낸다
EVM, 즉 이더리움과 호환되는 실행 환경의 한 네트워크에 토큰 T가 배포돼 있다고 하자. A의 잔액은 100개, B의 잔액은 0개다. 업무 서버는 A의 잔액을 조회한 뒤 B에게 10개를 보내려고 한다. 성공 후에는 A가 90개, B가 10개를 갖는다.
계산을 단순하게 하기 위해 T의 소수 자릿수는 0으로 둔다. 컨트랙트에 넣는 정수 10이 토큰 10개다.
발행·소각·토큰 전송 수수료와 다른 동시 거래는 없고, 네트워크 수수료에 필요한 기본 코인은 별도로 준비돼 있다.
실제 고객 거래가 아닌 설명용 조건이다.
이 예제의 ABI에는 다음 두 함수가 들어 있다. 입력 이름은 account, to, amount로 정한다.
balanceOf(address account)
returns (uint256)
transfer(
address to, uint256 amount
) returns (bool)
위 표기는 호출 형태를 줄여 쓴 것이다. 실제 ABI에는 balanceOf가 상태를 읽는 view이고,
transfer가 상태 변경에 사용하는 nonpayable 함수라는 정보도 있다.
Solidity ABI는 이 함수명·입출력 자료형·실행 성격을
프로그램이 읽을 수 있게 기술한다.
두 함수는 같은 컨트랙트에 있지만 업무 서버가 받는 결과는 다르다. 잔액 조회는 값을 읽는 일이고, 전송은 서명된 거래를 만들고 그 결과를 확인하는 일이다.
함수 목록에 실행할 장소를 연결한다
ABI만 읽어서는 어느 네트워크의 어떤 토큰을 호출할지 알 수 없다. 같은 ABI를 쓰는 토큰이 여러 네트워크에 있고, 한 네트워크 안에서도 주소가 다를 수 있기 때문이다.
BXB에서는 컨트랙트 자원과 배포된 주소를 관리하고, API 서버가 호출할 컨트랙트 주소와 그 네트워크를 연결한다. 여기서 컨트랙트 T는 호출 대상이며, 토큰을 받을 B의 주소와는 다르다.
등록 과정에서 만들어지는 결과를 세 가지로 나누면 이해하기 쉽다.
| 등록 결과 | 담는 내용 |
|---|---|
| API 명세 | HTTP 경로·메서드, 입력 위치와 자료형, 응답 형식 |
| 실행 설정 | 사용할 네트워크, 컨트랙트 주소와 함수의 연결 |
| 요청 처리 경로 | 해당 HTTP 요청을 받아 조회 또는 거래 실행으로 넘기는 처리기 |
API 명세에는 OpenAPI를 사용한다. 이 명세를 문서 화면에서 읽을 수도 있지만, BXB의 API 서버는 명세를 바탕으로 요청을 받을 경로와 처리기를 구성한다. 등록이 끝난 뒤에는 요청마다 ABI를 새로 등록하는 것이 아니라, 준비된 연결을 사용한다.
기본 경로에는 컨트랙트 주소와 함수 이름에 더해 함수 선택자가 들어간다. 함수 선택자는 함수 이름과 입력 자료형으로 구분하는 짧은 식별자다. 같은 이름의 함수가 있어도 입력 자료형이 다르면 다른 함수를 호출해야 하기 때문이다. 사람에게 읽기 쉬운 함수명과 실제 실행할 함수의 구분을 함께 유지하는 셈이다.
HTTP 입력을 컨트랙트의 인자로 옮긴다
예제의 두 함수에 적용되는 기본 매핑은 다음과 같다.
| 함수 | 기본 매핑 |
|---|---|
balanceOf | GET · query parameter에 account |
transfer | POST · JSON body에 to, amount |
조회 대상 A의 주소는 account에, 전송받을 B의 주소는 to에 넣는다.
이 입력 이름은 ABI를 따른다. 다른 컨트랙트의 ABI가 _owner나 value라는 이름을 쓰면
생성된 명세에서도 그 이름을 확인해야 한다.
이 기본 경로에서는 두 요청 모두 x-user-key 헤더도 사용한다.
예를 들어 A의 지갑에 연결된 별칭이 customer-a라면 그 값을 전달한다.
조회 요청에서도 호출에 사용할 지갑을 선택하지만, 조회 자체를 블록체인 거래로 서명하는 것은 아니다.
account는 잔액을 읽을 주소이고, x-user-key는 호출에 사용할 지갑을 찾는 값이다.
업무 서버가 A를 인증하고 거래를 허가하는 절차는 이 헤더와 별개로 필요하다. 별칭을 어떻게 고객과 지갑에 연결하는지는 고객 계정과 서명 경계를 다룬 글에서 설명했다.
GET과 POST만 보고 모든 동작을 판단해서도 안 된다.
BXB는 구조체나 다차원 배열처럼 query로 표현하기 어려운 입력이 있는 조회 함수를
POST와 request body로 옮길 수 있다. HTTP에 입력을 싣는 방식과 원장 상태를 변경하는지는 서로 다른 판단이다.
이 글의 balanceOf는 주소 하나를 받으므로 기본 GET 경로로 충분하다.
작은 수량에서 보이지 않던 문제가 큰 정수에서 드러난다
토큰 10개를 보낼 때는 amount를 숫자로 적든 문자열로 적든 차이가 없어 보인다.
하지만 uint256이 표현할 수 있는 범위는 JavaScript의 일반적인 Number가 정수를 정확히 다루는 범위보다 훨씬 크다.
별도의 입력 예제로 최소 단위 수량 9007199254740993을 생각해 보자.
앞의 A100/B0 잔액에서 실제로 보내려는 수량이 아니라, 숫자가 변하는 지점을 확인하기 위한 값이다.
const input =
'{"amount":9007199254740993}';
const data = JSON.parse(input);
String(data.amount);
// "9007199254740992"
JSON 문법에는 문제가 없지만, JavaScript에서 읽은 수량은 이미 1만큼 작아졌다.
JavaScript의 안전한 최대 정수는
9007199254740991이다. 더 큰 정수를 모두 정확하게 구별할 수는 없다.
서버에 도착한 뒤 더 큰 정수 자료형으로 바꿔도, 클라이언트에서 사라진 마지막 자릿수는 되찾을 수 없다.
따라서 BXB가 만드는 명세는 uint256 같은 정수를 십진수 문자열로 표현한다.
예제의 amount에 해당하는 schema의 핵심은 다음과 같다.
amount:
type: string
format: uint256
description: uint256
pattern: '^[0-9]+$'
HTTP에서는 문자열로 주고받되, 원래 컨트랙트 자료형이 uint256이라는 정보도 남긴다.
이렇게 해야 수량을 일반 문장이 아니라 정수 인자로 해석할 수 있다.
문자열 형식의 지정만으로 업무상 허용 수량이나 토큰 잔액까지 확인되는 것은 아니다.
문자열이라는 약속을 입력과 출력 양쪽에서 지킨다
명세에 string이라고 적는 것만으로 정밀도가 보존되지는 않는다.
요청을 받는 코드가 그 문자열을 해석하고, 응답을 만드는 코드도 같은 표현을 지켜야 한다.
BXB의 ABI 변환 경로는 십진수 문자열을 큰 정수 자료형인 BigInteger로 읽어
컨트랙트의 정수 인자를 구성한다. 조회 결과를 해석할 때는 정수를 다시 십진수 문자열로 내보낸다.
예제의 큰 값을 따라가면 보존해야 할 경로는 다음과 같다.
HTTP 입력의 십진수 문자열
"9007199254740993"
↓
큰 정수로 해석
↓
ABI의 uint256으로 인코딩
↓
같은 값을 ABI에서 디코딩
↓
HTTP 출력의 십진수 문자열
"9007199254740993"
이 흐름은 같은 정수 값을 입력하고 다시 읽는 경우의 표현 변환을 보여 준다.
transfer가 보낸 수량을 그대로 반환한다는 뜻은 아니다.
정수를 입력으로 받는 함수와 정수를 출력하는 조회 함수에서 같은 약속을 각각 지켜야 한다.
앞의 잔액 조회에서는 ABI의 반환값에 이름이 없으므로 BXB가 result0이라는 이름을 붙인다.
A의 잔액 100개는 {"result0":"100"}으로 표현된다. 반환값에 이름이 있는 ABI라면 그 이름을 사용한다.
클라이언트가 이 문자열을 화면에 표시하거나 계산할 때도 정밀도를 보존하는 자료형을 선택해야 한다.
토큰의 소수 자릿수 변환은 또 다른 문제다.
T의 소수 자릿수가 6이었다면 토큰 10개에 해당하는 최소 단위 수량은 10000000이다.
API에 넣을 문자열은 "10"이 아니라 "10000000"이 된다.
문자열 표현은 자릿수를 지키고, 단위 변환은 보내려는 수량을 정한다. 둘 다 맞아야 한다.
전송 응답과 토큰 이동의 완료를 구분한다
이제 소수 자릿수가 0인 원래 예제로 돌아가자.
업무 서버는 B의 주소와 amount의 문자열 "10", A의 지갑 별칭을 전송 경로에 전달한다.
BXB는 입력을 ABI에 맞춰 인코딩하고, 설정된 네트워크의 컨트랙트 T를 호출하는 거래를 구성한다.
A의 지갑에 연결된 서명 경로를 거쳐 거래를 전송한다.
이때 일반 비동기 전송 경로의 응답에는 transactionHash가 들어간다.
balanceOf의 잔액처럼 업무 결과를 직접 돌려주는 응답과는 성격이 다르다.
거래 해시는 이후 상태를 추적할 식별자다. HTTP 응답이나 해시가 있다는 사실만으로 노드의 접수 성공, 블록에서의 실행 성공과 고객에게 보여 줄 완료 상태를 한 번에 판정할 수는 없다. receipt, 즉 블록체인 실행 결과와 해당 컨트랙트의 결과를 후속 확인해야 한다.
ERC-20의 transfer는 bool을 반환하지만,
그 반환값이 일반 거래 receipt나 이 HTTP 응답에 그대로 들어오는 것은 아니다.
전송 이벤트와 잔액 등 컨트랙트의 의미에 맞는 결과를 확인하고, 업무에서 요구하는 확정 조건도 적용해야 한다.
재시도와 확인 대기의 자세한 기준은 별도의 거래 수명주기 문제다.
이 예제에서 성공을 확인했다면 A의 잔액은 90개, B의 잔액은 10개다.
이후 잔액 조회에서는 각각 "90", "10"이라는 정수 문자열을 읽는다.
합계 100개가 유지되는 이 결과까지 이어져야 고객의 “10개 보내기”가 완결된다.
생성한 API를 운영한다는 것
API를 등록하면 호출 경로와 명세가 생긴다. 이를 운영하려면 그 명세가 가리키는 실행 대상, 입출력 값의 표현, 거래 상태를 읽는 기준까지 함께 관리해야 한다. 문서 화면에서 작은 숫자 하나가 통과했다는 사실만으로 이 연결을 모두 확인했다고 볼 수는 없다.
예를 들어 잔액 조회의 "100"이 문자열로 유지되는지, 큰 정수의 마지막 자릿수가 입력과 출력에서 보존되는지,
전송 응답의 거래 해시로 실제 실행 결과를 이어서 찾을 수 있는지는 서로 다른 확인 항목이다.
함수명과 URL만 맞추는 테스트로는 놓치기 쉬운 부분이다.
API Service Factory와 관련된 설계 이력에는 「스마트 컨트랙트 포맷 변환을 통한 API 제공 시스템 및 방법」이라는 대한민국 등록특허 제10-2887854호가 있다. 이 연결을 제품으로 발전시킨 배경은 Polsto에서 BXB로 이어진 회고에 담았다.
자동화가 줄여 주는 것은 컨트랙트마다 반복해서 쓰던 인터페이스 연결 코드다. 업무 시스템은 무엇을 누구의 권한으로 실행할지 정하고, BXB는 그 요청의 자료형과 실행 대상을 이어 준다. 그 사이에서 수량 10은 끝까지 10으로 남고, 거래 식별자는 확인된 결과로 이어져야 한다.