Project

Next.js로 만드는 BFF with Upbit

Next.js로 BFF(Backend For Frontend) 패턴을 적용하여 Upbit API를 효율적으로 사용하는 방법을 알아보자!

최근에 Next.js를 사용해서 "모의 비트코인 매매" 프로젝트를 진행하게되었다. 별도의 서버를 구축하지 않고, Upbit에서 제공하는 Open API를 활용해서 만들어볼려고 한다.

우선, 내가 혼자서 Next.js 만으로 이 프로젝트를 진행하는 이유는 다음과 같다.

  1. 별도의 Express 서버를 구축하는 방법도 있지만, 아직 서버에 대한 지식이 많이 부족해서 성능이 최적회된 Express 기반의 서버를 구축하기 힘들었다.
  2. 이전에는 바이브 코딩으로 서버를 구축하고, Vercel 로 배포했었으나 조금 번거롭기도 했고, Next.js의 API 라우트를 활용해서 간단한 서버 기능을 대체할 수 있을 것 같았다.
  3. 최근에 Vite + Supabase 조합의 강의를 들으면서, Supabase 만으로도 간단하게 데이터를 저장하고, 인증기능을 구현할 수 있다는 점을 알게되었다.

따라서 이번 프로젝트에서는 Next.js와 Supabase 그리고 Upbit 에서 제공하는 Open API 만 활용해서 프로젝트를 진행하고 있다.

API 문서 살펴보기

우선 어느정도의 UI를 만들고, 코인 정보를 가져오기 위해 Upbit Open API 문서를 살펴보았다.

업비트 Open API 문서 : Upbit Developer Center

여기서 모든 코인 정보를 가져오고 싶었고, Upbit Open API 문서에서 다음과 같은 엔드포인트를 발견할 수 있었다.

페어 목록 조회 GET https://api.upbit.com/v1/market/all

이 API를 호출하면 다음과 같은 응답을 받을 수 있다.

[
  {
    "market": "KRW-BTC",
    "korean_name": "비트코인",
    "english_name": "Bitcoin",
    "market_event": {
      "warning": false,
      "caution": {
        "PRICE_FLUCTUATIONS": false,
        "TRADING_VOLUME_SOARING": false,
        "DEPOSIT_AMOUNT_SOARING": false,
        "GLOBAL_PRICE_DIFFERENCES": false,
        "CONCENTRATION_OF_SMALL_ACCOUNTS": false
      }
    }
  },
  {
    "market": "KRW-ETH",
    "korean_name": "이더리움",
    "english_name": "Ethereum",
    "market_event": {
      "warning": true,
      "caution": {
        "PRICE_FLUCTUATIONS": false,
        "TRADING_VOLUME_SOARING": false,
        "DEPOSIT_AMOUNT_SOARING": false,
        "GLOBAL_PRICE_DIFFERENCES": false,
        "CONCENTRATION_OF_SMALL_ACCOUNTS": false
      }
    }
  }
]

일단 이 API 에서 필요한 데이터는 market, korean_name, english_name 이 세가지 데이터이다. 나머지는 필요하지 않았다.

그 다음으로는 특정 코인의 현재가 정보를 가져오는 API를 살펴보았다.

페어 단위 현재가 조회 GET https://api.upbit.com/v1/ticker

Query Parameter로 markets 라는 키값에 조회하고자하는 페어 목록을 콤마(,)로 구분하여 전달하면 된다. 예를 들어, KRW-BTC,KRW-ETH 와 같이 전달하면 된다.

이 API를 호출하면 다음과 같은 응답을 받을 수 있다.

[
  {
    "market": "KRW-BTC",
    "trade_date": "20231111",
    "trade_time": "153045",
    "trade_timestamp": 1700179845000,
    "opening_price": 50000000,
    "high_price": 51000000,
    "low_price": 49000000,
    "trade_price": 50500000,
    "prev_closing_price": 49900000,
    "change": "RISE",
    "change_price": 600000,
    "change_rate": 0.012,
    "signed_change_price": 600000,
    "signed_change_rate": 0.012,
    "trade_volume": 0.1,
    "acc_trade_price": 1000000000,
    "acc_trade_price_24h": 2000000000,
    "acc_trade_volume": 20,
    "acc_trade_volume_24h": 40,
    "highest_52_week_price": 60000000,
    "highest_52_week_date": "2023-10-01",
    "lowest_52_week_price": 30000000,
    "lowest_52_week_date": "2023-03-01",
    "timestamp": 1700179845000
  }
]

여기서 내가 필요한 데이터는 trade_price (현재가) 와 change_rate (변동률) 그리고 상승과 하락 여부를 나타내는 change 이 세가지 데이터이다.

이렇게 만들다 보니까 약간의 문제점을 발견할 수 있었다.

  • 불필요한 데이터 전송: 200개 이상의 마켓 정보를 모두 받아야 함
  • 복잡한 데이터 구조: 두 개의 API를 호출해 병합해야 함
  • 클라이언트 부하: 데이터 가공 로직이 클라이언트에 집중됨
  • 보안 이슈: API 키나 로직이 클라이언트에 노출될 위험

이를 해결하기 위해 Backend For Frontend (BFF) 패턴을 적용해보기로 했다. Next.js의 Route Handler와 Server Component를 활용해 효율적인 데이터 파이프라인을 구축했보았다.

BFF(Backend For Frontend) 패턴 만들기

BFF란 무엇인가?

kakao-bff

Backend For Frontend (BFF) 는 프론트엔드에 최적화된 데이터를 제공하기 위한 중간 서버 레이어이다.

BFF는 클라이언트의 요구사항에 맞게 데이터를 가공하고, 여러 백엔드 서비스로부터 데이터를 집계하여 단일 API 엔드포인트를 제공한다. 이를 통해 클라이언트는 복잡한 백엔드 로직을 신경쓰지 않고도 필요한 데이터를 쉽게 얻을 수 있다.

주요 특징

bff-advantages

  1. 데이터 집계(Aggregation): 여러 외부 API를 하나로 통합
  2. 데이터 변환(Transformation): 클라이언트 요구사항에 맞게 가공
  3. 필터링(Filtering): 불필요한 필드 제거, 전송량 최소화
  4. 캐싱(Caching): 서버 레벨에서 응답 캐싱으로 성능 향상

전에 카테캠을 진행하면서 멘토님께서 BFF 패턴에 대해 소개해주셨던 기억이났다. 그때는 BFF 패턴이 왜 필요한지, 어떻게 만드는건지 잘 몰랐었는데, 이번 프로젝트를 진행하면서 BFF 패턴이 왜 필요한지, 어떻게 만드는건지 조금은 알게된 것 같다.

그때 멘토님께서 알려주신 BFF 관련 블로그 글도 다시 한번 읽어보았다.

카카오페이지는 BFF(Backend For Frontend)를 어떻게 적용했을까?

아키텍처 설계

전체 플로우

bff-architecture

클라이언트가 /api/market을 호출하면 Route Handler가 marketInfoHandler를 실행한다. Handler는 Upbit Market API에서 전체 마켓 리스트(200+ 종목)를 받아온 후, KRW 마켓만 필터링하고 market, korean_name을 추출한다.

그 다음 Upbit Ticker API에서 실시간 시세 데이터를 가져와 두 데이터를 병합한다. 최종적으로 market, koreanName, tradePrice, changeRate 4개 필드만 선택해 정제된 데이터 배열을 JSON으로 반환한다.

본격적으로 BFF 구현하기

1. 외부 API 호출 함수 구현

Market API (마켓 리스트)

// src/entities/market/model/ticker.api.ts
import { UPBIT_URL } from '@/shared';

export interface MarketAllItem {
  market: string;
  korean_name: string;
  english_name: string;
  market_warning: string;
}

export const marketAllAPI = async (): Promise<MarketAllItem[]> => {
  const response = await fetch(`${UPBIT_URL}/market/all?isDetails=false`, {
    next: { revalidate: 60 }, // 60초 캐시 (서버 컴포넌트 환경에서)
  });

  if (!response.ok) {
    console.error('Upbit API error', response.status, response.statusText);
    throw new Error('Failed to fetch market data');
  }

  const data: MarketAllItem[] = await response.json();
  return data;
};

포인트

  • next.revalidate: 60: 마켓 리스트는 자주 변하지 않으므로 60초 캐시
  • 타입 안전성을 위한 인터페이스 정의

Ticker API (실시간 시세)

// src/entities/market/model/ticker.api.ts
import { UPBIT_URL } from '@/shared';

export type ChangeState = 'RISE' | 'EVEN' | 'FALL';

export interface TickerResponse {
  market: string;
  trade_price: number;
  signed_change_rate: number;
  change: ChangeState;
  prev_closing_price: number;
  opening_price: number;
  high_price: number;
  low_price: number;
  trade_timestamp: number;
  // ... 기타 필드
}

export const tickerAPI = async (
  markets: string[],
): Promise<TickerResponse[]> => {
  const marketsQuery = markets.join(',');

  const response = await fetch(`${UPBIT_URL}/ticker?markets=${marketsQuery}`, {
    cache: 'no-store', // 실시간 데이터이므로 캐시 비활성화
  });

  if (!response.ok) {
    throw new Error('Failed to fetch ticker data');
  }

  return response.json();
};

포인트

  • cache: "no-store": 실시간 시세는 캐시하지 않음
  • 배열 파라미터로 여러 마켓을 한 번에 조회

2. Handler 구현 (데이터 집계 & 변환)

// src/entities/market/handler/market-info.handler.ts
import { marketAllAPI, tickerAPI } from '../model';

export const marketInfoHandler = async () => {
  // 1. 전체 마켓 리스트 조회
  const marketData = await marketAllAPI();

  // 2. KRW 마켓만 필터링 & 필요한 필드만 추출
  const KRWmarketList = marketData
    .filter((item) => item.market.startsWith('KRW-'))
    .map((item) => ({
      market: item.market,
      korean_name: item.korean_name,
    }));

  // 3. 실시간 시세 조회
  const tickerData = await tickerAPI(KRWmarketList.map((item) => item.market));

  // 4. 두 데이터 병합 & 최종 형태로 변환
  const result = tickerData.map((ticker) => {
    const marketInfo = KRWmarketList.find((m) => m.market === ticker.market);

    return {
      market: ticker.market,
      koreanName: marketInfo?.korean_name || '',
      tradePrice: ticker.trade_price,
      changeRate: parseFloat((ticker.signed_change_rate * 100).toFixed(2)),
    };
  });

  return result;
};

핵심 로직

  1. 필터링: 200+ 마켓 중 KRW-로 시작하는 원화 마켓만 선택
  2. 병합: market 필드를 기준으로 마켓 정보와 시세 정보 결합
  3. 변환:
    • signed_change_rate를 퍼센트로 변환 (× 100)
    • 소수 둘째 자리까지 반올림
    • 클라이언트에 필요한 4개 필드만 반환

3. Route Handler 구현

// src/app/api/market/route.ts
import { NextResponse } from 'next/server';

import { marketInfoHandler } from '@/entities/market/handler/market-info.handler';

export async function GET() {
  try {
    const data = await marketInfoHandler();

    return NextResponse.json({
      success: true,
      data,
    });
  } catch (error) {
    console.error('Market API error:', error);

    return NextResponse.json(
      {
        success: false,
        error: 'Failed to fetch market data',
      },
      { status: 500 },
    );
  }
}

포인트

  • 에러 핸들링 추가
  • 일관된 응답 형식 (success, data/error)
  • HTTP 상태 코드 적절히 반환

4. 클라이언트에서 BFF API 호출

tanstack-query를 사용한 데이터 페칭

import { useQuery } from '@tanstack/react-query';

import { marketAPI } from '../apis';

export const useGetMarketInfo = () => {
  return useQuery({
    queryKey: ['market-info'],
    queryFn: marketAPI,
    refetchInterval: 10000, // 10초 폴링
  });
};

5. 실제 응답 예시

API 응답

{
  "success": true,
  "data": [
    {
      "market": "KRW-BTC",
      "koreanName": "비트코인",
      "tradePrice": 50500000,
      "changeRate": 1.2
    },
    {
      "market": "KRW-ETH",
      "koreanName": "이더리움",
      "tradePrice": 3500000,
      "changeRate": -0.5
    }
    // ... 기타 마켓
  ]
}

성공적으로 BFF 패턴을 적용하여 Upbit API를 효율적으로 사용할 수 있게 되었다. 클라이언트는 단일 엔드포인트(/api/market)만 호출하면 필요한 모든 데이터를 얻을 수 있다.

Dobbit BFF