NFT 기반 차용증 거래 서비스 RABBIT

블록체인 기반의 차용증 거래 서비스로,
투명하고 안전한 P2P 대출서비스를 제공합니다.

rabbit_logo

프로젝트 개요

  • 기간 : 2025.03 - 2025.04, 6주
  • 역할 : Front-end 개발 50%, UX/UI 디자인 100%
  • 구현 정도 : 웹 배포 후 실 사용 테스트 및 최적화
  • 성과 : 디지털인증협회 블록체인 & AI 해커톤 예선 진출 (전국 20팀), 카카오뱅크 Finnect 챌린지 예선 진출 (서울권 8팀)

기술 스택

  • React v19, TypeScript, Vite, pnpm
  • 스타일링 : Tailwind CSS v4
  • 상태관리 : Zustand
  • 서버 상태/캐싱 : React Query v5
  • 테스트 및 API 모킹 : MSW

요약

  • MSW를 이용해 백엔드 개발 속도에 영향을 받지 않고 프론트엔드 개발을 진행할 수 있었습니다.
  • 반응형 웹 디자인을 적용하여 모바일과 데스크탑 모두 호환되는 웹 사이트를 구현했습니다.
  • 코드 스플리팅을 적용하여 index.js의 크기를 1.74MB에서 1.18MB로 약 32% 감소시켰습니다.
  • Zustand를 활용해 상태 관리를 구현하고, props drilling을 제거했습니다.

MSW

독립적인 프론트엔드 개발을 위해 MSW를 이용한 모킹을 구현했습니다.

async function enableMocking() {
  // Vite의 환경변수를 사용하여 development 환경일 때만 동적 임포트를 통해 번들 크기 최적화
  if (import.meta.env.MODE === "development") {
    const { worker } = await import("./shared/lib/browser");
    return await worker.start();
  }
}

//handlers를 등록하며 worker 인스턴스 생성
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);
export const handlers = [
  // 경매 목록 조회
  http.get(`${VITE_API_URL}/${VITE_API_VERSION}/auctions`, ({ request }) => {
    const url = new URL(request.url);
    const params = Object.fromEntries(url.searchParams) as AuctionListRequest;
    
    let filteredList = [...mockAuctionList.content];
  
    if (params.minPrice && Number(params.minPrice) !== 0) {
      filteredList = filteredList.filter(
        (item) => item.price >= Number(params.minPrice),
      );
    }

    if (params.maxPrice && Number(params.maxPrice) !== 0) {
      filteredList = filteredList.filter(
        (item) => item.price <= Number(params.maxPrice),
      );
    }

    ...


    // 페이지네이션 적용
    const pageNumber = params.pageNumber ? Number(params.pageNumber) : 0;
    const pageSize = params.pageSize ? Number(params.pageSize) : 10;
    const startIndex = pageNumber * pageSize;
    const endIndex = startIndex + pageSize;
    const paginatedList = filteredList.slice(startIndex, endIndex);
    const totalElements = filteredList.length;
    const totalPages = Math.ceil(totalElements / pageSize);
    const isLastPage = pageNumber >= totalPages - 1;

    const response: ApiResponse<AuctionListResponse> = {
      status: "SUCCESS",
      data: {
        content: paginatedList,
        pageNumber: pageNumber,
        pageSize: pageSize,
        totalElements: totalElements,
        totalPages: totalPages,
        last: isLastPage,
        hasNext: !isLastPage,
      },
    };

    return HttpResponse.json(response);
  })

MSW

  • 정적 Mock은 하드코딩된 JSON을 그대로 반환하기 때문에, 요청 파라미터에 따라 결과가 달라지는 시나리오는 검증할 수 없습니다. 따라서 MSW를 이용한 동적 Mock을 구현했습니다.

  • 이를 통해 API의 Parameter에 따라 달라지는 결과까지 확인할 수 있어, 실제 API와 유사한 환경에서 프론트엔드 기능을 검증할 수 있었습니다.

  • 또한 MSW는 네트워크 수준에서 동작하기 때문에 실제 렌더링 중에 Promise를 throw하여, Suspense에 따르는 Fallback UI까지 테스트할 수 있었습니다.

성과

  • 이전에는 백엔드 API가 완성되기 전까지 Mock Data를 하드코딩하거나, ApiResponse가 단순 Mock 데이터를 반환하도록 구현했습니다. 이로 인해 추후 실제 API 개발 시 코드를 다시 수정해야 하는 번거로움이 있었고, 백엔드 개발 지연이 프론트엔드 개발 지연으로 이어지는 경험도 있었습니다.

  • 하지만, MSW를 이용하여 API 명세서를 보고 API를 먼저 만든 후 테스트를 통해 프론트엔드 API 호출이 올바르게 구현된 것을 확인할 수 있었습니다. 따라서 백엔드와 독립적인 프론트엔드 개발이 가능했고, 백엔드 API 개발 지연에 따른 프론트엔드 개발 지연이 발생하지 않았습니다.

반응형 디자인

다양한 디바이스 환경에서도 동일한 수준의 서비스를 이용할 수 있도록 반응형 웹을 구현했습니다.

반응형 1 예시

반응형 디자인

  • 사용자는 데스크톱, 태블릿, 스마트폰 등 어떤 기기를 사용하더라도 일관된 경험을 제공받을 수 있습니다.

  • 채무와 채권 관리 업무를 언제 어디서나 원활하게 수행할 수 있어 사용자 편의성과 접근성을 크게 향상시켰습니다.

import { useEffect, useState } from "react";

type BreakPoint = "sm" | "md" | "lg" | "xl" | "2xl";

const breakpoints = {
  sm: "640px",
  md: "768px",
  lg: "1024px",
  xl: "1280px",
  "2xl": "1536px",
} as const;

export const useMediaQuery = (
  breakpoint: BreakPoint,
  type: "min" | "max" = "min",
) => {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const query = `(${type}-width: ${breakpoints[breakpoint]})`;
    const media = window.matchMedia(query);

    setMatches(media.matches);

    const listener = () => setMatches(media.matches);
    media.addEventListener("change", listener);
    return () => media.removeEventListener("change", listener);
  }, [breakpoint, type]);

  return matches;
};

export default useMediaQuery;
  • 전달받은 breakpoint에 따라 현재 화면이 해당 조건을 만족하는지 boolean 값으로 반환하는 Hook입니다.

  • window.matchMedia와 이벤트 리스너를 활용하여 화면 크기 변화에 따라 실시간으로 상태를 갱신합니다.


const AuctionBidHistory = ({ data }: AuctionBidHistoryProps) => {
  const isDesktop = useMediaQuery("lg");

  if (!data || data.length === 0) {
    return (
      <div className="w-full overflow-hidden rounded-lg bg-gray-900 p-4">
        <div className="flex min-h-[300px] items-center justify-center text-center text-base text-gray-400">
          입찰 내역이 없습니다.
        </div>
      </div>
    );
  }

  return (
    <div className="w-full overflow-hidden rounded-lg bg-gray-900">
      {isDesktop ? (
        <AuctionBidHistoryDesktop data={data} />
      ) : (
        <AuctionBidHistoryMobile data={data} />
      )}
    </div>
  );
};

  • useMediaQuery로 받은 값에 따라 데스크톱 또는 모바일 버전의 컴포넌트를 렌더링하는 방향으로 사용하였습니다.


    <div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
    
    <div className="flex h-full flex-col gap-2 lg:flex-row">
    
    <div className="text font-medium whitespace-nowrap text-white sm:text-xl">

  • Tailwind CSS의 반응형 유틸리티(grid-cols-*, sm:, md:, lg: 등)를 활용하여, 화면 크기에 따라 컬럼 수와 레이아웃이 자동으로 조정되도록 구현함으로써 별도의 컴포넌트를 교체하지 않고도 다양한 화면에서 일관된 UI를 유지할 수 있었습니다.

의도

  • 모든 디바이스에서 일관된 사용자 경험 제공

  • 접근성 최적화

기대 효과

  • 사용자 만족도 및 서비스 접근성 향상

  • 다양한 디바이스에서의 원활한 서비스 이용

코드 스플리팅

초기 로딩 속도 개선을 위해 페이지별 코드 스플리팅을 적용했습니다.


import DefaultLoadingFallback from "@/shared/ui/DefaultLoadingFallback";
import ErrorBoundary from "@/shared/ui/ErrorBoundary";
import { ComponentType, lazy, Suspense } from "react";

interface LazyComponentProps {
  fallback?: React.ReactNode;
}

export const withLazyComponent = <P extends object>(
  importFunc: () => Promise<{ default: ComponentType<P> }>,
  options: LazyComponentProps = {},
) => {
  const LazyComponent = lazy(importFunc);

  return (props: P) => (
    <ErrorBoundary>
      <Suspense fallback={options.fallback || <DefaultLoadingFallback />}>
        <LazyComponent {...props} />
      </Suspense>
    </ErrorBoundary>
  );
};

//페이지 컴포넌트 동적 임포트
const AuctionHistory = withLazyComponent(
  () => import("@/pages/auction/ui/AuctionHistory"),
);
const AuctionCreate = withLazyComponent(
  () => import("@/pages/auction/ui/AuctionCreate"),
);
const AuctionList = withLazyComponent(
  () => import("@/pages/auction/ui/AuctionList"),
);
const AuctionDetail = withLazyComponent(
  () => import("@/pages/auction/ui/AuctionDetail"),
);

//적용 전
→ dist/assets/index-x0IGiL1h.js         1,741.58 kB │ gzip: 543.13 kB │ map: 7,925.07 kB


//적용 후
→ dist/assets/index-BUJYRM9M.js         1,184.76 kB │ gzip: 387.46 kB │ map: 5,724.35 kB
  • 코드 스플리팅 적용 전, 초기 빌드 결과에서 index.js (1.7MB) 번들 파일의 크기가 크게 나타났습니다.

  • 이 문제를 해결하기 위해 lazy를 활용하여 라우트 기반 코드 스플리팅을 적용했습니다.

  • 사용자가 특정 페이지에 접속할 때만 해당 페이지에 필요한 청크를 동적으로 로드하도록 구현하여, 초기 진입 시 불필요한 코드의 로딩을 방지하고 초기 로딩 성능을 최적화했습니다.

  • 코드 스플리팅 적용 후, index.js의 크기가 1.74MB에서 1.18MB로 약 32% 감소했습니다. 또한 여러 페이지 컴포넌트들이 개별 청크로 분리되어 전체적인 번들 구조가 최적화되었습니다.

  • 초기 페이지 로딩 속도를 개선하여 사용자 경험을 향상시켰습니다. 사용자는 더 빠르게 첫 화면을 볼 수 있으며, 이후 페이지 이동 시에도 필요한 리소스만 로드하여 효율적인 렌더링이 가능해졌습니다.

  • 공통 로직을 담은 withLazyComponent를 생성하여, 새로운 페이지가 추가될 때마다 일관되고 간편하게 코드 스플리팅을 적용할 수 있도록 설계했습니다.

Zustand를 이용한 상태관리

Zustand를 활용해 상태 관리를 구현하고, props drilling을 제거했습니다.

export const useAuctionFilterStore = create<AuctionFilterState>((set) => ({
  maxPrice: "",
  minPrice: "",
  maxIr: "",
  minIr: "",
  maxRate: "",
  repayType: [],
  matTerm: "",
  matStart: "",
  matEnd: "",
  setMaxPrice: (maxPrice) => set({ maxPrice }),
  setMinPrice: (minPrice) => set({ minPrice }),
  setMaxIr: (maxIr) => set({ maxIr }),
  setMinIr: (minIr) => set({ minIr }),
  setMaxRate: (maxRate) => set({ maxRate }),
  setRepayType: (repayType) => set({ repayType }),
  setMatTerm: (matTerm) => set({ matTerm }),
  setMatStart: (matStart) => set({ matStart }),
  setMatEnd: (matEnd) => set({ matEnd }),
}));
...
  const minPrice = useAuctionFilterStore((state) => state.minPrice);
  const maxPrice = useAuctionFilterStore((state) => state.maxPrice);
  const maxIr = useAuctionFilterStore((state) => state.maxIr);
  const minIr = useAuctionFilterStore((state) => state.minIr);
  const maxRate = useAuctionFilterStore((state) => state.maxRate);
  const repayType = useAuctionFilterStore((state) => state.repayType);
  const matTerm = useAuctionFilterStore((state) => state.matTerm);
  const matStart = useAuctionFilterStore((state) => state.matStart);
  const matEnd = useAuctionFilterStore((state) => state.matEnd);
};

Zustand 사용 이유와 성과

  • Page → Filter → 세부 컴포넌트로 내려가는 props drilling을 제거하기 위해, Zustand 전역 상태를 활용했습니다.

  • 필터 상태 변경 시 필요한 컴포넌트만 리렌더링하도록 셀렉터 기반 구독을 적용하여 성능을 최적화했습니다.

  • 각 섹션은 독립적인 상태를 유지하면서, store를 통해 상태를 읽고 갱신할 수 있어, API 호출 시 모든 필터 조건을 쉽게 통합할 수 있었습니다. 이를 통해 props 전달과 상태 관리 복잡성을 줄였습니다.

디자인 시스템 구축

디자인 시스템을 구축하여 일관된 디자인을 유지하고 효율적인 개발을 가능하게 했습니다.

브랜드 컬러

Primary

#00FF66

Positive

#037DD6

Fail

#FF4646

Caution

#FFD700

버튼 변형

Variants

Sizes

States

그라데이션

Radial Large

Radial Small

Radial Accent

Brand Gradient

94deg, #00FF37 -15.54%, #F6FF00 111.18%

테두리

Border Gradient

linear-gradient(277deg, rgba(0,0,0,0) 30%, #fff 100%)

Input Border

linear-gradient(var(--background), var(--background)) padding-box

그레이스케일

Gray 900

#1a1a1a

Gray 800

#282828

Gray 700

#333333

Gray 600

#474747

Gray 500

#555555

Gray 400

#777777

Gray 300

#848484

Gray 200

#c0c0c0

Gray 100

#d9d9d9

타이포그래피

폰트 패밀리

Pretendard

font-pretendard

DNF Bit Bit v2

font-bit

Partial Sans KR

font-partial

Pixel Regular

font-pixel

DungGeunMo

font-dunggeunmo

폰트 크기

Heading 1

text-5xl font-bold (48px)

Heading 2

text-4xl font-semibold (36px)

Heading 3

text-3xl font-medium (30px)

Body Text

text-xl (20px)

Secondary Text

text-gray-200 (#e5e5e5)

Normal Text

text-base (16px)

Disabled Text

text-[#848484]

라운드

Small

rounded-sm (2px)

Medium

rounded-md (6px)

Large

rounded-lg (8px)

Extra Large

rounded-xl (12px)

UX 개선

UX 개선을 통해 사용자 이탈율을 줄였습니다.

채권자
만기일
원금
이자율
만기 총 수취액

일반 로딩 스피너

개선 버전

문제 상황

  • 블록체인 네트워크에 직접 접속하여 데이터를 받아오거나, 트랜잭션 처리 및 NFT 발행을 수행해야 하는 특성상 데이터 로딩 시간이 길어지는 문제가 있었습니다.

  • 로딩이 길어지는 구간에서 사용자 이탈이 발생했으며, 사용자가 로딩 화면을 단순 렉으로 오인 하고 새로고침하거나 페이지를 이탈했습니다.

해결 방안

  • 스켈레톤 UI를 도입하여 데이터를 불러오는 동안, 화면 구조를 미리 보여주는방식으로 개선했습니다.

  • 또한, Overlay를 통해 사용자가 이탈하면 안되는, 로직이 진행되는 상황임을 인지시켰습니다.

  • 시각적 피드백을 통해 사용자는 진행 상황을 인지할 수 있었고, 그 결과 로딩 구간에서의 사용자이탈률을 90% 이상 줄이는 성과를 거두었습니다.

회고

프로젝트를 진행하며 느낀 점을 정리했습니다.

Jira를 통한 프로젝트 일정 관리

Jira

프로젝트 일정 관리 방식

이번 프로젝트에서는 Jira를 활용하여 일정 관리를 진행하였습니다.초기 계획 단계에서 1시간을 1 Story Point로 설정하고, 주 40시간을 기준으로 업무를 배분하였습니다.매주 40시간을 기준으로 스스로 작업을 계획하고 이슈를 설정해야 했기 때문에, 자신의 역량을 객관적으로 파악하고 업무를 효율적으로 분배하는 능력을 기를 수 있었습니다.이를 통해 프로젝트를 체계적으로 분할하고 관리하는 역량이 향상되었습니다.

데이터 기반 프로젝트 관리

Jira를 사용하면서 가장 좋았던 점은 프로젝트를 “데이터 기반”으로 운영할 수 있었다는 점입니다.프로젝트를 진행하다 보면 예상치 못한 문제나 일정 지연이 발생하기 마련인데, 이때 팀 간 갈등이 생기기 쉽습니다.그러나 Jira를 통해 진행 상황과 이슈를 명확히 관리하면서 불필요한 오해나 갈등을 효과적으로 줄일 수 있었습니다.

스프린트 회고와 분석

특히 스프린트가 완료되지 못한 경우, 주간 회고를 통해 그 원인을 분석할 수 있었습니다.스토리 포인트를 지나치게 타이트하게 설정했는지, 단순히 작업이 지연되었는지 등을 데이터를 통해 명확히 파악할 수 있었습니다.이러한 과정을 통해 진짜 일정 관리의 문제인지, 아니면 개인적인 책임이 있는지를 구분할 수 있었습니다.

데이터 기반 소통의 효과

데이터를 기반으로 업무를 관리하다 보니, 의견 충돌의 여지가 줄어들었습니다.주관적인 판단이 아니라 Jira에 기록된 이슈 데이터를 바탕으로 문제를 분석하고 해결책을 도출할 수 있었기 때문입니다.그 결과, 일정 지연과 같은 상황에서도 감정적 대응 없이 객관적인 근거를 바탕으로 소통할 수 있었고, 팀워크를 안정적으로 유지할 수 있었습니다.

상호 이해의 중요성

Seporia

블록체인을 사용하며 겪은 문제

세포리아 테스트넷에 실제 코인을 발행하여 프로젝트를 진행하였습니다. 블록체인 네트워크에 데이터를 저장하고 트랜잭션을 처리하는 과정에서 예상치 못한 문제가 발생했습니다. 특히, 블록체인의 특성상 트랜잭션 처리 속도를 기술적으로 줄이는 것이 불가능하다는 현실을 마주했습니다. 평균 10초 이상 소요되는 트랜잭션 처리 시간은 사용자 경험 측면에서 큰 문제로 다가왔습니다.

기술적 한계와 디자인적 접근

처음에는 기술적으로 문제를 해결하려 했지만, 블록체인 특성상 처리 시간을 줄이는 것은 사실상 불가능했습니다. 따라서 UI/UX 측면에서 문제를 완화하기 위해 스켈레톤 화면과 플로우 안내 메시지를 추가하여, 사용자가 로딩 중에도 진행 상황을 직관적으로 이해할 수 있도록 구성했습니다.이러한 디자인적 접근을 통해 대기 시간이 길더라도 불편함을 최소화할 수 있었습니다.

상호 이해의 중요성

프로젝트를 진행하며, 내가 맡은 분야가 아니더라도 이해해야 한다는 점을 배웠습니다. 블록체인을 구현하는 역할은 아니였지만 블록체인 트랜잭션 발행이나 데이터 조회 같은 기술적인 내용을 깊이 파악하지 못하면, 화면을 만드는 것도 어려운 일이 될 수 있다고 느꼈습니다. 직접 담당하지 않는 기술 분야라도, 프로젝트의 전체 맥락에서 어떤 영향을 줄 수 있는지 충분히 이해해야 한다고 생각했습니다.