Coin Chart - 리스트 만들기

박상준

2025년 03월 12일

0

Chart
Next.js

초기 구상

나는 리스트의 주된 목적은 가상 화폐가 어떤 것들이 있는지 확인하는 용도라고 생각한다. 그래서 실시간으로 데이터를 받아서 업데이트할 필요가 없다고 판단했다. 그래서 setInterval을 통해 주기적으로 데이터 요청을 하는건 어떨까 싶었다.

Symbol 리스트 구현하기

Next Route API와 setInterval 방식

export async function GET(req: Request) {
  try {
    const url = new URL(req.url);
    const sortBy = url.searchParams.get('sortBy') || 'volume'; // 기본값 volume(거래량)
    const currencyFilter = url.searchParams.get('currency') || ''; // 특정 통화 필터링, 기본값 전체

우선 Next Route API를 사용했었다. 처음에는 웹소켓 기능은 나중이고 우선 Binance API 사용법을 익히고 데이터를 활용해보기 위해 서버 컴포넌트로 구현하려고 했기 때문에 이런 선택을 했었다. 하지만 굳이 필요없는 단계라는 것을 깨달았다. 만약 이 서비스가 서버리스도 되어있어서 백엔드 처리가 필요하다면 Next Route API를 사용하는게 맞다. 하지만 지금은 이미 제공하는 API가 있기 때문에 필요없는 과정을 거치는 것 뿐이다.

export const fetchSymbolList = async (sortBy: string, currency: string) => {
  try {
    // 거래중인 자산 조회
    const request = await fetch(
      `${BASE_BINANCE_URL}/exchangeInfo?symbolStatus=TRADING`
    );

그래서 helper함수를 만들어서 일반 함수로 변경해줬다.

    const request = await fetch(
      `${BASE_BINANCE_URL}/exchangeInfo?symbolStatus=TRADING`
    );
    const totalExchange: ExchangeInfo = await request.json();

    const currencyPairs = totalExchange.symbols
      .filter((s) => s.quoteAsset === currency)
      .map((s) => s.symbol);

    const batches = []; // symbols 요청을 나눠서 보내기 위한 batch 배열

    for (let i = 0; i < currencyPairs.length; i += 100) {
      batches.push(currencyPairs.slice(i, i + 100));
    }

이 코드에는 많은 사연들이 있다. 지금 이 코드의 목적은 현재 거래중인 자산을 가져오는 것이다. 그중, 현재 통화를 기준으로 필터링하고, 필터링된 값에서 symbol(이름)만 배열로 만들어주는 것이다. 그리고 이렇게 만든 가상 화폐 배열을 기반으로 batch처리를 하기 위한 배열을 만들어주는 것이다. 벌써 뭔가 복잡하고 불필요한 과정들이 많다는 생각이 든다. 하지만 이 코드를 작성할 당시에는 이게 최선이라고 느꼈다.

/api/v3/ticker/24hr을 사용하면 모든 symbol을 불러온다. 하지만 이렇게 했을때 기준이 되는 통화 기준으로 데이터를 필터링을 해야하는데 너무 많은 데이터를 불러오는게 비효율적이라고 생각했다.

추가적으로 USDT라는 통화로 데이터를 필터링했을때 이름에 USDT가 포함된 자산들이 있다... 일부러 헷갈리게 만든건지 모르겠지만, 당시 나는 통화가 이름 뒤에만 붙는 것이 아니라 앞에도 있을수 있다는 생각을 했다.

이러한 이유로 현재 거래중인 자산중에 통화 기준이 USDT인 자산만 불러오고, /api/v3/ticker/24hr?symbols=${symbolsList}로 여러개의 symbol 데이터를 가져오려 했다. 근데 batch처리를 한 이유는 이미지설명 요청하는 양마다 부하량이 지정되어 있다. 이 점수가 높으면 API사용이 중단될수 있기 때문에 나눠서 처리한 것이다. 거래중인 자산이 약 300개 정도로 추려졌기 때문에 100개씩 나눠서 요청 처리를 한것이다.

    const results: SymbolData[] = []; // 분할 실행한 결과를 담을 배열

    await Promise.all(
      batches.map(async (item) => {
        try {
          const symbolsList = JSON.stringify(item);
          const response = await fetch(
            `${BASE_BINANCE_URL}/ticker/24hr?symbols=${symbolsList}`
          );

          if (!response.ok) {
            throw new Error('URL 변환 중 오류가 발생했습니다.');
          }

          const res = await response.json();
          results.push(...res);
        } catch (err) {
          console.error('URL 변환 중 오류가 발생했습니다.', err);
        }
      })
    );

그래서 이렇게 Promise.all을 통해서 여러개의 요청을 처리하도록 구현했다. 그러면 API 부하도 줄어들고 효과적으로 데이터를 처리할 수 있다고 생각했다.

    const sortedData = results.sort((a, b) => {
      if (sortBy === 'priceDes') {
        // 가격 내림차순 정렬
        return parseFloat(b.lastPrice) - parseFloat(a.lastPrice);
      } else if (sortBy === 'priceAsc') {
        // 가격 오림차순 정렬
        return parseFloat(a.lastPrice) - parseFloat(b.lastPrice);
      } else {
        // 거래량 내림차순 정렬
        return parseFloat(b.quoteVolume) - parseFloat(a.quoteVolume);
      }
    });

그리고 컴포넌트에서 전달해주는 sort값을 통해서 데이터를 정렬하는 로직까지 넣어줬다.

export default function SymbolList() {
  const [keyword, setKeyword] = useState('');
  const [symbolData, setOrderData] = useState<SymbolData[] | null>(null);
  const [sortBy, setSortBy] = useState('priceDes');

  const changeKeyword = (value: string) => {
    setKeyword(value);
  };

  const changeSortBy = (value: string) => {
    setSortBy(value);
  };

  const settingOrderList = async () => {
    const data = await fetchSymbolList(sortBy, 'USDT');
    if (data) {
      setOrderData([...data]);
    }
  };

  useEffect(() => {
    settingOrderList();
    const intervalId = setInterval(settingOrderList, 5000); // 5초마다 데이터 갱신

    return () => clearInterval(intervalId);
  }, [sortBy]);

  if (!symbolData) {
    return <div className='border rounded-md p-3 h-[400px] w-[300px]'></div>;
  }

  const filteredList = symbolData.filter((item) =>
    item.symbol.toLowerCase().includes(keyword.toLowerCase())
  );

  return (
    <div className='relative rounded-lg border h-[500px] w-[300px] overflow-scroll'>
      <div className='sticky top-0 bg-white flex flex-col w-full pt-3 px-3 gap-2 text-sm'>
        <SymbolSearchBar onChange={changeKeyword} />
        <div className='flex justify-between'>
          <p>USDT</p>
          <Dropdown list={SORT_MENU} value={sortBy} onClick={changeSortBy} />
        </div>
      </div>
      <ul className='flex flex-col'>
        {filteredList.map((item) => (
          <li key={item.symbol}>
            <SymbolListItem symbol={item} />
          </li>
        ))}
      </ul>
    </div>
  );
}

그리고 실제 컴포넌트에서 setInterval을 통해 데이터를 매번 새롭게 가져오면 된다. 나는 실시간 데이터가 그렇게 중요하진 않을 것 같다고 판단해 5초의 시간을 넣어줬다.

추가적으로 검색 기능까지 넣어줬다. 어차피 이미 전체 배열을 가지고 있기 때문에 별도의 debounce기능을 넣어주진 않았다.

export default function SymbolListItem({ symbol }: Props) {
  const isPlus = Number(symbol.priceChangePercent) > 0;
  return (
    <Link href={`/en/trade/${symbol.symbol}`}>
      <div className='py-2 px-3 flex justify-between hover:bg-gray-100 text-sm'>
        <p>{symbol.symbol}</p>
        <div className='flex gap-5 text-center items-center'>
          <p>{formatNumber(symbol.lastPrice)}</p>
          <p
            className={`${isPlus ? 'text-green-500' : 'text-red-600'} text-xs`}
          >
            {isPlus && '+'}
            {symbol.priceChangePercent}%
          </p>
        </div>
      </div>
    </Link>
  );
}

이제 각 symbol에 대한 정보중, 상하향을 표현해주기 위해 isPlus라는 값을 만들어줬다. 상승일때는 +를 추가하고 한다. 퍼센트가 상승일때는 퍼센트만 오기 때문에 +를 추가해주고, -인 경우에는 그대로 넣어준다. Image 그렇게 만들어진 컴포넌트가 이러하다. 검색도 잘 이뤄지고, 각 가상화폐의 가격과 상하향까지 잘 보인다.

하지만 문제점은 실세 서비스에서는 이 데이터도 웹소켓으로 받는다는 것이다. 심지어 몇초 간격으로 데이터를 요청할지도 설정할수 있다. 이래서 공식문서를 잘 읽어봐야하는 것이다... 그래서 이 로직은 불필요한 로직도 포함되어 있고, 브라우저에서 추가 요청을 보내는 점에서 비효율적이기 때문에 리팩토링이 필요하다.

마무리

포스팅을 짧게짧게 하겠다. 포스팅이 길어지면 뭔가 늘어지는 기분이다. 하나씩 하나씩 정리해보겠다.

개의 댓글