신규: Helius가 Light Protocol을 인수했습니다
Solana 산술 연산 — Solana 프로그램 또는 스마트 컨트랙트에서 수학 연산을 수행하는 방법
블로그/기초

Solana 산술 연산: 금융 앱 구축을 위한 모범 사례

개발자 교육X의 Mike MacCanaLinkedIn의 Mike MacCana
읽는 데 9분

이 글을 편집하고 작성에 기여해 주신 0xIchigo와 Lostin에게 감사드립니다.

Solana는 경쟁이 치열한 분야입니다. 새로운 대출 프로토콜, 애그리게이터, 예측 시장, RWA 토큰화 등 무엇을 개발하든 빠르게 메인넷에 출시하고 싶을 수 있습니다.

하지만 블록체인 애플리케이션은 가치를 관리하므로 본질적으로 금융 애플리케이션이라는 점을 기억해야 합니다.

블록체인이든 전통 금융이든 금융 앱을 개발해 본 적이 없다면 금융 수학의 중요성을 알아야 합니다.

Solana 전용 프로그래밍 주제에 관한 훌륭한 자료는 많습니다. 각 명령어에 서명해야 하는 계정 확인, 사용하는 계정의 소유 프로그램 확인, 계정 재개설 공격 방지 등이 있습니다. Anchor의 계정 제약 조건을 사용하면 이러한 검사를 더 쉽게 구현할 수 있습니다. Rust도 Debug 모드에서 정수 오버플로와 언더플로를 감지하는 등 유용한 기본 동작을 제공합니다.

안전한 금융 프로그래밍에는 Solana 전용 주제 외에도 고려할 사항이 많습니다. 토큰 산술 연산에서 오류 하나만 발생해도 자산 유출, 의도하지 않은 인플레이션, 사용자 불만으로 이어질 수 있습니다. 다른 블록체인보다 트랜잭션 규모가 큰 Solana에서는 취약점이 더 빠르게 악용될 수 있습니다.

전통 금융의 여러 금융 프로그래밍 기법은 블록체인과 직접 관련이 없어 충분히 주목받지 못합니다. 하지만 사용자의 토큰을 보호하는 데 여전히 중요합니다.

이 글에서는 다음 내용을 구체적으로 다룹니다.

  • 정수와 최소 단위 사용
  • 정밀도 손실 방지 — 먼저 곱한 다음 나누기
  • 일관된 반올림 정책
  • 부동소수점을 사용하지 않는 이자 계산

정수와 최소 단위 사용

정밀도 부족은 스마트 컨트랙트에서 흔한 취약점입니다. 수학 연산이 정확하지 않으면 공격에 노출될 수 있습니다. Solana 애플리케이션에서 안전하게 수학 연산을 수행하려면 정수와 최소 단위를 사용해야 합니다.

간단한 예시를 살펴보겠습니다.

전통 금융에서 ‘USD로’ 처리한 모든 거래는 실제로 센트 단위로 처리됩니다. GBP를 사용했다면 펜스 단위로 처리됩니다. 

마찬가지로 SOL 트랜잭션은 lamport로, USDC 트랜잭션은 USDC의 백만분의 1 단위로 처리해야 합니다.

센트, 펜스, lamport는 모두 최소 단위(기본 단위라고도 함)입니다. 모든 연산을 최소 단위로 수행하는 이유는 간단합니다. 컴퓨터는 부동소수점 수를 정확하게 처리할 수 없기 때문입니다.

2진수로 표현한 1은 다음과 같습니다.

32의 자리16의 자리8의 자리4의 자리2의 자리1의 자리
000001

2진수로 표현한 9는 다음과 같습니다.

32의 자리16의 자리8의 자리4의 자리2의 자리1의 자리
001001

그렇다면 0.3 USDC는 어떻게 표현할까요? 

정답은 표현할 수 없다는 것입니다. 

코드
let answer = 0.1 + 0.2;
msg!("0.1 + 0.2 = {}", answer);

결과는 다음과 같습니다.

프로그램 로그: "0.1 + 0.2 = 0.30000000000000004"

대신 달러, GBP, SOL, USDC를 비롯한 모든 ‘통화’를 최소 단위의 정수 금액으로 취급하세요.

예를 들어 USDC에서 0.1과 0.2를 더하려면 다음과 같이 합니다.

코드
let answer_ints: u128 = 100000 + 200000;
msg!("100000 + 200000 = {}", answer_ints);

결과는 다음과 같습니다.

프로그램 로그: "100000 + 200000 = 300000"

최소 단위를 사용하면 손실이 발생하지 않습니다.

개발자는 토큰의 소수 자릿수를 사용하되 모든 토큰의 소수 자릿수가 같지는 않다는 점을 기억해야 합니다.

토큰 금액에 정수를 사용해야 한다는 점은 당연해 보일 수 있습니다. 하지만 토큰 금액뿐 아니라 모든 곳에서 정수를 사용해야 합니다.

백분율

백분율은 정수로 표현해야 합니다. 일반적으로 bps로 표기하는 베이시스 포인트(bip이라고도 함)를 사용합니다.

예를 들어 4.74%는 474 bps입니다.

복리

복리는 복리 횟수가 무한대에 가까워질 때의 극한을 나타내는 e(오일러 수)를 사용해 계산해서는 안 됩니다.

e를 사용하면 복리를 매끄러운 곡선으로 나타내는 ‘연속 복리’를 구현할 수 있지만, Rust에서 e 자체는 부동소수점 수로 표현됩니다.

e를 사용하면 반올림 오류가 발생할 뿐 아니라 다른 방식과도 결과가 달라집니다.

이 글의 뒷부분에서 두 문제를 모두 살펴보겠습니다.

오버플로와 언더플로

Rust는 정수를 고정 크기 변수로 저장합니다. 즉, 변수의 부호 여부에 따라 메모리에서 차지할 수 있는 공간이 제한됩니다.

예를 들어 u8 타입에는 0부터 255까지의 값을 저장할 수 있습니다. 이 범위를 벗어난 값을 저장하면 오버플로나 언더플로가 발생합니다.

오버플로란 무엇인가요?

오버플로는 값이 해당 변수 타입의 최대 저장 용량을 초과해 최솟값으로 순환하는 현상입니다.

예를 들어 u8에 256을 저장하면 값은 0으로 순환합니다. 257은 1, 258은 2, 511은 255로 순환하며 512는 다시 0으로 순환합니다.

언더플로란 무엇인가요?

언더플로는 값이 가능한 최솟값보다 작아져 최댓값으로 순환하는 현상입니다. u8에서 -1은 255, -2는 254로 순환하는 식입니다. 간단히 말해 언더플로는 오버플로와 같지만 반대 방향으로 동작합니다.

Rust에는 오버플로나 언더플로가 발생할 때 런타임에서 프로그램을 패닉 상태로 만드는 여러 검사가 있습니다. 하지만 릴리스 모드로 컴파일할 때는 이러한 검사가 포함되지 않습니다. 또한 Solana 개발 환경의 핵심 툴체인은 기본적으로 Solana 프로그램을 릴리스 모드로 컴파일합니다. 

오버플로와 언더플로 및 완화 방법을 자세히 알아보려면 Solana 프로그램 보안 가이드를 확인하세요.

먼저 곱한 다음 나누기

곱하기 전에 나누는 방식이 자연스럽게 느껴질 때가 많습니다. 예측 시장 구축을 예로 들어보겠습니다. 사용자가 승리한 결과에 베팅했다면 당첨금을 계산해야 합니다. 예측 시장의 당첨자는 승리한 결과에 걸린 전체 베팅액 중 자신이 차지하는 비율에 따라 지급받습니다. 머릿속으로는 다음과 같이 계산하기 쉽습니다.

당첨 풀 ÷ 승리한 결과의 총베팅액 × 베팅액 

매우 자연스러워 보입니다.

먼저 당첨 풀을 당첨금의 ‘1지분’을 나타내는 작은 조각으로 나눈 다음, 각 사용자에게 줄 지분 수를 계산합니다.

하지만 먼저 곱하면 중간 결과가 더 커집니다. 따라서 뒤이은 나눗셈에서 발생하는 반올림 오류의 영향을 줄일 수 있습니다. 

당첨 풀 × 베팅액 ÷ 승리한 결과의 총베팅액

예측 시장 예시

간단한 예시를 살펴보겠습니다. 컴퓨터가 아닌 사람이 계산한다면 정답은 10.5입니다.

먼저 나눠보겠습니다. 

코드
let answer: u128 = 7 / 2 * 3;
msg!("7 / 2 * 3 = {}", answer);

결과는 다음과 같습니다.

프로그램 로그: "7 / 2 * 3 = 9"

먼저 나누면 최소 단위 1.5만큼 손실됩니다.

먼저 곱하면 어떻게 될까요?

코드
let answer: u128 = 7 * 3 / 2;
msg!("7 * 3 / 2 = {}", answer);

결과는 다음과 같습니다.

프로그램 로그: "7 * 3 / 2 = 10"

먼저 곱하면 반올림 손실은 최소 단위 0.5에 불과합니다. 

이 개념은 더 넓게 ‘자릿수를 늘리는 연산을 먼저 수행한다’는 의미로 확장할 수 있습니다. 

예를 들어 거듭제곱이나 제곱근을 사용하는 더 복잡한 식을 계산한다면(위험 계산 등), 거듭제곱을 먼저 수행하고 제곱근은 나중에 계산하세요. 고정소수점 산술에서는 나눗셈을 먼저 수행할 경우 몫이 곱해지기 전에 내림 처리되어 정밀도가 떨어질 수 있기 때문입니다.

일관된 반올림 정책 사용

일관되지 않은 반올림 정책으로 토큰 하나보다 작은 차이가 발생하면 토큰이 사라지거나 갑자기 생성된 것처럼 보일 수 있습니다. 시간이 지나면 프로그램의 유동성이 고갈되거나 의도하지 않은 토큰 인플레이션이 발생할 수 있습니다.

Orca의 Will Thieme가 Solana 프로그램에서 흔히 발생하는 함정 피하기라는 Breakpoint 발표에서 지적했듯이, 토큰 하나를 얻는 것은 대단해 보이지 않습니다.

하지만 Solana에는 여러 명령어를 포함하면서 트랜잭션 수수료는 낮은, 전통 금융의 의미에서 실질적인 트랜잭션이 있습니다. 따라서 1 차이 오류를 악용하는 개별 명령어를 하나의 트랜잭션에 대량으로 넣고 매우 저렴하게 실행할 수 있습니다.

반올림 오류는 피할 수 없지만 일관된 반올림 정책으로 관리할 수 있습니다. 올림과 내림 중 무엇을 사용할지, ‘절반’(즉, 0.5)을 어떻게 처리할지 미리 결정하세요. 사사오입 방식이 더 일반적이며 일부 금융 표준에서는 이를 의무화합니다. 가장 중요한 점은 코드 전체에 해당 정책을 일관되게 적용하는 것입니다.

Rust 반올림 시 주의 사항

반올림 연산은 정밀도 손실의 흔한 원인이므로 개발자는 문제가 될 수 있는 여러 Rust 전용 함수를 알아야 합니다. 선택한 반올림 방식은 프로그램의 정확성과 동작에 큰 영향을 줄 수 있습니다.

반올림과 내림

예를 들어 try_round_u64() 함수는 가장 가까운 정수로 반올림합니다. 담보를 유동성으로 변환하는 프로그램을 구축할 때 올림하면 제공된 담보로 정당화할 수 있는 양보다 더 많은 유동성 토큰이 발행될 수 있습니다. 대신 try_floor_u64() 함수를 사용해 가장 가까운 정수로 내림해야 합니다.

포화 산술 함수

또한 개발자는 오버플로와 언더플로를 방지하기 위해 saturating_* 산술 함수(예: saturating_add)를 사용해 값을 각 타입의 최댓값과 최솟값으로 제한하는 경우가 많습니다. 하지만 이러한 함수는 미묘한 정밀도 손실을 일으킬 수 있습니다.

예를 들어 함수가 트랜잭션 금액에 보상 배수를 곱하고 그 결과가 변수 타입의 최댓값을 초과한다면 사용자에게 지급되는 보상이 부족해집니다.

프로그램에서 고정소수점 산술을 사용해야 하므로 이 점을 반드시 기억해야 합니다.

부동소수점을 사용하지 않는 이자 계산

복리를 계산할 때 오일러 수 e를 사용하는 경우가 많지만 e는 부동소수점 수입니다! 이자 계산에 부동소수점 수를 사용하지 마세요. 대신 고정소수점 산술을 사용하세요.

Solana의 spl-math 라이브러리에는 PreciseNumber이 있습니다. 예상할 수 있듯이 Solana의 제한적인 Rust 환경에서도 작동하며 정확한 정밀도를 유지하면서 작은 소수(소수점 이하 최대 12자리)를 표현할 수 있습니다.

코드
use spl_math::precise_number::PreciseNumber;

fn calculate_compound_interest(
   principal: u128,
   rate_basis_points: u128,
   time: u128,
   compounds_per_year: u128,
) -> u128 {
   // Formula: result = principal * (1 + rate/compounds_per_year)^(compounds_per_year * time)
   // Where rate_basis_points is expressed in basis points (500 for 5%)

   // Convert principal to PreciseNumber
   let principal = PreciseNumber::new(principal).unwrap();

   // Convert basis points to decimal percentage (divide by 10000)
   let rate = PreciseNumber::new(rate_basis_points)
       .unwrap()
       .checked_div(&PreciseNumber::new(10_000).unwrap())
       .unwrap();

   // Calculate rate/compounds_per_year
   let rate_per_period = rate
       .checked_div(&PreciseNumber::new(compounds_per_year).unwrap())
       .unwrap();

   // Calculate 'base', which is 1 + rate/compounds_per_year
   let one = PreciseNumber::new(1).unwrap();
   let base = rate_per_period.checked_add(&one).unwrap();

   // Calculate 'total_periods', which is compounds_per_year * time
   let total_periods = compounds_per_year.checked_mul(time).unwrap();

   // Calculate compound_factor, which is (1 + rate/compounds_per_year)^(compounds_per_year * time)
   let compound_factor = base.checked_pow(total_periods).unwrap();

   // Calculate result = principal * compound_factor
   principal
       .checked_mul(&compound_factor)
       .unwrap()
       .to_imprecise()
       .unwrap()
}

코드를 실행하면 차이를 확인할 수 있습니다.

  • 프로그램 로그: "spl-math PreciseNumber 사용"
  • 프로그램 로그: "$1000를 연 5%로 5년간 투자하고 연 1회 복리 적용:"
  • 프로그램 로그: "최종 금액: $1276"

반면 e를 사용하면 다음과 같습니다.

  • 프로그램 로그: " e 사용(이렇게 하지 마세요)"
  • 프로그램 로그: "$1000를 연 5%로 5년간 투자하고 연 1회 복리 적용:"
  • 프로그램 로그: "최종 금액: $1284"

결론

토큰 수학은 지루한 주제지만 주의를 기울이지 않으면 원치 않는 큰 사건이 발생할 수 있습니다. 온체인 앱은 사용자가 요구하는 수준의 정밀도와 보안으로 토큰을 처리해야 합니다.

이 글에서는 정수와 최소 단위 사용, 먼저 곱하기, 정밀도 손실 방지, 일관된 반올림 정책 유지, 부동소수점을 사용하지 않는 이자 계산을 살펴봤습니다.

이러한 산술 연산의 기본을 익혔다면 메인넷에 출시하기 전에 다른 전문가, 특히 Solana 전문 감사 업체에 코드 검토를 의뢰하세요.

추가 자료

Helius 구독하기

최신 Solana 개발 소식을 확인하고 새 게시물 알림을 받아보세요