// 에드센스


1. 느슨한 결합을 위한 노력

 

예시 정책.

1. 회원 도메인과 가족계정 도메인이 있다.

2. 회원의 인증정보가 초기화되면 가족계정에서 탈퇴된다.

 

 

 

1단계. 모놀리틱한 구조(그저 동기적인 코드로 구현)

회원의 인증이 초기화되면 -> 가족계정 서비스에서 탈퇴된다.

 

행위의 주체는 회원이다.

즉, 회원 본인인증 해제 로직과 가족계정 탈퇴 로직이 강한 결합을 이루고 있다.

 

 

 

 

 

2단계. 시스템의 물리적인 분리

유튜브 영상을 캡쳐해 온 것이라 발표자님 얼굴이 나온다ㅠ 출처는 밑에 정리해 두었다.

물리적으로는 분리되었지만 코드레벨의 호출이 HTTP 요청에 의한 호출로 변경되었을 뿐 여전히 강한 결합이다.

 

 

 

 

 

3. 비동기 HTTP 요청

비동기로 구현된 HTTP 요청은 쓰레드레벨의 의존을 제거할 뿐 여전히 강한 결합이다.

 

 

 

 

 

4. 메시지 큐를 사용한 요청

 

 

회원 인증 초기화시 가족계정 탈퇴를 요구하는 메시지를 발행함으로 물리적인 결합은 제거되었지만 논리적으로는 강하게 결합된 상태다.

회원인증을 담당하는 도메인에서 가족계정을 담당하는 도메인에서 해야할 작업을 명시하고 있으니까 말이다.

 

주의해야할 것.

요청을 위해 발행한 메시지가 대상 도메인에게 바라는 행위를 담은 메시지라면 그저 비동기 구조일 뿐,

우리가 다루고자 하는 이벤트가 아니다.

 

.

.

.

.

.

.

.

 

5. 드디어 느슨한 결합

메시지큐에 똑같이 메시지를 발행하였지만 4단계와 다른점이 있다면

회원 도메인에서 발행한 메시지는 가족계정의 탈퇴를 지시하는 메시지가 아니라 그저 "회원인증 초기화"를 알리는 메시지라는 점이다.

 

 

 

출처:

https://youtu.be/b65zIH7sDug

캡쳐본에서는 얼굴 가렸는데 썸네일에 대문짝만하게 나오네ㅋㅋ

 

 

 

 

 


2. 이벤트란?

여기서 말하는 이벤트는 다음과 같다.

밥을 먹고 설거지를 하는 서비스라면

밥을 먹고나서 "설거지를 하라"는 이벤트를 발행하는 것이 아닌

"밥을 먹었다"라는 이벤트를 발행하는 것이다.

 

 

다른 예시로,

 

 

경찰관이 자동차에게 "직진하라"는 이벤트를 발행한다면,

 

 

 

 

 

이렇게 차들이 많아지면 이벤트를 발행하는 경찰관에게는 부하가 걸리게 된다. 차 하나하나마다 발행을 해줘야하기 때문

 

 

 

 

하지만, 신호등은 우리에게 이벤트를 발행하지 않는다. 정확히 말하면 경찰관처럼 우리에게 직진하라고 지시하지 않는다.

 

그저 지금이 빨간불인지 파란불인지만 알려줄 뿐 자동차가 신호등을 관찰하다가 판단한다.

 

 

 

 

따라서 신호등은 자신의 상태만 메시지로 발행하고 후속 행동은 이를 관찰하는 자동차들이 판단하여 처리한다.

한마디로 줄여서

 

"주목할 만한 상태를 이벤트로 발행한다"

 

고 보면 될 것같다.

 

 

 

AWS에서 발생하는 거의 모든 동작은 이벤트를 발생시킨다. 다음은 EC2의 생명주기 변경 이벤트이다.

 

 

 

출처:

https://youtu.be/4aoKv545ViA

 

 

 

 

 


3. 이벤트 드리븐 아키텍쳐의 이모저모

 

이벤트 드리븐 아키텍쳐의 제약사항

  • 이벤트는 발생한 사건에 대해 약속된 형식 사용
  • 불변 : 과거의 메시지를 변경할 수 없음
  • 발생한 사건에 대한 결과 상태 전달
  • 생성자는 이벤트를 누가 처리했는지 이벤트 처리 상태에 관여하지 않음
  • 소비자는 이벤트를 누가 생성했는지, 생성자에 관심 갖지 않음

 

 

이벤트 드리븐 아키텍쳐 설계시 고려사항

Loosely coupling
  • 한 모듈이 따른 모듈에 너무 종속적이면 안된다.
  • 고려사항이라고는 하지만 비동기적인 프로그램을 하기 위해선 사실상 필수 사항

 

Removing dependencies 
  • 의존성을 제거
  • 의존성을 제거 해서 Loosely한 coupling 상태로 만들어야 함

 

Nonblocking transaction
  • 트랜잭션 단계에서 서로 영향을 주지 않도록 해야함.
  • 회원가입을 할때, 핸드폰 인증을 하고 이메일 인증을 순차적으로 진행을 하는데 이메일 인증에서 오류가 났다고 해서 핸드폰 인증까지 같이 취소가 되면 안됨

 

Asynchronized callback
  • 비동기 콜백은 필수는 아님
  • 그러나 사용의 편리성으로 가장 많이 사용

 

Fallback /Retry
  • 실패 했을 경우 어떻게 하고 어디까지 돌아가서 재시도 할것인가를 구분 지을 필요 있음
  • 딱히 정해진 방식이 없어서 설계자의 능력이 중요

 

Event logging
  • 비동기는 로그가 중구난방으로 찍히기 때문에, 이벤트 로그가 잘 찍히게 설계해야함.

 

 

 

 

이벤트 드리븐 아키텍쳐의 장단점

특징과 장점
  • Sevice간 호출이 많은 Microservice Architecture에 적합.
  • Infra Structure의 유연한 사용
  • Polyglot 구성에 용이 -> 모듈별로 서로 다른 언어와 DB 사용 가능
  • 가용성, 응답성 ↑ -> 효율성, 성능 ↑ -> 요청이 많아질 수록 성능 향상 기대
  • Fault Isolation : 에러가 생겨도 각각의 모듈에 독립적으로 생기기 때문에 다른 모듈은 정상 가동이 가능

 

 

단점
  • 설계 복잡도, 운영 복잡도 증가
  • Message Broker 의존성 증가 ->Fault Isolation의 장점이 있어도 모든 Message가 모이는 Message Broker 자체가 고장나면 시스템이 멈춰버림
  • 코드 가독성 하락 -> 눈에 익은 코드가 아니므로 코드 읽기가 어려움
  • 디버깅 난이도 상승 -> 스텝투스텝으로 동기적으로 따라갈 수가 없고, 중구난방으로 로그가 찍히기 때문에 어디서 고장이 났는지 파악하기 힘듬
  • log를 통한 system flow 가독성 하락

 

 

출처: https://be-developer.tistory.com/37

 

[빗썸테크아카데미] 첫번째 - OT, 동기/비동기와 Event-driven Architecture

[빗썸테크아카데미] 첫번째 - OT, 동기/비동기와 Event-driven Architecture https://be-developer.tistory.com/34 [빗썸 테크 아카데미] BE 심화 과정 최종 합격 후기 >< (+기술과제) [빗썸 테크 아카데미] BE 심화 과정

be-developer.tistory.com

 


1. 객체 정렬?

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());

 

 

 

stream을 쓰면 더더더 간단하게 표현할 수 있다.

list.sort(Comparator.comparing(Person::getHeight));

 

 

 

stream을 사용했을때 2개 이상의 기준을 적용하고 싶다면

list.sort(Comparator.comparing(Person::getHeight).thenComparing(Person::getWeight));

 

이렇게 thenComparing을 통해 이어주면 된다.

하나의 조건은 역순으로 하고싶다면 .reversed()를 붙혀주면 되는데 주의 할 것이

 

 

 

list.sort(Comparator.comparing(Person::getHeight).thenComparing(Person::getWeight).reversed());

 

이렇게 뒤에 reversed()를 바로 붙이면 height에 대한 정렬도 reversed()에 영향을 받아서 두가지 기준 다 내림차순 정렬이 돼 버린다.

 

 

 

두번째 기준만 내림차순으로 하고 싶다면

Comparator<Person> reverseCompare = Comparator.comparing(Person::getWeight).reversed();
list.sort(Comparator.comparing(Person::getHeight).thenComparing(reverseCompare));

 

이렇게 reverse용 Comparator를 따로 만들어서 넣어주어야 한다.

 

 

 

 

참고:

 

'Java' 카테고리의 다른 글

[Java] 해시/해시테이블이란?  (0) 2021.07.12
[Java] 제네릭(Generic)  (0) 2021.07.11
[Java] Garbage Collection  (4) 2021.07.01
[Java] Thread/MultiThread 4 - 동시성 문제  (0) 2021.06.29
[Java] Static 키워드  (0) 2021.06.28

혹시 제가 사용한 방법보다 더 간단하고 좋은 방법이 있다면 훈수 부탁드립니다.


1. 마주한 문제

클레이튼 기반의 dapp을 개발하며 블록체인의 트랜잭션과 서비스의 데이터베이스간의 싱크를 보장해야하는 상황을 마주했다.

 

 

성공한 트랜잭션이면 관련 정보를 데이터베이스에 반영해주고,

실패한 트랜잭션이면 관련 정보를 데이터베이스에 반영해주지 않는, 그런 요구사항이었다.

 

 

클레이튼 블록체인에서 자체적으로 실패한 트랜잭션에 대한 롤백은 시켜주기에 클레이튼의 성공여부를 먼저 확인하고 데이터베이스 반영 여부를 정해야했다. [클레이튼 트랜잭션] -> [데이터베이스 트랜잭션]의 플로우였기에 이 사이에 트랜잭션의 성공여부만 확인하고 데이터베이스의 트랜잭션을 발생시킬지 말지 결정하면 됐다. 

 

[클레이튼 트랜잭션] -> [트랜잭션이 성공했는지 확인] -> [데이터베이스 트랜잭션]

 

하지만 블록체인은 트랜잭션이 확정되기까지 걸리는 시간인 Finality라는 개념이 있기에 트랜잭션의 성공 여부를 조회하려면 이  Finality 시간 이후에 해야했다. 클레이튼의 경우 이 시간이 평균 1초라고 알려져있다. 평균이기에 실제 상황에서 약간의 오차는 있을 것 이다. 따라서 Finality만큼의 딜레이를 하고 트랜잭션 조회 -> 데이터베이스 트랜잭션 실행의 절차를 밟아야 했다. 

 

 

즉, 필요한 조건은

 

1. 트랜잭션 조회에 delay를 줘야한다. -> Finality 때문에

2. 적어도 2회 이상 retry를 하며 조회해야한다. -> Finality 시간이 일정하지 않기 때문에

3. 최대한 빠르게 트랜잭션의 성공 여부를 판단해야한다. -> 원활한 서비스 제공을 위해

 

 

 

 


2. 고려했던 선택지

세가지를 고려했었다. 참고로 NodeJS로 개발했다.

 

1. 스케줄러를 통해 짧은 간격으로 확정되지 않은 트랜잭션들의 성공 여부를 조회하기

  1. 트랜잭션을 발생시킬 때 transaction_history 테이블에 트랜잭션의 발생 여부를 기록해두고 is_confirmed는 false로 둔다.
  2. NodeJS의 스케줄러를 통해서 is_confirmed가 false인 기록들을 짧은 간격으로 주기적으로 조회하며 트랜잭션의 성공 여부를 확인한다.(트랜잭션 성공여부는 caverExtKAS 라이브러리의 getTransferHistoryByTxHash 함수를 사용했다)

 

문제점.

트랜잭션이 발생하지 않을때도 몇 초마다 항상 실행되기에 리소스 낭비라고 생각됐다.

 

 

 

2. 클레이튼 이벤트 리스너를 통해 트랜잭션이 수행될 때 발생되는 이벤트를 추적하여 판별하기

  1. 트랜잭션을 발생시킬 때 transaction_history 테이블에 트랜잭션의 발생 여부를 기록해두고 is_confirmed는 false로 둔다.
  2. 트랜잭션이 성공하면 열어둔 소켓을 통해 클레이튼 이벤트를 Listen할 수 있는데 트랜잭션이 성공하면 이벤트가 수신되므로(아마..?) 수신했다는 것은 해당 트랜잭션이 성공했다는 의미.

 

문제점.

가끔 성공한 트랜잭션의 이벤트가 리슨되지 않을때가 있었다. (왜일까..)

 

 

 

3. RabbitMQ를 통해서 딜레이와 재시도 기능을 구현하여 조회하기 (이 방법을 사용했다)

  1. 트랜잭션을 발생시킬 때 transaction_history 테이블에 트랜잭션의 발생 여부를 기록해두고 is_confirmed는 false로 둔다. 또 동시에 RabbitMQ로 발생한 트랜잭션의 정보를 publish한다.
  2. 컨슈머에서 이를 구독하고 이 시점에 트랜잭션 성공 여부를 조회한다.
  3. 트랜잭션이 성공이면 데이터베이스에도 관련 정보를 반영 후 ack으로 마무리하고, 실패면 retry_count를 1추가해서 DLX로 보낸다. 이곳에서 5000ms동안 대기하고 다시 작업큐로 메시지를 보내서 반복한다.(넉넉히 5초로 딜레이를 주었다)
  4. 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를 반환한다.

 

 

 

DLX의 개념과 동작방식은 아래 아저씨가 쉽게 설명해준다!

https://www.youtube.com/watch?v=ovE8NKAwqTI&ab_channel=MikeM%C3%B8llerNielsen 

 

 

 

 


4. 세부 구성

메시지는 

[1] -> [2]에서 5000ms 대기 -> [3] -> [4]의 순서로 움직인다.

 

 

 

 

 

후기.

뭔가 긴 고민끝에 찾은 방법이지만 결국은 기본적인 RabbitMQ의 활용법이었다!

RabbitMQ가 참 유용한 도구임을 새삼스래 느꼈던 경험이었다.

 

 

참고:

더보기

오늘의 강의는 뭘까~용


1. 스마트 컨트랙 코드는 실행할때 돈이 든다. 

왜?

나의 트랜잭션을 블록에 넣어줄 마이너는 땅파서 컴퓨터를 돌려주는게 아니기때문.

 

얼마나 들까?

밑에서 대~강 구해봤다.

 

왜 알아야하나?

알아야 나중에 코딩할때 더 값싼 방법으로 코딩할 수 있으니까

 

가스비는 일반적으로 gwei로 표시되며, 이는 ETH의 10억분의 1(0.000000001 ETH)이다

 

이더리움의 수수료

수수료 = gas(gasLimit) * gasPrice

 

예시)

수수료 = gas(21000) * gasPrice(1gwei)

수수료 = 21000000000000gwei

이때, 1ETH = 10 ** 18wei이니까

 

수수료 = 0.00021ETH 가 된다.

 

gas와 gasPrice는 다음과 같은 차이점이 있다.

  • gas(gasLimit) : 본 송금 '작업'에서 소비되는 가스량
    (estimated 한 수치라서 변경가능하다. 하지만 넘 작게하면 거부됨)
  • gasPrice : 내가 가스당 지불할 가격
    (경매처럼 내가 금액을 제안하는 것임)

 

MAX TOTAL : gas * gasPrice 로 형성된 최종지불금액

gasPrice를 높게 잡을수록 이더리움 블록체인에서 마이너들이 내 트랜잭션을 빠르게 채굴해준다.

 

 

 

 

확인해보자

이더스캔에서 아무 트랜잭션이나 한번 봐보자

 

Transaction Fee(0.000739157072325) = gas(21000) * gasPrice(0.000000035197955825)

임을 확인할 수 있다.

 

Gas Limit & Usage by Txn은 뭐냐?

Gas Limit(앞에서 말한 gas와 동일)은 내가 이 값 이상으로는 가스비를 내지 않을 것이다라는 최대한도

Usage는 실제로 사용된 가스비를 의미

이 예시의 경우는 Limit의 100%를 사용했다.

 

넉넉하게 Gas Limit을 주면 알아서 최적의 가스비로 지불이 된다.

너무 GasLimit을 낮게주면 트랜잭션이 실패되니 넉넉하게 주자

 

 

 

 


2. 우리가 작성한 스마트컨트랙이 사용할 Gas를 대~강 계산해보자

가스비가 많이드는 연산: 블록체인의 용량을 늘리는 연산(저장하는 연산)

 

 

참고로 한 블록에서 사용가능한 가스비가 정해져있기에(Block Gas Limit) 무제한으로 용량을 늘리는(정보를 저장하는) 연산을 할 수 없다.

 

이 블록안에 있는 모든 트랜잭션 들이 사용할 수 있는 gas limit은 30,029,295이다.

이 값을 초과할 정도의 연산은 블록에 들어갈 수 없다.

 

이더리움의 가스비는 다음과 같이 정해져있다

  • 32Bytes 저장에 20000gas가 든다
  • 32Bytes 기존 변수에 있는 값을 바꿀때 5000gas
  • 기존변수를 초기화해서 더 이상 쓰지 않을때 10000gas return

 

 

Lottery.sol의 가스비를 파헤쳐보자

우리가 만든 Bet함수를 실행할 때 발생하는 가스비를 계산해보자

truffle console을 통해서 Bet함수를 실행시켜보았고 90846gas가 발생했다

 

 

연속으로 한번 더 실행하면 75846gas가 발생한다.

왜 갑자기 적어졌지? 끝에서 설명하겠다.

 

 

 

 

일단 우리가 작성한 코드를 보자

 

  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;
  }

 

이러한 Bet함수에서 pushBet을 호출하는 부분이벤트를 emit하는 부분에서 가스비가 든다.

즉, 첫번째 발생한 90846가스로 계산을 시작해보면

90846 = 기본가스(21000) + pushBet(???가스) + emit(대략 5000gas)

 

 

자 그러면 pushBet함수를 보자

  function pushBet(bytes challenges) internal returns (bool) {
    BetInfo memory b; 
    b.bettor = msg.sender; // address형 변수 사용 (1)
    b.answerBlockNumber = block.number + BET_BLOCK_INTERVAL; // uint256형 변수 사용 (2)
    b.challenges = challenges; // bytes형 변수 사용 (3)

    _bets[_tail] = b; // 그닥 크지 않다 (4)
    _tail++; // 32Byte형 변수 사용(5)

    return true;
  }

(1)번은 address형 변수를 사용했으므로 20Byte를 사용한 것

(3)번은 Byte형 변수를 사용했으므로 1Byte를 사용한 것

이더리움 블록체인에서 사용하는 기본저장단위는 32Byte다

 

즉, 20Byte를 사용했든 1Byte를 사용했든 둘 다 32Byte를 사용한 것이므로 64Byte를 사용한 것이 맞으나

작은 단위의 바이트에 대해서는 쪼개서(?) 연산해준다고 한다.

따라서 (1)번과 (3)번 합쳐서 32Byte를 사용한 셈 치면 된다.

(1) + (3) = 20000gas (32Byte)

 

(2)번은 uint256형 변수를 사용했으므로 32Byte 사용한 것

(2) = 20000gas (32Byte)

(4) = 무시

(5) = 20000gas (32Byte)

 

 

즉, pushBet에서 발생한 gas는 (1) + (2) + (3) + (4) + (5) = 60000gas

 

 

90846 = 기본가스(21000) + pushBet(60000gas가스) + emit(대략 5000gas)

90846 = 86000gas + 기타 자잘한 연산들

 

이라고 할 수 있다.

 

그러면 왜 2번째 실행했을때는 75846gas만 발생했느냐?

 

32Bytes 기존 변수에 있는 값을 바꿀때 5000gas가 들기때문이다.

  function pushBet(bytes challenges) internal returns (bool) {
    BetInfo memory b; 
    b.bettor = msg.sender; // 함수 호출한 사람
    b.answerBlockNumber = block.number + BET_BLOCK_INTERVAL; 
    b.challenges = challenges; 

    _bets[_tail] = b;
    _tail++; // 초기 0에서 1되는 것은 20000gas지만 1에서 2되는 것은 수정이므로 5000gas만 든다

    return true;
  }

즉 첫번째 실행보다 15000gas가 덜 발생한다.

90846 - 15000 = 75846

대강 맞는것을 확인할 수 있다.

 

므ㅏ

강의출처:


1. 사용할 컨트랙에 대한 정보를 가져온다.

contract("Lottery", ([deployer, user1, user2]) => {
  ...
});

트러플에서는 mocha와 chai를 스마트컨트랙에서 사용할 수 있도록 조절해 놓았다고 한다.

따라서 테스트를 시작할 때 contract 키워드로 시작하면 된다.

 

첫번째 인자로는 컨트랙트 이름이 들어가고,

두번째 인자로는 콜백함수가 들어간다. 이 콜백함수의 파라미터로 ganache-cli에서 생성되는 10개의 account가 들어간다.

즉 최대 10개의 인자를 넣을 수 있다.

 

위 코드처럼 3개를 썼다는 것은 

deployer에 0xd53....

user1에 0xd07....

user2에 0x040....

이렇게 3개의 주소가 파라미터로 들어간 것이다.

 

 

 

 


2. 스마트 컨트랙 배포

테스트를 하려면 스마트컨트랙 코드가 블록에 들어가서 실행되어야 하므로 컨트랙을 배포해주자

contract("Lottery", ([deployer, user1, user2]) => {
  beforeEach(async () => {
    console.log("Before each");
    lottery = await Lottery.new(); //테스트에서 사용할 스마트 컨트랙 배포
  });
});

 

 

 

 


3. 테스트코드 작성

  let betAmount = 5 * 10 ** 15;
  let BET_BLOCK_INTERVAL = 3;
  
  describe("Bet", async () => {
    it("베팅큐에 값이 잘 들어갔는지 확인하기", async () => {
      // 베팅한다
      const receipt = await lottery.bet("0xab", {
        from: user1,
        value: betAmount, // 5 * 10 ** 15 -> 0.005ETH
      });

      // pot머니(상금)은 아직 결과가 들어나지 않았기에 0이어야 한다.
      let pot = await lottery.getPot();
      assert.equal(pot, 0);

      // 컨트랙트 주소로 0.005이더가 들어왔는지 확인한다
      let contractBalance = await web3.eth.getBalance(lottery.address); // web3가 자동으로 주입돼있다
      assert.equal(contractBalance, betAmount);

      // 베팅인포가 제대로 들어갔는지 확인한다
      let currentBlockNumber = await web3.eth.getBlockNumber();
      let bet = await lottery.getBetInfo(0); // 큐 제일 앞의 베팅정보를 가져온다

      // 큐에 넣은 베팅정보가 올바른지 확인
      assert.equal(
        bet.answerBlockNumber,
        currentBlockNumber + BET_BLOCK_INTERVAL
      );
      assert.equal(bet.bettor, user1);
      assert.equal(bet.challenges, "0xab");

      // 로그(BET이라는 이벤트)가 제대로 찍혔는지 확인한다
      await expectEvent.inLogs(receipt.logs, "BET");
    });

    it("0.005이더가 안들어왔을때는 실패해야한다", async () => {
      // 트랜잭션 실패
      await assertRevert(
        // 두번째 인자는 트랜잭션 오브젝트, transaction object란
        // (chainId, value, to, from, gas(limit), gasPrice)
        lottery.bet("0xab", { from: user1, value: 4000000000000000 }) // bet함수 실행 -> 0.004ETH가 들어갔기에 트잭이 실패나야한다
      );
    });
  });

 

 

event, emit을 테스트하기

const assert = require("chai").assert;

// 직접 발생시킨 event의 로그가 있는지 확인한다.
const inLogs = async (logs, eventName) => {
  const event = logs.find((e) => e.event === eventName);
  assert.exists(event);
};

module.exports = {
  inLogs,
};

[Lottery Dapp 개발하기] 2.4까지의 분량이다. 

밀린 블로그 글써내기중이라 그간 했던 분량을 한번에 쑤셔넣는중.

이 글을 읽는 여러분들에게 도움이 되면 좋겠지만 딱히 그럴려고 쓰는 글은 아니고 혼자서 끄적이는 글.

따라서 생략된 설명이나 이걸 왜 설명? 하는 부분이 있을것이다.

 

 

가보자~

강의 출처:


1. 코딩할 것들

  • 스마트 컨트랙
    • 베팅정보를 담는 구조체
    • 베팅정보를 저장할 큐 자료구조
    • 쌓인상금_가져오기()
    • 베팅큐에서_베팅정보_가져오기()
    • 베팅하기()
      • 베팅을_베팅큐로_밀어넣기()
      • 베팅을_베팅큐에서_빼기()
  • 테스트코드 (다음글에서)
    • beforeEach
    • 베팅큐에 베팅정보가 잘 들어간경우 테스트
    • 베팅큐에 베팅정보가 못들어간경우 테스트

 

 

 


2. Lottery.sol에 코딩한 것들

베팅 정보를 담는 구조체

  struct BetInfo {
    uint256 answerBlockNumber; // 맞출 블록의 넘버
    address payable bettor; // 돈을 건 사람 주소, 특정주소에 돈을 보내려면 payable을 써줘야함
    bytes challenges; // 문제. ex) 0xab
  }

 

 

 

BetInfo 구조체를 넣어줄 큐를 구현

  // 매핑으로 큐를 구현하기 위한 변수
  uint256 private _tail;
  uint256 private _head;

  // 키는 uint, 값은 BerInfo인 매핑
  mapping(uint256 => BetInfo) private _bets;

index - value의 쌍을 mapping을 통해 구현한다.

 

 

 

상금 액수 조회하기

  uint256 private _pot;
  
  // 스마트 컨트랙의 변수를 가져와서 쓰려면 view 키워드를 쓴다
  // 더 자세히는, Storage 자료를 읽어서 보여줄때 사용. 수정할때는 사용x
  // Storage 자료는 블록에 영구적으로 기록된 값(하드디스크)
  // 반대인 Memory 자료는 임시로 저장되는 값(RAM)
  function getPot() public view returns (uint256 value) {
    return _pot;
  }

 

 

 

베팅하기

  // 이벤트 객체
  event BET(uint256 index, address bettor, uint256 amount, bytes1 challenges, uint256 answerBlockNumber);

  function bet(bytes challenges) public payable returns (bool result) {
    // 베팅할 금액(0.005ETH)이 제대로 왔는지 확인
    // require는 if역할, msg.value는 컨트랙트가 받은금액, 'Not Enough ETH'은 조건이 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;
  }

event와 emit

트랜잭션이 완료되면 트랜잭션 영수증을 발행한다.

영수증에는 트랜잭션 실행 동안 발생했던 모든 행동로그가 기록되어 있다.

event는 이런 로그를 만들기 위한 객체

emit은 만들어진 event 객체를 체인에 올리기.

더보기

 

 

 

 

베팅정보들을 담은 큐에서 베팅정보 1개 꺼내오기

  function getBetInfo(uint256 index)
    public
    view
    returns (
      uint256 answerBlockNumber, // 반환값1
      address bettor, // 반환값2
      bytes challenges // 반환값3
    )
  {
    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;
  }

 

 

 

베팅큐에서 베팅정보 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;
  }
}

정말 오~랜만에 글을 쓴다.

써야지 써야지 하면서 너무 미뤘다.

 

 

 

더 이상 글을 안쓰면 블로그를 영영 잊게될까봐 다시 자리에 앉았다.

그래도 그간 쪼잡쪼잡 이거저거 맛보기는 하고있었는데 그 중 하나인 이더리움 dapp 개발 과정을 써볼까 한다.

유튜브 강좌를 보며 따라하는 것이고.. 그저 따라할 뿐...! 아직 어렵다

 

코딩하는 나의 모습

 

내가 보며 따라하는 강좌는 요거다.

https://youtu.be/Ud3_OrxNPDg

 

 


0. What we gonna do?

스마트컨트랙트를 기반으로하는 디앱을 만들어보는 프로젝트.

디앱이란?

대충 블록체인을 활용한 애플리케이션.

Decentralized App

 

 

 


1. Lottery Dapp preview

로또게임(?)을 만들건데 스마트컨트랙을 활용하여 만들 것이다.

 

이 프로젝트에서 우리는

  • 솔리디티를 사용한 스마트컨트랙 만들기
  • 배포스크립트 만들기
  • 테스트코드 만들기
  • 프론트에 연동하기

를 하게된다.

 

 

스마트컨트랙을 작성하여 블록체인에 올린다는 것은,

자바로 백엔드 코드를 작성하여 EC2에 올린다는 것과 같다.

 

블록체인도 결국 분산된 무수히 많은 컴퓨터들이니 블록이 곧 서버역할을 하는 셈이다. 스마트컨트랙은 코드역할이고.

따라서 기존 애플리케이션의 흔한 구조인

[프론트엔드]-[백엔드]가

[프론트엔드]-[블록체인]이 되는 것이다.

 

 

단, 경우에 따라서 디앱이라도 [프론트엔드]-[백엔드]-[블록체인]처럼 백엔드를 추가하는 구조도 많은데 아무래도 블록체인상에서 코드를 실행시키기 위해서는 가스비라는 비용이 발생하기에 이런 비용적인 측면을 개선하고자 백엔드단을 추가하기도 한다고 한다.

(블록체인을 구성하는 노드(컴퓨터)들을 제공하는 사람들은 땅파서 장사하는게 아니기에.. 이들에게 가스비라는 대가를 지불하고 우리의 코드를 실행시켜달라고 하는 것)

 

 

 

 

아무튼, 서론이 길었다.

 

우리가 만들 로터리 디앱은 어떤 프로젝트냐면,

https://youtu.be/div91ADl_7k

시연영상

 

 

블록해시 맞추기 게임이다.

 

내가 만든 트랜잭션이 들어가게 되는 블록보다 3개 뒤 블록의 블록해쉬를 때려맞추는 게임(재미는 없을듯)

 

 

예를들자면

  1. 내가 0xab라고 베팅을 한다.
  2. 이 베팅은 하나의 트랜잭션으로서 블록에 포함된다.
  3. 만약 이 트랜잭션이 3번 블록에 포함됐다고 하자.
  4. 3번 블록의 3개 뒷 블록인 6번 블록의 블록해쉬값이 0xab라면 내가 이긴 것이다.
  5. 즉 6번 블록이 생성완료되고 7번블록의 생성이 시작된 시점부터 게임결과를 알 수 있다.

 

pot머니라고 부르는 베팅금액을 가져가는 규칙은 이러하다.(전부 0.005ETH씩 베팅한다)

  1. 6번 블록이 완성되었을때 유저들이 베팅한 돈을 팟머니에 쌓는다.
  2. 맞춘사람이 여러명일 경우 첫번째로 맞춘 사람이 가져간다.
  3. 두글자중(ab중에서) 하나만 맞추었다면 돈을 돌려준다.
  4. 결과값을 검증할 수 없는 경우가 발생하는 경우 돈을 돌려준다.

 

 

동작으로만 보면 그닥 복잡하진 않아보인다.

이 프로젝트의 핵심은 스마트컨트랙을 작성하고 이를 배포하여 체인과 상호작용하는 것에 있다.

 

 

 

 


2. 개발 환경

  • NodeJS : 배포와 테스트 코드를 작성하기 위해
  • VSCode : ide 이거씀
  • Truffle : 솔리디티를 컴파일하고 스마트컨트랙을 테스트, 배포할 수 있는 블록체인 개발 프레임워크이다. Truffle 설치시 solc-js가 설치된다(JS기반의 솔리디티 컴파일러)
  • ganache-cli : 간이 블록체인. 근데 이제 내 PC 메모리로 실행되는. 블록체인을 쉽게 mock을 해놓은 툴(테스트 용도), 즉 테스트와 개발에 사용하는 이더리움 RPC 클라이언트.(테스트용 노드(서버)의 느낌으로 이해하면 되려나..?)

 

ganache-cli(편의상 가나슈라고 하겠다)를 실행하면 로컬에서 블록체인 환경이 실행되고 여기서 사용할 수 있는 계정 10개가 제공된다. 

 

이 간이 블록체인을 켜기 위해서는 콘솔창에서

ganache-cli -d -m tutorial

를 실행해준다

 

-d 옵션을 주면 생성되는 니모니깅 동일하고 주소와 키값이 동일하다

-m 옵션은 지정된 HD 지갑 니모닉 값으로 초기 주소를 생성하기 위해 사용한다.

우리는 니모닉으로 tutorial을 사용했다.

보통 니모닉은 12개 단어를 사용하지만 1개만 써도 된다(?)

 

즉 -d, -m 옵션을 사용하고 니모닉 단어(tutorial)를 사용하면 항상 같은 지갑주소가 생성된다.


1. 로컬에서 브랜치명 변경하기

 

git branch -m oldname newname

 

git branch -m [기존브랜치이름] [새로운브랜치이름]

간단하다.

 

 

 

 


2. 원격저장소의 브랜치명까지 변경하기

 

로컬저장소에서는 브랜치 이름이 변경되었다.

이어서 이 변경사항을 원격저장소에도 반영해보자.

 

현재 로컬저장소는 newname이지만 원격저장소는 oldname이다

 

 

 

 

아까 로컬에서의 git branch -m옵션으로 oldname브랜치의 이름을 newname으로 바꿨기에

이 사항을 원격저장소에 push한다.

 

git push origin :oldname

 

이전 이름인 oldname 앞에 콜론(:)을 붙혀서 oldname 브랜치의 삭제사항을 푸시하는 것이다.

그러면

요렇게 나오면서 

 

 

 

원격저장소에 있던 oldname 브랜치가 사라진다.

 

 

 

 

 

이제 이름을 바꾼 브랜치를 푸시하면 된다.

git push --set-upstream origin newname

그러면 이렇게 나오면서

 

 

 

원격저장소에도 수정된 이름의 브랜치가 잘 반영됨을 확인할 수 있다.

 

 

 

 

꿀팁 한 가지.

변경내용이 없어도 커밋을 하고싶다면

git commit --allow-empty -m "Empty Commit Message"

하면 된다 !

 

 

 

 

참고:

 

 

'Git' 카테고리의 다른 글

[Git] git stash  (0) 2021.11.24
[Git] Branch와 Merge  (0) 2021.08.14
[Git] BFG Repo-Cleaner를 사용한 민감한 히스토리 삭제  (0) 2021.08.14
[Git] git bash를 사용한 버전관리 시작하기  (0) 2021.08.13

+ Recent posts