Solana Account Model: SVM이 Java로 상태를 저장하는 방식 이해하기

1. 개요

Solana에서 모든 것은 account입니다: 지갑, token, 프로그램, 심지어 시스템 설정까지도요. Ethereum에 익숙하다면 contract가 자신의 storage를 관리하는 방식이 있을 텐데, Solana가 code와 데이터를 분리하는 방식은 처음에는 꽤 낯설 수 있습니다.

이 글에서는 account의 필드와 runtime이 적용하는 ownership 규칙을 설명합니다. Java 21로 rent를 계산하고, account 데이터를 읽고 해석하는 방법도 알아봅니다. 이 글을 읽고 나면 직접 getAccountInfo RPC를 호출하고 반환된 각 바이트를 이해할 수 있을 겁니다.

2. Solana account의 구성 요소

Solana를 거대한 key-value store로 생각할 수 있습니다. Key는 32바이트 주소이며, 보통 Base58 형식으로 표시됩니다. Value는 5개의 필드를 가진 account입니다:

필드 타입 의미
lamports u64 잔액. 1 SOL = 1,000,000,000 lamports
data byte[] 임의의 데이터. 최대 10 MiB
owner Pubkey 이 account를 소유한 프로그램
executable bool account가 프로그램 code를 포함하면 true
rent_epoch u64 레거시 필드. 거의 사용되지 않음

이를 Java의 record로 모델링할 수 있습니다:

public record SolanaAccount(
        String address,
        long lamports,
        byte[] data,
        String owner,
        boolean executable,
        long rentEpoch) {

    public double sol() {
        return lamports / 1_000_000_000.0;
    }

    public int space() {
        return data.length;
    }
}

주의할 점: account의 owner는 private key를 보유한 사람이 아닙니다. owner는 account에 쓸 수 있는 권한을 가진 프로그램입니다. 당신의 개인 지갑은 System Program이 owner입니다 (11111111111111111111111111111111). 당신은 단지 거래에 서명하는 사람일 뿐입니다.

2.1. Code와 데이터 분리

Solana의 프로그램은 stateless입니다. Code는 executable = true인 account에 위치합니다. 상태는 다른 account들에 위치하며, 이 account들의 owner는 프로그램을 가리킵니다.

예를 들어 지갑에 있는 USDC token은 4개의 account와 관련됩니다:

당신의 지갑         owner = System Program
Token account      owner = Token Program   (data: mint, owner, 잔액)
Mint account       owner = Token Program   (data: 총 공급량, decimals)
Token Program      executable = true       (code 포함)

이러한 분리 덕분에 하나의 Token Program이 모든 종류의 token을 서비스합니다. ERC-20처럼 각 token마다 별도의 contract를 배포할 필요가 없습니다.

3. JSON-RPC를 통해 account 읽기

JDK에 포함된 java.net.http.HttpClient와 Jackson을 사용하여 JSON을 파싱합니다. Spring Boot 3.x를 사용 중이면 Jackson이 이미 포함되어 있습니다. 그렇지 않으면 다음 dependency를 추가하세요:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.2</version>
</dependency>

Account를 읽는 client:

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.util.Base64;
import java.util.Optional;

public class SolanaRpcClient {

    private final HttpClient http = HttpClient.newHttpClient();
    private final ObjectMapper mapper = new ObjectMapper();
    private final URI endpoint;

    public SolanaRpcClient(String endpoint) {
        this.endpoint = URI.create(endpoint);
    }

    public Optional<SolanaAccount> getAccount(String address) throws Exception {
        String body = """
            {"jsonrpc":"2.0","id":1,"method":"getAccountInfo",
             "params":["%s",{"encoding":"base64"}]}
            """.formatted(address);

        HttpRequest request = HttpRequest.newBuilder(endpoint)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();

        String json = http.send(request, HttpResponse.BodyHandlers.ofString()).body();
        JsonNode value = mapper.readTree(json).path("result").path("value");

        if (value.isNull() || value.isMissingNode()) {
            return Optional.empty(); // account가 아직 존재하지 않음
        }
        byte[] data = Base64.getDecoder().decode(value.path("data").get(0).asText());
        return Optional.of(new SolanaAccount(
                address,
                value.path("lamports").asLong(),
                data,
                value.path("owner").asText(),
                value.path("executable").asBoolean(),
                value.path("rentEpoch").asLong()));
    }
}

간단한 설명:

  • encoding: base64를 사용하여 getAccountInfo를 호출합니다. response에서 data는 배열 [문자열, "base64"]이므로 첫 번째 요소를 가져와 decode합니다.
  • value가 null이면 account가 존재하지 않습니다. 이것은 정상적인 상태이지 오류가 아닙니다. 모든 주소는 lamports를 가질 때까지 “유효”합니다.
  • rentEpoch는 signed long의 범위를 초과할 수 있습니다 (값 u64::MAX). 이 경우 asLong()은 음수를 반환합니다. 정확한 값이 필요하면 Long.parseUnsignedLong 또는 BigInteger를 사용하세요.

devnet의 Token Program으로 테스트해봅시다:

public static void main(String[] args) throws Exception {
    var client = new SolanaRpcClient("https://api.devnet.solana.com");
    client.getAccount("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
          .ifPresent(acc -> System.out.printf(
                  "owner=%s executable=%s space=%d%n",
                  acc.owner(), acc.executable(), acc.space()));
}
owner=BPFLoader2111111111111111111111111111111111 executable=true space=134080

Token Program도 단지 하나의 account일 뿐입니다. Owner는 BPF Loader이며, 이는 bytecode를 로드하고 실행할 책임이 있는 프로그램입니다. 정확한 space 숫자는 cluster마다 다를 수 있습니다.

4. Runtime이 적용하는 ownership 규칙

SVM의 runtime은 각 instruction 이후 다음 규칙을 확인합니다:

  1. 오직 owner 프로그램만 account의 data를 변경할 수 있습니다.
  2. 오직 owner 프로그램만 lamports를 차감할 수 있습니다. 모든 프로그램은 account에 lamports를 추가할 수 있습니다.
  3. 오직 현재 owner만 owner를 변경할 수 있으며, data가 0으로 초기화되었을 때만 가능합니다.
  4. Account의 executable은 모든 일반적인 instruction에서 불변입니다.

처음 두 규칙을 구현해 로직을 명확히 해봅시다:

public final class OwnershipRules {

    private OwnershipRules() {}

    public static boolean canModifyData(String programId, SolanaAccount account) {
        return !account.executable() && account.owner().equals(programId);
    }

    public static boolean canChangeLamports(String programId,
                                            SolanaAccount account,
                                            long newLamports) {
        boolean isCredit = newLamports >= account.lamports();
        return isCredit || account.owner().equals(programId);
    }
}

동작을 확인하는 test를 작성합시다:

import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;

class OwnershipRulesTest {

    static final String SYSTEM = "11111111111111111111111111111111";
    static final String TOKEN = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";

    SolanaAccount tokenAccount = new SolanaAccount(
            "Acc1", 2_039_280, new byte[165], TOKEN, false, 0);

    @Test
    void onlyOwnerCanModifyData() {
        assertThat(OwnershipRules.canModifyData(TOKEN, tokenAccount)).isTrue();
        assertThat(OwnershipRules.canModifyData(SYSTEM, tokenAccount)).isFalse();
    }

    @Test
    void anyoneCanCreditButOnlyOwnerCanDebit() {
        assertThat(OwnershipRules.canChangeLamports(SYSTEM, tokenAccount, 3_000_000)).isTrue();
        assertThat(OwnershipRules.canChangeLamports(SYSTEM, tokenAccount, 1_000_000)).isFalse();
        assertThat(OwnershipRules.canChangeLamports(TOKEN, tokenAccount, 1_000_000)).isTrue();
    }
}

이로부터 System Program으로 token account에서 SOL을 직접 “인출”할 수 없는 이유를 알 수 있습니다. Owner가 Token Program이므로, Token Program의 instruction (예: CloseAccount)만 lamports를 차감할 수 있습니다.

주의: token account 데이터의 “Owner” (token 소유자)는 account의 owner 필드 (Token Program)와 다릅니다. 이것이 Solana를 처음 배울 때 가장 흔한 혼동 지점입니다.

5. Rent: 저장소 비용 지불

on-chain 데이터를 저장하려면 validator의 리소스가 필요합니다. 따라서 각 account는 rent-exempt 상태를 유지하기 위해 최소한의 lamports를 보유해야 합니다. 현재 모든 새로운 account는 rent-exempt 상태로 생성되어야 합니다. 잔액이 부족한 account는 생성 시 runtime에 의해 거부됩니다.

mainnet의 기본 파라미터를 사용한 공식:

minBalance = (dataLen + 128) * lamportsPerByteYear * exemptionYears
           = (dataLen + 128) * 3480 * 2

128은 runtime이 각 account마다 추가하는 고정 metadata overhead입니다.

public final class RentCalculator {

    private static final long ACCOUNT_STORAGE_OVERHEAD = 128;
    private static final long LAMPORTS_PER_BYTE_YEAR = 3_480;
    private static final long EXEMPTION_YEARS = 2;

    private RentCalculator() {}

    public static long minimumBalance(int dataLen) {
        if (dataLen < 0 || dataLen > 10 * 1024 * 1024) {
            throw new IllegalArgumentException("dataLen은 0..10 MiB 범위에 있어야 합니다");
        }
        return (dataLen + ACCOUNT_STORAGE_OVERHEAD)
                * LAMPORTS_PER_BYTE_YEAR * EXEMPTION_YEARS;
    }
}

친숙한 값들로 test해봅시다:

class RentCalculatorTest {

    @Test
    void walletAccountWithNoData() {
        assertThat(RentCalculator.minimumBalance(0)).isEqualTo(890_880L);
    }

    @Test
    void splTokenAccountOf165Bytes() {
        assertThat(RentCalculator.minimumBalance(165)).isEqualTo(2_039_280L);
    }
}

빈 지갑은 약 0.00089 SOL이 필요합니다. token account는 약 0.00204 SOL이 필요합니다. account를 닫을 때 이 모든 lamports가 반환됩니다. 이것이 많은 지갑이 “close empty token accounts” 버튼을 가지고 있어 SOL을 회수하는 이유입니다.

주의: production에서는 상수를 hard-code하는 대신 RPC getMinimumBalanceForRentExemption를 호출해야 합니다. rent 파라미터는 feature gate를 통해 변경될 수 있으며, test cluster는 다른 설정을 사용할 수 있습니다.

6. Account 데이터 디코딩

data는 단지 byte 배열입니다. 각 프로그램이 layout을 정의합니다. SPL token account (165바이트)의 경우 처음 3개 필드는:

offset 0   : mint   (32 byte)
offset 32  : owner  (32 byte) - token 소유자
offset 64  : amount (u64, little-endian)

pubkey를 표시하려면 간단한 Base58 인코더가 필요합니다:

import java.math.BigInteger;

public final class Base58 {

    private static final String ALPHABET =
            "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

    private Base58() {}

    public static String encode(byte[] input) {
        BigInteger value = new BigInteger(1, input);
        StringBuilder sb = new StringBuilder();
        BigInteger base = BigInteger.valueOf(58);
        while (value.signum() > 0) {
            BigInteger[] qr = value.divideAndRemainder(base);
            sb.append(ALPHABET.charAt(qr[1].intValue()));
            value = qr[0];
        }
        for (byte b : input) {           // 각 leading 0 byte -> 문자 '1'
            if (b != 0) break;
            sb.append('1');
        }
        return sb.reverse().toString();
    }
}

token account parser:

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;

public record TokenAccountData(String mint, String tokenOwner, long amount) {

    public static TokenAccountData parse(byte[] data) {
        if (data.length != 165) {
            throw new IllegalArgumentException("SPL token account이 아닙니다: " + data.length);
        }
        ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
        return new TokenAccountData(
                Base58.encode(Arrays.copyOfRange(data, 0, 32)),
                Base58.encode(Arrays.copyOfRange(data, 32, 64)),
                buf.getLong(64));
    }
}

중요한 점은 ByteOrder.LITTLE_ENDIAN입니다. Solana는 정수를 little-endian으로 serialize합니다 (Borsh 또는 C layout). Java의 ByteBuffer는 기본적으로 big-endian이므로, 이 줄을 잊으면 말도 안 되는 잔액을 얻게 됩니다.

자체 구성한 데이터로 parse test:

@Test
void parsesAmountInLittleEndian() {
    byte[] data = new byte[165];
    ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).putLong(64, 1_500_000L);

    TokenAccountData parsed = TokenAccountData.parse(data);

    assertThat(parsed.amount()).isEqualTo(1_500_000L);
    assertThat(parsed.mint()).isEqualTo("11111111111111111111111111111111");
}

32개의 0 바이트는 32개의 문자 1로 인코딩되어 System Program의 주소와 정확히 일치합니다. 6 decimals를 가진 USDC의 경우 amount = 1_500_000은 1.5 USDC에 해당합니다.

주의: amount는 u64입니다. Long.MAX_VALUE보다 큰 값은 Java에서 음수가 됩니다. 표시할 때는 Long.toUnsignedString(amount)를 사용하세요. Token-2022는 데이터를 165바이트보다 길게 만드는 extension도 있으므로, 두 표준을 모두 지원해야 한다면 길이를 고정적으로 확인하지 마세요.

7. Account model과 병렬 처리

Solana의 각 transaction은 접근할 모든 account를 미리 선언해야 합니다. 각 account마다 transaction은 isSigner 및 isWritable 플래그를 명시합니다. Runtime (Sealevel)은 이 목록을 기반으로 스케줄을 결정합니다:

  • 두 transaction이 동일한 account을 읽기만 한다면 병렬로 실행될 수 있습니다.
  • 두 transaction이 동일한 account에 쓴다면 순차적으로 실행되어야 합니다.
import java.util.List;

public record AccountMeta(String address, boolean isSigner, boolean isWritable) {}

public record Transaction(List<AccountMeta> accounts) {

    public boolean conflictsWith(Transaction other) {
        return accounts.stream().anyMatch(a -> other.accounts.stream().anyMatch(b ->
                a.address().equals(b.address()) && (a.isWritable() || b.isWritable())));
    }
}

conflictsWith 메서드는 두 transaction이 동일한 account을 사용하고 적어도 한쪽이 쓸 때 true를 반환합니다. 이것이 바로 read/write lock 원칙입니다.

실제 결과: 모든 사용자가 쓰는 “global state” account를 가진 프로그램을 설계하면 시스템이 순차적이 됩니다. 더 나은 방법은 사용자별로 상태를 분할하는 것입니다. 보통 ["user", userPubkey] 같은 seed에서 derive된 PDA (Program Derived Address)를 사용하여 이를 수행합니다.

8. 흔한 실수들

  • “owner”를 두 가지로 헷갈리기: account의 owner 필드는 프로그램이지 사용자가 아닙니다.
  • writable account 선언 잊기: writable privilege escalated 같은 오류로 transaction이 실패합니다.
  • rent를 위한 lamports 부족: account 생성이 insufficient funds for rent 오류를 발생시킵니다. createAccount를 호출하기 전에 space를 올바르게 계산하세요.
  • parse할 때 endianness 틀리기: 항상 little-endian을 사용하세요.
  • 존재하지 않는 account를 오류로 보기: value: null은 유효한 상태이므로 Optional로 처리해야 합니다.
  • 하지 말아야 할 것: 모든 것을 위해 거대한 데이터 account를 사용하지 마세요. 10 MiB 제한, rent 비용, write lock이 모두 당신에게 반대합니다.

9. 결론

Solana의 account model은 몇 가지 간단한 아이디어를 중심으로 합니다. 모든 것이 account입니다. 프로그램은 상태를 유지하지 않습니다. 오직 owner 프로그램만 데이터를 수정하고 lamports를 차감할 수 있습니다. 저장된 각 바이트는 최소 rent로 지불되어야 합니다.

당신은 Java 21로 JSON-RPC를 통해 account를 읽었고 test로 ownership 규칙을 검증했습니다. rent-exempt을 계산하고 raw 바이트 배열에서 SPL token account를 디코딩했습니다. account를 미리 선언하는 것도 Solana가 transaction을 병렬로 처리할 수 있도록 하는 핵심입니다.

다음 논리적 단계는 PDA와 Cross-Program Invocation을 이해하는 것입니다. 이 두 메커니즘은 프로그램이 account를 소유하고 서로를 안전하게 호출할 수 있도록 해줍니다.

solanasvmblockchainjavaaccount-model