[Sui Object] #3. Shared Objects

이 글은 Sui Object 시리즈의 3번째 글입니다.

1.png

개요

이전 글에서 Sui의 가장 기본적인 소유권 형태인 Address-Owned Object를 살펴보았습니다. Address-Owned Object는 32바이트 Address가 단독으로 소유하는 Object이기에, 소유자만이 접근하고 변경할 수 있다는 특징이 있었습니다.

이번 글에서는 정반대 성격을 가진 소유권 형태인 Shared Object를 다룹니다. Shared Object는 네트워크 위 누구나 접근하고 변경할 수 있는 공개된 Object로, Marketplace나 Game처럼 여러 참여자가 동시에 상태를 공유하고 변경해야 하는 시나리오에 적합합니다.

이 글에서는 Shared Object를 생성하는 방법, 사용하기 적합한 시나리오, 그리고 Move 코드에서 Shared Object와 상호작용하는 방법을 차례대로 살펴보겠습니다.


Shared Object 생성

Shared Object를 생성하려면 Sui의 transfer Module이 제공하는 share_object 함수를 사용합니다.

public fun share_object<T: key>(obj: T)

share_object는 인자로 받은 Object를 네트워크 위 모든 참여자가 접근할 수 있는 Shared Object로 변환합니다. 한 번 Shared Object가 된 Object는 다시 다른 소유권 형태로 되돌릴 수 없습니다.

이제 share_object를 호출하기 위한 조건과 권장 패턴을 차례대로 살펴보겠습니다.


key Ability 요구사항

share_object 함수의 타입 파라미터 T에는 key Ability가 요구됩니다.

public fun share_object<T: key>(obj: T)

key Ability는 어떤 struct가 Sui의 Object가 되기 위해 갖춰야 하는 기본 조건입니다. key를 가진 struct는 반드시 첫 번째 필드로 id: UID를 선언해야 하며, 이 UID가 Object의 고유 식별자 역할을 합니다.

module example::donut_shop {
    use sui::object::UID;

    public struct DonutShop has key {
        id: UID,
        price: u64,
        balance: u64,
    }
}

위 DonutShop struct는 key Ability를 가지므로 share_object에 그대로 전달할 수 있습니다. 반면 key Ability가 없는 struct를 인자로 넘기면 컴파일 단계에서 오류가 발생합니다.


init 함수에서 생성하기

Shared Object는 Module의 init 함수 안에서 생성하는 것이 권장됩니다.

init 함수는 Module이 처음 배포될 때 단 한 번만 실행되는 특수한 초기화 함수입니다. 시그니처는 다음과 같습니다.

fun init(ctx: &mut TxContext)

init 안에서 Shared Object를 생성하면 Module 배포와 동시에 해당 Object가 네트워크 위에 단 하나만 존재하게 됩니다. 이는 도넛 가게나 Marketplace처럼 시스템 전체에 단 하나만 존재해야 하는 Singleton 형태의 상태를 표현하기에 적합합니다.

module example::donut_shop {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::TxContext;

    public struct DonutShop has key {
        id: UID,
        price: u64,
        balance: u64,
    }

    fun init(ctx: &mut TxContext) {
        let shop = DonutShop {
            id: object::new(ctx),
            price: 10,
            balance: 0,
        };
        transfer::share_object(shop);
    }
}

위 예시에서는 donut_shop Module이 배포되는 순간 DonutShop Object 하나가 생성되어 곧바로 Shared Object로 공유됩니다. 이후 사용자가 별도의 "초기 설정" 트랜잭션을 호출할 필요가 없습니다.

물론 init 함수 외부에서도 Shared Object를 만들 수 있습니다. 다만 이 경우 호출 시점마다 새로운 Shared Object가 생성될 수 있으므로, 의도가 명확한 경우에만 사용하는 것이 좋습니다.


Shared Object를 사용하는 경우

Shared Object는 여러 참여자가 동시에 같은 상태에 접근하고 변경해야 하는 시나리오에 적합합니다. 대표적인 예시는 다음과 같습니다.

  • Marketplace: 누구나 NFT를 등록하고 구매할 수 있는 공개된 거래소. 등록된 매물 목록과 가격 정보가 하나의 Shared Object로 관리됩니다.
  • Game: 여러 플레이어가 함께 참여하는 게임의 공유 상태. 글로벌 랭킹 보드나 공동 인벤토리, 게임 월드 상태 등이 해당합니다.
  • DEX: Liquidity Pool처럼 누구나 자유롭게 유동성을 공급하거나 토큰을 교환할 수 있는 풀.
  • Governance: 누구나 투표할 수 있는 공개된 제안과 그 결과를 담는 Object.

이들의 공통점은 "한 명이 소유하지 않는 전역 상태(global state)"라는 점입니다. Address-Owned Object로는 이런 시나리오를 직접 표현하기 어려운데, 항상 소유자만이 접근할 수 있다는 제약 때문입니다.

다만 Shared Object는 자유로운 만큼 비용도 따릅니다. Sui는 Address-Owned Object를 사용하는 트랜잭션을 합의(Consensus) 없이 병렬로 처리할 수 있지만, Shared Object를 사용하는 트랜잭션은 순서를 정하기 위해 합의 과정을 거쳐야 합니다. 따라서 단일 소유자만으로 충분한 상태라면 Address-Owned Object를 우선 고려하고, 정말로 공유가 필요한 상태에 한해 Shared Object를 선택하는 것이 좋습니다.


Shared Object와 상호작용

Shared Object와 상호작용하는 방법을 도넛 가게 예제로 살펴보겠습니다. 도넛 가게는 누구나 도넛을 구매할 수 있지만, 가게가 모은 수익은 가게 주인만 가져갈 수 있는 형태입니다.

이를 표현하기 위해 앞서 정의한 DonutShop에 더해 가게 주인의 권한을 나타내는 ShopOwnerCap과 판매 단위인 Donut Object를 추가로 정의합니다.

public struct Donut has key, store {
    id: UID,
}

public struct ShopOwnerCap has key {
    id: UID,
}

fun init(ctx: &mut TxContext) {
    transfer::transfer(
        ShopOwnerCap { id: object::new(ctx) },
        tx_context::sender(ctx),
    );

    transfer::share_object(DonutShop {
        id: object::new(ctx),
        price: 10,
        balance: 0,
    });
}

init 함수는 Module 배포자에게 ShopOwnerCap을 Address-Owned Object로 전송하고, 동시에 DonutShop을 Shared Object로 공유합니다. 이렇게 두 Object의 소유권 형태를 다르게 설정하는 것이 권한 제어의 핵심입니다.

다음 절에서는 누구나 호출할 수 있는 buy_donut 함수와 ShopOwnerCap 보유자만 호출할 수 있는 collect_profits 함수를 차례로 살펴보겠습니다.


공개 함수로 접근 (buy_donut)

buy_donut 함수는 도넛을 한 개 구매하는 동작을 표현합니다. 누구나 호출할 수 있도록 public 함수로 선언하고, Shared Object인 DonutShop을 &mut 참조로 받아 상태를 변경합니다.

가게가 실제로 결제된 SUI를 보관할 수 있도록 DonutShop의 balance 필드 타입을 u64에서 Balance<SUI>로 변경하겠습니다.

use sui::balance::{Self, Balance};
use sui::coin::{Self, Coin};
use sui::sui::SUI;

public struct DonutShop has key {
    id: UID,
    price: u64,
    balance: Balance<SUI>,
}

이제 buy_donut을 작성합니다.

const EInsufficientPayment: u64 = 0;

public fun buy_donut(
    shop: &mut DonutShop,
    payment: Coin<SUI>,
    ctx: &mut TxContext,
): Donut {
    assert!(coin::value(&payment) >= shop.price, EInsufficientPayment);

    coin::put(&mut shop.balance, payment);

    Donut { id: object::new(ctx) }
}

buy_donut은 세 단계로 동작합니다.

  1. 결제 검증: assert!로 payment의 금액이 shop.price 이상인지 확인합니다.
  2. 수익 적립: coin::put으로 받은 payment를 가게의 balance에 그대로 더합니다.
  3. 상품 발행: 새 Donut Object를 만들어 호출자에게 반환합니다.

이처럼 함수가 public이고 어떤 권한 검증도 수행하지 않기 때문에, 네트워크 위 누구나 DonutShop을 인자로 전달하여 buy_donut을 호출할 수 있습니다. 한 명의 소유자에 묶이지 않은 공유된 상태에 누구나 참여할 수 있다는 점이 Shared Object의 핵심 가치입니다.


Capability로 권한 제어 (collect_profits)

buy_donut처럼 누구나 호출할 수 있는 함수만으로는 충분하지 않습니다. 가게가 모은 수익은 가게 주인만 인출할 수 있어야 합니다. 이런 권한 제어를 위해 Sui에서 흔히 사용하는 패턴이 Capability Pattern입니다.

Capability Pattern은 "특정 권한을 표현하는 Object"를 따로 정의하고, 권한이 필요한 함수에 그 Object를 인자로 받도록 하는 방식입니다. 함수를 호출하려면 해당 Capability Object를 소유하고 있어야 하므로, Object의 소유 관계가 곧 권한 검증이 됩니다.

도넛 가게 예제에서는 앞서 정의한 ShopOwnerCap이 가게 주인의 권한을 표현합니다. init 함수에서 Module 배포자에게 Address-Owned Object로 전송되기 때문에, ShopOwnerCap은 배포자만 소유하게 됩니다.

이제 collect_profits 함수를 작성합니다.

public fun collect_profits(
    _: &ShopOwnerCap,
    shop: &mut DonutShop,
    ctx: &mut TxContext,
): Coin<SUI> {
    let amount = balance::value(&shop.balance);
    coin::take(&mut shop.balance, amount, ctx)
}

collect_profits는 첫 번째 인자로 &ShopOwnerCap을 받습니다. 변수 이름을 _로 둔 이유는 함수 본문에서 ShopOwnerCap의 데이터를 직접 사용하지 않기 때문입니다. 단지 "호출자가 ShopOwnerCap을 가지고 있는지" 확인하는 역할만 합니다.

Sui는 트랜잭션 실행 단계에서 호출자가 &ShopOwnerCap 인자로 전달한 Object를 실제로 소유하고 있는지 검증하기 때문에, ShopOwnerCap을 가지지 않은 사용자가 collect_profits를 호출하려고 하면 트랜잭션 자체가 실패합니다. 함수 본문에는 별도의 assert!나 권한 체크 코드가 필요하지 않습니다.

함수는 DonutShop을 &mut으로 받아 balance에서 전액을 꺼내 Coin<SUI>로 반환합니다. 반환된 Coin은 트랜잭션을 호출한 가게 주인에게 자동으로 전달됩니다.


마무리

이번 글에서는 Sui의 두 번째 소유권 형태인 Shared Object를 살펴보았습니다.

  • sui::transfer::share_object 함수로 key Ability를 가진 Object를 Shared Object로 변환할 수 있으며, Module의 init 함수에서 생성하는 것이 권장됩니다.
  • Shared Object는 Marketplace나 Game처럼 여러 참여자가 동시에 같은 상태를 다뤄야 하는 시나리오에 적합합니다. 다만 트랜잭션 처리에 합의(Consensus)가 필요하기 때문에, 단일 소유자만으로 충분한 상태라면 Address-Owned Object를 우선 고려하는 것이 좋습니다.
  • Shared Object를 &mut으로 받는 public 함수는 누구나 호출할 수 있어 자유로운 참여를 가능하게 합니다. 한편 가게 수익을 인출하는 것처럼 권한이 필요한 작업은 Capability Pattern으로 통제할 수 있습니다.

다음 글에서는 한 번 만들어진 뒤 절대 변경되지 않는 Immutable Object를 다루겠습니다.


References

전체 목차:

  1. [Sui Object] #1. Object Model
  2. [Sui Object] #2. Address-Owned Objects
  3. [Sui Object] #3. Shared Objects
  4. [Sui Object] #4. Immutable Objects
  5. [Sui Object] #5. Wrapped Objects
  6. [Sui Object] #6. Party Objects