처음부터 만드는 영구 증명 블록체인 블로그: Polygon + Hexo + GitHub Actions 실전
·(고침)· / , , , · 회 읽음· 좋아요0보조 · 제목
AI 번역简体中文한국어
일반적인 정적 블로그는 '어떻게 발행할까'라는 문제는 해결했지만, '어떻게 증명할까'라는 문제는 완전히 해결하지 못했습니다. 글이 수정되었는지, 특정 버전이 언제 등장했는지, 사이트가 문을 닫은 뒤에도 원본 내용을 검증할 수 있는지는 모두 운영자와 호스팅 플랫폼에 달려 있습니다.
그래서 이런 방식을 택했습니다. Hexo가 사이트를 생성하고, GitHub Actions가 자동 빌드를 맡으며, Polygon PoS가 홈페이지와 글 데이터를 보관하고, GitHub Pages가 일반적인 웹 접속 환경을 제공합니다.
최종 아키텍처
전체 발행 과정은 이렇게 요약할 수 있습니다:
여기에는 서로 독립적인 컨트랙트 두 개가 있습니다:
HomepageArchive 홈페이지 HTML, 콘텐츠 해시, 발행 시각, 버전 번호를 저장합니다.
ArticleArchive 글 slug의 해시를 키로 삼아 글 JSON, 콘텐츠 해시와 버전 정보를 저장합니다.
웹페이지는 여전히 CDN이나 GitHub Pages에서 로드되므로 접속 속도는 일반 Hexo 블로그와 다르지 않습니다. 블록체인은 공개적이고 검증 가능하며 몰래 고쳐 쓸 수 없는 발행 기록을 제공합니다.
환경 준비
로컬에는 Node.js, pnpm, Git, 그리고 Polygon 지갑이 필요합니다:
BASH
node --version
pnpm --version
git --version
의존성을 설치하고 Hexo를 검증합니다:
BASH
pnpm install --frozen-lockfile
pnpm clean
pnpm build
pnpm server
프로덕션 발행에는 Polygon 메인넷을 사용하며, 체인 ID는 137, 가스 토큰은 POL입니다. 발행만 전담하는 지갑을 따로 만들어 쓰는 것을 권장하며, 주요 자산을 보관하는 지갑을 그대로 사용하지 마세요.
먼저 글의 온체인 형식을 설계하기
글을 Hexo가 생성한 HTML 그대로 컨트랙트에 밀어 넣을 수는 없습니다. HTML에는 테마 구조, 빌드 메타데이터, 스타일 세부 사항이 섞여 있어서 테마를 조금만 손봐도 모든 글이 새 버전으로 잡힙니다.
function publish(
bytes32 slugHash,
bytes calldata payload,
bytes32 contentHash
) external {
if (msg.sender != owner) revert NotOwner();
if (payload.length == 0) revert EmptyPayload();
if (payload.length > MAX_ARTICLE_BYTES) revert PayloadTooLarge(...);
if (publications[slugHash].contentHash == contentHash) {
revert UnchangedContent();
}
// 保存数据、增加版本,并发出事件
}
저는 글 한 편을 24,576 바이트로 제한했습니다. 여기서 제한하는 것은 최종 JSON의 UTF-8 바이트 수이지 Markdown 파일 크기가 아닙니다. gzip은 본문을 줄이지만 Base64는 다시 약 3분의 1만큼 부피를 키우므로, 트랜잭션을 보내기 전에 반드시 최종 payload를 확인해야 합니다.
컨트랙트는 두 개의 해시를 함께 저장합니다:
contentHash 는 글의 의미론적 내용이 바뀌었는지 판단하는 데 쓰입니다.
payloadHash 는 컨트랙트가 실제로 기록한 bytes를 대상으로 계산한 keccak256으로, 온체인 원본 바이트를 검증하는 데 쓰입니다.
'영구'란 도대체 무엇을 뜻할까
현재 구조는 업데이트할 때마다 publications[slugHash].payload를 덮어쓰므로, 컨트랙트 읽기 인터페이스는 최신 버전만 직접 반환할 수 있습니다. 구버전은 여전히 블록체인 히스토리, 트랜잭션 calldata, 이벤트에 남아 있지만 현재 mapping으로는 직접 조회할 수 없습니다.
initCode 에는 컨트랙트 바이트코드와 생성자 인자가 들어 있습니다. 배포자, salt, 컴파일 결과, owner가 완전히 같으면 최종 주소도 같습니다. 발행 스크립트는 먼저 eth_getCode를 호출합니다. 주소에 코드가 없으면 배포하고, 코드가 있으면 그대로 재사용합니다.
여기서 주의할 점은,발행 지갑을 바꾸면 컨트랙트 주소도 바뀐다는 것입니다. owner가 생성자 인자의 일부이기 때문입니다. Solidity 버전을 올리거나 최적화 파라미터를 바꾸거나 컨트랙트 코드나 salt를 조정해도 마찬가지로 새 주소가 나옵니다. 마이그레이션 계획 없이 이 값들을 함부로 바꾸지 마세요.
발행 스크립트가 중복 트랜잭션을 피하는 방법
글 발행 스크립트는 source/_posts를 스캔하며, 파일 이름이 숫자 slug와 일치할 것을 요구합니다:
검증할 때 웹페이지의 글자만 보지 마세요. 최소한 Polygonscan에서 다음을 확인해야 합니다:
네트워크가 Polygon PoS 메인넷이고 체인 ID가 137입니다.
트랜잭션 발신자가 예상한 발행 지갑입니다.
트랜잭션 대상이 예상한 컨트랙트 주소입니다.
트랜잭션 상태가 성공이고 올바른 블록 높이가 기록되었습니다.
컨트랙트가 반환한 payloadHash가 실제 payload의 Keccak-256과 일치합니다.
실제로 겪은 함정들
1. 테스트넷 Polygon이 무한정 무료라는 건 아닙니다
Amoy 테스트넷은 흐름을 검증하기에 적합하지만, 수도꼭지(faucet)에는 보통 수령 빈도와 수량 제한이 있습니다. 전체 HTML이나 글 payload의 calldata와 저장 비용은 매우 비싸서, 최초 배포에 최초 쓰기를 더하면 그날 받은 테스트 코인을 쉽게 초과합니다.
테스트할 때는 먼저 payload를 줄이고, '배포'와 '발행'을 따로 검증하며, 트랜잭션을 보내기 전에 gas estimate를 출력해야 합니다. 메인넷으로 전환할 때는 chain ID, RPC, 블록 익스플로러 주소, 가스 토큰을 함께 확인해야 하며, RPC URL 하나만 바꿔서는 안 됩니다.
2. 빌드 시각 탓에 홈페이지가 무한히 반복 업로드됩니다
HTML에 현재 시각, Git commit, 실시간 블록 높이, 난수 값이 들어 있기만 하면 원본 HTML 해시는 불안정해집니다. 어떤 필드가 내용을 나타내고 어떤 필드가 단지 표시용 메타데이터인지 먼저 정의한 뒤, 엄격하게 정규화해야 합니다.
3. RPC 불안정으로 CI가 간헐적으로 실패합니다
공용 RPC는 속도 제한이나 타임아웃이 있거나 배치 요청을 지원하지 않을 수 있습니다. 글 발행기는 불필요한 JSON-RPC batching을 꺼 두고, 네트워크를 명확히 지정하며, 로그에서 'RPC 실패', '잔액 부족', '트랜잭션 롤백', '확인 대기 타임아웃'을 구분해야 합니다.
실패했다고 무조건 다시 보내지 마세요. 먼저 온체인의 contentHash를 읽으세요. 직전 트랜잭션이 이미 브로드캐스트에 성공했는데 CI가 영수증을 기다리다 중단되었을 수 있기 때문입니다.
일반적인 정적 블로그는 '어떻게 발행할까'라는 문제는 해결했지만, '어떻게 증명할까'라는 문제는 완전히 해결하지 못했습니다. 글이 수정되었는지, 특정 버전이 언제 등장했는지, 사이트가 문을 닫은 뒤에도 원본 내용을 검증할 수 있는지는 모두 운영자와 호스팅 플랫폼에 달려 있습니다.
그래서 이런 방식을 택했습니다. Hexo가 사이트를 생성하고, GitHub Actions가 자동 빌드를 맡으며, Polygon PoS가 홈페이지와 글 데이터를 보관하고, GitHub Pages가 일반적인 웹 접속 환경을 제공합니다.
최종 아키텍처
전체 발행 과정은 이렇게 요약할 수 있습니다:
여기에는 서로 독립적인 컨트랙트 두 개가 있습니다:
웹페이지는 여전히 CDN이나 GitHub Pages에서 로드되므로 접속 속도는 일반 Hexo 블로그와 다르지 않습니다. 블록체인은 공개적이고 검증 가능하며 몰래 고쳐 쓸 수 없는 발행 기록을 제공합니다.
환경 준비
로컬에는 Node.js, pnpm, Git, 그리고 Polygon 지갑이 필요합니다:
의존성을 설치하고 Hexo를 검증합니다:
프로덕션 발행에는 Polygon 메인넷을 사용하며, 체인 ID는 137, 가스 토큰은 POL입니다. 발행만 전담하는 지갑을 따로 만들어 쓰는 것을 권장하며, 주요 자산을 보관하는 지갑을 그대로 사용하지 마세요.
먼저 글의 온체인 형식을 설계하기
글을 Hexo가 생성한 HTML 그대로 컨트랙트에 밀어 넣을 수는 없습니다. HTML에는 테마 구조, 빌드 메타데이터, 스타일 세부 사항이 섞여 있어서 테마를 조금만 손봐도 모든 글이 새 버전으로 잡힙니다.
저는 결국 아래의 정규화된 JSON을 사용했습니다:
content는 일반적인 Markdown이 아니라 다음과 같은 처리를 거친 결과물입니다:
결정성은 매우 중요합니다. 같은 글은 Windows에서 빌드하든 Linux에서 빌드하든 동일한 바이트 시퀀스를 얻어야 합니다. 그렇지 않으면 GitHub Actions가 실행될 때마다 새 내용으로 잘못 판단해 가스를 계속 지불하게 됩니다.
글 콘텐츠 해시는 압축된 Base64를 직접 대상으로 계산하지 않고, 정규화된 Markdown을 담은 의미론적 JSON을 대상으로 keccak256를 계산합니다. 이렇게 하면 해시가 특정 압축기 버전의 출력 세부 사항이 아니라 글 내용 자체를 나타냅니다.
이미지에 관하여
로컬 이미지는 실제 바이너리를 읽어 해시를 계산할 수 있지만, 원격 이미지에는 자주 간과되는 문제가 있습니다. 빌드 머신이 이를 안정적으로 내려받지 못할 수 있고, 원격지에 핫링크 방지, 타임아웃, 내용의 동적 변경이 있을 수 있습니다.
현재 구현은 외부 링크 이미지에 대해 '이미지 URL 문자열의 MD5'를 계산하는데, 이는 글이 어떤 주소를 참조했는지만 표시할 뿐,그 주소가 반환하는 이미지 내용을 증명하지도, 이미지가 영구적으로 사용 가능함을 보장하지도 못합니다.
엄격한 영구 이미지 아카이빙을 하려면 다음 중에서 선택할 수 있습니다:
따라서 '글 Markdown이 온체인에 올라갔다'와 '글이 참조하는 모든 리소스가 영구 저장되었다'는 서로 다른 이야기입니다.
Solidity 컨트랙트 설계
글 컨트랙트의 핵심 구조는 그리 복잡하지 않습니다:
기록할 때 문자열 slug을 그대로 쓰지 않고 먼저 다음을 계산합니다:
컨트랙트의 publish 함수는 몇 가지 검사를 수행해야 합니다:
저는 글 한 편을 24,576 바이트로 제한했습니다. 여기서 제한하는 것은 최종 JSON의 UTF-8 바이트 수이지 Markdown 파일 크기가 아닙니다. gzip은 본문을 줄이지만 Base64는 다시 약 3분의 1만큼 부피를 키우므로, 트랜잭션을 보내기 전에 반드시 최종 payload를 확인해야 합니다.
컨트랙트는 두 개의 해시를 함께 저장합니다:
'영구'란 도대체 무엇을 뜻할까
현재 구조는 업데이트할 때마다 publications[slugHash].payload를 덮어쓰므로, 컨트랙트 읽기 인터페이스는 최신 버전만 직접 반환할 수 있습니다. 구버전은 여전히 블록체인 히스토리, 트랜잭션 calldata, 이벤트에 남아 있지만 현재 mapping으로는 직접 조회할 수 없습니다.
모든 과거 버전을 컨트랙트 메서드에서 직접 읽게 하려면 다음과 같이 바꿔야 합니다:
대가는 계속 늘어나는 컨트랙트 스토리지와 더 높은 가스입니다. 개인 블로그라면 최신 버전만 읽기 가능한 상태로 남겨 두고 구버전은 트랜잭션 히스토리로 증명하는 편이 더 현실적인 절충안입니다.
CREATE2로 컨트랙트 주소 고정하기
CI는 로컬에 저장된 배포 결과에 의존할 수 없으므로, 저는 CREATE2로 컨트랙트 주소를 미리 계산합니다:
initCode 에는 컨트랙트 바이트코드와 생성자 인자가 들어 있습니다. 배포자, salt, 컴파일 결과, owner가 완전히 같으면 최종 주소도 같습니다. 발행 스크립트는 먼저 eth_getCode를 호출합니다. 주소에 코드가 없으면 배포하고, 코드가 있으면 그대로 재사용합니다.
여기서 주의할 점은,발행 지갑을 바꾸면 컨트랙트 주소도 바뀐다는 것입니다. owner가 생성자 인자의 일부이기 때문입니다. Solidity 버전을 올리거나 최적화 파라미터를 바꾸거나 컨트랙트 코드나 salt를 조정해도 마찬가지로 새 주소가 나옵니다. 마이그레이션 계획 없이 이 값들을 함부로 바꾸지 마세요.
발행 스크립트가 중복 트랜잭션을 피하는 방법
글 발행 스크립트는 source/_posts를 스캔하며, 파일 이름이 숫자 slug와 일치할 것을 요구합니다:
숫자 slug는 양의 정수여야 하고 중복될 수 없습니다. 이는 웹페이지 영구 링크의 일부이면서 온체인 글의 논리적 기본 키이기도 합니다. 발행한 뒤에는 원래 글을 다른 slug로 옮기지 마세요. 그렇지 않으면 온체인에서는 완전히 새로운 글로 취급됩니다.
각 글에 대해 스크립트는 먼저 온체인 기록을 읽습니다:
즉, CI가 실행될 때마다 모든 글을 스캔할 수 있지만, 새로 추가되거나 실제로 수정된 글만 트랜잭션을 보냅니다. 변경되지 않은 글은 발행 블록을 기준으로 과거 이벤트를 조회해 트랜잭션 해시를 복원하고 표시용 메타데이터를 생성합니다.
보내기 전에 estimateGas를 실행하고 gas limit의 20%를 버퍼로 남겨 둡니다:
잔액 확인은 네트워크가 반환한 maxFeePerGas 또는 gasPrice를 사용해야 합니다. 컨트랙트 최초 배포는 이후 발행보다 보통 비싸고, 전체 HTML이나 JSON을 저장하는 비용은 바이트 수에 따라 눈에 띄게 늘어납니다.
홈페이지는 왜 별도로 정규화해야 할까
홈페이지에는 Hexo가 생성한 전체 HTML이 저장되지만, 페이지 하단에 빌드 해시와 빌드 시각이 들어 있고, 페이지에는 직전 발행의 블록 높이와 트랜잭션 해시도 표시됩니다. HTML을 그대로 해시하면 순환이 생깁니다:
해결책은 contentHash를 계산하기 전에 두 개의 동적 영역을 제거하는 것입니다:
치환 로직은 각 영역이 정확히 한 번씩 나타나도록 요구해야 합니다. 템플릿 구조 변화를 조용히 무시하는 것은 위험합니다. 테마를 한 번 수정하면 동적 필드가 다시 해시에 들어가 매 빌드마다 비용을 중복 지불하게 될 수 있기 때문입니다.
주의할 점이 있습니다. 컨트랙트에 실제로 저장되는 것은 해당 빌드의 전체 HTML이며, 정규화는 콘텐츠 해시 계산과 중복 제거에만 사용됩니다. 빌드 시각만 바뀌면 온체인의 기존 HTML을 덮어쓰지 않는데, 이는 의도된 동작입니다.
GitHub Actions 이중 빌드 흐름
워크플로는 main 브랜치만 감시하며 contents: write 권한을 부여합니다:
전체 단계는 다음과 같습니다:
핵심 명령은 이렇게 쓸 수 있습니다:
.onchain/articles.json 은 같은 워크플로의 두 번째 빌드에서만 쓰이므로 .gitignore에 추가해야 합니다. 진짜 신뢰할 수 있는 데이터 소스는 여전히 Polygon이며, JSON 파일은 페이지를 생성할 때 쓰는 캐시에 불과합니다.
Secrets와 권한 설정
저장소의 Settings -> Secrets and variables -> Actions에서 다음을 설정해야 합니다:
유형
이름
용도
Secret
`POLYGON_PRIVATE_KEY`
발행 지갑 개인키
Variable
`POLYGON_RPC_URL`
선택 사항인 Polygon 메인넷 RPC
POLYGON_PRIVATE_KEY 는 지갑의 32바이트 개인키로, 보통 64자의 16진수 문자로 나타납니다. 스크립트는 0x 접두사가 있든 없든 호환하지만, 지갑 주소나 니모닉 이름, API Key를 넣어서는 안 됩니다.
개인키는 반드시 Secret에 넣어야 하며, 저장소 파일이나 Actions Variable, 빌드 로그, 프런트엔드 JavaScript에 두어서는 안 됩니다.GITHUB_TOKEN 는 GitHub Actions가 자동으로 제공하므로 직접 만들 필요가 없습니다.
발행 지갑에는 다음을 설정하는 것이 좋습니다:
온체인 전후 검증
로컬에서 먼저 빌드와 payload를 검증합니다:
컨트랙트 컴파일, 주소 계산, 글 용량만 시뮬레이션할 수도 있습니다:
발행이 끝나면 컨트랙트에서 글을 읽어 압축을 풀 수 있습니다:
검증할 때 웹페이지의 글자만 보지 마세요. 최소한 Polygonscan에서 다음을 확인해야 합니다:
실제로 겪은 함정들
1. 테스트넷 Polygon이 무한정 무료라는 건 아닙니다
Amoy 테스트넷은 흐름을 검증하기에 적합하지만, 수도꼭지(faucet)에는 보통 수령 빈도와 수량 제한이 있습니다. 전체 HTML이나 글 payload의 calldata와 저장 비용은 매우 비싸서, 최초 배포에 최초 쓰기를 더하면 그날 받은 테스트 코인을 쉽게 초과합니다.
테스트할 때는 먼저 payload를 줄이고, '배포'와 '발행'을 따로 검증하며, 트랜잭션을 보내기 전에 gas estimate를 출력해야 합니다. 메인넷으로 전환할 때는 chain ID, RPC, 블록 익스플로러 주소, 가스 토큰을 함께 확인해야 하며, RPC URL 하나만 바꿔서는 안 됩니다.
2. 빌드 시각 탓에 홈페이지가 무한히 반복 업로드됩니다
HTML에 현재 시각, Git commit, 실시간 블록 높이, 난수 값이 들어 있기만 하면 원본 HTML 해시는 불안정해집니다. 어떤 필드가 내용을 나타내고 어떤 필드가 단지 표시용 메타데이터인지 먼저 정의한 뒤, 엄격하게 정규화해야 합니다.
3. RPC 불안정으로 CI가 간헐적으로 실패합니다
공용 RPC는 속도 제한이나 타임아웃이 있거나 배치 요청을 지원하지 않을 수 있습니다. 글 발행기는 불필요한 JSON-RPC batching을 꺼 두고, 네트워크를 명확히 지정하며, 로그에서 'RPC 실패', '잔액 부족', '트랜잭션 롤백', '확인 대기 타임아웃'을 구분해야 합니다.
실패했다고 무조건 다시 보내지 마세요. 먼저 온체인의 contentHash를 읽으세요. 직전 트랜잭션이 이미 브로드캐스트에 성공했는데 CI가 영수증을 기다리다 중단되었을 수 있기 때문입니다.
참고 링크