본문 바로가기
Hyperledger Fabric

Hyperledger Fabric: High Throughput Chaincode

by namho46 2020. 10. 28.

 

 

하이퍼레저 패브릭은 프라이빗 네트워크로서, 스마트 컨트랙트와 같은 서비스를 하고자 할 때 해당 기능이 구현된 체인코드를 서비스 채널을 구성하는 Peer 에 설치하여 서비스 할 수 있다.

 

하이퍼레저 패브릭은 이더리움과 같은 퍼블릭 네트워크와는 달리, MSP 에 의해 허가된 멤버들이 참여하여 네트워크에 트랜잭션을 발생 시키기 때문에 PoW 혹은 PoS 와 같은 합의 방식을 사용하지 않고 Oderer 노드에게 요청된 트랜잭션을 순서대로 블록으로 구성 시 지정된 시간(BatchTimeout), 지정된 크기(BatchSize.AbsoluteMaxBytes), 지정된 최대 메시지 개수(BatchSize.MaxMessageCount) 와 같은 파라미터에 따라 블록을 구성하여 각 Peer 들에게 전파를 한다. 또한 최근에 Hyperledger Fabric v2.X 버전으로 업데이트 되면서 기존 Kafka 방식의 Orderer 를 Raft 방식으로 변경하여 개선된 Throughput 을 보여줌을 IBM 블로그에서 확인한 바 있다.

 

이더리움과 비교하면 하이퍼레저 패브릭은 트랜잭션 처리 속도가 뛰어나다고 볼 수 있다. 실제로 2개의 Peer(Endorser) 노드와 1개의 Orderer 노드 환경에서 간단한 바이트 암/복호 및 저장에 대한 체인코드에 대해서 IBM 에서는 785.58 TPS 가 나온다고 확인했다.(출처: www.ibm.com/blogs/blockchain/2019/01/answering-your-questions-on-hyperledger-fabric-performance-and-scale/)

 

하지만 이더리움과 비교 했을 때 성능이 높아졌지만 실 서비스로서 사용하기에는 1,000 TPS 도 안나온다는 것은 어려움이 있다. 본인은 초기에 이런 문제를 인지는 하더라도 구현 상에서는 고려하지 않고 실제 서비스를 감당 할 수 있는(대략 초당 10,000 건의 체인코드 실행 트랜잭션 발생) Fabric SDK 를 활용한 Gatway 를 개발했다. 이는 간단히 설명하면 다음과 같은 과정으로 처리한다.

 

  1. Chaincode 정보 및 Chaincode 실행 요청 메시지 전송
  2. 요청 메시지 가공 및 org1.peer0 에게 Chaincode 실행 요청
  3. org1.peer0 의 Chaincode 실행 결과 응답
  4. 다음 메시지 수신 및 2번 반복

위의 과정을 거쳤을 때 다음과 같은 문제가 발생했다.

  • 3번의 과정을 위해 요청한 트랜잭션이 Orderer에 의해 블록에 담기고 해당 블록이 Peer들에 전파되어 각 Peer가 블록에 있는 트랜잭션들을 검증을 끝낼 때 까지 대기하게 됨
  • 이는 즉 한 블록에 한 개의 트랜잭션만이 담기는 현상이 발생

위 문제는 트랜잭션 요청과 응답을 동기적으로 수행하는 과정에서 발생한 문제이다. 이를 해결하기 위해 go 언어의 goroutine 을 활용하여 2, 3번 과정을 비동기로 진행했고 또한 3번 과정은 이벤트 등록을 하여 처리했고 이는 다음과 같았다.

 

  1. Chaincode 정보 및 Chaincode 실행 요청 메시지 전송
  2. 요청 메시지로 트랜잭션 생성 및 이벤트 등록
  3. goroutine: org1.peer0 에게 Chaincode 실행 트랜잭션 요청
  4. goroutine: org1.peer0 의 Chaincode 실행 트랜잭션 이벤트 리스닝
  5. 다음 메시지 수신 및 2번 반복

위의 과정에서 3, 4번은 비동기로 실행됨을 알 수 있다. 하지만 이와 같은 실행은 다음과 치명적인 문제를 일으켰다.

 

  • error trying invoke chaincode. Error: Peer has rejected transaction with code MVCC_READ_CONFLICT

 

위의 에러 메시지를 해석하자면, Peer 에서 요청 받은 대로 체인코드를 실행하려고 보니 이미 해당 체인코드에 동일한 키로 데이터에 접근중이라는 것이다. 결국에는 비동기로 처리하는 과정에서 너무 빠른 속도로 트랜잭션을 요청하다보니 이전 트랜잭션을 Peer에서 처리하는 과정에서 동일한 키를 갖고 체인코드를 실행하는 트랜잭션이 요청되어 거절 된 것이다.

 

너무나 어이가 없어 구글에 검색해보니 다음과 같은 설명을 찾을 수 있었다.

해석하자면, "하이퍼레저 패브릭은 잠금 기능이 없는 낙관적인 동시성을 사용하며, 읽기/쓰기가 더러울 경우 롤백한다. 가능한 한 키 충돌을 피해야 하며 클라이언트 측에서 재시도 논리를 작성해야 할 수 있다" 즉, 하이퍼레저 패브릭은 잠금 기능이 없기 때문에 키가 충돌되면 롤백이 된다.

 

실제 서비스에 준하는 성능을 가져가고는 싶지만, 병렬 혹은 비동기로 처리하면 동일 키 접근으로 충돌나는 상황이다. 해당 답변에서 제안하는 방법은 두 가지 이다. 1) 최대한 키 충돌을 피할 것 2) 충돌 나면 재시도를 할 수 있도록 할것. 1)번에 대한 내용은 곧 다룰 것이고 2) 번의 내용을 고려 했을 때, 결국 성공이 될 때 까지 특정 interval 만큼 대기하며 시도하도록 할 수 있겠다. 하지만 본인이 개발하고자 했던 서비스의 요구사항은 Strict Order 즉, 요청 순서가 정확히 지켜져서 실행이 되어야 했기 때문에 결국 interval 만큼 모든 트랜잭션이 지연 될 것이 예상되어 간단히 테스트만 해보고 2)번 방법은 버렸다.

 

1)번 방법에 대해서 IBM 은 본 문서의 핵심 주제인 "High Throughput Chaincode" 를 소개한다. (출처: github.com/hyperledger/fabric-samples/tree/master/high-throughput)서

 

해당 방법의 개념은 굉장히 단순하다. 데이터 Update 요청 시 데이터의 Value 를 업데이트 하기 위해 해당 Value의 Key 로 접근하여 Read/Write 하는 것이 아니다. 이는 전통적인 Update 방식이라면 "High Throughput Chaincode" 에서의 Update 는 다음과 같다. Update 요청 자체를 Key 로 만든다. 여기서는 이를 Composite Key(합성키) 라고 명명한다. 설명이 명확하지 않기에 다음의 예시를 소개한다. (해당 예시는 golang 으로 작성되었으며, 단순한 더하기 빼기 연산을 하는 체인코드의 Update 함수의 일부이다.)

 

// Retrieve info needed for the update procedure
txid := APIstub.GetTxID()
compositeIndexName := "varName~op~value~txID"

// Create the composite key that will allow us to query for all deltas on a particular variable
compositeKey, compositeErr := APIstub.CreateCompositeKey(compositeIndexName, []string{name, op, args[1], txid})

 

위의 예시를 해석하면 다음과 같다.

  1. API 로 통해 요청된 트랜잭션 아이디를 로컬 변수 txid 에 저장한다.
  2. "varName~op~value~txID" 라는 패턴을 로컬 스트링 변수 compositeIndexName 에 저장한다.
  3. compositeIndexName 에 좌변수 이름(name), 연산 이름(op), 우변수 이름(args[1]), 트랜잭션 아이디(txid) 을 스트링으로 나열하여 이어붙인 compositeKey 를 생성한다.

이해를 돕기 위해 좀더 상세한 예를 들어, Alice 라는 변수에 10 을 더하기(+) 하는 트랜잭션(0x01) 에 대한 CompositeKey 는 "varName~op~value~txID~Alice~+~10~0x01" 가 된다. 이를 보면 Key 자체가 update 하고자 하는 액션의 정보 그 자체가 된 것이다.

 

하지만 실제로 Alice 라는 key 의 value 는 아직 10 이 더해지지 않은 상황이다. 그렇다면 이에 대한 반영은 언제 하는 것인가? 바로 해당 Key 에 대해 Get 을 호출 할 때 진행된다. 이때 Prune(직역: 가지치기하다, 불필요 한 부분을 제거하다) 함수가 내부에서 함께 호출된다. 이에 대한 개념적인 절차는 다음과 같다.

 

  1. key 에 대한 value 를 Get 요청시 해당 key 와 관련된 compositeKey 들을 순서대로 읽어들인다.
  2. 각 compositeKey 들은 각각 읽히고 나면 제거(pruning)한다.
  3. 모든 compositeKey 를 다 읽었으면 최종 value 를 업데이트한다.

이해를 돕기 위해 위에서 들었던 예시의 연장선상에서 설명하겠다. Alice에서 10 을 더하는 트랜잭션 0x01 을 Update 하고 다시 동일한 키 Alice 에 10 을 빼는 트랜잭션 0x02 그리고 Alice에 다시 10 을 더하는 트랜잭션 0x03 이 차례로 요청되었다면 다음과 같은 CompositeKey 가 저장되어 있을 것이다.

 

  1. CompositeKey#1 = varName~op~value~txID~Alice~+~10~0x01
  2. CompositeKey#2 = varName~op~value~txID~Alice~-~10~0x02
  3. CompositeKey#3 = varName~op~value~txID~Alice~+~10~0x03

이제 Alice 에 대한 Get 요청이 들어오면 다음과 같이 처리된다.

 

  1. Alice 키에 대한 현재 Value 조회 후 로컬 변수 value 에 저장
  2. Alice 키에 대한 CompoisteKey 조회
  3. CompositeKey#1 읽은 후 로컬 변수 value 에 10을 더하여 저장 후 CompositeKey#1 삭제
  4. CompositeKey#2 읽은 후 로컬 변수 value 에 10을 뺀후 저장 후 CompositeKey#2 삭제
  5. CompositeKey#3 읽은 후 로컬 변수 value 에 10을 더하여 저장 후 CompositeKey#3 삭제
  6. Alice 키에 대한 Value 에 로컬 변수 value 를 입력 후 업데이트

이렇게 되면 Get을 요청하게 되면 요청 시점까지 기록된 Update 에 대한 요청을 모두 반영 후 결과 값을 반환하기 때문에 전통적인 방식의 Get 요청과 동일한 결과를 기대 할 수 있게 된다.

 

위와 같은 방식은 앞에서 다뤘던 MVCC_READ_CONFLICT 문제를 바로 해결해 준다. 왜냐면 Update 요청 자체가 키가 되고 그 키들은 절대로 중복이 될 수 없기 때문이다. (txID 가 고유의 값 임을 생각하자.) 다만 우려스러운 점은 Get 을 실행 할 때 전통적인 방식의 Get 보다는 지연 시간이 증가 할 수 있기 때문에, Update 가 자주 발생하고 상대적으로 Get 이 자주 발생하지 않는 서비스를 개발 할 시에 장점을 가져갈 수 있다.

 

본인은 이런 IBM 에서 제안하는 "High Throuput Chaincode" 방식으로 서비스를 개발 했을때, 그렇지 않았을 때 보다 체인코드 실행에 대한 성능이 Update는 300 TPS 에서 1500TPS 까지 높일 수 있었다. Get 은 다만 3000 TPS 에서 2600 TPS 로 저하되었지만 Update가 Get에 비해 상대적으로 빈번하게 발생하는 서비스 였기 때문에 충분히 도입 할 만 했다.

 

지금까지 IBM 에서 소개하는 High Throughput 방식의 필요성과 그 개념을 필자가 개발하면서 직면했던 이슈와 관련지어 알아보았다.

 

 

댓글