class Person {
String name;
int weight;
int height;
public Person(String name, int height, int weight) {
this.name = name;
this.weight = weight;
this.height = height;
}
public void print() {
System.out.println(name + " " + height + " " + weight);
}
public int getWeight() {
return weight;
}
public int getHeight() {
return height;
}
}
여기 Person 클래스가 있다.
List<Person> list = new ArrayList<>();
Person 클래스로 이루어진 어레이리스트도 있다.
이 리스트를 정렬하고하자한다.
name을 기준으로 정렬을 해야할까 weight를 기준으로 정렬을 해야할까?
우리는 이 문제를 Comparable이나 Comparator를 통해서 해결할 수 있다.
1. Comparable
정렬하고자 하는 객체의 클래스에 Comparable 인터페이스를 구현하는 방법이다.
물론 그 클래스에 Comparable 인터페이스를 구현할 수 있다면 말이다.
// 정렬 대상 클레스에 인터페이스를 구현할 수 있다면 Comparable 사용 가능
class Person implements Comparable<Person> {
String name;
int weight;
int height;
public Person(String name, int height, int weight) {
this.name = name;
this.weight = weight;
this.height = height;
}
public void print() {
System.out.println(name + " " + height + " " + weight);
}
public int getWeight() {
return weight;
}
public int getHeight() {
return height;
}
@Override
public int compareTo(Person p) {
// 오름차순: this 객체 - 인자로 넘어온 객체
// 내림차순: 인자로 넘어온 객체 - this 객체
return this.height - p.height; // 오름차순 정렬
}
}
Collections.sort(list);
Comparable 인터페이스의 compareTo 메소드를 구현하면 된다.
위 코드는 height를 기준으로 오름차순 정렬의 예시다.
만약 1순위로 height를 기준으로 오름차순 정렬하고
height가 같은 객체가 있다면
2순위로 weight를 기준으로 내림차순 정렬하고 싶다면?
class Person implements Comparable<Person> {
String name;
int weight;
int height;
public Person(String name, int height, int weight) {
this.name = name;
this.weight = weight;
this.height = height;
}
public void print() {
System.out.println(name + " " + height + " " + weight);
}
public int getWeight() {
return weight;
}
public int getHeight() {
return height;
}
@Override
public int compareTo(Person p) {
// this 객체 > 인자로 넘어온 객체 => return 1이라는것은
// this 객체 - 인자로 넘어온 객체 > 0 => 오름차순
if (this.height > p.height) return 1;
else if (this.height == p.height) { // height가 같다면
// this 객체 < 인자로 넘어온 객체 => return 1이라면
// this 객체 - 인자로 넘어온 객체 < 0 => 내림차순
if (this.weight < p.weight) return 1; // weight를 내림차순으로
}
return -1;
}
}
이렇게 하면 된다.
2. Comparator
정렬하고자 하는 객체의 클래스에 Comparable 인터페이스를 구현할 수 없을때 사용하는 방법이다.
혹은 Comprable 인터페이스를 통해 이미 정렬기준이 정해져있지만 다른 기준으로 정렬하고 싶을때 사용하는 방법이다.
// 정렬 대상 클레스에 인터페이스를 구현할 수 없다면
// 혹은 Comparable을 통해 이미 정해져있는 정렬 기준과 다르게 정렬하고 싶다면
class Person {
String name;
int weight;
int height;
public Person(String name, int height, int weight) {
this.name = name;
this.weight = weight;
this.height = height;
}
public void print() {
System.out.println(name + " " + height + " " + weight);
}
public int getWeight() {
return weight;
}
public int getHeight() {
return height;
}
}
Person 클래스는 동일하다.
우리는 Comparator라는 일종의 정렬기준을 정의한 객체를 만들어서 Collections.sort()의 인자로 넣어주어야 한다.
public static <T> void sort(List<T> list, Comparator<? super T> c) {
list.sort(c);
}
Collections.sort 함수를 까보면 첫번째로 정렬 할 리스트와 두번째로 정렬 기준인 Comparator를 받는 것을 볼 수 있다.
Collections.sort(list, new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
// 오름차순
return o1.height - o2.height;
}
});
Comparator객체를 한번쓰고 버릴것 이니 익명클래스로 넣어주었다.
저 익명클래스의 객체는 Comparator 인터페이스를 구현받고있기에 compare 함수를 오버라이드하고있다.
정렬 기준이 2개 이상일 때도 Comparable과 동일하게 해주면 된다.
Collections.sort(list, new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
// height 기준 오름차순
if (o1.height > o2.height) return 1;
else if (o1.height == o2.height) {
// height가 같다면 weight 기준으로 내림차순
if (o1.weight < o2.weight) return 1;
}
return -1;
}
});
Comparator를 람다함수로 조금더 간단하게 표현할 수 있다.
Collections.sort(list, (a, b) -> a.getHeight() - b.getHeight());
클레이튼 기반의 dapp을 개발하며 블록체인의 트랜잭션과 서비스의 데이터베이스간의 싱크를 보장해야하는 상황을 마주했다.
성공한 트랜잭션이면 관련 정보를 데이터베이스에 반영해주고,
실패한 트랜잭션이면 관련 정보를 데이터베이스에 반영해주지 않는, 그런 요구사항이었다.
클레이튼 블록체인에서 자체적으로 실패한 트랜잭션에 대한 롤백은 시켜주기에 클레이튼의 성공여부를 먼저 확인하고 데이터베이스 반영 여부를 정해야했다. [클레이튼 트랜잭션] -> [데이터베이스 트랜잭션]의 플로우였기에 이 사이에 트랜잭션의 성공여부만 확인하고 데이터베이스의 트랜잭션을 발생시킬지 말지 결정하면 됐다.
[클레이튼 트랜잭션] -> [트랜잭션이 성공했는지 확인] -> [데이터베이스 트랜잭션]
하지만 블록체인은 트랜잭션이 확정되기까지 걸리는 시간인 Finality라는 개념이 있기에 트랜잭션의 성공 여부를 조회하려면 이 Finality 시간 이후에 해야했다. 클레이튼의 경우 이 시간이 평균 1초라고 알려져있다. 평균이기에 실제 상황에서 약간의 오차는 있을 것 이다. 따라서 Finality만큼의 딜레이를 하고 트랜잭션 조회 -> 데이터베이스 트랜잭션 실행의 절차를 밟아야 했다.
즉, 필요한 조건은
1. 트랜잭션 조회에 delay를 줘야한다. -> Finality 때문에
2. 적어도 2회 이상 retry를 하며 조회해야한다. -> Finality 시간이 일정하지 않기 때문에
3. 최대한 빠르게 트랜잭션의 성공 여부를 판단해야한다. -> 원활한 서비스 제공을 위해
2. 고려했던 선택지
세가지를 고려했었다. 참고로 NodeJS로 개발했다.
1. 스케줄러를 통해 짧은 간격으로 확정되지 않은 트랜잭션들의 성공 여부를 조회하기
트랜잭션을 발생시킬 때 transaction_history 테이블에 트랜잭션의 발생 여부를 기록해두고 is_confirmed는 false로 둔다.
NodeJS의 스케줄러를 통해서 is_confirmed가 false인 기록들을 짧은 간격으로 주기적으로 조회하며 트랜잭션의 성공 여부를 확인한다.(트랜잭션 성공여부는 caverExtKAS 라이브러리의 getTransferHistoryByTxHash 함수를 사용했다)
문제점.
트랜잭션이 발생하지 않을때도 몇 초마다 항상 실행되기에 리소스 낭비라고 생각됐다.
2. 클레이튼 이벤트 리스너를 통해 트랜잭션이 수행될 때 발생되는 이벤트를 추적하여 판별하기
트랜잭션을 발생시킬 때 transaction_history 테이블에 트랜잭션의 발생 여부를 기록해두고 is_confirmed는 false로 둔다.
트랜잭션이 성공하면 열어둔 소켓을 통해 클레이튼 이벤트를 Listen할 수 있는데 트랜잭션이 성공하면 이벤트가 수신되므로(아마..?) 수신했다는 것은 해당 트랜잭션이 성공했다는 의미.
문제점.
가끔 성공한 트랜잭션의 이벤트가 리슨되지 않을때가 있었다. (왜일까..)
3. RabbitMQ를 통해서 딜레이와 재시도 기능을 구현하여 조회하기 (이 방법을 사용했다)
트랜잭션을 발생시킬 때 transaction_history 테이블에 트랜잭션의 발생 여부를 기록해두고 is_confirmed는 false로 둔다. 또 동시에 RabbitMQ로 발생한 트랜잭션의 정보를 publish한다.
컨슈머에서 이를 구독하고 이 시점에 트랜잭션 성공 여부를 조회한다.
트랜잭션이 성공이면 데이터베이스에도 관련 정보를 반영 후 ack으로 마무리하고, 실패면 retry_count를 1추가해서 DLX로 보낸다. 이곳에서 5000ms동안 대기하고 다시 작업큐로 메시지를 보내서 반복한다.(넉넉히 5초로 딜레이를 주었다)
3번에서 반복 횟수동안 모두 트랜잭션이 확정이 안됐거나 실패 판정이 나면 데이터베이스에 관련 정보를 반영하지 않고 ack을 반환한다.
이 방법을 선택한 이유
RabbitMQ를 통해서 사용자가 몰릴때 비동기적으로 안정적인 로직 수행이 가능하기에
RabbitMQ의 Durability를 통해 중요한 정보를 잃어버리지 않을 수 있기에
RabbitMQ의 DLX 기능을 통해 딜레이와 재시도를 구현할 수 있기에
3. RabbitMQ 구성
RabbitMQ는 위와같이 구성해보았다.
[1], [2]
클레이튼 트랜잭션을 발생시킴과 동시에 트랜잭션의 정보와 retry_count를 담은 메시지를 publish한다.
[3], [4]
지정한 익스체인지에서 지정한 큐로 메시지를 전달한다.
[5]
트랜잭션의 성공 여부를 확인하는 컨슈머에서 트랜잭션의 성공 여부를 확인한다.
성공이라면 서비스 로직을 실행하고 ack을 반환한다.
[6]
트랜잭션이 아직 확정되지 않았거나 실패했다면 WAITING 익스체인지로 같은 메시지를 그대로 전달한다.
이때마다 retry_count에 1 씩 더해서 전달한다.
[7]
전달된 메시지는 TX.Q.WAIT_CONFIRM_CHECK 큐로 보내지는데 이 큐는 DLX와 TTL 설정이 돼있다.
[8]
지정한 TTL 시간이 지나면 메시지가 x-dead-letter-exchange로 지정된 익스체인지로 x-dead-letter-routing-key 바인딩 키를 사용해서 전달된다.
즉, 이 큐에서 5000ms 동안 대기하고 TX.E.DIRECT 익스체인지로 가게된다.
이때 바인딩키는 TX를 가지고간다.
TX는 TX.E.DIRECT와 TX.Q.CONFIRM_CHECK의 바인딩키다.
따라서 TX.E.DIRECT 익스체인지에 도착한 메시지는 TX.Q.CONFIRM_CHECK 큐로 다시 보내진다.
[4], [5], [6], [7], [8]의 과정을 최대 3번까지 반복하고도 트랜잭션이 실패라고 판별되면 최종적으로 실패처리(데이터베이스에 반영하지 않기)를 한 후 ack를 반환한다.
function pushBet(bytes challenges) internal returns (bool) {
BetInfo memory b; // 베팅정보를 하나 생성하고 세팅한다
b.bettor = msg.sender; // 함수 호출한 사람
b.answerBlockNumber = block.number + BET_BLOCK_INTERVAL; // block.number는 현재 이 트랜잭션이 들어가게되는 블록넘버를 가져온다
b.challenges = challenges; // 내가 베팅한 값
_bets[_tail] = b; // 큐에 넣고
_tail++; // 테일 포인터 조정
return true;
}
베팅큐에서 베팅정보 1개 pop
function popBet(uint256 index) internal returns (bool) {
// 스마트컨트랙에서 덧셈이든, 뺄셈이든 하면 가스를 소모한다.
// delete를 하면 가스를 돌려받는다. 왜? 이더리움 블록체인에 저장하고 있는 데이터를 더 이상 저장하지 않겠다는 것이기에
// 즉, 상태 데이터베이스에 있는 값을 그냥 가져오겠다는 것이기에
// 그러니 필요하지 않은 값이 있다면 delete를 해주자
delete _bets[index];
return true;
}
3. 전체 코드
pragma solidity >=0.4.21 <0.6.0;
contract Lottery {
struct BetInfo {
uint256 answerBlockNumber; // 맞출 블록의 넘버
address payable bettor; // 돈을 건 사람 주소, 특정주소에 돈을 보내려면 payable을 써줘야함
bytes challenges; // 문제. ex) 0xab
}
// 매핑으로 큐를 구현하기 위한 변수
uint256 private _tail;
uint256 private _head;
// 키는 uint, 값은 BerInfo인 매핑
mapping(uint256 => BetInfo) private _bets;
address public owner;
uint256 internal constant BLOCK_LIMIT = 256; // 블록해시를 확인할 수 있는 제한
uint256 internal constant BET_BLOCK_INTERVAL = 3; // 2번 블록에서 베팅을 하면 5번 블록에서 결과가 나온다
uint256 internal constant BET_AMOUNT = 5 * 10**15; // 0.005ETH
uint256 private _pot;
// 이벤트 로그들을 한번에 모을 수 있다, BET이라는 로그들을 찍어줘
// (몇번째 배팅인지, 누가 배팅했는지, 얼마 배팅했는지, 어떤글자로 베팅했는지, 어떤 블록에 정답이 있는지)
event BET(uint256 index, address bettor, uint256 amount, bytes1 challenges, uint256 answerBlockNumber);
constructor() public {
owner = msg.sender; // msg.sender는 전역변수
}
function getPot() public view returns (uint256 value) {
// 스마트 컨트랙의 변수를 가져와서 쓰려면 view 키ㅕ드를 쓴다
return _pot;
}
// Bet (베팅하기)
/*
@dev 베팅을 한다. 유저는 0.005ETH를 보내야하고 베팅용 1byte 글자를 보낸다, 큐에 저장된 베팅 정보는 이후 distribute 함수에서 해결한다
@param challenges 유저가 베팅하는 글자
@return 함수가 잘 수행되었는지 확인하는 bool 값
*/
function bet(bytes challenges) public payable returns (bool result) {
// 돈이 제대로 왔는지 확인
// require는 if역할, msg.value는 컨트랙트가 받은금액, 문자열은 조건이 false때 출력할 문구
require(msg.value == BET_AMOUNT, 'Not Enough ETH');
// 큐에 베팅정보를 넣기
require(pushBet(challenges), 'Fail to add a new Bew Info');
// 이벤트 로그를 찍는다
// (몇번째 배팅인지, 누가 배팅했는지, 얼마 배팅했는지, 어떤글자로 베팅했는지, 어떤 블록에 정답이 있는지)
emit BET(_tail - 1, msg.sender, msg.value, challenges, block.number + BET_BLOCK_INTERVAL);
return true;
}
// 베팅한 값을 큐에 저장함
// Distribute (검증하기)
// 베팅한 값을 토대로 결과값을 검증
// 검증결과가 틀리면 팟머니에 돈을 넣고, 맞으면 돈을 유저에게 준다
// 베팅정보들을 담고있는 큐에서 베팅정보 가져오기
function getBetInfo(uint256 index)
public
view
returns (
uint256 answerBlockNumber,
address bettor,
bytes challenges
)
{
BetInfo memory b = _bets[index]; // memory형 변수는 함수가 끝나면 지워짐, storage형 변수는 블록에 영영 기록됨
answerBlockNumber = b.answerBlockNumber; // 반환값1
bettor = b.bettor; // 반환값2
challenges = b.challenges; // 반환값3
}
// 큐 push
function pushBet(bytes challenges) internal returns (bool) {
BetInfo memory b; // 베팅정보를 하나 생성하고 세팅한다
b.bettor = msg.sender; // 함수 호출한 사람
b.answerBlockNumber = block.number + BET_BLOCK_INTERVAL; // block.number는 현재 이 트랜잭션이 들어가게되는 블록넘버를 가져온다
b.challenges = challenges; // 내가 베팅한 값
_bets[_tail] = b; // 큐에 넣고
_tail++; // 테일 포인터 조정
return true;
}
// 큐 pop
function popBet(uint256 index) internal returns (bool) {
// delete를 하면 가스를 돌려받는다. 왜? 상태데이터베이스에 저장된 값을 그냥 뽑아오겠다는 것이기에
// 그러니 필요하지 않은 값이 있다면 delete를 해주자
delete _bets[index];
return true;
}
}