Skip to content

Conversation

@Dobbymin
Copy link
Owner

@Dobbymin Dobbymin commented Nov 13, 2025

📝 요약 (Summary)

Supabase Realtime을 활용한 실시간 호가창 시스템 구축. Upbit API에서 1초마다 데이터를 수집하여 Supabase에 저장하고, WebSocket을 통해 실시간으로 클라이언트에 전달하는 아키텍처를 구현했습니다.

✅ 주요 변경 사항 (Key Changes)

  • Supabase market_orders 테이블 생성 및 Realtime 설정
  • Upbit API → Supabase 동기화 API 구현 (/api/market-order-sync)
  • Realtime 구독 커스텀 훅 구현 (useMarketOrderRealtime)
  • 호가창 UI 컴포넌트 완성 (거래량 그래프 바 포함)
  • React Query를 활용한 데이터 동기화 최적화
  • 1초 간격 자동 갱신 시스템 구축

💻 상세 구현 내용 (Implementation Details)

1. API 구현

/src/app/api/market-order-sync/route.ts

// POST: Upbit → Supabase 동기화
export async function POST(request: Request) {
  const { market } = await request.json();
  
  // 1. Upbit API 호출
  const upbitData = await marketOrderAPI(market);
  
  // 2. Supabase UPSERT
  const { error } = await supabase
    .from("market_orders")
    .upsert({
      market,
      orderbook_units: upbitData.orderbook_units as unknown as Json,
      timestamp: upbitData.timestamp,
    }, { onConflict: "market" });
    
  return NextResponse.json({ success: true });
}

// GET: Supabase에서 데이터 조회
export async function GET(request: Request) {
  const market = request.nextUrl.searchParams.get("market");
  const { data } = await supabase
    .from("market_orders")
    .select("*")
    .eq("market", market)
    .single();
    
  return NextResponse.json(data);
}

핵심 로직:

  • UPSERT: onConflict: "market"로 중복 방지
  • 타입 변환: as unknown as Json으로 JSONB 호환성 확보
  • 에러 핸들링: 400/404/500 상태 코드 반환

2. Realtime 구독 훅

/src/entities/market/model/hooks/useMarketOrderRealtime.ts

export const useMarketOrderRealtime = (market: string) => {
  const [data, setData] = useState<MarketOrder | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const supabase = createClient();

    // 초기 데이터 로드
    const fetchInitialData = async () => {
      const { data: initialData } = await supabase
        .from("market_orders")
        .select("*")
        .eq("market", market)
        .single();
      
      if (initialData) {
        setData({
          ...initialData,
          orderbook_units: initialData.orderbook_units as OrderbookUnit[],
        });
      }
      setIsLoading(false);
    };

    fetchInitialData();

    // Realtime 구독
    const channel = supabase
      .channel(`market_orders:${market}`)
      .on(
        "postgres_changes",
        {
          event: "*",
          schema: "public",
          table: "market_orders",
          filter: `market=eq.${market}`,
        },
        (payload) => {
          console.log("Realtime update:", payload);
          if (payload.new) {
            setData({
              ...(payload.new as MarketOrder),
              orderbook_units: (payload.new as MarketOrder).orderbook_units as OrderbookUnit[],
            });
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [market]);

  return { data, isLoading, error: null };
};

동작 원리:

  1. 초기 로드: SELECT 쿼리로 최신 데이터 가져오기
  2. 채널 구독: market_orders:${market} 채널 생성
  3. 필터링: filter: market=eq.${market}로 특정 마켓만 수신
  4. 이벤트 처리: INSERT/UPDATE 시 payload.new로 상태 업데이트
  5. 클린업: 언마운트 시 removeChannel로 메모리 누수 방지

4. UI 컴포넌트

/src/features/home/components/features/order/OrderTable.tsx

export const OrderTable = () => {
  const market = "KRW-WAXP";
  const { data: marketOrder, isLoading } = useMarketOrderRealtime(market);

  // React Query Mutation으로 동기화
  const { mutate: syncData } = useMutation({
    mutationFn: () => syncMarketOrderAPI(market),
    onError: (error) => console.error("Sync failed:", error),
    onSuccess: () => console.log("Sync successful"),
  });

  // 1초마다 동기화 실행
  useEffect(() => {
    syncData(); // 즉시 실행

    const interval = setInterval(() => {
      syncData();
    }, 1000);

    return () => clearInterval(interval);
  }, [market, syncData]);

  // 최대 거래량 계산 (그래프 바 비율용)
  const maxVolume = Math.max(
    ...marketOrder.orderbook_units.map(o => Math.max(o.bid_size, o.ask_size))
  );

  return (
    <Table>
      {/* 매수 호가 - 파란색 */}
      {bidOrders.map((order) => {
        const volumePercent = (order.bid_size / maxVolume) * 100;
        return (
          <TableRow className="relative bg-blue-100">
            {/* 그래프 바 */}
            <div 
              className="absolute left-0 bg-blue-200" 
              style={{ width: `${volumePercent}%` }} 
            />
            <TableCell className="relative z-10">
              {order.bid_size.toLocaleString()}
            </TableCell>
            <TableCell className="text-increase">
              {order.bid_price.toLocaleString()}
            </TableCell>
          </TableRow>
        );
      })}

      {/* 매도 호가 - 빨간색 */}
      {askOrders.map((order) => {
        const volumePercent = (order.ask_size / maxVolume) * 100;
        return (
          <TableRow className="relative bg-red-100">
            {/* 그래프 바 (오른쪽 정렬) */}
            <div 
              className="absolute right-0 bg-red-200" 
              style={{ width: `${volumePercent}%` }} 
            />
            <TableCell className="text-decrease">
              {order.ask_price.toLocaleString()}
            </TableCell>
            <TableCell className="relative z-10">
              {order.ask_size.toLocaleString()}
            </TableCell>
          </TableRow>
        );
      })}
    </Table>
  );
};

UI 구현 포인트:

  • 거래량 그래프: absolute 포지셔닝으로 배경 바 구현
  • 색상 코딩: 매수(파란색), 매도(빨간색)로 직관적 구분
  • 비율 계산: (order.size / maxVolume) * 100으로 동적 너비
  • z-index: 텍스트가 그래프 위에 표시되도록 z-10 적용

5. 타입 정의

/src/shared/types/database.type.ts

export type Database = {
  public: {
    Tables: {
      market_orders: {
        Row: {
          id: number;
          market: string;
          orderbook_units: Json;
          timestamp: number;
          created_at: string;
          updated_at: string;
        };
        Insert: {
          market: string;
          orderbook_units: Json;
          timestamp: number;
        };
        Update: {
          orderbook_units?: Json;
          timestamp?: number;
        };
      };
    };
  };
};

export type MarketOrder = Database["public"]["Tables"]["market_orders"]["Row"];

🚀 트러블 슈팅 (Trouble Shooting)

1. RLS 정책 위반 오류

문제:

new row violates row-level security policy for table "market_orders"

원인: 익명 클라이언트로 UPSERT 시도 시 RLS 정책이 INSERT/UPDATE를 차단

해결:

-- RLS 정책 수정
CREATE POLICY "Allow all operations for authenticated and anon" 
ON market_orders FOR ALL 
USING (true) 
WITH CHECK (true);

또는 API Route에서 Service Role Key 사용:

import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // Service Role
);

2. JSONB 타입 불일치

문제: OrderbookUnit[] 타입을 JSONB 컬럼에 직접 할당 불가

해결:

// 쓰기 시
orderbook_units: upbitData.orderbook_units as unknown as Json

// 읽기 시
orderbook_units: initialData.orderbook_units as OrderbookUnit[]

3. Realtime 구독 메모리 누수

문제: 컴포넌트 언마운트 후에도 WebSocket 연결 유지

해결:

useEffect(() => {
  const channel = supabase.channel(...).subscribe();
  
  return () => {
    supabase.removeChannel(channel); // 필수!
  };
}, [market]);

4. useEffect 무한 루프

문제: syncData 함수가 매 렌더링마다 재생성되어 무한 루프 발생

해결:

// useMutation의 mutate 함수를 의존성 배열에 추가
useEffect(() => {
  // ...
}, [market, syncData]); // syncData는 안정적인 참조

⚠️ 알려진 이슈 및 참고 사항 (Known Issues & Notes)

1. Upbit API 속도 제한

  • 제한: 초당 10회 요청
  • 현재 설정: 1초마다 1회 호출
  • 주의: 여러 마켓 동시 조회 시 Throttling 필요

2. Realtime 연결 안정성

  • 제한: 동시 채널 수 제한 (Free tier: 200개)
  • 고려사항: 프로덕션 환경에서 연결 재시도 로직 필요
  • 권장:
    channel.on("system", { event: "CHANNEL_ERROR" }, (payload) => {
      console.error("Channel error:", payload);
      // 재연결 로직
    });

3. 성능 최적화 여지

  • 현재: 1초마다 전체 호가 데이터 갱신
  • 개선안:
    • Redis 캐싱 레이어 추가
    • Upbit WebSocket API로 직접 전환
    • 변경된 호가만 PATCH 업데이트

4. 다중 마켓 지원

  • 현재: 단일 마켓(KRW-WAXP) 하드코딩
  • TODO: 마켓 선택 UI 추가 및 동적 구독

5. 에러 바운더리

  • 현재: 컴포넌트 레벨 에러 처리만 존재
  • 권장:
    <ErrorBoundary fallback={<OrderTableError />}>
      <OrderTable />
    </ErrorBoundary>

6. 타입 안정성

  • 주의: JSONB 컬럼은 런타임 검증 없음
  • 권장: Zod 스키마로 런타임 검증 추가
    const OrderbookUnitSchema = z.object({
      ask_price: z.number(),
      bid_price: z.number(),
      ask_size: z.number(),
      bid_size: z.number(),
    });

📸 스크린샷 (Screenshots)

호가창 UI

image
  • 매도 호가: 파란색 배경 + 좌측 정렬 그래프 바
  • 매수 호가: 빨간색 배경 + 우측 정렬 그래프 바
  • 현재가: 검은색 테두리로 구분
  • 거래량: 최대 거래량 대비 비율로 그래프 바 너비 결정

실시간 동기화

  • 1초마다 자동 갱신
  • WebSocket을 통한 즉각적 UI 업데이트
  • 여러 브라우저 창 간 데이터 동기화 확인

#️⃣ 관련 이슈 (Related Issues)

@Dobbymin Dobbymin requested a review from Copilot November 13, 2025 14:21
@Dobbymin Dobbymin self-assigned this Nov 13, 2025
@Dobbymin Dobbymin linked an issue Nov 13, 2025 that may be closed by this pull request
@vercel
Copy link

vercel bot commented Nov 13, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
dobbit Ready Ready Preview Comment Nov 13, 2025 2:21pm

@gemini-code-assist
Copy link

Summary of Changes

Hello @Dobbymin, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 PR은 Upbit API에서 실시간 호가 데이터를 가져와 Supabase에 저장하고, 이를 WebSocket을 통해 클라이언트 UI에 실시간으로 반영하는 시스템을 구축합니다. 이를 통해 사용자들은 최신 시장 데이터를 즉각적으로 확인할 수 있으며, 거래량에 따른 시각적인 피드백도 제공됩니다.

Highlights

  • 실시간 호가창 시스템 구축: Supabase Realtime을 활용하여 Upbit API에서 1초마다 호가 데이터를 수집하고, 이를 Supabase에 저장한 후 WebSocket을 통해 클라이언트에 실시간으로 전달하는 아키텍처를 구현했습니다.
  • 데이터베이스 및 API 구현: Supabase에 market_orders 테이블을 생성하고 Realtime 기능을 설정했습니다. 또한, Upbit 데이터를 Supabase에 동기화하는 /api/market-order API 엔드포인트를 구현했습니다.
  • 실시간 데이터 구독 및 UI 통합: Supabase Realtime 구독을 위한 커스텀 훅 (useMarketOrderRealtime)을 개발하고, 이를 활용하여 거래량 그래프 바를 포함한 실시간 호가창 UI 컴포넌트 (OrderTable)를 완성했습니다.
  • 성능 최적화 및 자동 갱신: React Query를 사용하여 데이터 동기화를 최적화하고, 1초 간격으로 Upbit 데이터를 Supabase에 자동 갱신하는 시스템을 구축하여 최신 정보를 유지합니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

이 PR은 Supabase Realtime과 Upbit API를 연동하여 실시간 호가창을 구현하는 중요한 기능 추가입니다. 전반적인 아키텍처는 잘 설계되었으나, 실제 운영 환경에서 발생할 수 있는 몇 가지 심각한 문제점들이 보입니다. 특히 데이터 동기화 방식, API 인증, 그리고 실시간 데이터 처리의 정확성과 관련된 부분에서 수정이 필요합니다. 아래에 각 파일별로 상세한 리뷰와 코드 제안을 남겼으니 확인 부탁드립니다.

}

// Supabase에 저장 (upsert)
const supabase = createClient();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

API 라우트에서 Supabase에 데이터를 upsert할 때 클라이언트용 createClient()를 사용하고 있습니다. 이 함수는 익명(anon) 키를 사용하는 브라우저 클라이언트를 생성하므로, market_orders 테이블의 RLS(행 수준 보안) 정책에 의해 쓰기 작업이 차단됩니다.

서버 환경에서는 RLS를 우회하기 위해 서비스 역할(service role) 키를 사용하는 서버용 클라이언트를 생성해야 합니다. PR 설명에 언급하신 것처럼 @supabase/supabase-jscreateClient를 서비스 키와 함께 사용하도록 수정해야 합니다.

Comment on lines +24 to +34
useEffect(() => {
// 즉시 실행
syncData();

// 1초마다 동기화
const interval = setInterval(() => {
syncData();
}, 1000);

return () => clearInterval(interval);
}, [market, syncData]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

데이터 동기화를 각 클라이언트가 1초마다 setInterval을 통해 개별적으로 트리거하고 있습니다. 만약 여러 사용자가 동시에 이 페이지에 접속하면, (사용자 수) x (초당 1회) 만큼의 요청이 서버로 전송됩니다. 이는 Upbit API의 요청 제한(초당 10회)을 쉽게 초과할 수 있으며, 서버에 불필요한 부하를 유발합니다.

데이터 동기화 로직은 클라이언트에서 분리하여, 서버 측에서 단일 프로세스(예: Cron Job, 별도 워커)가 주기적으로 실행하도록 구현하는 것이 바람직합니다. 클라이언트는 동기화된 데이터를 Supabase Realtime을 통해 수신하기만 해야 합니다.

Comment on lines +20 to +51
useEffect(() => {
const supabase = createClient();

// 초기 데이터 로드
const fetchInitialData = async () => {
try {
const { data: marketData, error: fetchError } = await supabase
.from("market_orders")
.select("*")
.eq("market", market)
.single();

if (fetchError) {
if (fetchError.code !== "PGRST116") {
// 데이터가 없는 경우가 아니라면 에러 처리
throw fetchError;
}
} else if (marketData) {
setData({
market: marketData.market,
orderbook_units: marketData.orderbook_units as OrderbookUnit[],
timestamp: marketData.timestamp,
});
}
setIsLoading(false);
} catch (err) {
setError(err as Error);
setIsLoading(false);
}
};

fetchInitialData();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

초기 데이터를 가져오는 fetchInitialData 함수 호출과 실시간 구독 설정 사이에 경쟁 조건(race condition)이 존재합니다. 만약 초기 데이터를 가져온 직후, 그리고 실시간 구독이 활성화되기 전에 데이터베이스에 변경이 발생하면 해당 업데이트를 놓치게 됩니다.

이 문제를 해결하려면, subscribe의 콜백 함수를 사용하여 채널이 성공적으로 구독되었을 때(SUBSCRIBED 상태) 초기 데이터를 가져오도록 로직을 변경하는 것이 좋습니다. 이렇게 하면 데이터 누락 없이 안전하게 초기 상태를 동기화할 수 있습니다.

예시:

.subscribe(async (status) => {
  if (status === 'SUBSCRIBED') {
    await fetchInitialData();
  }
});

// fetchInitialData(); // 이 위치에서 호출 제거

Comment on lines +64 to +112
{/* 매도 호가 (Ask) - 파란색 */}
{askOrders.map((order, index) => {
const volumePercent = maxVolume > 0 ? (order.ask_size / maxVolume) * 100 : 0;
return (
<TableRow
key={`ask-${order.ask_price}-${index}`}
className='border-b border-white bg-blue-100 text-sm hover:bg-blue-50'
>
<TableCell className='relative w-1/3 p-0'>
{/* 그래프 바 배경 (오른쪽에서 왼쪽으로) */}
<div className='absolute top-0 right-0 h-full bg-blue-200' style={{ width: `${volumePercent}%` }} />
<div className='relative z-10 px-4 py-2 text-end font-roboto text-xs text-black'>
{order.ask_size.toLocaleString()}
</div>
</TableCell>
<TableCell className='relative w-1/3 p-0'>
<div className='relative z-10 px-4 py-2 text-center font-roboto font-semibold text-increase'>
{order.ask_price.toLocaleString()}
</div>
</TableCell>
<TableCell className='w-1/3' />
</TableRow>
);
})}

<TableRow className='flex h-full border-white bg-red-100 px-0 text-sm hover:bg-red-300'>
<TableCell className='w-1/3' />
<TableCell className='w-1/3 text-center font-roboto text-black'>17.9</TableCell>
<TableCell className='w-1/3 text-start font-roboto text-black'>324,130.913</TableCell>
</TableRow>
<TableRow className='flex h-full border-white bg-red-100 px-0 text-sm hover:bg-red-300'>
<TableCell className='w-1/3' />
<TableCell className='w-1/3 text-center font-roboto text-black'>17.9</TableCell>
<TableCell className='w-1/3 text-start font-roboto text-black'>324,130.913</TableCell>
</TableRow>
<TableRow className='flex h-full border-white bg-red-100 px-0 text-sm hover:bg-red-300'>
<TableCell className='w-1/3' />
<TableCell className='w-1/3 text-center font-roboto text-black'>17.9</TableCell>
<TableCell className='w-1/3 text-start font-roboto text-black'>324,130.913</TableCell>
</TableRow>
<TableRow className='flex h-full border-white bg-red-100 px-0 text-sm hover:bg-red-300'>
<TableCell className='w-1/3' />
<TableCell className='w-1/3 text-center font-roboto text-black'>17.9</TableCell>
<TableCell className='w-1/3 text-start font-roboto text-black'>324,130.913</TableCell>
</TableRow>
<TableRow className='flex h-full border-white bg-red-100 px-0 text-sm hover:bg-red-300'>
<TableCell className='w-1/3' />
<TableCell className='w-1/3 text-center font-roboto text-black'>17.9</TableCell>
<TableCell className='w-1/3 text-start font-roboto text-black'>324,130.913</TableCell>
</TableRow>
<TableRow className='flex h-full border-white bg-red-100 px-0 text-sm hover:bg-red-300'>
<TableCell className='w-1/3' />
<TableCell className='w-1/3 text-center font-roboto text-black'>17.9</TableCell>
<TableCell className='w-1/3 text-start font-roboto text-black'>324,130.913</TableCell>
</TableRow>
{/* 매수 호가 (Bid) - 빨간색 */}
{bidOrders.map((order, index) => {
const volumePercent = maxVolume > 0 ? (order.bid_size / maxVolume) * 100 : 0;
return (
<TableRow
key={`bid-${order.bid_price}-${index}`}
className='border-b border-white bg-red-100 text-sm hover:bg-red-50'
>
<TableCell className='w-1/3' />
<TableCell className='relative w-1/3 border-white p-0'>
<div className='relative z-10 px-4 py-2 text-center font-roboto font-semibold text-decrease'>
{order.bid_price.toLocaleString()}
</div>
</TableCell>
<TableCell className='relative w-1/3 border-white p-0'>
{/* 그래프 바 배경 (오른쪽에서 왼쪽으로) */}
<div className='absolute inset-0 right-0 h-full bg-red-200' style={{ width: `${volumePercent}%` }} />
<div className='relative z-10 px-4 py-2 text-start font-roboto text-xs text-black'>
{order.bid_size.toLocaleString()}
</div>
</TableCell>
</TableRow>
);
})}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

호가창 UI의 색상 및 그래프 바 정렬이 코드, 주석, PR 설명, 스크린샷 간에 서로 일치하지 않아 혼란을 유발합니다.

  • 코드 주석: 매도(Ask) - 파란색, 매수(Bid) - 빨간색
  • 코드 구현: askOrdersbg-blue-100, bidOrdersbg-red-100
  • 스크린샷: 매도(위)는 빨간색, 매수(아래)는 파란색
  • PR 설명: 매수 호가: 파란색 배경 + 좌측 정렬 그래프 바, 매도 호가: 빨간색 배경 + 우측 정렬 그래프 바
  • 그래프 바 정렬: 코드에서 askOrdersbidOrders의 그래프 바가 모두 오른쪽(right-0)을 기준으로 정렬되어 있는 것으로 보입니다.

일반적으로 매수는 상승을 의미하는 파란색/초록색, 매도는 하락을 의미하는 빨간색을 사용합니다. 의도한 UI 디자인에 맞게 코드, 주석, 스타일을 통일성 있게 수정해야 합니다.

Comment on lines +38 to +42
setData({
market: marketData.market,
orderbook_units: marketData.orderbook_units as OrderbookUnit[],
timestamp: marketData.timestamp,
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

데이터베이스에서 가져온 orderbook_units 데이터를 OrderbookUnit[] 타입으로 강제 형변환(as)하고 있습니다. JSONB 타입은 런타임에 타입 안전성을 보장하지 않으므로, 예기치 않은 데이터 구조로 인해 런타임 에러가 발생할 수 있습니다.

PR 설명에서도 언급하셨듯이, Zod와 같은 라이브러리를 사용하여 런타임에 데이터의 유효성을 검사하고 파싱하는 것이 더 안전한 방법입니다. 이를 통해 타입 안정성을 높이고 잠재적인 버그를 예방할 수 있습니다. 실시간으로 수신되는 payload.new 데이터(72-76행)에도 동일하게 적용하는 것을 권장합니다.

const volumePercent = maxVolume > 0 ? (order.ask_size / maxVolume) * 100 : 0;
return (
<TableRow
key={`ask-${order.ask_price}-${index}`}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

React 리스트 렌더링 시 key prop에 index를 사용하고 있습니다. 실시간으로 데이터가 변경되고 순서가 바뀔 수 있는 리스트에서 indexkey로 사용하면, React가 컴포넌트의 상태를 잘못 재사용하거나 예기치 않은 동작을 유발할 수 있습니다.

호가 데이터에서 ask_price는 각 항목을 고유하게 식별할 수 있으므로, 이를 key로 사용하는 것이 더 안정적입니다. 94행의 bidOrders 렌더링 시에도 동일하게 적용해주세요.

Suggested change
key={`ask-${order.ask_price}-${index}`}
key={`ask-${order.ask_price}`}

</TableCell>
<TableCell className='relative w-1/3 border-white p-0'>
{/* 그래프 바 배경 (오른쪽에서 왼쪽으로) */}
<div className='absolute inset-0 right-0 h-full bg-red-200' style={{ width: `${volumePercent}%` }} />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

매수 호가(bid order)의 거래량 그래프 바에 적용된 Tailwind CSS 클래스가 absolute inset-0 right-0 h-full로 되어 있습니다. inset-0top: 0; right: 0; bottom: 0; left: 0;을 의미하므로 right-0h-full 클래스는 중복되거나 의도치 않은 레이아웃을 유발할 수 있습니다.

의도한 레이아웃을 명확히 표현하기 위해 중복되는 클래스를 제거하는 것이 좋습니다. 예를 들어, askOrders에서 사용된 것처럼 absolute top-0 right-0 h-full로 수정할 수 있습니다.

Suggested change
<div className='absolute inset-0 right-0 h-full bg-red-200' style={{ width: `${volumePercent}%` }} />
<div className='absolute top-0 right-0 h-full bg-red-200' style={{ width: `${volumePercent}%` }} />

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements a real-time order book system using Supabase Realtime and Upbit API integration. The system fetches order book data from Upbit every second, stores it in Supabase, and streams updates to clients via WebSocket subscriptions.

Key Changes:

  • Database schema with market_orders table and Realtime publication
  • API endpoint (/api/market-order) for syncing Upbit data to Supabase
  • Custom React hook (useMarketOrderRealtime) for WebSocket-based data subscriptions
  • Order book UI component with volume visualization bars

Reviewed Changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
supabase/migrations/create_market_orders.sql Creates market_orders table with JSONB storage, indexes, RLS policies, and update trigger for real-time order book data
src/shared/types/database.type.ts Adds TypeScript definitions for market_orders and other database tables (chart, coins, trade, transactions, wallet)
src/features/home/components/features/order/OrderTable.tsx Implements order book UI with real-time updates, volume bars, and 1-second polling; contains color/styling inconsistencies
src/entities/market/model/types/orderbook-units.type.ts Defines OrderbookUnit type structure for ask/bid price and size data
src/entities/market/model/types/index.ts Exports orderbook-units type for use across the application
src/entities/market/model/index.ts Adds hooks export to entity module public API
src/entities/market/model/hooks/useMarketOrderRealtime.ts Custom hook for subscribing to Supabase Realtime updates with initial data fetch and cleanup
src/entities/market/model/hooks/useGetMarketOrder.ts React Query hook for polling Upbit API directly (appears unused in current implementation)
src/entities/market/model/hooks/index.ts Exports market order hooks for external consumption
src/entities/market/model/apis/market-order.api.ts API functions for fetching from Upbit and syncing via internal API endpoint; contains return type mismatch
src/entities/market/model/apis/index.ts Adds market-order API to exports
src/app/api/market-order/route.ts Next.js API route handling Upbit→Supabase sync and queries; uses browser client instead of server client

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

</TableCell>
<TableCell className='relative w-1/3 border-white p-0'>
{/* 그래프 바 배경 (오른쪽에서 왼쪽으로) */}
<div className='absolute inset-0 right-0 h-full bg-red-200' style={{ width: `${volumePercent}%` }} />
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of both inset-0 and right-0 is redundant. inset-0 sets all sides (top, right, bottom, left) to 0, so adding right-0 has no effect. For a bar that grows from left to right, you should use absolute left-0 h-full instead. This will properly anchor the bar to the left side.

Suggested change
<div className='absolute inset-0 right-0 h-full bg-red-200' style={{ width: `${volumePercent}%` }} />
<div className='absolute right-0 h-full bg-red-200' style={{ width: `${volumePercent}%` }} />

Copilot uses AI. Check for mistakes.
return NextResponse.json({ error: "Market is required" }, { status: 400 });
}

const supabase = createClient();
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as line 23 - in an API route (server-side), using createClient() from @/shared creates a browser client, which is not appropriate for server-side operations. For API routes, you should use a server-specific client or the Service Role Key.

Copilot uses AI. Check for mistakes.
const volumePercent = maxVolume > 0 ? (order.ask_size / maxVolume) * 100 : 0;
return (
<TableRow
key={`ask-${order.ask_price}-${index}`}
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using index in the key is not ideal when the list order might change (e.g., prices shifting). If two orders have the same ask_price, this could lead to React rendering issues. Consider using a more stable unique identifier, or at minimum combine with timestamp: key={ask-${order.ask_price}-${marketOrder.timestamp}-${index}}. The same issue applies to line 94 for bid orders.

Suggested change
key={`ask-${order.ask_price}-${index}`}
key={`ask-${order.ask_price}-${marketOrder.timestamp}-${index}`}

Copilot uses AI. Check for mistakes.
});

if (!response.ok) {
console.error("Upbit API error", response.status, response.statusText);
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Console.error statements in production code should be wrapped in development checks or replaced with proper logging. Since this is called every second from the component, these logs will quickly clutter the console. The same applies to line 33.

Copilot uses AI. Check for mistakes.
FOR SELECT
TO anon
USING (true);

Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RLS policies only allow SELECT for anonymous users (line 29), but the API route at /api/market-order uses a browser client to perform UPSERT operations. Since the API route runs with an anonymous client context, it will be blocked by RLS when trying to INSERT or UPDATE. You need to either: 1) Add a policy allowing anonymous users to INSERT/UPDATE, 2) Use a Service Role Key in the API route (as mentioned in the PR description), or 3) Call the API with an authenticated user context.

Suggested change
-- Create policy to allow insert access for anonymous users
CREATE POLICY "Allow insert access for anonymous users"
ON market_orders
FOR INSERT
TO anon
WITH CHECK (true);
-- Create policy to allow update access for anonymous users
CREATE POLICY "Allow update access for anonymous users"
ON market_orders
FOR UPDATE
TO anon
USING (true)
WITH CHECK (true);

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +87
{/* 매도 호가 (Ask) - 파란색 */}
{askOrders.map((order, index) => {
const volumePercent = maxVolume > 0 ? (order.ask_size / maxVolume) * 100 : 0;
return (
<TableRow
key={`ask-${order.ask_price}-${index}`}
className='border-b border-white bg-blue-100 text-sm hover:bg-blue-50'
>
<TableCell className='relative w-1/3 p-0'>
{/* 그래프 바 배경 (오른쪽에서 왼쪽으로) */}
<div className='absolute top-0 right-0 h-full bg-blue-200' style={{ width: `${volumePercent}%` }} />
<div className='relative z-10 px-4 py-2 text-end font-roboto text-xs text-black'>
{order.ask_size.toLocaleString()}
</div>
</TableCell>
<TableCell className='relative w-1/3 p-0'>
<div className='relative z-10 px-4 py-2 text-center font-roboto font-semibold text-increase'>
{order.ask_price.toLocaleString()}
</div>
</TableCell>
<TableCell className='w-1/3' />
</TableRow>
);
})}
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "매도 호가 (Ask) - 파란색" (Ask orders - blue color) but the description states that ask orders should be displayed in red (매도=sell=red). According to the PR description, "매수 호가: 파란색" (buy/bid orders in blue) and "매도 호가: 빨간색" (sell/ask orders in red). The colors are swapped. The ask orders (sell orders) should use red styling (bg-red-100, bg-red-200), not blue.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +112
{/* 매수 호가 (Bid) - 빨간색 */}
{bidOrders.map((order, index) => {
const volumePercent = maxVolume > 0 ? (order.bid_size / maxVolume) * 100 : 0;
return (
<TableRow
key={`bid-${order.bid_price}-${index}`}
className='border-b border-white bg-red-100 text-sm hover:bg-red-50'
>
<TableCell className='w-1/3' />
<TableCell className='relative w-1/3 border-white p-0'>
<div className='relative z-10 px-4 py-2 text-center font-roboto font-semibold text-decrease'>
{order.bid_price.toLocaleString()}
</div>
</TableCell>
<TableCell className='relative w-1/3 border-white p-0'>
{/* 그래프 바 배경 (오른쪽에서 왼쪽으로) */}
<div className='absolute inset-0 right-0 h-full bg-red-200' style={{ width: `${volumePercent}%` }} />
<div className='relative z-10 px-4 py-2 text-start font-roboto text-xs text-black'>
{order.bid_size.toLocaleString()}
</div>
</TableCell>
</TableRow>
);
})}
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "매수 호가 (Bid) - 빨간색" (Bid orders - red color) but bid orders should be displayed in blue according to the PR description. The colors are swapped. The bid orders (buy orders) should use blue styling (bg-blue-100, bg-blue-200), not red.

Copilot uses AI. Check for mistakes.
const volumePercent = maxVolume > 0 ? (order.bid_size / maxVolume) * 100 : 0;
return (
<TableRow
key={`bid-${order.bid_price}-${index}`}
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using index in the key is not ideal when the list order might change. If two orders have the same bid_price, this could lead to React rendering issues. Consider using a more stable unique identifier, or at minimum combine with timestamp: key={bid-${order.bid_price}-${marketOrder.timestamp}-${index}}.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +19
console.error("Sync failed:", error);
},
onSuccess: () => {
console.log("Sync successful");
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Console.error statements in production code can impact performance when called frequently (every second in this case). Consider removing or wrapping in a development-only check: if (process.env.NODE_ENV === 'development') console.error(...). The same applies to the console.log on line 19.

Suggested change
console.error("Sync failed:", error);
},
onSuccess: () => {
console.log("Sync successful");
if (process.env.NODE_ENV === 'development') {
console.error("Sync failed:", error);
}
},
onSuccess: () => {
if (process.env.NODE_ENV === 'development') {
console.log("Sync successful");
}

Copilot uses AI. Check for mistakes.
.single();

if (error) {
console.error("Supabase upsert error:", error);
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While console.error in API routes is acceptable for debugging, consider using a proper logging service in production (e.g., Winston, Pino) for better monitoring and error tracking. The same applies to lines 46 and 73.

Copilot uses AI. Check for mistakes.
@Dobbymin Dobbymin merged commit d9c302d into main Nov 13, 2025
8 of 10 checks passed
@Dobbymin Dobbymin deleted the feat#03-order branch November 13, 2025 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[기능 구현] 호가 기능 구현

2 participants