React에서 이더리움 결제 QR 코드 만들기: EIP-681과 ETH·wei 변환

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

React 입력값을 검증해 이더리움 결제 QR 코드를 만들고 모바일 지갑으로 스캔하는 흐름

이더리움 결제 QR 코드는 단순히 지갑 주소를 QR 이미지로 바꾸는 기능이 아닙니다. 받는 주소, 네트워크, 금액을 지갑 앱이 이해할 수 있는 EIP-681 결제 요청 URI로 조합한 뒤 QR 코드에 넣어야 합니다.

이 글에서는 다음 결과물을 만듭니다.

  • 받는 주소와 ETH 금액 입력
  • ethers로 주소 형식 검증
  • ETH 문자열을 정확한 wei 정수로 변환
  • 체인 ID가 포함된 EIP-681 URI 생성
  • qrcode.react로 SVG QR 코드 표시
  • QR에 포함된 원문을 화면에 함께 표시하고 복사

QR 코드를 스캔한다고 곧바로 송금되는 것은 아닙니다. 호환 지갑이 결제 요청을 읽고 사용자가 주소, 네트워크, 금액과 수수료를 최종 확인한 뒤 서명해야 전송됩니다.


기존 방식에서 무엇이 달라졌나

예전 글은 react-ether-qrcodeethereum-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를 사용하는 것이 안전합니다.

사용자가 입력한 ETH 문자열을 정확한 wei 정수로 변환한 뒤 QR 결제 요청으로 전달하는 흐름


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 생성 화면만 보고 완료로 판단하지 마세요.

  1. 개발 서버를 휴대전화가 접근할 수 있는 환경에서 엽니다.
  2. 본인이 관리하는 테스트 주소와 네트워크를 입력합니다.
  3. 아주 작은 금액 또는 테스트넷 자산으로 QR을 생성합니다.
  4. 사용할 지갑으로 QR을 스캔합니다.
  5. 지갑에 표시된 네트워크, 전체 받는 주소와 금액을 비교합니다.
  6. 취소 버튼으로 빠져나와도 아무 전송이 일어나지 않는지 확인합니다.
  7. 실제 전송 테스트가 필요하면 최종 확인 후 트랜잭션 결과를 탐색기에서 검증합니다.

지갑마다 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은 결제 요청일 뿐 결제 완료가 아닙니다. 사용자가 지갑에서 최종 정보를 확인하고 서명해야 하며, 서비스는 블록체인에서 실제 입금을 별도로 확인해야 합니다.

공식 참고 자료

댓글

이 블로그의 인기 게시물

pyautogui 예제 모니터 특정 위치 색상 구하고 비교해서 클릭 이벤트 하기

vscode 에서 WSL 개발환경 동작하지 않는 경우

React에서 Socket.IO Client 연결하기: CORS와 useEffect 정리