
우선순위 수수료: Solana의 트랜잭션 수수료 메커니즘 이해하기
이 글에서 다루는 내용
Solana는 빠릅니다. 하지만 현존하는 가장 빠른 블록체인에서도 사용자는 중요한 트랜잭션이 최적의 방식으로 처리되기를 원합니다. 우선순위 수수료를 사용하면 사용자의 트랜잭션을 실행 순서 대기열의 앞쪽에 배치할 수 있습니다. 사용자가 트랜잭션에 추가할 수 있는 선택적 수수료입니다.
이 글에서는 Solana의 트랜잭션 처리 방식을 간략히 살펴봅니다. 트랜잭션과 그 수명 주기, 트랜잭션 수수료의 작동 방식을 설명합니다. 이어서 우선순위 수수료와 이를 프로그래밍 방식으로 구현하는 방법, 권장 사례를 알아봅니다.
트랜잭션과 수명 주기
트랜잭션은 Solana 프로그램을 호출하고 상태를 변경하는 데 사용됩니다. 트랜잭션은 검증인에게 어떤 계정에서 어떤 작업을 수행할지, 필요한 권한이 있는지를 알려주는 명령어 묶음입니다. 각 명령어는 단일 프로그램 호출을 위한 지시문입니다.
Solana에서 트랜잭션의 일반적인 수명 주기는 다음과 같습니다.
- 사용자는 수행하려는 작업의 명확한 목표를 정합니다. 예를 들어 Alice는 Bob에게 10 SOL을 보내려고 합니다
- 사용자는 원하는 작업을 위한 트랜잭션을 생성합니다. 예를 들어 Alice는 자신의 계정에서 Bob의 계정으로 10 SOL을 전송하는 명령어가 포함된 트랜잭션을 만듭니다. 최근 블록 해시도 포함하고 개인 키로 트랜잭션에 서명합니다
- 사용자는 트랜잭션을 네트워크로 전송한 후 성공적으로 추가되었는지에 대한 정보를 받습니다. 예를 들어 Alice는 confirmed 커밋 상태로 트랜잭션을 전송합니다. 트랜잭션이 확인되면 트랜잭션 서명을 받습니다. Alice는 Orb 같은 블록 탐색기에서 이 서명을 사용해 Bob에게 10 SOL을 성공적으로 보냈는지 확인할 수 있습니다. Alice의 계정에서는 10 SOL이 차감되고 Bob의 계정에는 10 SOL이 입금됩니다
사용자는 서명된 트랜잭션을 네트워크로 보낼 때 Helius 같은 RPC 제공업체를 이용합니다. Helius의 RPC는 트랜잭션을 수신하고 현재 리더 일정을 확인합니다. Solana에서는 특정 시점마다 지정된 검증인만 원장에 항목을 추가할 수 있습니다. 리더는 현재 슬롯의 블록 생성을 담당하며 연속된 슬롯 4개를 배정받습니다. 서명된 트랜잭션은 현재 리더와 다음 리더 2명에게 전송됩니다.
현재 리더는 서명된 트랜잭션을 검증하고 기타 전처리 단계를 수행한 뒤 실행 일정을 정합니다. Ethereum 같은 다른 L1과 달리 Solana에는 전역 트랜잭션 대기열이 없습니다. 대부분의 검증인은 Solana Labs에서 제공하는 스케줄러 구현을 사용합니다. 그러나 Jito 검증인 클라이언트를 실행하는 검증인은 의사 멤풀인 MempoolStream을 사용해 트랜잭션 순서를 정합니다. 기본 스케줄러는 멀티스레드 방식이며, 각 스레드는 실행 대기 중인 트랜잭션 대기열을 유지합니다. 트랜잭션은 선입선출(FIFO) 방식과 우선순위 수수료를 조합해 블록에 정렬됩니다. 트랜잭션이 실행 스레드에 다소 무작위로 할당되므로 이 순서는 본질적으로 비결정적이라는 점에 유의해야 합니다.
트랜잭션이 실행되면 Turbine을 통해 전파되고 수수료가 그에 따라 지불됩니다.
Solana의 트랜잭션 수수료 작동 방식
트랜잭션 수수료는 Solana에서 트랜잭션을 처리하기 위해 지불하는 소액의 수수료입니다. 현재 리더는 네트워크를 통해 전송된 트랜잭션을 처리해 원장 항목을 생성합니다. 트랜잭션이 상태의 일부로 확인되면 Solana의 경제 설계를 지원하기 위한 수수료가 지불됩니다. 트랜잭션 수수료는 근본적으로 다음과 같은 방식으로 Solana에 기여합니다.
- 검증인에게 보상을 제공합니다
- 트랜잭션에 실시간 비용을 부과해 네트워크 공간 사용량을 줄입니다
- 프로토콜이 확보하는 최소 수수료를 통해 네트워크에 장기적인 경제적 안정성을 제공합니다
Solana는 단기적으로 네트워크를 보호하기 위해 인플레이션 기반의 프로토콜 보상에 의존합니다. 이를 위해 네트워크는 예정된 전역 인플레이션율에 따라 검증인에게 보상합니다. 장기적으로는 보안을 유지하기 위해 트랜잭션 수수료에 의존합니다. 각 트랜잭션 수수료의 고정 비율(초기 설정은 50%)은 소각되고 나머지는 현재 리더에게 전송됩니다. Solana는 SOL의 가치를 강화하는 동시에 악의적인 검증인이 트랜잭션을 검열하지 못하도록 수수료를 소각합니다.
트랜잭션 수수료는 서명당 정적으로 설정된 기본 수수료와 트랜잭션 중 사용된 컴퓨팅 리소스를 컴퓨트 유닛(CU) 단위로 측정해 계산합니다. 이 기본 수수료는 서명당 목표 램포트의 50%에서 1000%까지 설정될 수 있습니다. 현재 서명당 기본 목표는 10,000으로 설정되어 있습니다. 각 트랜잭션에는 컴퓨트 예산이라는 최대 CU 예산이 할당됩니다. 이 예산을 초과하면 런타임이 트랜잭션을 중단하고 오류를 반환합니다. 트랜잭션당 최대 예산은 140만 CU이며, 블록 공간 한도는 4,800만 CU입니다.
우선순위 수수료란?
이러한 제한 때문에 연산량이 많은 트랜잭션이 블록 공간을 가득 채우면 다른 트랜잭션이 지연될 수 있습니다. Solana는 트랜잭션이 리더의 대기열에서 다른 트랜잭션보다 우선 처리되도록 선택적 수수료를 도입했습니다. 이를 우선순위 수수료라고 합니다. 이 수수료를 지불하면 트랜잭션의 우선순위가 올라가 더 빠르게 실행됩니다. 시간에 민감하거나 가치가 높은 트랜잭션에 유용합니다. 트랜잭션의 수수료 우선순위는 요청하는 컴퓨트 유닛 수에 따라 결정됩니다. 더 많은 컴퓨트 유닛을 요청할수록 트랜잭션 대기열에서 우선순위를 유지하기 위해 더 높은 수수료를 지불해야 합니다. 컴퓨트 유닛이 많을수록 더 많은 비용을 부과해 연산량이 많은 트랜잭션 스팸을 방지합니다.
우선순위 수수료는 트랜잭션의 컴퓨트 예산과 마이크로 램포트 단위로 측정한 컴퓨트 유닛 가격을 곱한 값입니다. priorityFees = computeBudget * computeUnitPrice
컴퓨트 예산
computeBudget은 트랜잭션이 사용할 수 있는 최대 컴퓨트 유닛 수, 트랜잭션에서 수행할 수 있는 여러 작업의 비용, 트랜잭션이 준수해야 할 운영 범위를 지정합니다. 다음 작업에는 컴퓨팅 비용이 발생합니다.
- SBF 명령어 실행
- 프로그램 간 데이터 전달
- 시스템 호출 실행(예: 로깅, 프로그램 주소 생성, CPI)
크로스 프로그램 호출(CPI)에서 호출된 프로그램은 호출하는 상위 프로그램의 컴퓨팅 예산 내에서 작동합니다. 호출된 프로그램이 남은 컴퓨팅 예산을 모두 사용하거나 설정된 한도를 초과하면 전체 프로그램 호출 체인이 실패합니다. 여기에는 프로세스를 시작한 최초 트랜잭션의 실행도 포함됩니다.
현재 컴퓨트 예산은 여기에서 확인할 수 있습니다.
우선순위 수수료를 프로그래밍 방식으로 구현하는 방법
트랜잭션의 우선순위 수수료는 SetComputeUnitPrice 명령어https://github.com/solana-labs/solana/blob/2971e84ec87815adb1e4def95cbcd8d0d96845f5/sdk/src/compute_budget.rs#L59와 선택적인 SetComputeUnitLimit 명령어를 설정해 지정합니다. SetComputeUnitPrice 명령어를 제공하지 않으면 추가 수수료가 없으므로 트랜잭션은 기본적으로 최저 우선순위가 됩니다. SetComputeUnitLimit 명령어를 제공하지 않으면 트랜잭션의 명령어 수와 기본 컴퓨트 유닛 한도를 곱해 한도를 계산합니다. 런타임은 컴퓨트 유닛 가격과 한도를 사용해 우선순위 수수료를 계산하고, 이 수수료를 기준으로 해당 트랜잭션의 우선순위를 정합니다.
우선순위 수수료를 프로그래밍 방식으로 추가하려면 원하는 트랜잭션에 이 명령어들을 넣어야 합니다. Javascript에서는 다음과 같습니다.
import {
Keypair,
Connection,
PublicKey,
Transaction,
SystemProgram,
LAMPORTS_PER_SOL,
sendAndConfirmTransaction,
ComputeBudgetProgram,
} from "@solana/web3.js";
async function main() {
// Initialize an RPC client
const clusterUrl = "http://127.0.0.1:8899";
const connection = new Connection(clusterUrl, "confirmed");
// Initialize new sender and receiver keypairs
const fromKeypair = Keypair.generate();
const toPubkey = new PublicKey(Keypair.generate().publicKey);
// Airdrop SOL to the from_keypair
const airdropAmount = 100 * LAMPORTS_PER_SOL;
try {
const signature = await connection.requestAirdrop(
fromKeypair.publicKey,
airdropAmount
);
console.log("Airdrop requested. Signature:", signature);
await connection.confirmTransaction({
signature,
confirmation: "confirmed",
});
} catch (e) {
console.error("Failed to request airdrop:", e);
return;
}
// Check if airdrop was successful
const balance = await connection.getBalance(fromKeypair.publicKey);
if (balance < airdropAmount) {
console.error(
"Airdrop was not successful. The current balance is insufficient"
);
return;
}
// Airdrop SOL to the toPubkey
const airdropAmountTo = 100 * LAMPORTS_PER_SOL; // 1 SOL in lamports
try {
const signature = await connection.requestAirdrop(toPubkey, airdropAmount);
console.log("Airdrop requested. Signature:", signature);
await connection.confirmTransaction({
signature,
confirmation: "confirmed",
});
} catch (e) {
console.error("Failed to request airdrop:", e);
return;
}
// Check if airdrop was successful
const balanceTo = await connection.getBalance(toPubkey);
if (balance < airdropAmount) {
console.error(
"Airdrop was not successful. The current balance is insufficient"
);
return;
}
console.log(`Account balance: ${balance / LAMPORTS_PER_SOL} SOL`);
// Create the priority fee instructions
const computePriceIx = ComputeBudgetProgram.setComputeUnitPrice({
microLamports: 1,
});
const computeLimitIx = ComputeBudgetProgram.setComputeUnitLimit({
units: 200_000,
});
// Create the transfer instruction
const transferIx = SystemProgram.transfer({
fromPubkey: fromKeypair.publicKey,
toPubkey,
lamports: 100_000,
});
// Create the transaction with priority fees
const transaction = new Transaction().add(
computePriceIx,
computeLimitIx,
transferIx
);
// Fetch the recent blockhash and sign the transaction
transaction.recentBlockhash = (
await connection.getLatestBlockhash()
).blockhash;
transaction.sign(fromKeypair);
// Send the transaction
try {
const txid = await sendAndConfirmTransaction(connection, transaction, [
fromKeypair,
]);
console.log("Transaction sent successfully with signature", txid);
} catch (e) {
console.error("Failed to send transaction:", e);
}
}
main();이 코드에서는 다음 작업을 수행합니다.
- Localhost를 사용해 테스트 환경을 설정합니다
- 새 지갑 2개(
fromKeypair및toPubkey)를 생성합니다 - 두 지갑에 각각 100 SOL을 에어드롭합니다
- 에어드롭 성공 여부를 확인합니다
- 우선순위 수수료 명령어(
computePriceIx및computeLimitIx)를 생성합니다 fromKeypair에서toPubkey로 100, 000 램포트를 보내는 전송 명령어를 생성합니다- 새 트랜잭션을 생성하고 모든 명령어를 추가합니다
- 최신 블록 해시를 트랜잭션에 첨부하고 서명합니다
- 트랜잭션을 전송하고 성공적으로 전송되었는지 확인합니다
이것으로 끝입니다. 우선순위 수수료는 더 빠른 실행을 위해 비용을 지불하도록 트랜잭션에 추가하는 명령어일 뿐입니다. 이 코드의 대부분은 개발 환경과 SOL을 주고받을 두 지갑을 구성합니다. 여기서 주목할 부분은 우선순위 수수료를 추가하는 명령어를 생성하고 트랜잭션에 첨부하는 과정입니다.
// Other code
const computePriceIx = ComputeBudgetProgram.setComputeUnitPrice({
microLamports: 1,
});
const computeLimitIx = ComputeBudgetProgram.setComputeUnitLimit({
units: 200_000,
});
// Other code
const transaction = new Transaction().add(computePriceIx, computeLimitIx, transferIx);
// Rest of the code권장 사례
이 명령어들은 순차적으로 실행되므로 순서가 중요합니다. 예를 들어 컴퓨팅 한도를 늘리기 전에 트랜잭션이 기본 컴퓨팅 한도인 200k CU를 초과하면 트랜잭션이 실패합니다. 다음과 같은 트랜잭션이 있다고 가정해 보겠습니다.
- 명령어 1은 100k를 사용합니다
- 명령어 2는 150k를 사용합니다
- 명령어 3은 컴퓨트 한도를 늘립니다
명령어 2와 3의 순서를 바꾸지 않으면 이 트랜잭션은 실패합니다. 따라서 트랜잭션에 다른 명령어를 추가하기 전에 컴퓨트 한도 명령어를 넣는 것이 좋습니다. 트랜잭션에 우선순위 수수료를 추가할 때 SetComputeLimit 명령어를 반드시 사용할 필요는 없습니다. 완전히 선택 사항입니다. SetComputePrice 명령어의 위치는 중요하지 않습니다.
최근에 지불된 우선순위 수수료 목록을 가져오려면 getRecentPrioritizationFees RPC 메서드를 사용하는 것이 좋습니다. 이 데이터로 트랜잭션에 적절한 우선순위 수수료를 추정하면 클러스터에서 처리될 가능성을 높이고 지불 수수료를 최소화할 수 있습니다. 또는 Helius의 새로운 Priority Fee API를 사용할 수 있으며, 다음 섹션에서 설명합니다.
트랜잭션은 수수료를 최소화할 수 있도록 실행에 필요한 최소한의 컴퓨트 유닛만 요청해야 합니다. 요청한 컴퓨트 유닛 수가 트랜잭션에서 실제로 사용한 총 유닛 수를 초과해도 비용은 조정되지 않습니다.
Phantom 같은 일부 지갑 제공업체는 dApp이 트랜잭션 우선순위 수수료를 설정할 수 있도록 지원합니다. 하지만 최종 사용자에게 불필요한 복잡성을 초래하는 경우가 많다는 이유로 이를 권장하지 않습니다. 대신 dApp 개발자가 사용자를 대신해 Phantom이 우선순위 수수료를 적용하도록 맡길 것을 권합니다. 예를 들어 Solfare는 Solana의 부하 상태를 자동으로 감지하고 수수료를 소폭 인상해 트랜잭션이 다른 트랜잭션보다 우선 처리되도록 합니다.
Helius Priority Fee API
getRecentPrioritizationFees RPC 메서드로 우선순위 수수료를 계산하는 방식은 슬롯 단위 수수료 계산에 직관적으로 적합합니다. 하지만 네트워크 상태가 계속 변하고 getRecentPrioritizationFees' 응답 특성상 어려울 수 있습니다. 이 응답은 지난 150개 블록의 값 목록을 반환하며, 수수료의 최솟값을 판단하는 데만 유용합니다.
Helius Priority Fee API는 새로운 메서드인 getPriorityFeeEstimate을 도입합니다. 이 메서드는 전역 및 로컬 수수료 시장을 고려해 응답을 단일 값으로 간소화합니다. 미리 정의된 백분위수 집합을 사용해 추정치를 결정합니다. 이러한 백분위수 또는 레벨은 NONE(0번째 백분위수)부터 UNSAFE_MAX(100번째 백분위수이며 사용자가 실수로 자금을 소진하지 않도록 안전하지 않음으로 표시)까지입니다. 사용자는 모든 우선순위 레벨을 받도록 지정할 수도 있고 lookbackSlots을 통해 계산에 사용하는 범위를 조정할 수도 있습니다. 따라서 getRecentPrioritizationFees과 마찬가지로 사용자는 추정치 계산 시 일정 수의 이전 슬롯을 조회할 수 있습니다.
예를 들어 다음 스크립트로 Jupiter v6 프로그램의 모든 우선순위 수수료 레벨을 계산할 수 있습니다.
const url = `https://mainnet.helius-rpc.com/?api-key=`;
const getRecentPrioritizationFees = async () => {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "getPriorityFeeEstimate",
params: [{
"accountKeys": ["JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"],
"options": {
"includeAllPriorityFeeLevels": true,
}
}]
}),
});
const data = await response.json();
console.log("Fee: ", data);
};
getRecentPrioritizationFees();이 엔드포인트는 현재 활발히 개발 중입니다. 자세한 내용은 Helius 문서를 참조하세요.
결론
Solana 트랜잭션의 작동 방식을 살펴보면 네트워크 효율성과 경제적 인센티브의 균형을 맞추는 정교한 시스템을 확인할 수 있습니다. 트랜잭션과 수수료, 우선순위 수수료의 작동 방식을 이해하면 개발자와 사용자가 더 정확한 판단을 내리고 Solana에서의 상호작용을 최적화할 수 있습니다. 우선순위 수수료를 프로그래밍 방식으로 구현하면 가치가 높거나 시간에 민감한 트랜잭션을 처리할 새로운 방법이 열립니다. 이를 통해 Solana에서 더 유연하고 효율적으로 작업할 수 있습니다.
여기까지 읽어주셔서 감사합니다, anon! 아래에 이메일 주소를 입력하고 Solana의 새로운 소식을 빠짐없이 받아보세요. 더 깊이 알아볼 준비가 되셨나요? 지금 Discord에 참여해 최고 성능의 블록체인에서 미래를 만들어 보세요.
추가 자료 / 더 읽어보기
관련 아티클
Helius 구독하기
최신 Solana 개발 소식을 확인하고 새 게시물 알림을 받아보세요


