Cosmos SDK v0.47.X과 v0.50.X의 구조 차이

2025. 3. 20. 18:44Blockchain/Cosmos

728x90
반응형

현재 시점 Cosmos SDK의 release 버전은 v0.50.X이다. Cosmos SDK는 v0.47.X까지 릴리즈를 하고 단숨에 v0.50.X로 버전을 올렸다. 그만큼 급격한 차이가 존재하는 부분이 생겼기 때문이다. (v0.47.X도 계속 업데이트 중이다.) 모든 변경 부분을 세세하게 언급할 수는 없지만 코어 개발 상 주요하게 변경된 부분에 대해서 확인한다.

비교 대상은 SDK v0.47.5v0.50.11이다. (이하 v0.47.X는 구버전, v0.50.X는 신버전으로 구분하여 설명한다.)

모듈의 패키지화

Cosmos SDK의 각 모듈들의 설정이 가장 크게 변경되었다. 모든 모듈이 해당되는 건 아니지만, 몇몇 모듈은 go module로써 별도로 import가 필요하다. 기존에 존재하던 evidence, feegrant 등이 해당되며, 새로이 개발된 circuit 모듈 같은 경우도 go module로써 따로 관리가 된다.

import 주소는 github이 아닌 cosmossdk.io로 가져오며 cosmossdk.io를 사용하는 모듈 및 라이브러리 예시는 아래와 같다.


require (
    cosmossdk.io/api v0.7.6
    cosmossdk.io/client/v2 v2.0.0-beta.3
    cosmossdk.io/collections v0.4.0
    cosmossdk.io/core v0.11.1
    cosmossdk.io/depinject v1.1.0
    cosmossdk.io/errors v1.0.1
    cosmossdk.io/log v1.4.1
    cosmossdk.io/math v1.4.0
    cosmossdk.io/store v1.1.1
    cosmossdk.io/tools/confix v0.1.2
    cosmossdk.io/x/circuit v0.1.1
    cosmossdk.io/x/evidence v0.1.1
    cosmossdk.io/x/feegrant v0.1.1
    cosmossdk.io/x/nft v0.1.1
    cosmossdk.io/x/tx v0.13.7
    cosmossdk.io/x/upgrade v0.1.4
)

부가적으로, Coin의 amount나 여러 곳에 자주 사용되는 sdk.Int의 기능들 대부분이 cosmossdk.io/math로 넘어갔다. 이렇게 Cosmos SDK 내에서 구현되었던 여러 기능들이 위의 require에 선언된 라이브러리로 넘어감에 따라 변경된 부분들이 조금씩 존재하는 것을 인지해야 한다.

Module 로직 변경 부분

StateDB 사용

버전이 업데이트 되면서 가장 큰 부분은 개인적으로 stateDB 접근 방법이다. gov 모듈을 예시로 확인해보자.


// v0.47.5
// x/gov/keeper/keeper.go

// Keeper defines the governance module Keeper
type Keeper struct {
    authKeeper types.AccountKeeper
    bankKeeper types.BankKeeper

    // The reference to the DelegationSet and ValidatorSet to get information about validators and delegators
    sk types.StakingKeeper

    // GovHooks
    hooks types.GovHooks

    // The (unexposed) keys used to access the stores from the Context.
    storeKey storetypes.StoreKey

    // The codec for binary encoding/decoding.
    cdc codec.BinaryCodec

    // Legacy Proposal router
    legacyRouter v1beta1.Router

    // Msg server router
    router *baseapp.MsgServiceRouter

    config types.Config

    // the address capable of executing a MsgUpdateParams message. Typically, this
    // should be the x/gov module account.
    authority string
}

// v0.50.11
// x/gov/keeper/keeper.go

// Keeper defines the governance module Keeper
type Keeper struct {
    authKeeper  types.AccountKeeper
    bankKeeper  types.BankKeeper
    distrKeeper types.DistributionKeeper

    // The reference to the DelegationSet and ValidatorSet to get information about validators and delegators
    sk types.StakingKeeper

    // GovHooks
    hooks types.GovHooks

    // The (unexposed) keys used to access the stores from the Context.
    storeService corestoretypes.KVStoreService

    // The codec for binary encoding/decoding.
    cdc codec.Codec

    // Legacy Proposal router
    legacyRouter v1beta1.Router

    // Msg server router
    router baseapp.MessageRouter

    config types.Config

    // the address capable of executing a MsgUpdateParams message. Typically, this
    // should be the x/gov module account.
    authority string

    Schema                 collections.Schema
    Constitution           collections.Item[string]
    Params                 collections.Item[v1.Params]
    Deposits               collections.Map[collections.Pair[uint64, sdk.AccAddress], v1.Deposit]
    Votes                  collections.Map[collections.Pair[uint64, sdk.AccAddress], v1.Vote]
    ProposalID             collections.Sequence
    Proposals              collections.Map[uint64, v1.Proposal]
    ActiveProposalsQueue   collections.Map[collections.Pair[time.Time, uint64], uint64] // TODO(tip): this should be simplified and go into an index.
    InactiveProposalsQueue collections.Map[collections.Pair[time.Time, uint64], uint64] // TODO(tip): this should be simplified and go into an index.
    VotingPeriodProposals  collections.Map[uint64, []byte]                              // TODO(tip): this could be a keyset or index.
}

gov 모듈의 keeper를 보면 차이를 명확히 알 수 있을 것이다. Keeper 내부에 collections 라이브러리를 통해 Item, Map 타입의 저장소를 선언하는 것이 가장 큰 차이점이다. 마치 CosmWasm의 그것과 형식이 비슷하다.

구버전의 stateDB 사용

기존 구버전 SDK는 keeper를 통해 stateDB에 데이터를 저장할 때 KVStore()로 각 모듈에 해당하는 stateDB 인스턴스를 불러왔다. gov 모듈의 SetProposal() 함수를 확인해본다.


// SetProposal sets a proposal to store.
// Panics if can't marshal the proposal.
func (keeper Keeper) SetProposal(ctx sdk.Context, proposal v1.Proposal) {
    bz, err := keeper.MarshalProposal(proposal)
    if err != nil {
        panic(err)
    }

    store := ctx.KVStore(keeper.storeKey)

    if proposal.Status == v1.StatusVotingPeriod {
        store.Set(types.VotingPeriodProposalKey(proposal.Id), []byte{1})
    } else {
        store.Delete(types.VotingPeriodProposalKey(proposal.Id))
    }

    store.Set(types.ProposalKey(proposal.Id), bz)
}

SetProposal()에는 ctx.KVStore(keeper.storeKey)를 통해 해당 모듈의 stateDB 인스턴스를 가져온다. keeper.storeKey는 Cosmos app chain의 NewApp()을 통해 선언된다.

store는 저장을 위한 Set(), 삭제를 위한 Delete(), 조회를 위한 Get()등이 존재한다. 이 기능을 활용하기 위해서는 key-value DB 형태인 stateDB의 key를 불러와야 하며, 각각의 키는 모듈의 types에 보통 정의된다. 구버전 gov 모듈의 types/keys.go는 아래와 같다.


// x/gov/types/keys.go

const (
    // ModuleName is the name of the module
    ModuleName = "gov"

    // StoreKey is the store key string for gov
    StoreKey = ModuleName

    // RouterKey is the message route for gov
    RouterKey = ModuleName
)

var (
    ProposalsKeyPrefix            = []byte{0x00}
    ActiveProposalQueuePrefix     = []byte{0x01}
    InactiveProposalQueuePrefix   = []byte{0x02}
    ProposalIDKey                 = []byte{0x03}
    VotingPeriodProposalKeyPrefix = []byte{0x04}

    DepositsKeyPrefix = []byte{0x10}

    VotesKeyPrefix = []byte{0x20}

    // ParamsKey is the key to query all gov params
    ParamsKey = []byte{0x30}
)


...


// ProposalKey gets a specific proposal from the store
func ProposalKey(proposalID uint64) []byte {
    return append(ProposalsKeyPrefix, GetProposalIDBytes(proposalID)...)
}

// VotingPeriodProposalKey gets if a proposal is in voting period.
func VotingPeriodProposalKey(proposalID uint64) []byte {
    return append(VotingPeriodProposalKeyPrefix, GetProposalIDBytes(proposalID)...)
}


...

SetProposal()에서 사용된 stateDB key인 ProposalKey()를 살펴보면, byte 타입의 prefix와 각 proposal ID의 bytes 값을 append하여 생성한다. 이 prefix를 가진 key들만 iterate를 할 수 있는 기능도 존재하기 때문에 이러한 구조를 가진다.

SetProposal()에 있는 store.Set(types.ProposalKey(proposal.Id), bz)를 보면, ProposalKeyPrefixproposal.Id가 조합된 key값에 proposal의 byte값인 bz가 value로 지정되어 저장되는 형식이다.

Set()과 마찬가지로 Delete()Get()도 key값을 가져와서 value를 핸들링 하는 형식이다.

신버전의 stateDB 사용

신버전에서 동일한 SetProposal()을 확인해 본다.


// SetProposal sets a proposal to store.
func (keeper Keeper) SetProposal(ctx context.Context, proposal v1.Proposal) error {
    if proposal.Status == v1.StatusVotingPeriod {
        err := keeper.VotingPeriodProposals.Set(ctx, proposal.Id, []byte{1})
        if err != nil {
            return err
        }
    } else {
        err := keeper.VotingPeriodProposals.Remove(ctx, proposal.Id)
        if err != nil {
            return err
        }
    }

    return keeper.Proposals.Set(ctx, proposal.Id, proposal)
}

위에서 확인한 store.Set(types.ProposalKey(proposal.Id), bz)과 동일한 기능은 가장 하단의 keeper.Proposals.Set(ctx, proposal.Id, proposal)에서 확인할 수 있다.

keeper.Proposals는 keeper structure에서 선언된 값이며, 이 값은 keeper.go에서 초기화가 되어 있다.


// x/gov/keeper/keeper.go


// v1.Proposal을 저장하기 위한 stateDB 형식
Proposals collections.Map[uint64, v1.Proposal]

// 위 Proposals의 초기화
Proposals: collections.NewMap(sb, types.ProposalsKeyPrefix, "proposals", collections.Uint64Key, codec.CollValue[v1.Proposal](cdc)),

초기화를 통해 새로운 Map 형식의 데이터 스킴이 생성된다. 이렇게 스킴을 지정해 놓으면 조금 더 정적이다라고 볼 수 있는데, collections.Map[uint64, v1.Proposal]로 되어 있으면 key값은 uint64로만 지정하여 접근해야 하는데, 구버전의 경우 bytes 형식의 값이면 모두 key로 설정 가능하기 때문에 좀 더 유의해야 하는 부분이 존재하는 것이 차이점이다.

초기화 단계에서 사용된 collections.Uint64Key가 key의 타입이 uint64인 것, codec.CollValue[v1.Proposal](cdc)를 통해 value의 타입이 v1.Proposal임을 나타낸다.

유의할 점은 위에 string값으로 들어간 "proposals"같이 데이터 스킴의 name은 유니크해야 한다.

신버전에서도 types.ProposalKeyPrefix처럼 구버전의 key와 비슷한 파라미터가 존재하는데, 쓰임은 같으나 선언 방법이 다르다. 해당 prefix는 구버전과 동일하게 types/keys.go에 보통 선언된다.


// x/gov/types/keys.go

const (
    // ModuleName is the name of the module
    ModuleName = "gov"

    // StoreKey is the store key string for gov
    StoreKey = ModuleName

    // RouterKey is the message route for gov
    RouterKey = ModuleName
)

var (
    ProposalsKeyPrefix            = collections.NewPrefix(0)  // ProposalsKeyPrefix stores the proposals raw bytes.
    ActiveProposalQueuePrefix     = collections.NewPrefix(1)  // ActiveProposalQueuePrefix stores the active proposals.
    InactiveProposalQueuePrefix   = collections.NewPrefix(2)  // InactiveProposalQueuePrefix stores the inactive proposals.
    ProposalIDKey                 = collections.NewPrefix(3)  // ProposalIDKey stores the sequence representing the next proposal ID.
    VotingPeriodProposalKeyPrefix = collections.NewPrefix(4)  // VotingPeriodProposalKeyPrefix stores which proposals are on voting period.
    DepositsKeyPrefix             = collections.NewPrefix(16) // DepositsKeyPrefix stores deposits.
    VotesKeyPrefix                = collections.NewPrefix(32) // VotesKeyPrefix stores the votes of proposals.
    ParamsKey                     = collections.NewPrefix(48) // ParamsKey stores the module's params.
    ConstitutionKey               = collections.NewPrefix(49) // ConstitutionKey stores a chain's constitution.
)

collections 라이브러리에서 prefix를 설정할 수 있는 함수를 제공한다.

다시 SetProposal()로 돌아와서, keeper.Proposals.Set(ctx, proposal.Id, proposal)에서 확인할 수 있듯이 collections의 Map으로 선언된 ProposalsSet()의 함수를 지원하여 구버전과 동일하게 사용된다.

참고로, collections를 이용하여 Get()을 조회할 시, 입력한 key값에 대한 value가 stateDB에 없다면 error를 리턴한다. Error 처리가 필요하다면 아래와 같은 방식으로 처리할 수 있다.

import (
    errorsmod "cosmossdk.io/errors"
)

type queryServer struct{ k *Keeper }

func (q queryServer) SomeQueryFunction(ctx context.Context, req *types.SomeInfoRequest) (*types.SomeInfoResponse, error) {
    someInfo, err := q.k.GetInfo(ctx, req.A)
    if err != nil {
        if errorsmod.IsOf(err, collections.ErrNotFound) {
            // handle error
            ...


        } else {
            return nil, err
        }
    }

Key값이 없을 때 error를 바로 리턴하고 싶지 않을 때에 사용할 수 있는 방법이다. Key가 없을 때는 collections.ErrNotFound의 wrapping된 error가 리턴되어, errorsmodIsOf를 사용하여 ErrNotFound의 경우에만 에러 핸들링을 수행하면 된다.

모듈의 CLI

신버전에서는 각 모듈의 조회를 위한 query CLI 구현 방법이 달라졌다. 각 모듈의 module.go 에서 GetTxCmd()는 그대로 있으나, GetQueryCmd()가 사라졌다. query CLI는 각 모듈의 autocli.go에 정의되어 있다. 개발자가 GetQueryCmd()module.go에 사용해서 자체적으로 개발한 query CLI를 써도 app chain의 module manager가 GetQueryCmd()를 확인하여서 기존과 동일하게 구현해도 된다.

gov 모듈의 AutoCLIOptions()를 확인해보자.


// x/gov/autocli.go

import (
    "fmt"

    autocliv1 "cosmossdk.io/api/cosmos/autocli/v1"
    govv1 "cosmossdk.io/api/cosmos/gov/v1"

    "github.com/cosmos/cosmos-sdk/version"
)

// AutoCLIOptions implements the autocli.HasAutoCLIConfig interface.
func (am AppModule) AutoCLIOptions() *autocliv1.ModuleOptions {
    return &autocliv1.ModuleOptions{
        Query: &autocliv1.ServiceCommandDescriptor{
            Service: govv1.Query_ServiceDesc.ServiceName,
            RpcCommandOptions: []*autocliv1.RpcCommandOptions{
                {
                    RpcMethod: "Params",
                    Use:       "params",
                    Short:     "Query the parameters of the governance process",
                    Long:      "Query the parameters of the governance process. Specify specific param types (voting|tallying|deposit) to filter results.",
                    PositionalArgs: []*autocliv1.PositionalArgDescriptor{
                        {ProtoField: "params_type", Optional: true},
                    },
                },

                ...

이렇게 gRPC로 미리 생성된 service name들을 가져와서 구버전에서 query CLI를 사용하던 것과 동일하게 구현가능하다.

여기서 유의할 점은 govv1.Query_ServiceDesc.ServiceName이다. 이는 protobuf로 pb.go 파일을 생성할 때 선언되는 친구들인데, import를 보면 govv1 "cosmossdk.io/api/cosmos/gov/v1"에서 받아오는 것을 볼 수 있다.

신버전에서는 workspace 바로 하위 디렉토리에 api라는 것이 존재하며, 위 import된 것과 동일한 파일을 확인할 수 있다. gov의 경우는 api/cosmos/gov/v1/query_grpc.pb.go에서 똑같이 Query_ServiceDesc를 볼 수 있다.

새로운 모듈을 만드는 개발자들도 동일하게 query_grpc.pb.go를 참조하여 autocli를 구현할 수 있다. Cosmos SDK에서 제공하는 scripts/protocgen-pulsar.sh를 활용하면 사용자가 원하는 디렉토리에 Query_ServiceDesc를 생성할 수 있다. Cosmos SDK에서 protobuf를 컴파일 하도록 지원하는 scripts/protocgen.sh(make proto-gen)에서 종료 시 자동으로 pulsar를 실행한다.

Context 사용

모듈의 대부분 기능에서 인수로 받는 sdk.Context가 많이 사라졌다. gov 모듈의 types/expected_keepers.go로 예시를 들어본다.


// v0.47.5
// x/gov/types/expected_keepers.go

// StakingKeeper expected staking keeper (Validator and Delegator sets) (noalias)
type StakingKeeper interface {
    // iterate through bonded validators by operator address, execute func for each validator
    IterateBondedValidatorsByPower(
        sdk.Context, func(index int64, validator stakingtypes.ValidatorI) (stop bool),
    )

    TotalBondedTokens(sdk.Context) math.Int // total bonded tokens within the validator set
    IterateDelegations(
        ctx sdk.Context, delegator sdk.AccAddress,
        fn func(index int64, delegation stakingtypes.DelegationI) (stop bool),
    )
}

// AccountKeeper defines the expected account keeper (noalias)
type AccountKeeper interface {
    GetAccount(ctx sdk.Context, addr sdk.AccAddress) types.AccountI

    GetModuleAddress(name string) sdk.AccAddress
    GetModuleAccount(ctx sdk.Context, name string) types.ModuleAccountI

    // TODO remove with genesis 2-phases refactor https://github.com/cosmos/cosmos-sdk/issues/2862
    SetModuleAccount(sdk.Context, types.ModuleAccountI)
}

// StakingKeeper expected staking keeper (Validator and Delegator sets) (noalias)
type StakingKeeper interface {
    ValidatorAddressCodec() addresscodec.Codec
    // iterate through bonded validators by operator address, execute func for each validator
    IterateBondedValidatorsByPower(
        context.Context, func(index int64, validator stakingtypes.ValidatorI) (stop bool),
    ) error

    TotalBondedTokens(context.Context) (math.Int, error) // total bonded tokens within the validator set
    IterateDelegations(
        ctx context.Context, delegator sdk.AccAddress,
        fn func(index int64, delegation stakingtypes.DelegationI) (stop bool),
    ) error
}

// AccountKeeper defines the expected account keeper (noalias)
type AccountKeeper interface {
    AddressCodec() addresscodec.Codec

    GetAccount(ctx context.Context, addr sdk.AccAddress) sdk.AccountI

    GetModuleAddress(name string) sdk.AccAddress
    GetModuleAccount(ctx context.Context, name string) sdk.ModuleAccountI

    // TODO remove with genesis 2-phases refactor https://github.com/cosmos/cosmos-sdk/issues/2862
    SetModuleAccount(context.Context, sdk.ModuleAccountI)
}

각 expected keeper의 function들을 보면, 구버전에서는 sdk.Context를 인수로 받고, 신버전에서는 context.Context를 인수로 받는 것을 확인할 수 있다. 정적타입인 Go이기 때문에 expected keeper 같은 곳에서 함수를 선언할 때 정확히 context.Context로 받아와야하지만, sdk.Contextcontext.Context interface의 receiver 함수들을 가지고 있으니 실행할 때에 sdk.Context를 넣어도 동작을 가능하다.

이외에도, module.go에 구버전에서 EndBlock()이 받는 인수인 abci.RequestEndBlock이 제거되었거나, 아래 처럼 interface receiver 함수가 정의되어 있는지 확인할 목적으로 인터페이스 구현검증이 더 추가되었는 등 자잘한 변경점이 굉장히 많다.


// x/gov/module.go
// v0.47.5

var (
    _ module.EndBlockAppModule   = AppModule{}
    _ module.AppModuleBasic      = AppModuleBasic{}
    _ module.AppModuleSimulation = AppModule{}
)

// v0.50.11

var (
    _ module.AppModuleBasic      = AppModuleBasic{}
    _ module.AppModuleSimulation = AppModule{}
    _ module.HasGenesis          = AppModule{}
    _ module.HasServices         = AppModule{}
    _ module.HasInvariants       = AppModule{}

    _ appmodule.AppModule     = AppModule{}
    _ appmodule.HasEndBlocker = AppModule{}
)

CLI 사용법

CLI 사용 중에서도 개발자들이 자주 쓰는 몇몇 기능들을 소개한다.

config file 변경

가장 먼저 눈에 띄는 부분은 CLI로 app.toml, client.toml config.toml 등을 다룰 수 있다는 점이다. 예를 들어, Cosmos SDK에서 디폴트로 LCD HTTP는 enable이 true로 되어 있다. 이를 구버전에서 변경하려면 HOME_DIR/config/app.toml에서 직접 변경해야 했지만 CLI 명령으로 변경이 가능하다.


$DAEMON config set app api.enable true

CLI에서 config라는 subcommand를 통해 LCD enable 값을 true로 변경 가능하다. appapp.toml을 지칭하며 api.enable은 TOML 파일의 api 테이블 상 enable 값을 지칭하고, true는 이를 TRUE로 세팅하겠다는 뜻이다.

예를 들어 sh 파일로 실행 스크립트를 만들 때 enable의 줄 위치가 120이라 가정 시, 기존에는


sed -i "120s/false/true/" ~/HOME/config/app.toml

sed로 변경을 하는 방법을 사용했지만, CLI 명령을 통해 줄 수 확인도 안하고 간편하게 변경할 수 있게 된다.

genesis 명령

노드 세팅 시 자주 사용하는 add-genesis-account, gentx, collect-gentxs 등이 모두 genesis subcommand 하위로 들어갔다. 예를 들어 신버전에서는 이렇게 사용해야 한다.


$DAEMON genesis collect-gentxs

자주 사용하는 명령이라 기억하면 좋을듯 하다.

결론

해당 글은 눈으로 보기에도 확연히 차이가 나는 굵직한 것들만 가져왔다. 이외에도 변경된 부분이 굉장히 많다. 태그 버전을 한번에 높인 이유를 체감할 수 있다.

Cosmos SDK는 개발자가 쉽고 편하게, 복잡한 consensus layer를 크게 고려하지 않아도 블록체인을 개발할 수 있도록 만든 것이 주된 목표중 하나라고 알고 있다. 기존에 Cosmos SDK로 개발을 하던 사람들은 급격한 변화에 적응하는 것이 조금 오래 걸릴 수도 있을 것 같지만, 처음 다뤄보는 사람들은 구버전 보다 신버전으로 개발하는 것이 편할 것 같다는 생각이 든다. 이렇게 생각했을 때 Cosmos SDK contributer들은 초심을 잃지 않고 개발을 하고 있다는 것을 느낀다.

728x90
반응형