EVM vs SVM: Solana가 병렬로 실행되는 이유
1. 개요
Ethereum Virtual Machine (EVM)은 transaction을 순차적으로 처리하며, 각 block은 연속된 작업들의 queue입니다. Solana Virtual Machine (SVM)은 반대로 여러 CPU core에서 초당 수만 개의 transaction을 병렬로 실행합니다. 이 글에서는 왜 이러한 차이가 있는지 설명합니다 — SVM이 “마법처럼” 빠른 것이 아니라, 그 account 모델이 runtime에게 미리 누가 누구와 충돌할지 알려주기 때문입니다. 이 글을 읽은 후, Sealevel 메커니즘을 이해하고, Java로 병렬 scheduler를 직접 구현해볼 수 있으며, 이 모델이 언제 실패하는지 알게 될 것입니다.
2. EVM의 순차 실행 모델
EVM은 전체 state를 모든 smart contract가 공유하는 Merkle Patricia Trie에 저장합니다. contract A의 increment() 함수를 호출하는 transaction은 어느 storage slot에 접근할지 미리 선언하지 않습니다 — 이것은 실행 중에만 알 수 있습니다. 왜냐하면 EVM은 수정 가능한 state machine (Turing-complete)이고, if/else 분기가 입력 데이터에 따라 어느 slot에 쓸지 결정할 수 있기 때문입니다.
contract Counter {
uint256 public count;
function increment() public {
count += 1; // 유일한 storage slot, 미리 선언하지 않음
}
}
count는 고정된 storage slot이지만, EVM runtime은 bytecode를 실행하기 전에 — 시뮬레이션 없이 — 이 함수가 정확히 그 slot을 읽고 쓸 것임을 알 방법이 없습니다. bytecode를 미리 분석하는 것이 불가능하기 때문에, Ethereum client (geth, Nethermind…)는 block 내에서 transaction을 순차적으로 실행해야 하며, 다음 transaction이 항상 이전 transaction이 업데이트한 state를 보도록 보장합니다.
2.1. 미리 충돌을 예측할 수 없는 이유
EVM bytecode의 모든 경로를 분석하고 영향받는 storage slot 집합을 추론하는 충분히 저렴한 “static analysis” 메커니즘은 없습니다. Monad나 Block-STM (Aptos) 같은 일부 프로젝트는 optimistic concurrency로 해결합니다: 병렬로 실행하고, 충돌을 감지하면 rollback하고 다시 실행합니다. 이것은 “먼저 실행하고 확인하는” 접근법입니다 — SVM이 하는 “미리 선언하고 스케줄링하는” 방식과는 완전히 다릅니다.
EVM Block (mainnet 기본값):
tx1 -> tx2 -> tx3 -> tx4 (순차, 하나씩)
3. SVM의 명시적 Account 모델 (Sealevel)
Solana은 모든 transaction이 읽거나 쓸 account 목록을 transaction message에서 명시적으로 선언하도록 강제합니다. Runtime은 시뮬레이션 없이 알 수 있습니다 — client가 이미 명확하게 말했기 때문입니다.
pub fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let counter_account = next_account_info(accounts_iter)?;
let mut data = counter_account.try_borrow_mut_data()?;
let mut count = u64::from_le_bytes(data[0..8].try_into().unwrap());
count += 1;
data[0..8].copy_from_slice(&count.to_le_bytes());
Ok(())
}
accounts는 외부에서 전달된 slice입니다 — client는 counter_account가 어느 account인지, writable인지 아니면 읽기 전용인지 명시해야 하며, 이는 transaction을 만들 때부터입니다. Program은 실행 중에 다른 account를 자동으로 “발견”할 수 없습니다. 정확히 이 제약 — 비록 제한적으로 보이지만 — 이 Sealevel이 병렬로 스케줄링할 수 있게 해줍니다.
3.1. Transaction에서 writable/readonly를 즉시 선언하기
Transaction message (간단히):
instruction: increment_counter
accounts:
- counter_pda (writable)
- fee_payer (writable, signer)
- system_program (readonly)
Runtime은 이 metadata만 읽으면 됩니다 — bytecode를 실행할 필요 없이 — 이 transaction이 counter_pda와 fee_payer에 쓰고, system_program을 읽을 것임을 알 수 있습니다. 이것이 EVM과의 핵심 차이점입니다.
4. 충돌 감지 메커니즘 및 병렬 batch 스케줄링
Runtime이 각 transaction의 read-set/write-set을 알게 되면, 병렬 스케줄링 문제는 친숙한 graph coloring 문제가 됩니다: 두 transaction이 동시에 실행될 수 있으려면 그들이 접근하는 account 집합이 write-write 또는 write-read 방식으로 교집합이 없어야 합니다.
public record BlockTransaction(String id, Set<String> writes, Set<String> reads) {}
writes와 reads는 각각 transaction이 수정할 account와 읽기 전용으로 접근할 account입니다. 두 transaction이 충돌하는 경우:
public final class SealevelScheduler {
public static boolean conflicts(BlockTransaction a, BlockTransaction b) {
return !Collections.disjoint(a.writes(), b.writes())
|| !Collections.disjoint(a.writes(), b.reads())
|| !Collections.disjoint(a.reads(), b.writes());
}
}
각 조건 설명:
writes∩writes≠ ∅ → 둘 다 같은 account에 쓰려고 함 → 순차 실행 필요writes∩reads≠ ∅ → 한쪽은 읽고 한쪽은 같은 account에 쓰려고 함 → 순차 실행 필요reads∩reads≠ ∅ → 괜찮음, 동시 읽기는 exclusive lock이 필요 없음
4.1. Java를 사용한 시뮬레이션: batch를 그룹화한 후 병렬 실행
public final class ParallelExecutor {
private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
public List<BlockTransaction> nextNonConflictingBatch(
Deque<BlockTransaction> queue, Set<String> lockedAccounts) {
List<BlockTransaction> batch = new ArrayList<>();
for (BlockTransaction tx : queue) {
boolean overlaps = Stream.concat(tx.writes().stream(), tx.reads().stream())
.anyMatch(lockedAccounts::contains);
if (!overlaps) {
batch.add(tx);
lockedAccounts.addAll(tx.writes());
}
}
queue.removeAll(batch);
return batch;
}
public void runBatch(List<BlockTransaction> batch, Consumer<BlockTransaction> execute) {
List<CompletableFuture<Void>> futures = batch.stream()
.map(tx -> CompletableFuture.runAsync(() -> execute.accept(tx), pool))
.toList();
futures.forEach(CompletableFuture::join);
}
}
nextNonConflictingBatch는 transaction queue를 스캔하여 현재 lockedAccounts와 겹치지 않는 transaction을 batch에 추가하고, 그것이 쓰는 account를 잠급니다. runBatch는 virtual thread (Java 21)를 사용하여 전체 batch를 동시에 실행하고, join으로 batch 전체가 완료될 때까지 기다린 후 다음 batch를 만듭니다 — 정확히 Sealevel scheduler가 transaction을 “wave”에 따라 그룹화하는 방식입니다.
참고: 이것은 개념을 설명하기 위한 간단한 시뮬레이션입니다. 실제 Sealevel은 병렬 정도를 최대화하기 위해 더 복잡한 알고리즘(각 account별 dependency graph, 단순 순차 batch가 아님)을 사용합니다.
4.2. JUnit 5를 사용한 conflict 로직 검증
class SealevelSchedulerTest {
@Test
void disjointAccountsHaveNoConflict() {
var tx1 = new BlockTransaction("tx1", Set.of("walletA"), Set.of());
var tx2 = new BlockTransaction("tx2", Set.of("walletB"), Set.of());
assertThat(SealevelScheduler.conflicts(tx1, tx2)).isFalse();
}
@Test
void sameWriteAccountsThenConflict() {
var tx1 = new BlockTransaction("tx1", Set.of("poolAccount"), Set.of());
var tx2 = new BlockTransaction("tx2", Set.of("poolAccount"), Set.of());
assertThat(SealevelScheduler.conflicts(tx1, tx2)).isTrue();
}
@Test
void concurrentReadsHaveNoConflict() {
var tx1 = new BlockTransaction("tx1", Set.of(), Set.of("priceOracle"));
var tx2 = new BlockTransaction("tx2", Set.of(), Set.of("priceOracle"));
assertThat(SealevelScheduler.conflicts(tx1, tx2)).isFalse();
}
}
이 세 test는 read-read는 lock이 필요 없고, write-write는 lock 필요, write-read는 lock 필요라는 원리를 정확히 설명합니다 — 전체 SVM 병렬화 문제의 기초입니다.
5. EVM vs SVM 비교
| 기준 | EVM | SVM |
|---|---|---|
| State 모델 | 공유 trie, bytecode를 통한 암묵적 접근 | 독립적 account, transaction에서 명시적 선언 |
| 충돌을 아는 시점 | 실행 후(또는 시뮬레이션 후)에만 알 수 있음 | 실행 전 transaction metadata에서 미리 알 수 있음 |
| 실행 방식 | block 내 순서대로 순차 | 겹치지 않는 batch별로 병렬 |
| Cross-contract call | 런타임에 임의로 다른 contract 호출 | Cross-Program Invocation (CPI)도 account를 미리 선언해야 함 |
| 개발자 책임 | account set을 고려할 필요 없음 | 정확하고 충분한 account를 선언해야 함 — 누락하면 오류, 초과하면 병렬성 손실 |
| 병렬화 개선 방향 | Optimistic concurrency (Block-STM, Monad) | Dependency graph 스케줄링 최적화 (Sealevel, Firedancer) |
6. Solana Program 작성 시 Trade-off와 일반적인 오류
명시적 account 선언은 병렬성의 원천이지만, 동시에 두 가지 흔한 오류의 원천이기도 합니다:
Account 누락. Program 로직이 client가 transaction에 나열하지 않은 account에 쓸 필요가 있으면, 프로그램이 런타임에 “account not found in accounts list” 같은 오류로 실패합니다 — SVM은 EVM처럼 실행 중에 account를 “발견”할 수 없기 때문입니다.
과다 선언 또는 hotspot 생성. 너무 많은 transaction이 같은 account에 쓴다면 (예: 유일한 liquidity pool, global counter), 이들은 순차 실행되어야 합니다 — EVM 상황과 정확히 같지만, 당신이 직접 만든 것입니다. 이것은 EVM에 익숙한 개발자의 흔한 사고 오류입니다: “global variable 하나”라는 관성을 Solana에 그대로 가져오면서 account를 shard별로 분산시키지 않는 것입니다.
틀림: 모든 user 거래가 `global_stats`에 쓴다 -> 병렬성 손실
올바름: 각 user가 자신의 account를 가짐 (user별 PDA), 정기적으로만 집계
7. 병렬화가 도움이 되지 않는 경우
참고: 애플리케이션이 본질적으로 순차 queue (예: 주문 제출 순서로 가격을 책정하는 경매)라면, runtime 계층의 병렬화는 도움이 되지 않습니다 — 비즈니스 로직을 직렬화해야 하며, SVM은 서로 독립적인 transaction들에 대해서만 절약할 수 있습니다.
마찬가지로, blockchain이 아닌 중앙집중식 시스템을 설계하면서 throughput을 높이기 위해 Sealevel 모델을 “배우고” 싶다면, 대부분 작업이 명확히 분리된 read/write set을 가질 때만 적용할 가치가 있습니다. 모든 작업이 같은 테이블/레코드를 건드린다면, dependency graph 관리 비용이 단순 순차 lock보다 더 비쌉니다.
8. 결론
EVM은 순차 실행됩니다. 왜냐하면 bytecode가 누가 누구와 충돌할지 미리 알려주지 않기 때문입니다 — 충돌 예측은 시뮬레이션이나 rollback으로만 가능합니다. SVM은 병렬로 실행됩니다. 왜냐하면 모든 transaction이 처음부터 읽고 쓸 account를 선언하도록 강제하기 때문에, Sealevel이 dependency graph를 구성하고 겹치지 않는 batch를 만들어 여러 core에서 실행할 수 있습니다. Trade-off는 Solana 개발자가 account를 직접 분산시키고 hotspot을 피해야 한다는 것입니다 — 그렇지 않으면 실수로 SVM을 느린 순차 EVM으로 변환하게 됩니다.