[Sui Object] #3. Shared Objects
목차
Sui의 Shared Object 생성 방법, 사용 시기, Capability Pattern을 활용한 권한 제어를 도넛 가게 예제로 알아봅니다. 이 글은 Sui Object 시리즈의 3번째 글입니다. 이전 글에서 Sui의 가장 기본적인 소유권 형태인 Address-Owned Object를 살펴보았습니다. Address-Owned Object는 32바이트 Address가 단독으로 소유하는 Object이기에, 소유자만이 접근하고 변경할 수 있다는 특징이 있었습니다. 이번 글에서는 정반대 성격을 가진 소유권 형태인 Shared Object를 다룹니다. Shared Object는 네트워크 위 누구나 접근하고 변경할 수 있는 공개된 Object로, Marketplace나 Game처럼 여러 참여자가 동시에 상태를 공유하고 변경해야 하는 시나리오에 적합합니다. 이 글에서는 Shared Object를 생성하는 방법, 사용하기 적합한 시나리오, 그리고 Move 코드에서 Shared Object와 상호작용하는 방법을 차례대로 살펴보겠습니다. Shared Object를 생성하려면 Sui의 이제 위 Shared Object는 Module의 위 예시에서는 물론 Shared Object는 여러 참여자가 동시에 같은 상태에 접근하고 변경해야 하는 시나리오에 적합합니다. 대표적인 예시는 다음과 같습니다. 이들의 공통점은 "한 명이 소유하지 않는 전역 상태(global state)"라는 점입니다. Address-Owned Object로는 이런 시나리오를 직접 표현하기 어려운데, 항상 소유자만이 접근할 수 있다는 제약 때문입니다. 다만 Shared Object는 자유로운 만큼 비용도 따릅니다. Sui는 Address-Owned Object를 사용하는 트랜잭션을 합의(Consensus) 없이 병렬로 처리할 수 있지만, Shared Object를 사용하는 트랜잭션은 순서를 정하기 위해 합의 과정을 거쳐야 합니다. 따라서 단일 소유자만으로 충분한 상태라면 Address-Owned Object를 우선 고려하고, 정말로 공유가 필요한 상태에 한해 Shared Object를 선택하는 것이 좋습니다. Shared Object와 상호작용하는 방법을 도넛 가게 예제로 살펴보겠습니다. 도넛 가게는 누구나 도넛을 구매할 수 있지만, 가게가 모은 수익은 가게 주인만 가져갈 수 있는 형태입니다. 이를 표현하기 위해 앞서 정의한 다음 절에서는 누구나 호출할 수 있는 가게가 실제로 결제된 SUI를 보관할 수 있도록 이제 이처럼 함수가 Capability Pattern은 "특정 권한을 표현하는 Object"를 따로 정의하고, 권한이 필요한 함수에 그 Object를 인자로 받도록 하는 방식입니다. 함수를 호출하려면 해당 Capability Object를 소유하고 있어야 하므로, Object의 소유 관계가 곧 권한 검증이 됩니다. 도넛 가게 예제에서는 앞서 정의한 이제 Sui는 트랜잭션 실행 단계에서 호출자가 함수는 이번 글에서는 Sui의 두 번째 소유권 형태인 Shared Object를 살펴보았습니다. 다음 글에서는 한 번 만들어진 뒤 절대 변경되지 않는 Immutable Object를 다루겠습니다. 전체 목차:
개요
Shared Object 생성
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 함수에서 생성하기
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와 상호작용
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 참조로 받아 상태를 변경합니다.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은 세 단계로 동작합니다.assert!로 payment의 금액이 shop.price 이상인지 확인합니다.coin::put으로 받은 payment를 가게의 balance에 그대로 더합니다.Donut Object를 만들어 호출자에게 반환합니다.public이고 어떤 권한 검증도 수행하지 않기 때문에, 네트워크 위 누구나 DonutShop을 인자로 전달하여 buy_donut을 호출할 수 있습니다. 한 명의 소유자에 묶이지 않은 공유된 상태에 누구나 참여할 수 있다는 점이 Shared Object의 핵심 가치입니다.Capability로 권한 제어 (collect_profits)
buy_donut처럼 누구나 호출할 수 있는 함수만으로는 충분하지 않습니다. 가게가 모은 수익은 가게 주인만 인출할 수 있어야 합니다. 이런 권한 제어를 위해 Sui에서 흔히 사용하는 패턴이 Capability Pattern입니다.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을 가지고 있는지" 확인하는 역할만 합니다.&ShopOwnerCap 인자로 전달한 Object를 실제로 소유하고 있는지 검증하기 때문에, ShopOwnerCap을 가지지 않은 사용자가 collect_profits를 호출하려고 하면 트랜잭션 자체가 실패합니다. 함수 본문에는 별도의 assert!나 권한 체크 코드가 필요하지 않습니다.DonutShop을 &mut으로 받아 balance에서 전액을 꺼내 Coin<SUI>로 반환합니다. 반환된 Coin은 트랜잭션을 호출한 가게 주인에게 자동으로 전달됩니다.마무리
sui::transfer::share_object 함수로 key Ability를 가진 Object를 Shared Object로 변환할 수 있으며, Module의 init 함수에서 생성하는 것이 권장됩니다.&mut으로 받는 public 함수는 누구나 호출할 수 있어 자유로운 참여를 가능하게 합니다. 한편 가게 수익을 인출하는 것처럼 권한이 필요한 작업은 Capability Pattern으로 통제할 수 있습니다.References