Solidity 튜토리얼 1: Youtube 광고 수익 정산 계약

Yoonjae Yoo
DNEXT
Published in
22 min readMar 25, 2018

스마트 계약(Smart contract)은 많은 것을 가능하게 하지만 가장 매력적인 점은 중재자 없이 거래를 가능하게 한다는 것입니다. 계약의 내용을 상대방이 위반할지 의심할 필요도 없고 분쟁이 생겨 법적인 다툼을 할 필요도 없습니다.

스마트 계약을 작성해서 이더리움 네트워크에 배포(deploy)해 놓는다면, 이후 모든 과정은 정확히 스마트 계약에 쓰여진 대로 자동으로 이루어지기 때문에 거래의 양측은 위험부담을 크게 줄일 수 있습니다. 최초 한번, 스마트 계약이 서로의 이익을 만족시키도록작성되어 있는지 확인만 하면 됩니다. 이 모든 것은 이더리움 가상 머신(EVM)은 결정적(deterministic)으로 설계된 시스템이기 때문에 가능한 것입니다.

DNext에서는 블록체인 전문 교육 과정 홍보를 위해 Youtube 채널을 활용하고 있습니다. 자체적으로 채널을 운영하는 것이 아니고 암호화폐 전문 채널에 광고를 삽입하는 방식인데, Solidity 기반의 스마트 계약을 통해 광고 성과에 대한 수익 정산을 하고 있습니다. 이 포스트에서는 해당 스마트 계약이 어떤 과정을 통해 작성되었는지 단계별로 알려드리려고 합니다.

해당 계약에서 수익 정산에 대한 조건은 아래와 같습니다.

  • 각각의 영상에 대해 따로 정산을 수행
  • 수익은 A 주소에 지급
  • 1 조회수당 X wei 지급
  • 최대 Y wei 지급
  • A 주소의 소유자는 1회에 한해 정산 요청 가능
  • 계약의 배포자는 A 주소의 정산 요청 후 (만약 존재한다면) 남은 금액 환불 가능

비어있는(empty) 계약 작성

우선 아무 역할도 하지 않는 비어있는 계약을 작성해볼 예정입니다. Solidity 파일의 확장자는 .sol이고, 보통 하나의 파일에 하나의 계약을 포함하고 있습니다. 비어있는 계약을 담고있는 몸체는 아래와 같습니다. 우리의 계약 이름은 AdPerformance라고 지었습니다.

pragma solidity ^0.4.21;contract AdPerformance {
}

이 계약은 그 자체로써 완전한(complete) 계약이지만 아무 역할도 하지 않습니다. 즉, 이 계약을 배포할 수 있지만 이후에 해당 계약과는 아무런 상호작용(interaction)을 할 수 없습니다. 따라서 테스트 목적이 아니라면 위와 같은 계약은 작성하지 않는 것이 좋습니다.

첫줄의 pragma solidity ^0.4.21을 주목하시기 바랍니다. 모든 Solidity 계약 파일은 최상단에 해당 줄을 삽입하여야 합니다. ^0.4.21은 Solidity 버전을 나타내는데 버전은 지속적으로 업데이트되고 있으므로 최신 버전을 명시해주는 것이 좋습니다. 최신 버전에서 추가된 기능을 사용하려면 반드시 해당 버전을 명시해야 합니다.

상태 변수(state variables)

Solidity 기반의 스마트 계약은 상태 변수라는 것을 포함할 수 있습니다. 이 상태 변수의 내용은 EVM의 계약 계정(contract account) 중 저장소(storage)에 저장됩니다. 따라서 해당 스마트 계약에 대해 여러번의 거래(transaction)가 일어나더라도 사라지지 않고 블록체인 상에 영속적(persistent)으로 기록되어야할 데이터를 이 상태 변수에 저장하면 됩니다.

우리는 아래와 같이 다섯가지의 상태 변수를 정의할 것입니다.

  • 소유자(owner) 주소 : 스마트 계약을 배포한 계정의 주소가 저장되고 권한 인증을 위해 필요합니다.
  • 보상 지급(beneficiary) 주소 : 광고 수익을 정산받을 주소입니다.
  • 1 조회수 당 지급 금액 : 광고 시청 횟수에 따라 지급할 금액으로써 gwei 단위로 표시합니다.
  • Youtube 영상 ID : 광고를 삽입한 영상의 고유 ID입니다.
  • 정산 여부 : 1회만 정산이 가능하므로 정산 여부를 저장합니다.

위 다섯가지 상태 변수를 정의한 우리의 스마트 계약은 아래와 같습니다.

pragma solidity ^0.4.21;contract AdPerformance {
address owner; // 소유자 주소
address beneficiary; // 보상 지급 주소
uint gweiToPayPerView; // 1 조회수 당 지급 금액
string youtubeId; // Youtube 영상 ID
bool withdrawn; // 정산 여부
}

여기서 //으로 시작하는 부분은 주석(comment)으로써 오로지 사람에게만 유효한 문자열입니다. 즉, //로 시작하는 부분은 EVM에서는 무시됩니다.

생성자(constructor)

Solidity 계약과 상호작용을 하기 위해서는 함수(function)를 호출해야 합니다. 이 중 한가지 특수한 형태의 함수가 있는데, 생성자라고 부르는 것으로써 최초 계약 배포 시점에 한해 1회만 수행되는 함수입니다. 생성자 함수의 이름은 반드시 계약 이름과 동일해야 합니다. 비어있는 생성자 함수를 추가하면 아래와 같은 형태가 됩니다.

pragma solidity ^0.4.21;contract AdPerformance {
address owner;
address beneficiary;
uint gweiToPayPerView;
string youtubeId;
bool withdrawn;
function AdPerformance() {
// 내용은 이곳에 들어갑니다.
}
}

비어있는 생성자는 아무런 역할을 하지 못하므로 아래와 같이 생성자의 내용을 채워넣어줍니다. 보통 생성자에는 상태 변수의 데이터를 초기화시켜주는 코드가 들어갑니다.

    function AdPerformance(address _beneficiary, uint _gweiToPayPerView, string _youtubeId) public payable {
owner = msg.sender;
beneficiary = _beneficiary;
gweiToPayPerView = _gweiToPayPerView;
youtubeId = _youtubeId;
withdrawn = false;
}

우리의 생성자에서 _beneficiary, _gweiToPayPerView, _youtubeId와 같은 부분은 인자(arguments)라고 부릅니다. 인자는 생성자와 함수 모두 가질 수 있는데, 함수의 실행에 필요한 외부 데이터를 전달해주는 역할을 합니다. 위 생성자의 인자는 각각 보상 지급 주소, 1 조회수 당 지급 금액, Youtube 영상 ID에 해당하는 상태 변수를초기화해주는 역할을 합니다.

인자를 통해 초기화되지 않은 상태 변수도 있습니다. ownerwithdrawn인데 owner의 경우 msg.sender라는 값으로 초기화하고 있고, withdrawnfalse라는 값으로 초기화하고 있습니다. 여기서 msg.sender란 해당 함수를 수행시킨 주체로써 생성자의 경우 계약 배포자의 주소를 나타냅니다. 생성자는 반드시 계약 배포자만 실행시킬 수 있는 특수한 형태의 함수이기 때문에 그렇습니다. 그리고 withdrawn은 최초 배포 시점에서 정산이 이루어지지 않았으므로 false로 초기화합니다.

또 한가지 눈여겨 보아야할 점은 public payable입니다. 이 중 public은 해당 함수를 공개적으로 노출시키는 역할을 합니다. public하지 않은 함수는 계약의 바깥에서 실행할 수 없습니다. 그리고 payable은 해당 함수를 실행하는 거래(transaction)에 금액(value)가 포함될 수 있다는 것을 나타냅니다. payable 하지 않은 함수를 통해서는 금액을 입금할 수가 없습니다. 해당 생성자 내부에서는 드러나있지 않지만, 우리는 최대 지급 가능 금액(Y wei)를 이체할 것이고 추후 정산을 위해서 활용될 것입니다.

함수(functions)

생성자를 작성했다면 이제는 함수를 추가할 차례입니다. 이더리움의 각각의거래는 반드시 하나의 함수만 수행시킬 수 있습니다. 따라서 함수를 정교하게 설계하여 효과적으로 상호작용할 수 있도록 만드는 것이 중요합니다.

우리가 작성할 함수는 아래와 같습니다.

  • 정산(withdraw) : 광고 성과에 따른 금액을 정산 주소(beneficiary)로 지급하는 함수입니다. 정산 주소에 해당하는 계정만 실행할 수 있고, 단 한번만 실행될 수 있습니다.
  • 정산 여부(isWithdrawn) : 정산이 이루어졌는지 확인하는 함수입니다. 누구나 실행할 수 있고 true 혹은 false의 값을 반환합니다.
  • 환급(refund) : 정산이 끝난 후 계약 배포자가 남은 금액을 가져가는 함수입니다. 만약 Y wei 이상 정산이 되었다면 남은 금액이 없으므로 환불이 이루어지지 않을 것입니다.

정산

정산 함수의 내용은 아래와 같습니다.

    function withdraw() public {
require(msg.sender == beneficiary);
require(!withdrawn);

string memory query = strConcat('json(https://www.googleapis.com/youtube/v3/videos?id=',
youtubeId,
'&key=AIzaSyAhV6cw7pjvrrBoSkIDxff4gvovbF_9rXk%20&part=statistics).items.0.statistics.viewCount');
oraclize_query('URL', query);
}

가장 첫줄에서 require(msg.sender == beneficiary)를 통해 반드시 beneficiary에 해당하는 주소만 함수를 수행할 수 있도록 제약하고 있습니다. 여기서 require()란 Solidity가 제공하는 내장 함수로써 인자로 전달되는 bool 값이 false 이면 즉시 함수 실행을 종료하고 모든 상태 변환을 되돌리는 역할을 합니다. true라면 정상적으로 수행되고 다음 줄로 실행이 이어지게 됩니다.

마찬가지로 두번째 줄의 require(!withdrawn)을 통해 단 한번만 정산이 이루어지는 것을 보장합니다. 정산이 끝나면 withdrawn 상태 변수를 true로 변경해줄 예정입니다.

그리고 이후 나오는 내용은 Oraclize 쿼리를 위한 내용입니다. 여기서 Oraclize란, 이더리움 블록체인 바깥에서 일어나는 일들을 스마트 계약이 알 수 있게 도와주는 오라클 서비스입니다. 기본적으로 이더리움 블록체인은 바깥에서 일어나는 일을 알 수가 없습니다. 따라서 오라클 서비스를 사용해야 하는데 그 중 사용도가 가장 높은 것이 바로 Oraclize 입니다. Oraclize 서비스의 기능을 사용하기 위해서는 우선 해당 라이브러리를 가져오는 작업이 필요합니다. 이는 다음과 같은 한줄의 코드로 가능합니다.

import "github.com/oraclize/ethereum-api/oraclizeAPI.sol";

또한 우리의 스마트 계약이 usingOraclize 계약을 상속받도록 해줘야합니다. 이를 위해서는 위에서 작성했던 계약 정의 부분을 아래와 같이 바꿔줘야 합니다.

pragma solidity ^0.4.21;import "github.com/oraclize/ethereum-api/oraclizeAPI.sol";contract AdPerformance is usingOraclize {
address owner; // 소유자 주소
address beneficiary; // 보상 지급 주소
uint gweiToPayPerView; // 1 조회수 당 지급 금액
string youtubeId; // Youtube 영상 ID
bool withdrawn; // 정산 여부
// 생성자 및 함수가 위치...
}

우리의 스마트 계약에서 오라클 서비스가 필요한 이유는 Youtube 영상의 조회수를 알아내야하기 때문입니다. 이를 위해서는 Oraclize 쿼리를 생성해서 조회해야 하는데 그 쿼리의 내용은 json(https://www.googleapis.com/youtube/v3/videos?id=[영상 ID]&key=[YouTube Data API 키]).items.0.statistics.viewCount와 같은 형태입니다. 간단히 설명하자면 YouTube Data API를 사용해 해당 영상의 통계 정보를 JSON 형태로 가져오고 이 중 조회수에 해당하는 부분을 파싱하는 것입니다. YouTube Data API에 대한 자세한 내용은 여기, Oraclize API에 대한 자세한 내용은 여기를 참조해주세요.

위 코드에서 쿼리를 조합할 때 strConcat 함수를 사용합니다. 그 이유는 Solidity에서 문자열 연쇄(concatenation) 기능을 제공해주지 않기 때문입니다. 따라서 우리는 strConcat 함수를 정의해서 사용합니다. 내용은 최하단의 전체 소스 코드에서 확인이 가능합니다. (해당 함수는 여기에서 참조했습니다.)

이렇게 쿼리 조합이 완성되었으면 oraclize_query() 호출을 통해 Oraclize 서버로 쿼리를 전송해줘야 합니다. 여기서 oraclize_query() 함수는 Oraclize 에서 제공하는 API입니다. 그런데 조금 이상하지 않나요? withdraw 함수는 oraclize_query() 함수를 호출하는 것이 끝입니다. 실제 정산을 수행하는 부분이 빠져 있습니다.

실제 정산 작업은 __callback() 이라는 함수에서 실행되어야 합니다. 이렇게 두번으로 나누어서 작업을 하는 이유는 Oraclize 서비스에 대해 (1) 외부 데이터를 요청하고, (2) 해당 데이터를 받아 처리하는 과정이 비동기적으로 일어나야 하기 때문입니다. 즉, Oraclize라는 외부 서버에 의존하기 때문에 발생하는 현상으로 이해하시면 됩니다.

__callback 함수의 내용은 아래와 같습니다. 함수의 이름은 반드시 _callback 이어야 합니다. 그렇지 않으면 Oraclize 서버가 해당 함수를 실행할 수 없습니다.

    function __callback(bytes32, string result) public {
require(msg.sender == oraclize_cbAddress());
require(!withdrawn);

uint viewCount = stringToUint(result);
uint amount = viewCount * gweiToPayPerView * 1000000000;
uint balance = address(this).balance;

if (balance < amount) {
amount = balance;
}

beneficiary.transfer(amount);
withdrawn = true;
}

우선 msg.senderoraclize_cbAddress()가 동일한지 확인합니다. 여기서 oraclize_cbAddress()는 Oraclize의 계정 주소를 나타내므로, 다른 외부인이 해당 함수를 실행하지 못하도록 방지합니다. 그리고 require(!withdrawn)을통해 정산이 이루어지지 않았음을 보장합니다.

__callback 함수는 반드시 첫번째 bytes32 자료형(type)의 인자와 두번째 string(문자열) 자료형(type)의 인자를 가져야 하고 이 때 두번째 인자인 result에는 우리가 쿼리를 통해 요청했던 데이터의 값이 담겨 있습니다. 즉, 광고를 정산할 영상의 조회수가 문자열의 형태로 전달이 되는 것입니다.

따라서 이 문자열 데이터를 uint(정수형)로 변환해주는 작업이 필요하고 이 역할을 하는 함수가 바로 stringToUint() 입니다. Solidity 언어에서 기본적으로 문자열을 정수형으로 변환하는 기능을 제공해주지 않으므로 우리는 직접정의한 stringToUnit() 함수를 사용할 것입니다. 내용은 최하단의 전체 소스 코드에서 확인이 가능합니다.

조회수를 정수형의 변수 viewCount에 저장한 후, viewCount * gweiToPayPerView * 1000000000를 통해 정산될 금액인 amount를 계산합니다. 1000000000을 곱하는 이유는 애초에 1 조회수 당 지급할 금액을 gwei 단위로 설정했기 때문입니다. (1 gwei = 1,000,000,000 wei) Solidity 내부에서는 모든 금액은 wei 단위로 계산됩니다. (1 wei = 10-¹⁸ ETH)

이렇게 정산될 금액을 게산했으면, 계약 계정의 잔액(최대 지급 가능 금액 Y wei)과 비교해주어야 합니다. 이 때, 계정의 잔액을 가져오는 코드는 아래와 같습니다.

uint balance = address(this).balance

위 코드에서 this는 해당 함수가 수행되고 있는 계약 계정을 나타내고 이를 address 자료형으로 변환해줌으로써 balance 정보를 가져올 수 있게 됩니다. (balance 또한 wei 단위로 계산됩니다.) 그리고 만약 amountbalance보다 크다면 amountbalance와 같도록 설정해주어야 합니다.

마지막으로 금액을 지급 주소(beneficiary)에게 전송하고 withdrawn 상태 변수를 true로 바꿔주면 모든 정산 과정이 끝나게 됩니다.

beneficiary.transfer(amount);
withdrawn = true;

정산 여부

정산 여부를 확인하는 isWithdrawn() 함수는 아래와 같이 비교적 간단합니다.

    function isWithdrawn() public view returns (bool) {
return withdrawn;
}

여기서 중요한 것이 viewreturns (bool)입니다. view 키워드는 해당 함수가 이더리움 블록체인의 저장소를 참조만 하고 있을 뿐 변경은 하지 않는다는 것을 의미합니다. 또한 returns (bool)은 논리 자료형(bool)의 값을 반환한다는 의미입니다.

view로 선언된 함수는 읽기 전용(read-only)이므로 거래(transaction)을 발생시킬 필요도, 그 실행 내역이 블록체인에 포함될 필요도 없습니다. view가 아닌 함수는 기본적으로 거래를 발생해서 블록에 기록되기까지의 시간동안 기다려야 그 효과가 발생되어 상태 전이가 일어나는 반면에, 해당 함수를 사용하면 그럴 필요가 없이 정산이 되었는지 여부를 바로 확인할 수 있습니다.

환급

정산이 끝났으면 남은 금액을(만약 존재한다면) 환급받을 수 있어야 합니다. 기본적으로 금액은 계약 계정의 소유이고, 해당 함수가 없다면 남은 금액을 다른 외부 계정으로 가져올 방법이 없을 것입니다.

    function refund() public {
require(msg.sender == owner);
require(withdrawn);

uint balance = address(this).balance;
if (balance > 0) {
owner.transfer(balance);
}
}

우선 require(msg.sender == owner)require(withdrawn)를 통해 함수를 실행하는 계정이 최초 계약 배포 계정(owner)과 동일한지, 그리고 정산이 이루어졌는지를 확인합니다.

그리고 남은 잔액을 확인해서 0보다 크다면 최초 계약 배포 계정에게로 전송하게 됩니다.

정리

위에서 설명한 내용이 포함된 전체 소스 코드는 아래와 같습니다.

pragma solidity ^0.4.21;import "github.com/oraclize/ethereum-api/oraclizeAPI.sol";contract AdPerformance is usingOraclize {
address owner;
address beneficiary;
uint gweiToPayPerView;
string youtubeId;
bool withdrawn;

function AdPerformance(address _beneficiary, uint _gweiToPayPerView, string _youtubeId) public payable {
owner = msg.sender;
beneficiary = _beneficiary;
gweiToPayPerView = _gweiToPayPerView;
youtubeId = _youtubeId;
withdrawn = false;
}

function withdraw() public {
require(msg.sender == beneficiary);
require(!withdrawn);

string memory query = strConcat('json(https://www.googleapis.com/youtube/v3/videos?id=',
youtubeId,
'&key=AIzaSyAhV6cw7pjvrrBoSkIDxff4gvovbF_9rXk%20&part=statistics).items.0.statistics.viewCount');
oraclize_query('URL', query);
}

function __callback(bytes32, string result) public {
require(msg.sender == oraclize_cbAddress());
require(!withdrawn);

uint viewCount = stringToUint(result);
uint amount = viewCount * gweiToPayPerView * 1000000000;
uint balance = address(this).balance;

if (balance < amount) {
amount = balance;
}

beneficiary.transfer(amount);
withdrawn = true;
}

function isWithdrawn() public view returns (bool) {
return withdrawn;
}

function refund() public {
require(msg.sender == owner);
require(withdrawn);

uint balance = address(this).balance;
if (balance > 0) {
owner.transfer(balance);
}
}

function stringToUint(string s) internal pure returns (uint result) {
bytes memory b = bytes(s);
uint i;
result = 0;
for (i = 0; i < b.length; i++) {
uint c = uint(b[i]);
if (c >= 48 && c <= 57) {
result = result * 10 + (c - 48);
}
}
}

function strConcat(string _a, string _b, string _c, string _d, string _e) internal returns (string){
bytes memory _ba = bytes(_a);
bytes memory _bb = bytes(_b);
bytes memory _bc = bytes(_c);
bytes memory _bd = bytes(_d);
bytes memory _be = bytes(_e);
string memory abcde = new string(_ba.length + _bb.length + _bc.length + _bd.length + _be.length);
bytes memory babcde = bytes(abcde);
uint k = 0;
for (uint i = 0; i < _ba.length; i++) babcde[k++] = _ba[i];
for (i = 0; i < _bb.length; i++) babcde[k++] = _bb[i];
for (i = 0; i < _bc.length; i++) babcde[k++] = _bc[i];
for (i = 0; i < _bd.length; i++) babcde[k++] = _bd[i];
for (i = 0; i < _be.length; i++) babcde[k++] = _be[i];
return string(babcde);
}

function strConcat(string _a, string _b, string _c, string _d) internal returns (string) {
return strConcat(_a, _b, _c, _d, "");
}
function strConcat(string _a, string _b, string _c) internal returns (string) {
return strConcat(_a, _b, _c, "", "");
}
function strConcat(string _a, string _b) internal returns (string) {
return strConcat(_a, _b, "", "", "");
}
}

수익 정산에 대한 조건은 아래와 같았습니다.

  • 각각의 영상에 대해 따로 정산을 수행
  • 수익은 A 주소에 지급
  • 1 조회수당 X wei 지급
  • 최대 Y wei 지급
  • A 주소의 소유자는 1회에 한해 정산 요청 가능
  • 계약의 배포자는 A 주소의 정산 요청 후 (만약 존재한다면) 남은 금액 환불 가능

우선 하나의 계약 코드를 여러번 배포할 수 있다는 점에서 각각의 영상에 대해 따로 정산을 수행되는 조건이 달성되었습니다. 수익은 A 주소에 지급된다는 조건은 생성자에서 beneficiary를 설정하고 추후 __callback에서 beneficiary.transfer(amount)를 실행함으로써 만족됩니다. 1 조회수당 X wei 지급 조건은 생성자에서 gweiToPayPerView를 설정하고 __callback에서 amount를 계산할 때 반영됩니다.

최대 Y wei 지급은 계약을 배포할 때 Y wei를 전송하고, __callback에서 balance보다 크다면 amount를 제한하는 방식으로 구현되었습니다. 정산 전에 withdrawn 상태 변수를 확인함으로써 A 주소의 소유자는 1회에 한해 정산 요청 가능이 가능하고, refund() 함수를 통해 계약의 배포자는 A 주소의 정산 요청 후 (만약 존재한다면) 남은 금액 환불 가능합니다.

이렇게 모든 수익 정산 조건이 만족된 계약을 작성할 수 있었습니다. 남은 과정은 계약의 쌍방이 해당 계약을 검토하고, 이에 동의한다면 이더리움 블록체인에 배포한 뒤(이 때, 계약 계정이 생성됩니다.) 원하는 시점에 보상 지급 계정이 정산을 수행하는 것입니다. 물론 그 후에 환급이 가능합니다.

복잡하지 않은 계약이지만 이를 통해 자연어로 명시된 조건들이 어떻게 스마트 계약의 형태로 구성될 수 있는지 알아보았습니다. 위 코드는 실제 정산 과정에서 사용되고 있는 코드로써, 이를 참고해서 다양한 응용이 가능할 것입니다.

한글로 된 솔리디티 관련 자료가 많이 없는 상황에서 스마트 계약 작성자들에게 많은 도움이 되기를 바랍니다. 그리고 관련한 도움이 필요하다면 DNext에서 운영하고 있는 DNext Campus를 언제든 방문해주시기 바랍니다. 블록체인, 이더리움, 스마트 계약에 관련된 주제로 정규 교육 과정을 운영중에 있습니다.

감사합니다.

--

--