[Sui Object] #5. Wrapped Objects
목차
Sui의 Wrapped Object 개념과 제약 사항, Direct·Option·vector 세 가지 Wrapping 방식을 Trusted Swap, SimpleWarrior 예제로 알아봅니다. 이 글은 Sui Object 시리즈의 5번째 글입니다. 이전 글에서는 한 번 만들어지면 누구도 변경할 수 없는 Immutable Object를 살펴보았습니다. 이로써 Sui의 세 가지 소유권 형태인 Address-Owned, Shared, Immutable Object를 모두 다루었습니다. 이번 글에서는 조금 다른 관점의 주제인 Wrapped Object를 다룹니다. Wrapping은 하나의 Object를 다른 Object의 필드 안에 중첩(Nesting)시켜 데이터 구조를 구성하는 패턴입니다. Object가 다른 Object 안에 감싸지면 더 이상 온체인에 독립적으로 존재하지 않으며, 오직 감싸고 있는 Wrapper Object를 통해서만 접근할 수 있습니다. 이러한 특성 덕분에 강력한 캡슐화와 Object 잠금(Locking) 같은 패턴을 구현할 수 있습니다. 이 글에서는 Wrapped Object의 개념과 제약 사항, Object를 감싸고(wrap) 다시 꺼내는(unwrap) 방법, 그리고 세 가지 Wrapping 방식인 Direct Wrapping, Move에서 struct를 정의할 때, 다른 struct 타입을 필드로 포함시킬 수 있습니다. Wrapping은 이 구조를 활용하여 하나의 Object를 다른 Object의 필드 안에 중첩시키는 것을 말합니다. 이때 감싸진 Object를 Wrapped Object, 감싸고 있는 바깥 Object를 Wrapper Object라고 부릅니다. 다음 예제에서 여기서 한 가지 조건이 있습니다. Object가 다른 Object 안에 감싸지면, 더 이상 온체인에 독립적으로 존재하지 않습니다. 이로 인해 Wrapped Object는 다음과 같은 특징을 가집니다. 이러한 특성은 곧 강력한 캡슐화를 의미합니다. Wrapped Object는 Wrapper Object를 정의한 Module이 제공하는 함수를 통해서만 다룰 수 있으므로, 개발자가 의도한 방식 외의 접근을 원천적으로 차단할 수 있습니다. Wrapped Object의 동작을 확인하기 위해, 앞에서 정의한 주목할 점은 함수의 동작을 단계별로 살펴보면 다음과 같습니다. 여기서 다시 등장한 Direct Wrapping은 Object 타입을 다른 Object의 필드로 직접 포함시키는 방식입니다. 앞에서 살펴본 Direct Wrapping의 가장 큰 특징은 Wrapper를 파괴하지 않고는 안에 있는 Object를 꺼낼 수 없다는 점입니다. 이 강력한 제약 덕분에 Object를 특정 조건이 충족될 때까지 잠가두는(Locking) 패턴을 구현할 수 있습니다. 이번 섹션에서는 이를 활용한 신뢰 기반 교환(Trusted Swap) 예제를 살펴보겠습니다. 다음과 같은 시나리오를 생각해보겠습니다. 희귀도( 사용자끼리 직접 교환하려면 두 사람이 동시에 트랜잭션에 서명해야 하는 번거로움이 있습니다. 대신 제3자 서비스에 교환을 위임하되, 서비스가 Object를 마음대로 빼돌릴 수 없도록 보장하고 싶습니다. 이때 Direct Wrapping이 해답이 됩니다. 교환 요청을 표현하는 교환을 원하는 사용자는 함수의 동작을 살펴보면 다음과 같습니다. 이제 서비스는 서비스는 조건이 맞는 두 개의 함수의 동작을 단계별로 살펴보겠습니다. 이 구조에서 서비스 운영자는 교환을 성사시키는 것 외에는 아무것도 할 수 없습니다. 검증 조건을 우회할 수도, Object를 빼돌릴 수도 없습니다. Direct Wrapping이 프로토콜 차원에서 신뢰를 보장해주는 것입니다. Direct Wrapping은 강력하지만, Wrapper를 만드는 시점에 반드시 감쌀 Object가 존재해야 합니다. 하지만 때로는 Object가 있을 수도, 없을 수도 있는 유연한 구조가 필요합니다. 이럴 때는 필드 타입을 게임 캐릭터를 생각해보겠습니다. 전사( 검을 장착하는 여기서 주의할 점은 이미 장착된 검이 있는지 먼저 확인해야 한다는 것입니다. 이처럼 마지막 방식은 농장( 전체 목차:
개요
Option을 통한 Wrapping, vector를 통한 Wrapping을 실제 예제 코드와 함께 차례대로 살펴보겠습니다.Wrapped Object란?
Bar는 Foo의 필드로 포함되어 있습니다.public struct Bar has key, store {
id: UID,
value: u64,
}
public struct Foo has key {
id: UID,
bar: Bar,
}key ability를 가진 Object 타입의 필드로 들어가려면, 해당 struct는 반드시 store ability를 가져야 합니다. 위 예제에서 Bar가 store를 가지고 있기 때문에 Foo 안에 포함될 수 있습니다. Bar처럼 key를 함께 가진 struct는 그 자체로 독립적인 Object가 될 수도 있고, 다른 Object 안에 감싸진 Wrapped Object가 될 수도 있습니다.특징과 제약 사항
Wrapped Object 생성과 해제
Foo와 Bar를 이용해 Object를 감싸는 wrap 함수와 다시 꺼내는 unwrap 함수를 직접 구현해보겠습니다.wrap으로 감싸기
wrap 함수는 Bar를 값으로 받아 Foo의 필드에 넣고, 완성된 Foo를 트랜잭션 발신자에게 전송합니다.public fun wrap(bar: Bar, ctx: &mut TxContext) {
let foo = Foo {
id: object::new(ctx),
bar,
};
transfer::transfer(foo, ctx.sender());
}bar를 참조(&)가 아닌 값으로 전달받는다는 것입니다. Move에서 Object를 값으로 받으려면 호출자가 해당 Object를 소유하고 있어야 합니다. 즉, 자신이 소유한 Object만 감쌀 수 있습니다. 함수가 실행되면 bar는 Foo의 일부가 되어 온체인에서 독립적인 Object로는 더 이상 조회되지 않습니다.unwrap으로 꺼내기
unwrap 함수는 Foo를 분해하여 안에 있던 Bar를 다시 꺼냅니다.public fun unwrap(foo: Foo, ctx: &TxContext) {
let Foo { id, bar } = foo;
id.delete();
transfer::transfer(bar, ctx.sender());
}Foo를 id와 bar 필드로 분해합니다.Foo의 id를 삭제합니다. Wrapper Object 자체는 이 시점에 소멸합니다.bar를 트랜잭션 발신자에게 전송합니다. 이제 bar는 다시 온체인에 독립적인 Object로 존재하게 됩니다.bar의 Object ID는 감싸기 전과 동일합니다. Wrapping과 Unwrapping을 거치더라도 Object의 정체성은 그대로 유지된다는 것을 알 수 있습니다.Direct Wrapping
Foo와 Bar의 관계가 바로 Direct Wrapping입니다.Trusted Swap 예제
scarcity)와 스타일(style)을 가진 NFT 성격의 Object가 있고, 사용자들은 자신의 Object를 같은 희귀도, 다른 스타일의 Object와 교환하고 싶어 합니다.public struct Object has key, store {
id: UID,
scarcity: u8,
style: u8,
}
public fun new(scarcity: u8, style: u8, ctx: &mut TxContext): Object {
Object { id: object::new(ctx), scarcity, style }
}SwapRequest를 다음과 같이 정의합니다.public struct SwapRequest has key {
id: UID,
owner: address,
object: Object,
fee: Balance<SUI>,
}SwapRequest는 교환을 요청한 소유자의 주소(owner), 교환 대상 Object, 그리고 서비스에 지불할 수수료(fee)를 담습니다. Object가 SwapRequest 안에 직접 감싸져 있으므로, 서비스는 Module이 정의한 함수를 통해서만 이 Object를 다룰 수 있습니다.request_swap으로 교환 요청하기
request_swap 함수를 호출하여 자신의 Object를 서비스에 보냅니다.public fun request_swap(
object: Object,
fee: Coin<SUI>,
service: address,
ctx: &mut TxContext,
) {
assert!(coin::value(&fee) >= MIN_FEE, EFeeTooLow);
let request = SwapRequest {
id: object::new(ctx),
owner: ctx.sender(),
object,
fee: coin::into_balance(fee),
};
transfer::transfer(request, service)
}MIN_FEE) 이상인지 검증합니다.object를 값으로 받아 SwapRequest 안에 감쌉니다. 이 순간 Object는 온체인에서 독립적으로 존재하지 않게 됩니다.Coin<SUI>를 Balance<SUI>로 변환하여 함께 담습니다. Coin은 독립적인 Object 타입이고, Balance는 다른 struct에 내장하기 위한 단순 값 타입이기 때문입니다.SwapRequest를 서비스 주소로 전송합니다.SwapRequest를 소유하게 되었지만, 안에 감싸진 Object를 임의로 꺼내거나 전송할 수는 없습니다. 할 수 있는 일은 Module이 정의한 execute_swap을 호출하는 것뿐입니다.execute_swap으로 교환 실행하기
SwapRequest를 찾으면 execute_swap을 호출하여 교환을 실행합니다.public fun execute_swap(s1: SwapRequest, s2: SwapRequest): Balance<SUI> {
let SwapRequest { id: id1, owner: owner1, object: o1, fee: fee1 } = s1;
let SwapRequest { id: id2, owner: owner2, object: o2, fee: fee2 } = s2;
assert!(o1.scarcity == o2.scarcity, EBadSwap);
assert!(o1.style != o2.style, EBadSwap);
transfer::transfer(o1, owner2);
transfer::transfer(o2, owner1);
id1.delete();
id2.delete();
fee1.join(fee2);
fee1
}SwapRequest를 패턴 매칭으로 분해하여 안에 있던 Object들을 꺼냅니다(unwrap).o1은 s2의 소유자에게, o2는 s1의 소유자에게 전송하여 교환을 완료합니다.id를 삭제합니다.Balance는 서비스 운영자가 수익으로 가져갑니다.Option을 통한 Wrapping
Option으로 선언하여 Object를 감쌀 수 있습니다.SimpleWarrior 예제
SimpleWarrior)는 검(Sword)과 방패(Shield)를 장착할 수 있지만, 처음에는 아무 장비도 없을 수 있고 나중에 더 좋은 장비로 교체할 수도 있어야 합니다.public struct SimpleWarrior has key {
id: UID,
sword: Option<Sword>,
shield: Option<Shield>,
}
public struct Sword has key, store {
id: UID,
strength: u8,
}
public struct Shield has key, store {
id: UID,
armor: u8,
}SimpleWarrior는 sword와 shield 필드를 Option 타입으로 선언했습니다. 덕분에 장비가 없는 상태(none)와 장착한 상태(some)를 모두 표현할 수 있습니다. 전사를 생성할 때는 두 필드를 모두 비워둡니다.public fun create_warrior(ctx: &mut TxContext) {
let warrior = SimpleWarrior {
id: object::new(ctx),
sword: option::none(),
shield: option::none(),
};
transfer::transfer(warrior, ctx.sender())
}equip_sword로 장비 교체하기
equip_sword 함수는 다음과 같이 구현합니다.public fun equip_sword(warrior: &mut SimpleWarrior, sword: Sword, ctx: &mut TxContext) {
if (warrior.sword.is_some()) {
let old_sword = warrior.sword.extract();
transfer::transfer(old_sword, ctx.sender());
};
warrior.sword.fill(sword);
}Sword는 drop ability가 없기 때문에, 기존 검 위에 새 검을 덮어쓰는 식으로 암묵적으로 버릴 수 없습니다. 그래서 is_some()으로 기존 검의 존재를 확인하고, 있다면 extract()로 꺼내 발신자에게 돌려준 뒤, fill()로 새 검을 장착합니다.Option을 통한 Wrapping은 Direct Wrapping과 달리 Wrapper를 파괴하지 않고도 Object를 넣고 뺄 수 있다는 유연함을 제공합니다.vector를 통한 Wrapping
vector를 통한 Wrapping입니다. 같은 타입의 Object를 0개, 1개, 또는 여러 개 담아야 할 때 사용합니다.Farm)에서 여러 마리의 애완동물(Pet)을 기르는 예제를 살펴보겠습니다.public struct Pet has key, store {
id: UID,
cuteness: u64,
}
public struct Farm has key {
id: UID,
pets: vector<Pet>,
}Farm은 vector<Pet> 필드를 통해 여러 Pet을 감쌉니다. vector 안에 감싸진 Pet들 역시 온체인에 독립적으로 존재하지 않으며, 오직 Farm Object를 통해서만 접근할 수 있습니다.Option이 "있거나 없거나"의 유연함을 제공했다면, vector는 "개수의 유연함"을 제공하는 셈입니다. 동일 타입의 Object 컬렉션을 하나의 Wrapper로 묶어 관리하고 싶을 때 적합한 방식입니다.References