Back to Blog

Solana 간편 가이드 02: 처음부터 시작하는 첫 번째 Solana 스마트 컨트랙트 작성법

June 21, 2024
10 min read

소개

2024년, Solana는 총 잠긴 가치(TVL) 기준 네 번째로 높은 순위의 퍼블릭 블록체인으로 급부상하며 투자자와 개발자들의 관심을 사로잡았습니다.

BlockSec은 Solana의 기본 개념, Solana 스마트 컨트랙트 작성 튜토리얼, Solana 트랜잭션 분석 가이드를 포함한 "Solana Simplified" 시리즈를 기획했습니다. 이 시리즈의 목표는 독자들이 Solana 생태계를 이해하고 Solana에서 프로젝트를 개발하고 트랜잭션을 수행하는 데 필요한 핵심 기술을 습득할 수 있도록 돕는 것입니다.

이 시리즈의 첫 번째 글에서는 Solana 네트워크의 운영 메커니즘, 계정 모델, 트랜잭션 등 핵심 개념을 심층적으로 살펴보며 올바르고 고성능의 Solana 컨트랙트를 작성하기 위한 탄탄한 기반을 마련했습니다.

이번 글에서는 게시글을 작성하고 표시하는 Solana 프로그램(즉, Solana 스마트 컨트랙트) 작성 방법을 안내합니다. 이를 통해 첫 번째 글의 개념을 확고히 하고, 아직 다루지 않은 Solana의 몇 가지 기능을 소개할 것입니다. 관련 프로그램 및 테스트 코드는 GitHub에 공개되어 있습니다.

환경 설정

참고: 아래 명령어는 Ubuntu 시스템만을 대상으로 합니다. Windows 및 macOS 시스템에서는 일부 명령어가 작동하지 않을 수 있습니다.

👉 대체 명령어를 사용하거나 로컬 개발 환경 설정 및 Solana CLI 설치를 참고하여 해결할 수 있습니다.

Solana 프로그램을 로컬 환경에서 컴파일하고 배포할 것입니다. Solana 프로그램 개발에 앞서 몇 가지 명령어와 의존성을 설치해야 합니다.

Rust

Solana 프로그램은 주로 Rust 프로그래밍 언어로 작성되므로, 다음 명령어를 실행하여 Rust를 설치해야 합니다:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

node.js & TypeScript

테스트 스크립트는 TypeScript로 작성되어 있으므로, Node.js와 TypeScript 툴체인을 설치해야 합니다:

curl -fsSL https://deb.nodesource.com/setup_15.x | sudo -E bash -
sudo apt-get install -y nodejs npm
npm install -g typescript
npm install -g ts-node

Solana CLI

다음으로, 지갑 생성 및 컨트랙트 배포와 같은 작업을 위한 명령어를 제공하는 Solana CLI 도구를 설치해야 합니다:

sh -c "$(curl -sSfL https://release.solana.com/stable/install)"

현재 터미널에 변경 사항을 적용하려면 다음 명령어를 실행하세요:

source ~/.profile

solana --version을 실행하여 Solana CLI가 성공적으로 설치되었는지 확인할 수 있습니다:

$ solana --version
solana-cli 1.17.25 (src:d0ed878d; feat:3580551090, client:SolanaLabs)

로컬 지갑

다음으로, 아래 명령어를 실행하여 로컬 파일 시스템 지갑을 생성합니다:

solana-keygen new

기본적으로 이 명령어는 ~/.config/solana/id.json 경로에 개인 키 파일을 생성하며, 이후 테스트 스크립트에서 사용됩니다. solana address 명령어를 사용하여 생성된 주소를 확인할 수 있습니다:

$ solana address
9FcGDHvzs8Czo4B1pQq8GPdXfdGHqNqayvh6bu4hVGfy

개발 네트워크(Devnet)

프로그램은 개발 네트워크(devnet)에 배포될 것이므로, 다음 명령어를 실행하여 Solana CLI를 devnet으로 전환해야 합니다:

solana config set --url https://api.devnet.solana.com

SOL 토큰

컨트랙트 배포 및 테스트 트랜잭션 전송에는 비용이 발생하므로 SOL 토큰을 요청해야 합니다. solana airdrop 2를 실행하거나 공개 웹 파우셋에서 직접 토큰을 요청할 수 있습니다.

프로그램 작성

소개할 프로그램은 사용자가 게시글을 올리고 현재 게시된 모든 게시글을 나열할 수 있도록 합니다. 이 프로그램은 세 가지 유형의 명령어를 처리합니다:

  • init 명령어: 현재 게시글의 최대 인덱스를 저장하는 데이터 계정을 생성하여 프로그램을 초기화합니다.
  • post 명령어: 게시된 게시글을 새 데이터 계정에 저장합니다.
  • list 명령어: 게시된 모든 게시글을 로그에 출력합니다.

아래는 프로그램의 구조입니다:

$ tree program/
program/
├── Cargo.lock
├── Cargo.toml
└── src
    ├── instructions
    │   ├── init.rs
    │   ├── list.rs
    │   ├── mod.rs
    │   └── post.rs
    ├── lib.rs
    └── processor.rs

Cargo.toml은 프로젝트의 외부 라이브러리를 지정합니다:

[dependencies]
borsh = "0.10.0"
borsh-derive = "0.10.0"
solana-program = "=1.17.25"

참고: solana-program의 버전은 설치된 Solana CLI 버전과 일치해야 합니다(solana --version으로 확인 가능). 그렇지 않으면 컴파일 중 오류가 발생할 수 있습니다.

컨트랙트의 진입 함수는 processor.rs에 정의되어 있습니다. 이 파일은 필요한 의존성을 가져오는 것으로 시작합니다(다른 Rust 파일에서 유사한 부분은 생략합니다):

다음으로 컨트랙트 진입 함수의 정의가 이어집니다:

프로그램의 10번째 줄에서 Solana 라이브러리의 entrypoint! 매크로는 진입 함수를 process_instruction으로 지정합니다. 이 함수는 세 가지 매개변수를 받습니다:

  • program_id: 현재 프로그램이 배포된 주소.
  • accounts: 명령어에 관련된 모든 계정.
  • instruction_data: 명령어 처리에 사용되는 바이트 배열.

process_instructioninstruction_data의 첫 번째 바이트를 추출하여 명령어 유형을 식별하고 이를 처리할 해당 함수를 호출합니다. 다음으로 이 세 가지 함수가 어떻게 구현되는지 살펴보겠습니다.

init

세 함수 각각은 program/src/instructions 디렉터리 아래 동일한 이름의 파일에 정의되어 있습니다. init.rs부터 시작하겠습니다.

init 명령어는 추가적인 바이트 배열이 필요하지 않으므로, program_idaccounts 두 가지 매개변수만 받습니다.

12번째 줄에서 15번째 줄 사이에서 프로그램은 필요한 계정 정보를 순서대로 추출한 후, find_program_address 함수를 호출하여 현재 최대 게시글 인덱스를 저장하는 데이터 계정의 주소인 index_pda_key를 계산합니다. 그런 다음 프로그램은 index_pda_keyindex_pda의 주소와 같은지 검증합니다.

여기서 index_pda의 주소는 PDA(Program Derived Addresses)라고 불리는 특수한 주소입니다. 공개 키에서 생성된 "지갑" 계정 주소와 달리, PDA는 선택적 바이트 배열, "bump"라고 불리는 바이트, 그리고 프로그램의 주소로부터 파생됩니다. 바이트 배열과 프로그램 주소는 호출자가 명시적으로 제공하며, bump는 find_program_address 함수에 의해 자동으로 생성되어 반환됩니다. bump는 이 방식으로 생성된 주소가 공개 키로 생성된 주소와 충돌하지 않도록 보장합니다.

index_pda의 주소가 예상대로임을 확인한 후, SystemProgram이 제공하는 create_account 명령어를 사용하여 index_pda를 생성합니다. 22번째 줄에서 프로그램은 현재 최대 게시글 인덱스(0으로 초기화)를 기록하는 IndexAccountData 타입의 변수를 생성합니다. 아래 그림과 같이 이 타입은 BorshSerializeBorshDeserialize 트레이트를 구현하여 Borsh 형식으로 직렬화 및 역직렬화할 수 있습니다:

23번째 줄에서 24번째 줄은 계정 데이터를 저장하는 데 필요한 공간과 계정 생성을 위한 최소 임대료를 계산합니다.

27번째 줄에서 37번째 줄은 SystemProgramcreate_account 명령어를 생성하고 invoke_signed 메서드를 사용하여 이를 호출합니다. 한 프로그램 내에서 다른 프로그램의 명령어를 호출하는 행위를 CPI(Cross-Program Invocation)라고 합니다. Solana에서는 CPI를 수행하기 위해 invokeinvoke_signed 두 가지 함수를 사용할 수 있습니다.

invoke 함수는 주어진 명령어를 직접 처리하며, 함수 시그니처는 다음과 같습니다:

pub fn invoke(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>]
) -> Result<(), ProgramError>

invoke_signedinvoke의 유일한 차이점은 invoke_signed가 PDA 계정에 서명하는 방법을 제공한다는 것입니다. PDA 계정은 공개-개인 키가 없어 직접 서명을 제공할 수 없습니다. invoke_signed는 이 문제를 해결합니다. 이 함수는 signers_seeds라는 추가 매개변수를 받으며, 함수 시그니처는 다음과 같습니다.

find_program_address 함수가 작동하는 방식과 유사하게, invoke_signed는 제공된 signers_seeds와 호출자의 program_id를 기반으로 PDA 주소 집합을 계산합니다. account_infos에 이러한 PDA 주소가 포함되어 있으면, 해당 주소들은 서명을 제공한 것처럼 서명자로 표시됩니다.

pub fn invoke_signed(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>],
    signers_seeds: &[&[&[u8]]]
) -> Result<(), ProgramError>

여기서 create_account 명령어는 생성되는 계정이 서명자여야 하므로, invoke_signed를 사용하여 index_pda에 서명을 제공해야 합니다.

프로그램의 마지막 부분에서 컨트랙트는 index_dataserialize 메서드를 호출하여 데이터를 직렬화하고 이를 index_pdadata 필드에 씁니다.

post_article

다음으로 post 명령어의 구현을 살펴보겠습니다. init 명령어와 달리 post 명령어는 게시된 게시글을 받으므로, 게시글의 직렬화된 데이터를 저장하는 instruction_data_inner라는 추가 매개변수가 있습니다.

항상 그렇듯이 프로그램은 명령어에 필요한 계정 정보를 추출하는 것으로 시작합니다. 각 게시글마다 별도의 PDA 계정을 생성하므로, 프로그램은 23번째 줄에서 new_article_pda라는 추가 계정을 받습니다.

init 명령어와 유사하게, 27번째 줄에서 30번째 줄은 find_program_address를 사용하여 전달된 index_pda의 주소가 올바른지 검증합니다. 그런 다음 data 필드의 데이터를 역직렬화하여 현재 할당된 최대 인덱스를 읽습니다.

33번째 줄에서 40번째 줄은 new_article_pda의 주소도 검증합니다. 프로그램은 문자열 "ARTICLE_PDA"와 현재 인덱스를 사용하여 새 주소를 생성합니다. 인덱스는 게시글이 업로드될 때마다 증가하여 생성된 주소가 고유하도록 보장합니다.

다음으로, 프로그램은 instruction_data_innerPostArticleData 타입의 게시글 데이터로 역직렬화합니다:

이 구조체는 titlecontent 두 가지 필드만 가집니다. 45번째 줄에서 49번째 줄에서 프로그램은 게시글이 단일 계정에 담기기에 너무 크지 않은지 확인하는 검사를 수행합니다.

이후 단계들도 init 명령어와 유사합니다: 프로그램은 먼저 계정에 필요한 공간과 임대료를 계산한 다음, SystemProgramcreate_account 명령어를 호출하여 게시글을 저장할 PDA 계정을 생성합니다. 마지막으로 게시된 게시글은 new_article_pdadata 필드에 직렬화되고, 현재 최대 인덱스는 1 증가하여 index_pdadata 필드에 직렬화됩니다.

list_articles

마지막으로 list 명령어의 구현을 살펴보겠습니다. 이 명령어는 모든 게시글을 나열해야 하므로, 15번째 줄에서 게시글을 저장하는 모든 PDA 계정을 나타내기 위해 벡터가 사용되며, index_pda 계정의 주소도 검증됩니다.

그 후 프로그램은 벡터의 크기가 현재 최대 인덱스와 동일한지 확인하고 각 계정의 주소 정확성을 검사합니다.

프로그램의 마지막 부분에서 각 계정을 순회하며 solana-program의 msg! 매크로를 사용하여 역직렬화된 게시글 내용을 하나씩 출력합니다.

트랜잭션 테스트

컨트랙트를 테스트하기 위해 저장소의 client/main.ts에 위치한 TypeScript 스크립트를 사용합니다.

스크립트 상단에서 필요한 모든 라이브러리를 가져오고 세 가지 전역 상수를 정의합니다. KEYPAIR_PATHsolana-keygen new를 사용하여 생성된 개인 키 파일의 경로를 나타내고, PROGRAM_ID는 이전 섹션에서 작성한 배포된 프로그램의 주소이며, POST_ARTICLE_SCHEMA는 게시글 직렬화에 사용되는 객체입니다.

스크립트의 본문에서는 먼저 loadKeyFromFile 함수를 호출하여 개인 키 파일을 파싱하고 트랜잭션 수수료 지불자로 사용할 Keypair 객체를 얻습니다. 그런 다음 @solana/web3.js가 제공하는 findProgramAddressSync 메서드를 사용하여 indexPda의 주소를 계산합니다. 이 메서드는 Rust 컨트랙트와 동일한 알고리즘을 사용하므로 동일한 매개변수로 동일한 주소를 계산합니다. connection 객체는 테스트에 devnet을 사용할 것임을 지정합니다.

다음으로 첫 번째 초기화 트랜잭션을 전송합니다. 단일 명령어가 포함된 트랜잭션을 생성합니다. 이 명령어의 data 필드에는 "0"이라는 단 하나의 바이트만 포함되어 있으며, 이는 init 명령어에 대한 호출임을 나타냅니다. 그런 다음 트랜잭션은 sendAndConfirmTransaction을 사용하여 전송되고 확인됩니다.

두 번째 트랜잭션은 post 명령어를 호출하여 게시글을 올립니다. 56번째 줄에서 63번째 줄까지 스크립트는 두 개의 게시글을 직렬화하고 해당 PDA 주소를 계산합니다. 그런 다음 두 개의 post 명령어가 단일 트랜잭션에 추가되어 이 게시글들을 생성합니다.

마지막 트랜잭션은 list 명령어를 호출하고, 트랜잭션이 확인된 후 로그가 출력됩니다.

스크립트로 프로그램을 테스트하려면 KEYPAIR_PATH를 개인 키 경로로 교체하세요(기본 경로가 아닌 경우). 다음으로, 아래 명령어를 실행하여 Rust 스마트 컨트랙트를 컴파일하고 배포합니다:

cd program
cargo build-bpf
solana program deploy ./target/deploy/hello_solana.so

그런 다음 배포된 주소를 복사하여 PROGRAM_ID 필드에 붙여넣습니다. 프로젝트의 루트 디렉터리에서 npm install을 실행하여 의존성을 설치한 후, npm start를 실행하여 스크립트를 실행합니다.

그 후 Solscan을 사용하여 테스트 트랜잭션의 실행 세부 정보를 확인할 수 있습니다.

게시글을 발행하는 두 번째 트랜잭션을 직접 살펴보겠습니다. 이 트랜잭션에는 두 개의 명령어가 포함되어 있으며, 각 명령어의 Instruction Data Raw의 첫 번째 바이트는 0x01입니다. 또한 각 명령어에는 내부 명령어가 포함되어 있으며, 이는 System ProgramCreateAccount 명령어에 대한 호출입니다.

마찬가지로 모든 게시글을 나열하는 세 번째 트랜잭션도 확인할 수 있습니다. Program Logs 섹션에서 두 게시글의 인덱스, 제목, 내용이 출력된 것을 확인할 수 있습니다.

결론

이 글에서는 먼저 Solana 프로그래밍 및 런타임 환경을 로컬에 설정하는 방법을 설명했습니다. 그런 다음 Solana 컨트랙트의 구현 로직을 자세히 설명했습니다. 마지막으로 TypeScript 스크립트를 사용하여 컨트랙트 기능을 테스트했습니다. 이 단계별 튜토리얼을 통해 간단한 Solana 스마트 컨트랙트를 작성하는 방법을 익히셨기를 바랍니다 🥳.

다음 글에서는 Phalcon Explorer를 사용하여 Solana 트랜잭션을 조회하고 분석하는 방법에 대한 자세한 가이드를 제공할 예정입니다(Phalcon Explorer는 이제 Solana를 지원합니다). 기대해 주세요!

시리즈의 다른 글 읽기: