[Hello Sui] #3. Frontend 연결하기

이 글은 Hello Sui 시리즈의 3번째 글입니다.

1.png

개요

지난 포스트에서는 Sui CLI를 설치하고 Move 스마트 계약을 Testnet에 배포하는 방법을 알아보았습니다. 이번 포스트에서는 배포된 Move Package와 상호작용하는 React 기반 Frontend를 연결해 보겠습니다.

Sui에서 제공하는 dApp Kit을 활용하면 블록체인 트랜잭션을 손쉽게 React 컴포넌트에서 호출할 수 있습니다. 이 포스트에서는 아래 내용을 다룹니다.

  • Frontend 프로젝트 구조 파악
  • App.tsx, CreateGreeting.tsx, Greeting.tsx 컴포넌트 분석
  • 배포한 Package ID를 constants.ts에 연결하는 방법
  • Slush Wallet으로 트랜잭션을 승인하며 앱 동작 확인

Frontend 프로젝트 구조

이 포스트에서 사용하는 Frontend 프로젝트는 Vite + React + TypeScript 기반입니다. Sui 공식 문서의 예제인 sui-stack-hello-world/ui 디렉토리를 기준으로 살펴보겠습니다.

ui/
├── index.html
├── package.json
├── pnpm-lock.yaml
├── src/
│   ├── App.tsx             # 앱 루트 컴포넌트
│   ├── constants.ts        # Package ID 상수 정의
│   ├── CreateGreeting.tsx  # Greeting 생성 컴포넌트
│   ├── Greeting.tsx        # Greeting 조회·수정 컴포넌트
│   ├── dapp-kit.ts         # dApp Kit 설정
│   ├── main.tsx            # 앱 진입점
│   └── networkConfig.ts    # 네트워크 설정
├── tsconfig.json
└── vite.config.mts

주요 파일의 역할은 다음과 같습니다.

파일역할
App.tsx앱 최상위 컴포넌트. Slush Wallet 연결 버튼과 각 컴포넌트를 렌더링합니다.
constants.ts배포된 Move Package ID를 상수로 정의합니다. Frontend와 Package를 연결하는 핵심 파일입니다.
CreateGreeting.tsxMove Package의 new 함수를 호출해 Greeting Object를 생성하는 트랜잭션을 실행합니다.
Greeting.tsx생성된 Greeting Object를 조회하고 update_text 함수로 텍스트를 수정합니다.
networkConfig.tsTestnet 등 네트워크 환경 설정을 관리합니다.

주요 컴포넌트

이 포스트의 핵심은 src/ 아래 세 개의 컴포넌트입니다. 각 컴포넌트가 Move Package의 어떤 함수와 연결되는지 살펴보겠습니다.

App.tsx

function App() {
  const currentAccount = useCurrentAccount();
  const [greetingId, setGreeting] = useState(() => {
    const hash = window.location.hash.slice(1);
    return isValidSuiObjectId(hash) ? hash : null;
  });

  return (
    <>
      <Flex
        position="sticky"
        px="4"
        py="2"
        justify="between"
        align={"center"}
        style={{
          borderBottom: "1px solid var(--gray-a2)",
        }}
      >
        <Box>
          <Heading>dApp Starter Template</Heading>
        </Box>

        <Box style={{ display: "flex", gap: 10, alignItems: "center" }}>
          {currentAccount && (
            <Button
              variant="soft"
              onClick={() => {
                window.open(`https://faucet.sui.io/?address=${currentAccount.address}`, '_blank');
              }}
            >
              Get Testnet SUI
            </Button>
          )}
          <ConnectButton />
        </Box>
      </Flex>
      <Container>
        <Container
          mt="5"
          pt="2"
          px="4"
          style={{ background: "var(--gray-a2)", minHeight: 500 }}
        >
          {currentAccount ? (
            greetingId ? (
              <Greeting id={greetingId} />
            ) : (
              <CreateGreeting
                onCreated={(id) => {
                  window.location.hash = id;
                  setGreeting(id);
                }}
              />
            )
          ) : (
            <Heading>Please connect your wallet</Heading>
          )}
        </Container>
      </Container>
    </>
  );
}

export default App;

App.tsx는 앱의 루트 컴포넌트입니다. useCurrentAccount 훅으로 지갑 연결 여부를 확인하고, 상태에 따라 다른 컴포넌트를 렌더링합니다.

  • 지갑 미연결 시: "Please connect your wallet" 안내
  • 지갑 연결 + Greeting 없음: CreateGreeting 컴포넌트 표시
  • 지갑 연결 + Greeting 있음: Greeting 컴포넌트 표시

상단 헤더에는 ConnectButton과 Testnet SUI를 받을 수 있는 Faucet 버튼이 위치합니다.


CreateGreeting.tsx

export function CreateGreeting({
  onCreated,
}: {
  onCreated: (id: string) => void;
}) {
  const helloWorldPackageId = useNetworkVariable("helloWorldPackageId");
  const dAppKit = useDAppKit();

  const { mutate: signAndExecute } = useMutation({
    mutationFn: (tx: Transaction) =>
      dAppKit.signAndExecuteTransaction({ transaction: tx }),
  });

  const [waitingForTxn, setWaitingForTxn] = useState(false);

  const create = () => {
    setWaitingForTxn(true);

    const tx = new Transaction();

    tx.moveCall({
      arguments: [],
      target: `${helloWorldPackageId}::greeting::new`,
    });

    signAndExecute(tx, {
      onSuccess: (result) => {
        const txData = result.Transaction ?? result.FailedTransaction;
        if (txData?.effects) {
          const created = txData.effects.changedObjects.filter(
            (obj) => obj.idOperation === "Created",
          );
          const objectId = created[0]?.objectId;
          if (objectId) {
            onCreated(objectId);
          }
        }
        setWaitingForTxn(false);
      },
    });
  };

  return (
    <Container>
      <Button
        size="3"
        onClick={() => {
          create();
        }}
        disabled={waitingForTxn}
      >
        {waitingForTxn ? <ClipLoader size={20} /> : "Create Greeting"}
      </Button>
    </Container>
  );
}

CreateGreeting.tsx는 Move Package의 new 함수를 호출해 새로운 Greeting Object를 생성하는 컴포넌트입니다.

useDAppKit 훅과 useMutation을 조합해 트랜잭션을 생성하고, Slush Wallet을 통해 서명·제출합니다. 이전 포스트에서 Sui CLI로 직접 실행했던 아래 명령과 동일한 동작을 버튼 클릭으로 대체합니다.

sui client call --package $PACKAGE_ID --module greeting --function new

Greeting.tsx

export function Greeting({ id }: { id: string }) {
  const helloWorldPackageId = useNetworkVariable("helloWorldPackageId");
  const client = useCurrentClient();
  const dAppKit = useDAppKit();
  const queryClient = useQueryClient();

  const { mutate: signAndExecute } = useMutation({
    mutationFn: (tx: Transaction) =>
      dAppKit.signAndExecuteTransaction({ transaction: tx }),
  });

  const { data, isPending, error } = useQuery({
    queryKey: ["getObject", id],
    queryFn: () =>
      client.core.getObject({ objectId: id, include: { json: true } }),
  });

  const [newText, setNewText] = useState("");
  const [waitingForTxn, setWaitingForTxn] = useState(false);

  const executeMoveCall = () => {
    setWaitingForTxn(true);

    const tx = new Transaction();

    tx.moveCall({
      target: `${helloWorldPackageId}::greeting::update_text`,
      arguments: [tx.object(id), tx.pure.string(newText)],
    });

    signAndExecute(tx, {
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ["getObject", id] });
        setWaitingForTxn(false);
        setNewText("");
      },
    });
  };

  if (isPending) return <Text>Loading...</Text>;

  if (error) return <Text>Error: {error.message}</Text>;

  if (!data) return <Text>Not found</Text>;

  const fields = data.object.json as { text: string } | null;

  return (
    <>
      <Heading size="3">
        Greeting{" "}
        <Link
          href={`https://testnet.suivision.xyz/object/${id}`}
          target="_blank"
        >
          {id}
        </Link>
      </Heading>

      <Flex direction="column" gap="2">
        <Text>Text: {fields?.text}</Text>
        <Flex direction="row" gap="2">
          <TextField.Root
            placeholder={fields?.text}
            value={newText}
            onChange={(e) => {
              setNewText(e.target.value);
            }}
            disabled={waitingForTxn}
          />
          <Button onClick={() => executeMoveCall()} disabled={waitingForTxn}>
            {waitingForTxn ? <ClipLoader size={20} /> : "Update"}
          </Button>
        </Flex>
      </Flex>
    </>
  );
}

Greeting.tsx는 생성된 Greeting Object를 조회하고, 텍스트를 수정하는 컴포넌트입니다.

useCurrentClient로 Sui 클라이언트를 가져오고, useQuery로 온체인의 Greeting Object 데이터를 읽어와 화면에 표시합니다. 텍스트 수정은 CreateGreeting.tsx와 동일하게 useDAppKit + useMutation 조합으로 update_text 함수를 호출하는 트랜잭션을 실행합니다. 트랜잭션 완료 후에는 queryClient.invalidateQueries로 Object 데이터를 다시 조회합니다.


Package ID 연결하기

Frontend와 Move Package를 연결하려면 배포한 Package ID를 constants.ts에 입력해야 합니다.

constants.ts 파일을 열면 아래와 같은 상수가 정의되어 있습니다.

export const TESTNET_HELLO_WORLD_PACKAGE_ID = "<YOUR_PACKAGE_ID>";

<YOUR_PACKAGE_ID> 부분을 이전 포스트에서 배포 시 출력된 Package ID로 교체합니다.

export const TESTNET_HELLO_WORLD_PACKAGE_ID = "0xabcd1234...";

Package ID는 배포 당시 터미널 출력에서 확인할 수 있으며, Sui Explorer에서 트랜잭션 기록을 조회할 수도 있습니다.


의존성 설치 및 앱 실행

Package ID를 설정했다면 의존성을 설치하고 로컬 개발 서버를 실행합니다.

pnpm install
pnpm dev

브라우저에서 http://localhost:5173으로 접속하면 앱이 실행됩니다.


앱 동작 확인

앱이 실행되면 지갑을 연결하고 Create Greeting 버튼을 클릭합니다. Slush Wallet에서 트랜잭션을 승인하면 Greeting Object가 온체인에 생성됩니다.

생성 후에는 아래와 같이 Greeting Object의 텍스트와 Update 버튼이 표시됩니다.

Greeting 컴포넌트 - 초기 상태

텍스트 입력란에 새로운 문구를 입력하고 Update 버튼을 클릭합니다. Slush Wallet에서 트랜잭션을 승인하면 온체인 데이터가 업데이트되고 화면에 반영됩니다.

Greeting 컴포넌트 - 텍스트 업데이트 후

이렇게 React Frontend에서 Move Package의 함수를 호출해 온체인 데이터를 읽고 수정할 수 있습니다.


References

전체 목차:

  1. [Hello Sui] #1. Sui Cli 설치하기
  2. [Hello Sui] #2. Smart Contract 배포하기
  3. [Hello Sui] #3. Frontend 연결하기