블록체인 뿅뿅

체인링크 VRF 응용 - 당첨차 추첨 컨트랙트 만들기 (2)

bimppap 2024. 2. 7. 02:22

 

 

이전 글은 아래 링크에서 확인할 수 있다.

체인링크 VRF 응용 - 당첨차 추첨 컨트랙트 만들기 (1)

 


 

컨트랙트에서 트랜잭션이 실행되려면 가스가 필수적으로 든다. 코드의 길이와 데이터 변경 유무에 따라 필요한 가스량이 달라지기 때문에, 좋은 컨트랙트는 최소한의 가스로 최대의 효율을 내는 컨트랙트라고 배웠다. 때문에 최대의 효율을 내기 위해선 가스가 언제 덜, 혹은 더 쓰이는지 알아야 한다. 지난 글에서 짜인 코드는 효율과 가스비를 고려하지 않고 작동하기만 하는 코드였다. 이번 글에서는 가스비, 데이터량 등 환경적 제약을 고려하여 리팩토링하는 과정을 보여줄 예정이다.

 

1. 한 번에 1만 개의 주소를 넣을 순 없다.

데이터량에 대해 고민한 이유는 신청자 인원 수 때문이다. 컨트랙트에 약 1만 명에 가까운 주소를 세팅해야 하는데, 한 번에 다 넣을 수 없었기 때문이다. 한 트랜잭션에 담을 수 있는 데이터양은 너무 많으면 안 된다. 기본적으로 트랜잭션 데이터는 0바이트로 최대 780kb, 0이 아닌 바이트로 최대 46kb가 가능하다. (참고)

 

위처럼 말하면 실감이 잘 안 날 테니 블록체인 주소를 예시로 들어보겠다. 한 트랜잭션에 1000개의 주소를 한번에 넣는 건 가능하나 1500개의 주소를 넣을 순 없다. 그 말인 즉슨 만 개의 주소를 세팅하려면 트랜잭션을 최소 10 번을 써야 한다는 뜻이다. 가스비를 1000gwei 기준으로 잡는다면 만 개의 주소를 세팅하는 데 약 250 MATIC이 드는 셈이다.

 

    // 변경 전
    function setApplicants(address[] memory _applicants) public onlyOwner {
        applicants = _applicants;
    }

 

기존의 메서드는 필드 변수에 세팅하는 것만 가능하기에 신청자를 세팅한 후 따로 추가하는 메서드도 구현했다.

 

    // 변경 후
    function setApplicants(address[] memory _applicants) external onlyRole(DEFAULT_ADMIN_ROLE) {
        applicants = _applicants;
    }

    function addApplicants(address[] memory _applicants) external onlyRole(DEFAULT_ADMIN_ROLE) {
        for (uint i = 0; i < _applicants.length; i++) {
            applicants.push(_applicants[i]);
        }
    }

 

그러나 한 번의 당첨을 위해 250 MATIC을 사용할 순 없었기에 다른 방안을 생각해야 했다. 문자열로 바꾸어 넣는 건 어떨까 하여 변경해보니, 최대 700개의 주소(=29400자의 문자)을 한번에 넣을 수 있는 걸 확인할 수 있었다. address 타입을 사용할 때보다 적은 양이었다. 이에 팀원이 제시한 방법은 체크섬이었다. 42자로 이뤄진 주소를 8자리로 줄이되, 체크섬을 포함하여 더 적은 데이터량으로 주소를 넣을 수 있도록 바꾸자고 하였다. 이러면 3000개의 주소(=24000자의 문자)를 한 번에 넣을 수 있었다. 10번 쓸 트랜잭션을 3-4번으로 줄이게 되는 것이다. 하여 최종적으로 나온 코드는 아래와 같았다.

 

    // 최종 코드
    function setApplicantStrings(string[] memory _applicants) external onlyRole(DEFAULT_ADMIN_ROLE) {
        applicantStrings = _applicants;
    }

    function addApplicantStrings(string[] memory _applicants) external onlyRole(DEFAULT_ADMIN_ROLE) {
        for (uint i = 0; i < _applicants.length; i++) {
            applicantStrings.push(_applicants[i]);
        }
    }

 

 

2. VRF의 callbackGasLimit

 

callbackGasLimit은 VRF에서 Coordinator를 통해 requestRandomWords 메서드를 사용할 때 보내는 파라미터 중 하나이다. requestRandomWord를 사용하면 fulfillRandomWords가 콜백 메서드로 작동한다. 이때 폴리곤 메인넷 기준 체인링크에서 제공하는 callbackGasLimit의 최대치는 2,500,000이며 난수는 한 요청에 최대 500개까지 받을 수 있다. 그렇다면 난수 1개당 가스가 5,000가 드는 거라 예상할 수도 있다.

 

그러나 fulfillRandomWords에도 요청받은 난수를 가공하는 로직이 들어가기 때문에 실제로 500개를 요청하는 건 불가능했다. 내가 구현한 fulfillRandomWords의 경우, 요청받은 난수를 0~신청자 수 만큼의 범위로 제한시켰다. 또한 중복 당첨을 피하기 위해 while문으로 설정한 범위 내에서 이미 당첨된 숫자가 나오지 않을 때까지 난수에 일정 값을 더한다.

 

결과적으로 callbackGasLimit을 최대치로 맞춰놓은 상태에서 한번에 요청할 수 있는 난수 갯수는 50개였다. 이에 requestRandomWords를 시행하는 rollDice 메서드와 콜백함수인 fulfillRandomWords는 아래와 같이 변경되었다.

 

    // 추첨해야할 인원이 50으로 맞아떨어지지 않을 것을 대비해 1~50 범위의 갯수로 난수 생성을 요청한다
    function rollDice(uint32 _numWords) external onlyRole(ROLLER_ROLE) returns (uint256 requestId) {
        require(applicantCount >= _numWords, "applicants should be same or more than numWords");
        require(_numWords > 0 && _numWords <= 50, "numWords range is 1 ~ 50");

        requestId = COORDINATOR.requestRandomWords(
            s_keyHash,
            s_subscriptionId,
            requestConfirmations,
            callbackGasLimit,
            _numWords
        );

        s_counter[count] = requestId; // 주사위 굴림 회차 mapping에 요청 requestId를 저장한다.
        s_results[requestId] = [0]; // 결과가 나오기 전까지 난수 요청의 결과값은 0으로 고정된다.

        emit DiceRolled(requestId, drawingCollection, count);
        count++;
    }
    
    
    
    function fulfillRandomWords(
        uint256 requestId,
        uint256[] memory randomWords
    ) internal override {
        s_results[requestId] = new uint256[](randomWords.length);

        for (uint256 i = 0; i < randomWords.length; i++) {
            uint256 randomNumber = randomWords[i] % applicantCount;
            
            // 한 컬렉션에서 당첨 난수를 뽑을 때 중복이 나오지 않도록 한다.
            while (isNumberExists[drawingCollection][randomNumber]) {
                randomNumber = (randomNumber + 1) % applicantCount;
            }

            s_results[requestId][i] = randomNumber;
            isNumberExists[drawingCollection][randomNumber] = true;
        }

        emit DiceLanded(requestId, s_results[requestId]);
    }

 

뽑아야 할 당첨자는 백명 단위였기에 난수 요청을 여러 번 하는 방식으로 바뀌었다. 이 때, 난수 요청을 여러번 하는 로직은 컨트랙트가 아닌 스크립트로 구현하였다.

 

3. 개선해야 할 점

 

위에 적은 두 가지 외에도 개발을 하면서 겪은 난황은 많았다. 옥텟 주소로 트랜잭션을 실행하려고 하니 데이터 사이즈가 크다고 자르거나 500에러를 뱉기도 하고, 체인링크 공식 문서를 제대로 안 읽고 파라미터 값을 잘못 넣었다가 불분명한 메세지로 오래 헤매기도 했다. 테스트넷에서 가스비를 2배로 보냈다가 가스비가 너무 높다는 메세지(tx fee exceeds the configured gap)를 받으며 트랜잭션이 실행이 안 되는가 하면 30분 동안 트랜잭션이 팬딩 상태에 빠져 있기도 했다.

 

개발 중에는 도저히 안 풀릴 것처럼 느껴졌는데 돌이켜보니 좀 더 차분하게 진행했더라면 겪지 않았을 문제들이었던 것 같다. 또한 블록체인을 실습 위주로만 배워 이론쪽으로 많이 부족하다는 걸 느꼈다.

 

코드를 짜는 데에도 아쉬움이 많이 남았다. 나름 효율적으로 짜기 위해 노력했지만, 고민할 시간이 더 있었다면 보다 나은 코드가 되지 않았을까 생각한다.

 

 

4. 회고

 

개발도 추첨도 끝났으니 조심스레 밝혀보자면, 이번에 개발한 컨트랙트는 기사화도 되었다.

 

기사 일부를 캡쳐해왔다.

 

 

정확히는 컨트랙트에 쓰인 체인링크 기술이 기사화된 것이지만... 어쨋든 이 기술을 사용한 추첨 컨트랙트를 짠 건 나니까 큰 기여를 했다고 본다. 실수해선 안 된다는 부담감에 마음 고생을 했지만 결과적으론 문제 없이 해냈기에 뿌듯하다.

 

개발을 하면서 느꼈지만, 블록체인은 확실히 정보가 투명하여 공정성을 보장한다는 장점이 있다. 그러나 모든 걸 블록체인에 담기엔 비용이 만만치 않다. 블록체인을 잘 사용하려면 온체인과 오프체인의 역할을 조화롭게 나누어 비용을 최대한 절감하면서 투명성을 지키는 게 핵심이라고 생각한다. 어려운 일이겠지만 기반이 잘 다져진다면 못할 것도 없다 본다.