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는 signedlong의 범위를 초과할 수 있습니다 (값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 이후 다음 규칙을 확인합니다:
- 오직 owner 프로그램만 account의
data를 변경할 수 있습니다. - 오직 owner 프로그램만 lamports를 차감할 수 있습니다. 모든 프로그램은 account에 lamports를 추가할 수 있습니다.
- 오직 현재 owner만
owner를 변경할 수 있으며, data가 0으로 초기화되었을 때만 가능합니다. - 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를 소유하고 서로를 안전하게 호출할 수 있도록 해줍니다.