[Sui Object] #4. Immutable Objects

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

1.png

개요

이전 글에서는 네트워크 위 누구나 접근하고 변경할 수 있는 Shared Object를 살펴보았습니다. Shared Object는 자유롭게 공유되는 만큼 트랜잭션 처리에 합의(Consensus) 과정이 필요하다는 비용을 동반했습니다.

이번 글에서는 또 다른 소유권 형태인 Immutable Object를 다룹니다. Immutable Object는 한 번 만들어지면 누구도 변경, 전송, 삭제할 수 없는 영구히 불변인 Object입니다. 소유자가 없기 때문에 누구나 자유롭게 읽을 수 있고, 변경이 일어나지 않는다는 보장 덕분에 합의 없이도 병렬로 트랜잭션을 처리할 수 있다는 강점이 있습니다.

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


Immutable Object란?

Immutable Object는 한 번 만들어지면 그 상태가 영원히 고정되는 Object입니다. 일반적인 Object는 소유자가 자신의 권한으로 필드 값을 변경하거나 다른 주소로 전송할 수 있지만, Immutable Object는 어떤 트랜잭션으로도 내부 상태를 바꾸거나 다른 곳으로 옮기거나 삭제할 수 없습니다.

Immutable Object의 핵심 특성은 다음 두 가지로 요약할 수 있습니다.

  • 소유자 없음: Immutable Object는 특정 Address에 속하지 않습니다. 네트워크 위 누구나 자유롭게 접근하여 읽을 수 있으며, "소유권"이라는 개념 자체가 적용되지 않습니다.
  • 영구 불변: 한 번 Immutable로 만들어진 Object는 다시 변경 가능한 상태로 되돌릴 수 없습니다. freeze 작업은 단방향이며, 이후로 해당 Object는 read-only 형태로만 존재합니다.

이러한 특성 덕분에 Immutable Object는 변경이 일어나지 않는다는 사실이 프로토콜 차원에서 보장됩니다. Sui는 이를 활용하여 Immutable Object를 참조하는 트랜잭션을 합의(Consensus) 없이 병렬로 처리할 수 있습니다. 여러 트랜잭션이 동시에 같은 Immutable Object를 읽더라도 충돌이 발생할 수 없기 때문입니다.


언제 사용할까?

Immutable Object는 한 번 정의된 뒤 변경될 일이 없으면서 다수의 참여자가 공통으로 참조해야 하는 데이터를 다룰 때 적합합니다. 대표적인 예시는 다음과 같습니다.

  • 게임 정적 데이터: 몬스터 능력치 테이블, 아이템 스펙, 스킬 정의처럼 게임 시작 시점에 확정되고 이후 바뀌지 않는 데이터.
  • NFT Collection Metadata: NFT Collection의 이름, 심볼, 발행자 정보처럼 한 번 정해진 뒤 영원히 유지되어야 하는 메타데이터.
  • Configuration Object: Protocol의 상수 값, 수수료 비율 테이블처럼 배포 시점에 고정되는 설정값.
  • 공유 라이브러리 데이터: 여러 Module이 공통으로 참조하는 사전(Dictionary), 변환 테이블 등.

이런 시나리오의 공통점은 "값이 절대로 변하지 않아야 한다는 보장이 필요한 read-only 데이터"라는 점입니다.

같은 read-only 용도라면 Shared Object를 & 참조로만 받도록 함수를 작성해도 되지 않을까 생각할 수 있습니다. 하지만 Shared Object는 잠재적으로 변경이 가능한 Object이기 때문에, 읽기만 한다고 하더라도 트랜잭션 처리에 합의(Consensus)가 필요합니다. 반면 Immutable Object는 변경 불가능성이 프로토콜 차원에서 보장되므로 합의 없이 병렬 처리가 가능합니다.

따라서 "여러 참여자가 공유하지만 변경은 절대 일어나지 않는다"는 조건이 명확하다면, Shared Object보다 Immutable Object를 선택하는 것이 성능 면에서 유리합니다.


Immutable Object 생성하기

Immutable Object를 만들려면 Sui의 transfer Module이 제공하는 public_freeze_object 함수를 사용합니다.

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

public_freeze_object는 인자로 받은 Object를 영구히 변경 불가능한 상태로 고정합니다. 한 번 freeze된 Object는 어떤 방법으로도 다시 변경 가능한 상태로 되돌릴 수 없습니다.

함수 시그니처에서 주목할 점은 obj를 참조가 아닌 값(by value)으로 받는다는 것입니다. Move의 소유권 규칙상 값을 함수에 넘기면 호출자는 더 이상 그 Object를 직접 다룰 수 없기 때문에, freeze 시점 이후로는 누구도 해당 Object의 상태를 변경할 수 없다는 점이 언어 수준에서 보장됩니다.

또한 타입 파라미터 T에는 key Ability가 요구되는데, 이는 freeze 대상이 반드시 Sui의 Object여야 함을 의미합니다. Sui에서 key Ability를 가진 struct만이 UID를 가지고 Object로서 네트워크에 존재할 수 있기 때문입니다.

이제 public_freeze_object를 활용하는 두 가지 패턴을 차례대로 살펴보겠습니다. 하나는 이미 만들어진 Object를 나중에 freeze하는 방식이고, 다른 하나는 Object를 생성하자마자 곧바로 freeze하는 방식입니다.


public_freeze_object로 freeze하기

가장 기본적인 패턴은 일반 Object를 먼저 만든 뒤, 별도의 트랜잭션이나 함수 호출로 freeze하는 방식입니다.

게임의 정적 설정값을 담는 GameConfig Object를 예로 살펴보겠습니다.

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

    public struct GameConfig has key {
        id: UID,
        max_players: u64,
        starting_gold: u64,
        starting_level: u64,
    }

    public fun create_config(
        max_players: u64,
        starting_gold: u64,
        starting_level: u64,
        ctx: &mut TxContext,
    ): GameConfig {
        GameConfig {
            id: object::new(ctx),
            max_players,
            starting_gold,
            starting_level,
        }
    }

    public fun freeze_config(config: GameConfig) {
        transfer::public_freeze_object(config);
    }
}

위 코드의 동작은 다음과 같습니다.

  1. create_config는 새 GameConfig Object를 만들어 호출자에게 반환합니다. 이 시점에서 GameConfig는 트랜잭션 송신자가 단독으로 소유하는 일반적인 Address-Owned Object입니다.
  2. 호출자는 반환받은 GameConfig의 필드 값을 확인하거나, 필요하다면 추가 함수를 호출해 값을 조정할 수 있습니다.
  3. 검토가 끝나면 freeze_config를 호출하여 GameConfig를 영구히 Immutable Object로 변환합니다.

이 패턴의 강점은 freeze 직전에 Object를 다듬을 기회가 있다는 점입니다. 한 번 freeze된 후에는 어떤 변경도 불가능하기 때문에, 값을 신중하게 확정해야 하는 Configuration Object나 NFT Metadata처럼 "최종 검토 후 잠그는" 흐름이 자연스럽게 표현됩니다.

freeze_config 함수가 config를 참조가 아닌 값으로 받는 점에도 주목해주세요. Move의 소유권 규칙상 값을 함수에 넘기는 순간 호출자는 해당 Object를 잃게 되므로, freeze 이후 같은 Object를 다른 곳에서 다시 사용할 수 없도록 강제됩니다.


생성과 동시에 freeze하기

또 다른 패턴은 Object를 만들자마자 같은 함수 안에서 곧바로 freeze하는 방식입니다. Module 배포 시점에 단 하나의 Immutable Object가 자동으로 생성되어야 한다면 init 함수가 가장 자연스러운 위치입니다.

앞서 살펴본 GameConfig를 init에서 생성과 동시에 freeze하도록 바꿔보겠습니다.

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

    public struct GameConfig has key {
        id: UID,
        max_players: u64,
        starting_gold: u64,
        starting_level: u64,
    }

    fun init(ctx: &mut TxContext) {
        let config = GameConfig {
            id: object::new(ctx),
            max_players: 100,
            starting_gold: 50,
            starting_level: 1,
        };
        transfer::public_freeze_object(config);
    }
}

이렇게 작성하면 game_config Module이 배포되는 순간 단 하나의 GameConfig가 만들어져 곧바로 Immutable Object가 됩니다. 이후 네트워크 위 누구든 이 GameConfig를 읽어 게임의 정적 설정값을 참조할 수 있게 됩니다.

이 패턴은 앞 절의 "먼저 만들고 나중에 freeze하기"와 비교했을 때 다음과 같은 차이가 있습니다.

  • 변경 기회 자체가 없음: Object가 만들어지는 순간 곧바로 freeze되므로, 누구도 변경 가능한 상태의 Object에 접근할 수 없습니다. 의도치 않은 수정이나 잘못된 값이 남을 위험이 원천적으로 차단됩니다.
  • 단일 트랜잭션 보장: 생성과 freeze가 같은 함수에서 처리되므로 두 동작 사이의 시간 차로 인한 race condition이 발생할 여지가 없습니다.
  • 싱글톤 표현에 적합: init 함수는 Module 배포 시 단 한 번만 실행되기 때문에, "시스템 전체에 정확히 하나만 존재하는 불변 데이터"를 표현하기에 알맞습니다.

반대로 freeze 직전에 값을 다듬을 여유가 필요하다면 앞 절의 패턴이, 처음부터 확정된 값을 잠그면 충분하다면 이 절의 패턴이 적합합니다. 두 방식 중 무엇이 더 좋다기보다는 Object의 생성 흐름에 따라 골라 쓰면 됩니다.


Immutable Object와 상호작용하기

Immutable Object는 변경, 전송, 삭제가 모두 불가능하기 때문에 Move 코드에서 다루는 방식이 일반 Object와 다릅니다. 가장 큰 차이는 함수에 인자로 전달할 때 어떤 형태로 받느냐에 있습니다.

앞서 살펴본 GameConfig를 다시 예로 들어 보겠습니다. GameConfig는 게임의 정적 설정을 담고 있으므로, 게임을 시작하거나 플레이어를 생성하는 함수들이 이 값을 참조해야 합니다. 그러나 Immutable Object이므로 어떤 함수도 이 값을 변경할 수는 없습니다.

이러한 제약은 Move의 타입 시스템과 Sui의 런타임 검증을 통해 강제됩니다. 다음 절들에서 Immutable Object가 함수 인자로 어떻게 전달되는지, 이 특성이 어떤 동시성 이점을 가져오는지, 그리고 어떤 동작이 금지되는지를 차례대로 살펴보겠습니다.


읽기 전용 참조(&T)로 전달

Immutable Object는 Move 함수에 반드시 immutable reference(&T) 형태로만 전달될 수 있습니다. &mut T(mutable reference)나 T(by value)로 받는 함수에는 Immutable Object를 인자로 넘길 수 없습니다.

GameConfig를 참조하여 새 플레이어를 만드는 함수를 작성해보겠습니다.

module example::player {
    use sui::object::{Self, UID};
    use sui::tx_context::TxContext;
    use example::game_config::GameConfig;

    public struct Player has key, store {
        id: UID,
        gold: u64,
        level: u64,
    }

    public fun create_player(
        config: &GameConfig,
        ctx: &mut TxContext,
    ): Player {
        Player {
            id: object::new(ctx),
            gold: config.starting_gold,
            level: config.starting_level,
        }
    }
}

create_player는 GameConfig를 &GameConfig 형태로 받습니다. 함수 본문에서는 config.starting_gold, config.starting_level 같이 필드 값을 읽기만 할 뿐, 어떤 필드도 변경하지 않습니다. 호출자는 freeze된 GameConfig의 Object ID만 트랜잭션 인자로 전달하면 Sui 런타임이 알아서 해당 Object를 immutable reference로 함수에 넘겨줍니다.

만약 함수 시그니처를 config: &mut GameConfig로 잘못 작성하면, 트랜잭션 실행 단계에서 Sui 런타임이 "Immutable Object를 mutable reference로 전달할 수 없다"는 오류를 내며 트랜잭션을 실패시킵니다. by-value(config: GameConfig)로 받는 경우도 마찬가지로 거부됩니다. 결과적으로 어떤 함수도 Immutable Object의 상태를 건드릴 수 없다는 점이 코드 차원에서 보장됩니다.

또한 한 번에 여러 함수가 같은 Immutable Object를 참조해도 아무런 문제가 없습니다. 일반적인 Object라면 & 참조와 &mut 참조가 동시에 존재할 수 없다는 borrow checker의 제약이 있지만, Immutable Object는 애초에 &mut가 불가능하기 때문에 여러 곳에서 동시에 &로 빌려 쓰는 일이 자유롭습니다.


동시성 이점

Immutable Object의 가장 큰 실질적 강점은 합의(Consensus) 없이 병렬로 처리될 수 있다는 점입니다. 이 이점이 왜 중요한지 이해하려면 Sui가 트랜잭션을 처리하는 두 가지 경로를 떠올리는 것이 도움이 됩니다.

Sui는 트랜잭션이 사용하는 Object의 종류에 따라 처리 경로를 다르게 선택합니다.

  • Fast Path: 단일 소유자만 다룰 수 있는 Address-Owned Object나, 변경 자체가 불가능한 Immutable Object만 사용하는 트랜잭션은 합의 과정을 거치지 않고 빠르게 검증되어 확정됩니다. 다른 트랜잭션과 순서를 맞출 필요가 없기 때문입니다.
  • Consensus Path: Shared Object를 사용하는 트랜잭션은 여러 트랜잭션 사이의 순서를 결정해야 하므로 합의 과정을 거칩니다. 합의는 정확성을 보장해주는 대신 추가 지연 시간을 발생시킵니다.

Immutable Object는 누구나 접근할 수 있다는 점에서 Shared Object와 비슷해 보이지만, 변경이 일어나지 않는다는 보장 덕분에 트랜잭션 순서를 결정할 필요가 없습니다. 두 트랜잭션이 같은 Immutable Object를 동시에 읽더라도, 그 값이 어느 트랜잭션 시점에서든 동일하기 때문에 어떤 순서로 처리되어도 결과가 같습니다.

앞서 본 create_player 예시를 떠올려보면 이 이점이 실제로 어떻게 드러나는지 알 수 있습니다. 수천 명의 사용자가 동시에 같은 GameConfig를 참조해 새 플레이어를 만들더라도, Sui는 이 트랜잭션들을 합의 없이 병렬로 처리합니다. 만약 GameConfig가 Shared Object였다면 매번 합의 과정을 거쳐야 했을 것이고, 동시 처리 가능한 트랜잭션 수가 크게 제한되었을 것입니다.

같은 read-only 시나리오라면 Shared Object보다 Immutable Object가 처리량과 지연 시간 모두에서 유리하다는 점을 기억해두면, 적절한 소유권 형태를 고르는 판단에 큰 도움이 됩니다.


제약 사항

Immutable Object가 제공하는 강력한 불변성 보장에는 명확한 제약이 따라옵니다. 일반 Object에서 가능했던 여러 동작이 Immutable Object에서는 모두 금지됩니다.

  • 필드 값 변경 불가: &mut T로 받는 함수에 Immutable Object를 전달할 수 없습니다. 따라서 freeze 이후로는 어떤 필드도 새로운 값으로 덮어쓸 수 없습니다.
  • 소비/삭제 불가: by-value(T)로 받는 함수도 호출할 수 없습니다. Move에서 Object를 삭제하려면 id를 꺼내 object::delete로 소멸시켜야 하는데, 이 동작은 Object를 by-value로 받아야만 가능하기 때문입니다. 결과적으로 한 번 만들어진 Immutable Object는 네트워크에서 영원히 제거되지 않습니다.
  • 전송 불가: Immutable Object는 소유자가 존재하지 않으므로 transfer::transfer로 다른 주소에 보낼 수 없습니다. public_freeze_object 자체가 by-value를 요구하기 때문에, freeze 이후 그 Object를 다른 함수에 넘겨 transfer를 시도하는 일조차 불가능합니다.
  • Dynamic Field 변경 불가: Dynamic field를 추가하거나 제거하는 동작 역시 내부적으로 Object의 UID에 대한 &mut 접근이 필요합니다. Immutable Object에서는 &mut UID를 얻을 수 없기 때문에 freeze 이후 dynamic field 구성을 바꿀 방법이 없습니다.

이러한 제약은 컴파일 시점이나 트랜잭션 실행 시점에서 자동으로 검증됩니다. 잘못 작성된 함수를 호출하더라도 상태가 망가지는 일은 없으며, 트랜잭션 자체가 실패할 뿐입니다.

특히 freeze는 단방향이므로, Object를 freeze하기 전에 정말로 변경이 영원히 필요 없을지 신중하게 검토해야 합니다. 향후 값을 갱신하거나 정책을 바꿀 가능성이 조금이라도 있다면 Shared Object를 사용하고 권한을 제한하는 편이 안전합니다.


Immutable Object 테스트하기

지금까지 살펴본 Immutable Object의 생성과 상호작용은 모두 실제 네트워크에서의 흐름이었습니다. 하지만 Module을 개발할 때는 매번 배포해 동작을 확인하기보다는, Move의 단위 테스트로 빠르게 검증하는 것이 일반적입니다.

문제는 Immutable Object를 다루는 테스트가 일반 Object와 약간 다르다는 점입니다. 일반 Object는 송신자가 소유하고 있으므로 트랜잭션 인자로 그대로 꺼내 쓰면 되지만, Immutable Object는 소유자가 없어서 "누구의 Object를 가져온다"는 표현이 성립하지 않습니다.

Sui는 이를 해결하기 위해 sui::test_scenario Module에 Immutable Object 전용 API를 제공합니다. 다음 절에서 그 사용 방법을 살펴보겠습니다.


test_scenario::take_immutable / return_immutable

sui::test_scenario Module은 Immutable Object를 다루기 위해 두 가지 핵심 함수를 제공합니다.

public fun take_immutable<T: key>(scenario: &Scenario): T
public fun return_immutable<T: key>(obj: T)
  • take_immutable<T>은 시나리오 안에 존재하는 Immutable Object 중 타입 T에 해당하는 Object를 가져옵니다. 일반 Object를 가져오는 take_from_sender와 달리 소유자 인자가 필요 없는데, 이는 Immutable Object에는 소유자라는 개념이 없기 때문입니다. 트랜잭션 송신자가 누구든 동일한 방법으로 접근할 수 있습니다.
  • return_immutable<T>은 가져온 Object를 시나리오에 다시 돌려놓습니다. Move의 소유권 규칙상 take_immutable이 반환하는 값은 함수 안에서 반드시 어딘가로 소비되어야 하기 때문에, 테스트가 끝나기 전에 명시적으로 반환해주어야 합니다.

GameConfig와 create_player의 동작을 검증하는 테스트를 작성해보겠습니다.

#[test_only]
module example::game_config_tests {
    use sui::test_scenario;
    use sui::transfer;
    use example::game_config::{Self, GameConfig};
    use example::player::{Self, Player};

    const PUBLISHER: address = @0xCAFE;
    const ALICE: address = @0xA11CE;

    #[test]
    fun test_create_player_uses_immutable_config() {
        let mut scenario = test_scenario::begin(PUBLISHER);

        // Module 배포 시점에 GameConfig가 생성되고 freeze된다.
        {
            game_config::init_for_testing(test_scenario::ctx(&mut scenario));
        };

        // Alice가 별도 트랜잭션에서 새 플레이어를 만든다.
        test_scenario::next_tx(&mut scenario, ALICE);
        {
            let config = test_scenario::take_immutable<GameConfig>(&scenario);
            let player = player::create_player(
                &config,
                test_scenario::ctx(&mut scenario),
            );

            assert!(player::gold(&player) == 50, 0);
            assert!(player::level(&player) == 1, 1);

            transfer::public_transfer(player, ALICE);
            test_scenario::return_immutable(config);
        };

        test_scenario::end(scenario);
    }
}

테스트의 흐름은 다음과 같습니다.

  1. test_scenario::begin으로 새 시나리오를 열고, 첫 트랜잭션 송신자로 PUBLISHER를 지정합니다.
  2. init_for_testing 헬퍼로 init 함수를 시뮬레이션합니다. 이 시점에서 GameConfig가 생성되어 freeze됩니다.
  3. next_tx로 시나리오를 ALICE의 트랜잭션 단계로 넘깁니다. Alice는 PUBLISHER가 만든 Immutable Object에 자유롭게 접근할 수 있습니다.
  4. take_immutable<GameConfig>(&scenario)로 GameConfig를 가져와 create_player에 &GameConfig로 전달합니다.
  5. 반환된 Player의 필드 값을 assert!로 검증합니다. starting_gold = 50, starting_level = 1이 Player에 그대로 반영되어야 합니다.
  6. 사용을 마친 config는 반드시 return_immutable로 반환해 시나리오에 돌려주고, Player는 Alice에게 transfer하여 정리합니다.
  7. 마지막으로 test_scenario::end로 시나리오를 종료합니다.

여기서 한 가지 더 주목할 점은 init_for_testing 같은 #[test_only] 헬퍼의 존재입니다. init 함수는 일반 코드에서 직접 호출할 수 없기 때문에, 테스트에서는 같은 동작을 수행하는 별도 헬퍼를 Module 안에 만들어둡니다. 이런 패턴은 Sui Module을 단위 테스트할 때 자주 등장하므로 익숙해지면 좋습니다.


마무리

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

  • sui::transfer::public_freeze_object 함수로 key Ability를 가진 Object를 영구히 변경 불가능한 상태로 만들 수 있으며, 이미 만들어진 Object를 freeze하는 패턴과 생성과 동시에 freeze하는 패턴 두 가지가 일반적입니다.
  • Immutable Object는 한 번 freeze된 뒤 다시 되돌릴 수 없으므로, 게임 정적 데이터나 NFT Collection Metadata처럼 "값이 절대로 변하지 않아야 한다는 보장"이 필요한 read-only 데이터에 적합합니다.
  • Immutable Object는 함수에 &T 형태로만 전달되며, &mut T나 by-value 전달은 모두 금지됩니다. 이러한 불변성 덕분에 합의(Consensus) 없이 Fast Path로 병렬 처리되어 Shared Object보다 처리량과 지연 시간 면에서 유리합니다.
  • 단위 테스트에서는 test_scenario::take_immutable과 return_immutable로 Immutable Object에 접근할 수 있으며, 소유자 인자 없이 누구나 동일한 방법으로 가져올 수 있습니다.

지금까지 이 시리즈에서는 Sui의 Object Model을 시작으로 Address-Owned Object, Shared Object, 그리고 이번 글의 Immutable Object까지 세 가지 핵심 소유권 형태를 차례대로 살펴보았습니다. 각 소유권 형태는 접근 가능한 범위와 변경 가능성에 따라 명확히 구분되며, Sui의 트랜잭션 처리 모델은 이 구분 위에서 동시성 보장과 성능 최적화를 동시에 달성합니다.

다음 글에서는 한 Object가 다른 Object를 자신의 필드 안에 품는 Wrapped 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