React에서 이더리움 결제 QR 코드 만들기: EIP-681과 ETH·wei 변환
전면 개정 안내 (2026-07-24)
2021년의 오래된 React 전용 이더리움 QR 패키지 예제를 폐기하고, 표준 결제 URI인 EIP-681과 현재 React 환경을 기준으로 다시 작성했습니다.

이더리움 결제 QR 코드는 단순히 지갑 주소를 QR 이미지로 바꾸는 기능이 아닙니다. 받는 주소, 네트워크, 금액을 지갑 앱이 이해할 수 있는 EIP-681 결제 요청 URI로 조합한 뒤 QR 코드에 넣어야 합니다.
이 글에서는 다음 결과물을 만듭니다.
- 받는 주소와 ETH 금액 입력
ethers로 주소 형식 검증- ETH 문자열을 정확한 wei 정수로 변환
- 체인 ID가 포함된 EIP-681 URI 생성
qrcode.react로 SVG QR 코드 표시- QR에 포함된 원문을 화면에 함께 표시하고 복사
QR 코드를 스캔한다고 곧바로 송금되는 것은 아닙니다. 호환 지갑이 결제 요청을 읽고 사용자가 주소, 네트워크, 금액과 수수료를 최종 확인한 뒤 서명해야 전송됩니다.
기존 방식에서 무엇이 달라졌나
예전 글은 react-ether-qrcode와 ethereum-qr-code에 의존해 패키지가 기대하는 속성을 맞추는 방식이었습니다. 이런 전용 컴포넌트는 관리가 중단되거나 React 버전이 바뀌면 쉽게 동작하지 않습니다.
이번에는 역할을 분리합니다.
| 역할 | 사용 도구 |
|---|---|
| 결제 요청 형식 | EIP-681 ethereum: URI |
| 주소와 금액 검증 | ethers v6 |
| QR 렌더링 | qrcode.react |
| React 개발 환경 | Vite |
결제 요청 문자열은 직접 만들기 때문에 특정 이더리움 QR 패키지의 내부 구현에 묶이지 않습니다. QR 라이브러리는 완성된 문자열을 그림으로 표시하는 역할만 합니다.
EIP-681 결제 URI 이해하기
네이티브 ETH 결제 요청의 기본 형태는 다음과 같습니다.
ethereum:<받는_주소>@<체인_ID>?value=<wei_금액>
예를 들어 0.01 ETH는 다음처럼 변환됩니다.
0.01 ETH
→ 10000000000000000 wei
→ ethereum:0x...@1?value=10000000000000000
value는 사람이 보는 ETH가 아니라 가장 작은 단위인 wei 정수입니다.
| 단위 | 기준 |
|---|---|
| 1 wei | 가장 작은 단위 |
| 1 gwei | 10⁹ wei |
| 1 ETH | 10¹⁸ wei |
JavaScript의 number로 큰 정수를 계산하면 안전 정수 범위를 넘을 수 있습니다. 금액 입력은 문자열로 받고 parseEther()가 반환하는 bigint를 사용하는 것이 안전합니다.

1. React 프로젝트 준비
Vite의 React 템플릿으로 프로젝트를 만듭니다.
npm create vite@latest eth-payment-qr -- --template react
cd eth-payment-qr
npm install
npm install ethers qrcode.react
npm run dev
Vite가 안내하는 Node.js 요구 버전을 확인하고, 패키지 관리자가 만든 package-lock.json도 함께 보관하세요.
2. 결제 QR 컴포넌트 작성
src/App.jsx를 다음 내용으로 바꿉니다.
import { useMemo, useState } from "react";
import { getAddress, parseEther } from "ethers";
import { QRCodeSVG } from "qrcode.react";
import "./App.css";
const SAMPLE_ADDRESS =
"0xfb6916095ca1df60bb79Ce92ce3ea74c37c5d359";
function createPaymentRequest(address, amount, chainId) {
const normalizedAddress = getAddress(address.trim());
const normalizedAmount = amount.trim();
const normalizedChainId = chainId.trim();
if (!/^\d+$/.test(normalizedChainId) || normalizedChainId === "0") {
throw new Error("체인 ID는 0보다 큰 정수여야 합니다.");
}
const wei = parseEther(normalizedAmount);
if (wei <= 0n) {
throw new Error("금액은 0보다 커야 합니다.");
}
return {
wei: wei.toString(),
uri:
`ethereum:${normalizedAddress}` +
`@${normalizedChainId}?value=${wei.toString()}`
};
}
export default function App() {
const [address, setAddress] = useState(SAMPLE_ADDRESS);
const [amount, setAmount] = useState("0.01");
const [chainId, setChainId] = useState("1");
const [copyMessage, setCopyMessage] = useState("");
const payment = useMemo(() => {
try {
return {
...createPaymentRequest(address, amount, chainId),
error: ""
};
} catch (error) {
return {
uri: "",
wei: "",
error: error instanceof Error
? error.message
: "입력값을 확인하세요."
};
}
}, [address, amount, chainId]);
async function copyUri() {
if (!payment.uri) return;
try {
await navigator.clipboard.writeText(payment.uri);
setCopyMessage("결제 URI를 복사했습니다.");
} catch {
setCopyMessage("복사하지 못했습니다. URI를 직접 선택하세요.");
}
}
return (
<main className="page">
<section className="card">
<div className="form-panel">
<p className="eyebrow">EIP-681 PAYMENT REQUEST</p>
<h1>Ethereum 결제 QR</h1>
<label>
받는 주소
<input
value={address}
onChange={(event) => setAddress(event.target.value)}
spellCheck="false"
/>
</label>
<label>
보낼 금액(ETH)
<input
value={amount}
onChange={(event) => setAmount(event.target.value)}
inputMode="decimal"
placeholder="0.01"
/>
</label>
<label>
체인 ID
<input
value={chainId}
onChange={(event) => setChainId(event.target.value)}
inputMode="numeric"
placeholder="1"
/>
</label>
{payment.error ? (
<p className="error" role="alert">{payment.error}</p>
) : (
<>
<dl>
<dt>wei</dt>
<dd>{payment.wei}</dd>
<dt>결제 URI</dt>
<dd>{payment.uri}</dd>
</dl>
<button type="button" onClick={copyUri}>
결제 URI 복사
</button>
<p className="copy-message" aria-live="polite">
{copyMessage}
</p>
</>
)}
</div>
<div className="qr-panel">
{payment.uri ? (
<QRCodeSVG
value={payment.uri}
size={260}
level="M"
marginSize={4}
title="이더리움 결제 요청 QR 코드"
/>
) : (
<p>유효한 입력값을 넣으면 QR 코드가 표시됩니다.</p>
)}
</div>
</section>
</main>
);
}
예제 주소는 EIP-681 문서에 나온 샘플일 뿐 실제 수취 주소가 아닙니다. 테스트할 때 반드시 본인이 관리하는 주소로 교체하세요.
3. 화면 스타일 적용
src/App.css를 다음과 같이 작성합니다.
:root {
color: #e5e7eb;
background: #071126;
font-family: Inter, Pretendard, system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
button,
input {
font: inherit;
}
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.card {
width: min(960px, 100%);
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr);
overflow: hidden;
border: 1px solid #263756;
border-radius: 24px;
background: #101b31;
box-shadow: 0 30px 80px rgb(0 0 0 / 35%);
}
.form-panel,
.qr-panel {
padding: clamp(24px, 5vw, 48px);
}
.eyebrow {
color: #67e8f9;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
}
h1 {
margin: 8px 0 28px;
}
label {
display: grid;
gap: 8px;
margin-top: 16px;
font-weight: 700;
}
input {
width: 100%;
min-height: 46px;
padding: 0 12px;
border: 1px solid #3a4a68;
border-radius: 10px;
color: #f8fafc;
background: #091326;
}
input:focus-visible,
button:focus-visible {
outline: 3px solid #22d3ee;
outline-offset: 2px;
}
dl {
display: grid;
gap: 6px;
margin: 24px 0;
}
dt {
color: #93c5fd;
font-weight: 700;
}
dd {
margin: 0 0 10px;
overflow-wrap: anywhere;
color: #cbd5e1;
font-family: ui-monospace, monospace;
}
button {
min-height: 44px;
padding: 0 16px;
border: 0;
border-radius: 10px;
color: white;
background: #4f46e5;
cursor: pointer;
}
.error {
margin-top: 20px;
color: #fca5a5;
}
.copy-message {
min-height: 24px;
color: #a7f3d0;
}
.qr-panel {
display: grid;
place-items: center;
min-height: 420px;
color: #475569;
background: #f8fafc;
}
.qr-panel svg {
width: min(260px, 100%);
height: auto;
}
@media (max-width: 760px) {
.card {
grid-template-columns: 1fr;
}
.qr-panel {
min-height: 340px;
}
}
4. 코드에서 꼭 확인할 부분
주소는 getAddress()로 검증한다
getAddress()는 20바이트 이더리움 주소 형식을 검사하고 체크섬 형태로 정규화합니다. 사용자가 붙여 넣은 주소가 잘못됐다면 QR을 만들지 않고 오류를 보여줍니다.
주소의 앞뒤 몇 글자만 보고 맞다고 판단하면 안 됩니다. 결제 화면과 지갑의 최종 확인 화면에서 전체 주소를 다시 확인해야 합니다.
금액은 문자열로 parseEther()에 전달한다
const wei = parseEther("0.01");
console.log(wei); // 10000000000000000n
다음처럼 부동소수점 연산을 거치지 마세요.
// 사용하지 말 것
const wei = 0.1 * 10 ** 18;
사용자가 입력한 "0.1"을 그대로 parseEther()에 전달하면 18자리 소수 단위를 정확한 bigint로 변환할 수 있습니다.
체인 ID를 URI에 포함한다
같은 주소 형식이라도 사용자가 다른 네트워크에 연결돼 있을 수 있습니다. @<chainId>를 포함하면 요청한 네트워크가 무엇인지 지갑에 전달할 수 있습니다.
메인넷이라면 체인 ID는 1입니다. 테스트나 사설 네트워크를 사용한다면 해당 네트워크의 정확한 체인 ID로 바꾸고, QR 옆에도 사람이 읽을 수 있는 네트워크 이름을 표시하세요.
5. 실제 기기에서 테스트하기
QR 생성 화면만 보고 완료로 판단하지 마세요.
- 개발 서버를 휴대전화가 접근할 수 있는 환경에서 엽니다.
- 본인이 관리하는 테스트 주소와 네트워크를 입력합니다.
- 아주 작은 금액 또는 테스트넷 자산으로 QR을 생성합니다.
- 사용할 지갑으로 QR을 스캔합니다.
- 지갑에 표시된 네트워크, 전체 받는 주소와 금액을 비교합니다.
- 취소 버튼으로 빠져나와도 아무 전송이 일어나지 않는지 확인합니다.
- 실제 전송 테스트가 필요하면 최종 확인 후 트랜잭션 결과를 탐색기에서 검증합니다.
지갑마다 EIP-681의 체인 전환이나 파라미터 지원 범위가 다를 수 있습니다. 서비스에서 지원할 지갑과 운영체제 조합을 직접 테스트해야 합니다.
보안과 운영 체크리스트
결제 QR은 돈이 이동할 수 있는 입력값을 전달하므로 일반 링크보다 엄격하게 다뤄야 합니다.
- QR 옆에 네트워크, 전체 주소와 금액을 텍스트로 함께 표시합니다.
- 서버에서 주문 금액과 수취 주소를 결정하고 HTTPS로 전달합니다.
- 쿼리스트링이나 사용자 입력으로 받은 수취 주소를 무조건 신뢰하지 않습니다.
- DOM 변조나 악성 스크립트가 주소를 바꾸지 못하도록 의존성과 배포 환경을 관리합니다.
- QR 생성과 실제 입금 확인을 같은 것으로 취급하지 않습니다.
- 입금 완료는 신뢰할 수 있는 RPC와 충분한 확인 수를 기준으로 서버에서 판정합니다.
- 개인 키나 시드 문구는 QR 생성 웹앱에 입력하거나 저장하지 않습니다.
- 금액 없는 주소 QR과 금액이 지정된 결제 요청 QR을 UI에서 명확히 구분합니다.
EIP-681의 value도 사용자가 수정할 수 있는 제안값입니다. QR 코드가 금액을 강제하거나 결제를 보증하지는 않습니다.
자주 발생하는 문제
| 증상 | 원인 | 확인 방법 |
|---|---|---|
| 금액이 너무 작게 표시됨 | ETH 값을 wei로 넣지 않음 | parseEther(amount).toString() 확인 |
| QR은 읽지만 금액이 비어 있음 | 단순 주소만 인코딩함 | URI의 ?value= 확인 |
| 다른 네트워크가 열림 | 체인 ID 누락 또는 지갑 미지원 | @chainId와 지갑 지원 범위 확인 |
| 주소 오류가 발생함 | 길이·16진수·체크섬 오류 | getAddress() 결과 확인 |
| 모바일에서 QR이 잘 안 읽힘 | 여백 부족, 크기 부족, 낮은 대비 | 흰 배경·검은 전경과 4모듈 여백 유지 |
마무리
React에서 이더리움 결제 QR을 만들 때 핵심은 특정 QR 컴포넌트가 아니라 표준 결제 요청 문자열을 정확히 만드는 것입니다.
주소는 getAddress()로 검증하고, 금액은 문자열 상태에서 parseEther()로 wei 정수로 변환합니다. 그 결과를 EIP-681 URI에 넣은 뒤 QRCodeSVG로 표현하면 패키지 역할이 분리되고 테스트도 쉬워집니다.
마지막으로 QR은 결제 요청일 뿐 결제 완료가 아닙니다. 사용자가 지갑에서 최종 정보를 확인하고 서명해야 하며, 서비스는 블록체인에서 실제 입금을 별도로 확인해야 합니다.
댓글
댓글 쓰기