
Gemma 4 12B에게 dApp 제작을 요청해 보았습니다. 실수 없이 말이죠.
요약
Gemma 4 12B 모델을 로컬 환경에서 사용하여 dApp(last-clicker 게임)을 제작하는 과정을 실험했습니다. 모델은 보안적으로 안전한 Solidity 코드를 작성하는 데 성공했으나, 프로젝트 전체를 컴파일 가능한 상태로 구성하는 데는 한계를 보였습니다.
핵심 포인트
- Gemma 4 12B는 재진입 공격을 방지하는 안전한 Solidity 로직을 작성함
- 로컬 llama.cpp 환경에서 API 없이 초당 20~40 토큰 속도로 실행 가능
- 프로젝트 전체 구조(Foundry/Hardhat 혼용 등) 구성 시 컴파일 오류 발생
- 오픈 모델의 코드 작성 능력과 프로젝트 통합 능력 사이의 격차 확인
노트북에서 돌아가는 무료 모델이 컨트랙트와 프론트엔드를 포함한 저의 dApp 전체를 작성했지만, 정작 자신이 만든 버그는 단 하나도 찾아내지 못했습니다.
저는 한 가지 궁금한 점이 있었습니다. 내 컴퓨터에서 직접 실행할 수 있는 무료 오픈 모델이 실제로 EVM 체인을 위한 무언가를 구축할 수 있을까? 그래서 이를 테스트로 설정했습니다. 로컬의 Gemma 4 12B가 코드를 작성하고, Claude가 프롬프트를 보내고 컴파일러가 출력하는 내용을 다시 붙여넣으며 이를 운영했습니다. 저는 모든 프롬프트와 깨진 파일들을 보관했으므로, 여러분은 12B 모델이 어디서 도움을 주고 어디서 무너지는지 직접 확인할 수 있습니다.
이 모델은 6월 3일에 Apache 2.0 라이선스로 출시된 새로운 Gemma 4 12B로, 원하는 대로 사용할 수 있습니다. 약 16GB 정도의 용량을 차지하므로, 저는 API 키 없이 그리고 노트북 외부로 아무것도 나가지 않도록 llama.cpp를 사용하여 제 개인 기기에서 실행했습니다. 초당 20~40 토큰(tokens) 정도의 속도를 냈습니다. 제가 제작하도록 시킨 것은 last-clicker라는 게임입니다. 클릭할 때마다 아주 작은 수수료를 지불하며, 각 클릭은 짧은 카운트다운을 초기화합니다. 타이머가 종료되었을 때 마지막으로 클릭한 사람이 상금을 가져갑니다. 저는 Foundry의 로컬 노드인 Anvil을 대상으로 이를 구축했습니다.
첫 번째 초안은 컴파일되지 않는 좋은 코드였습니다
저는 다음과 같이 한 번의 프롬프트를 주었습니다:
Foundry를 사용하여 Solidity로 "last clicker" 게임을 만들어줘: 클릭당 작은 수수료로 자금이 충전되는 상금(pot), 클릭할 때마다 초기화되는 짧은 카운트다운, 그리고 타이머가 종료되었을 때 마지막으로 클릭한 사람이 상금을 청구할 수 있는 구조. 컨트랙트를 작성해줘.
게임 로직은 첫 번째 시도에 바로 정확하게 나왔고, 보안 측면에서도 그러했습니다. claim() 함수는 돈을 보내기 전에 잔액을 먼저 정리합니다:
function claim() external {
require(block.timestamp >= gameEndTime, "Timer has not expired yet");
require(msg.sender == lastClickListener, "You were not the last clicker");
...
상태(state)를 먼저 처리하고 외부 호출(external call)을 마지막에 배치하는 이 순서는, 수신자가 claim()을 다시 호출하여 잔액이 업데이트되기 전에 컨트랙트의 자금을 빼가는 재진입 공격 (reentrancy attack)을 방지합니다. 이는 2016년 DAO 해킹의 원인이 된 버그이며, 저는 12B 모델이 초보적인 버전을 작성할 것이라고 예상했지만, 모델은 안전한 버전을 작성했습니다.
모델이 할 수 없었던 것은 컴파일(compile)이 가능한 프로젝트를 나에게 전달하는 것이었습니다. 테스트 파일은 다음과 같이 열렸습니다:
import "hardhat"; // 표준을 사용하는 경우, 하지만 Foundry의 경우 다음을 사용합니다:
import "../src/LastClicker.sol";
이는 Foundry 프로젝트 내에 Hardhat 임포트(import)가 포함된 상태이며, 모델이 스스로를 수정하려다 포기한 흔적인 미완성된 주석이 달려 있었습니다. 컨트랙트(contract)는 생성자(constructor)를 두 번 선언했습니다:
constructor() {
gameActive = true;
gameEndTime = block.timestamp + COUNTDOWN_DURATION;
...
그리고 테스트는 Foundry에는 존재하지 않는 배포 헬퍼(deploy helper)를 사용하여 스스로를 설정했습니다:
game = LastClicker(deploy(LastClicker.sol));
그 어떤 것도 컴파일되지 않았기에, 저는 첫 번째 에러인 Hardhat 임포트 부분만 다시 붙여넣었습니다. 그러자 모델은 단 한 번의 과정으로 파일 전체를 다시 작성하며, 제가 지적하지 않은 에러를 포함하여 모든 컴파일 에러를 수정했습니다. 보일러플레이트(boilerplate) 코드를 완전히 기억하지 못할 때, 이는 다시 성공(green) 상태로 돌아가는 빠른 방법입니다.
그 후 모델은 자신의 테스트를 디버깅(debug)하지 못했습니다
코드가 컴파일되었기에 테스트를 실행했습니다. 세 가지 테스트 모두 자금이 이동하는 첫 번째 줄에서 리버트(revert)되었습니다:
vm.prank(player1);
game.click{value: 0.001 ether}(); // 리버트: player1이 보유한 ether가 없음
테스트에서 계좌에 자금을 공급하지 않았던 것입니다. Foundry에서는 vm.deal을 사용하여 테스트 주소에 잔액을 부여하며, 그 한 줄이 세 가지 에러를 모두 해결합니다. 저는 실패 내용을 모델에게 전달했습니다. 모델은 문제가 타이밍 문제라고 확신했는지 vm.warp를 추가했고, 그다음 라운드에는 vm.roll을 추가했습니다. 세 라운드가 지났음에도 테스트는 가스(gas) 비용까지 포함하여 이전과 정확히 똑같이 실패하고 있었고, 모델은 실제 원인이 자신의 출력값 속에 그대로 방치되어 있는 동안에도 여전히 시계(clock)를 수정하고 있었습니다.
그래서 저는 테스트를 고쳐달라고 요청하는 것을 멈추고, 대신 원인을 알려주었습니다:
테스트가 첫 번째
click{value:}에서 리버트되는 이유는 플레이어 계좌의 잔액이 0이기 때문입니다. Foundry에서는vm.deal로 주소에 자금을 공급합니다. 테스트를 수정하세요.
vm.deal을 추가했고, 세 가지 테스트 중 하나가 통과되었습니다. 나머지 두 개에는 각각 별도의 버그가 있었습니다. 하나는 시계(clock)를 전혀 진행시키지 않는 타이머 체크였고, 다른 하나는 플레이어 주소가 address(1)과 address(2)로 설정된 것이었는데, 이들은 프리컴파일(precompiles) 주소라 이더(ether)를 받을 수 없습니다. 정확한 원인을 지목한 후에야 각각 통과되었습니다. 모델은 당신이 건네준 수정 사항은 적용할 수 있지만, 스스로 해결책을 찾아내지는 못합니다.
프론트엔드는 완성된 것처럼 보였지만 속이 비어 있었습니다
저는 viem을 사용한 싱글 페이지 프론트엔드(single-page frontend)를 요청했습니다. 모델이 반환한 레이아웃은 실시간 카운트다운이 포함된 깔끔한 다크 카드 형태로 진정으로 훌륭했습니다. 하지만 그 아래의 Web3 레이어는 임포트(imports) 문부터 시작해 완전히 새로 만들어낸 허구였습니다:
import {
createPublicClient, createWalletClient, parseEther,
publicAddress, solidityAbiInterpreter, formatEther
...
publicAddress와 solidityAbiInterpreter는 viem의 일부가 아닙니다. 마치 존재할 법한 이름처럼 들리는데, 바로 이것이 문제의 핵심입니다. 그 후 모델은 자신이 만들어낸 메서드를 통해 트랜잭션(transactions)을 전송했습니다:
const hash = await walletClient.sendTransaction({
to: CONTRACT_ADDRESS,
data: contract.writeMethods.click.encoded, // 실제로는 존재하지 않는 것
...
모델은 잘못된 구조로 체인 설정(chain config)을 구축했고, 실제 지갑 메서드가 아닌 wallet_switchChain을 호출했습니다 (실제 메서드는 wallet_switchEthereumChain입니다). 모델은 자신이 덜 접해본 라이브러리에 대해서는 올바른 코드의 윤곽만 알고 있을 뿐, 세부 사항을 자신감 넘치는 허구로 채워 넣습니다. 그리고 컨트랙트(contract)와 UI 사이를 잇는 결합 작업은 거의 대부분이 이러한 세부 사항으로 이루어집니다. 저는 배선 작업을 직접 다시 작성했습니다. 인터페이스는 모델의 작업이었지만, 배관 작업(plumbing)은 제 몫이었습니다.
반전: 그것은 Monad였고, 단 한 줄이면 충분했습니다
저는 모델에게 이 작업이 어떤 체인을 위한 것인지 전혀 말하지 않았습니다. 말해줄 내용이 없었기 때문입니다. Anvil은 그저 EVM일 뿐이며, 모델이 작성한 모든 코드는 평범한 EVM 코드였습니다. 컨트랙트와 테스트가 모두 통과(green)되자, 저는 Foundry를 단 하나의 URL로 지정했습니다.
forge create src/LastClicker.sol:LastClicker --rpc-url https://testnet-rpc.monad.xyz --broadcast
Foundry는 엔드포인트에서 체인 ID(chain id)를 스스로 읽어 들였고, 배포는 단 한 번의 시도만에 성공했습니다. Monad의 익스플로러(explorer)에서 소스 코드를 검증(verifying)하는 과정 또한 완벽하게 일치하는 결과로 돌아온 또 하나의 API 호출이었습니다. 해당 체인은 Monad였고(제가 이곳에서 일하고 있으니 참고해서 들어주세요), 모델은 이를 알 필요가 전혀 없었습니다. Monad는 EVM 바이트코드(bytecode)를 실행하며, 모델이 이미 알고 있던 Solidity 코드가 정확했기 때문입니다. 전체 빌드 과정에서 Monad에 특화된 유일한 세부 사항은 그 RPC URL 하나뿐이었으며, 가스비(gas)를 위한 테스트넷 MON조차 API 호출을 통해 에이전트 수도꼭지(agent faucet)로부터 가져온 것이었습니다.
한 가지 솔직한 주의사항을 말씀드리자면, forge의 린터(linter)가 검증인(validator)이 조작할 수 있는 block.timestamp에 의존하는 타이머를 지적했습니다. 이는 12초 주기 체인보다는 1초 주기 체인에서 더 중요한 문제이며, 메인넷(mainnet)에 올리기 전에는 이를 더 엄격하게 수정해야 할 것입니다.
결과물은 https://gemma-last-clicker.vercel.app에서 확인할 수 있습니다. 약간의 테스트넷 MON이 들어있는 지갑을 연결하고 클릭해 보세요.
모든 클릭은 약 1초 만에 확정되는 실제 트랜잭션이며 비용은 1센트의 아주 작은 일부에 불과합니다. 이것이 바로 마지막 순간의 클릭으로 구성된 게임이 완전히 온체인(on-chain) 상에서 존재할 수 있는 유일한 이유입니다.
그렇다면 얼마나 사용 가능한가요?
무료 로컬 모델을 빠른 주니어 개발자처럼 취급하세요. 이 모델은 수천 번은 보았을 법한 부분들, 즉 표준 컨트랙트 로직 (standard contract logic)과 깔끔한 HTML에 대해서는 진정으로 뛰어난 성능을 보여주며, 요청하지 않아도 올바른 보안 패턴 (security pattern)을 찾아냅니다. 첫 번째 초안 (first draft)을 작성하는 데 있어 실제 시간을 절약해 줍니다. 하지만 특정 라이브러리의 실제 API (API)를 다루거나 스택 트레이스 (stack trace)를 읽어야 하는 순간 무너져 버리며, 이번 전체 빌드 과정에서 모델 스스로 버그를 찾아낸 적은 단 한 번도 없었습니다. 모든 에러는 컴파일러 (compiler)나 저에 의해 발견되었습니다.
따라서 12B 모델을 사용하면 작동 가능한 컨트랙트의 초안과 보기 좋은 프론트엔드 (frontend) 쉘을 얻을 수 있으며, 그 이후의 디버깅 (debugging)과 통합 (integration) 작업은 수동으로 진행해야 합니다. 학습용이나 금방 버릴 용도라면 그것으로 충분합니다. 하지만 실제로 배포하고 그대로 방치할 프로젝트라면, 모델이 읽지 못하는 에러를 읽을 수 있는 누군가가 반드시 옆에 있어야 합니다.
해당 리포지토리(repo)에는 코드와 제가 사용한 모든 프롬프트 (prompt)가 포함되어 있습니다: https://github.com/portdeveloper/gemma-last-clicker. Monad에 깔끔하게 배포할 수 있게 해준 최종 파일은 그 안에 있는 MONAD_CONTEXT.md입니다.
질문이 있으신가요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
