-
Notifications
You must be signed in to change notification settings - Fork 0
실시간 호가창 구현 #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
실시간 호가창 구현 #4
Changes from all commits
f8b3f80
735ff20
597e7ab
cb41d5f
a0946b9
5b07fc3
9111e46
258d8d0
8b7949f
d109061
9a36f49
1924b0c
1270651
e5c2480
974f54c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
|
|
||
| import { marketOrderAPI } from "@/entities"; | ||
| import { Json, createClient } from "@/shared"; | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| try { | ||
| const { market } = await request.json(); | ||
|
|
||
| if (!market) { | ||
| return NextResponse.json({ error: "Market is required" }, { status: 400 }); | ||
| } | ||
|
|
||
| // Upbit API에서 데이터 가져오기 | ||
| const upbitData = await marketOrderAPI(market); | ||
| const marketData = upbitData[0]; | ||
|
|
||
| if (!marketData) { | ||
| return NextResponse.json({ error: "No data from Upbit API" }, { status: 404 }); | ||
| } | ||
|
|
||
| // Supabase에 저장 (upsert) | ||
| const supabase = createClient(); | ||
|
||
| const { data, error } = await supabase | ||
| .from("market_orders") | ||
| .upsert( | ||
| { | ||
| market: marketData.market, | ||
| orderbook_units: marketData.orderbook_units as unknown as Json, | ||
| timestamp: marketData.timestamp, | ||
| }, | ||
| { | ||
| onConflict: "market", | ||
| }, | ||
| ) | ||
| .select() | ||
| .single(); | ||
|
|
||
| if (error) { | ||
| console.error("Supabase upsert error:", error); | ||
|
||
| return NextResponse.json({ error: error.message }, { status: 500 }); | ||
| } | ||
|
|
||
| return NextResponse.json({ success: true, data }); | ||
| } catch (error) { | ||
| console.error("Sync error:", error); | ||
| return NextResponse.json({ error: "Internal server error" }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| // GET 요청으로 특정 마켓 조회 | ||
| export async function GET(request: NextRequest) { | ||
| try { | ||
| const { searchParams } = new URL(request.url); | ||
| const market = searchParams.get("market"); | ||
|
|
||
| if (!market) { | ||
| return NextResponse.json({ error: "Market is required" }, { status: 400 }); | ||
| } | ||
|
|
||
| const supabase = createClient(); | ||
|
||
| const { data, error } = await supabase.from("market_orders").select("*").eq("market", market).single(); | ||
|
|
||
| if (error) { | ||
| if (error.code === "PGRST116") { | ||
| return NextResponse.json({ error: "Market not found" }, { status: 404 }); | ||
| } | ||
| return NextResponse.json({ error: error.message }, { status: 500 }); | ||
| } | ||
|
|
||
| return NextResponse.json(data); | ||
| } catch (error) { | ||
| console.error("Get error:", error); | ||
| return NextResponse.json({ error: "Internal server error" }, { status: 500 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| export * from "./market-all.api"; | ||
| export * from "./market-order.api"; | ||
| export * from "./ticker.api"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { UPBIT_URL } from "@/shared"; | ||
|
|
||
| import { OrderbookUnit } from "../types"; | ||
|
|
||
| export interface MarketOrderResponse { | ||
| market: string; | ||
| orderbook_units: OrderbookUnit[]; | ||
| timestamp: number; | ||
| } | ||
|
|
||
| export const marketOrderAPI = async (markets: string): Promise<MarketOrderResponse[]> => { | ||
| const response = await fetch(`${UPBIT_URL}/orderbook?markets=${markets}`, { | ||
| cache: "no-store", | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| console.error("Upbit API error", response.status, response.statusText); | ||
|
||
| throw new Error("Failed to fetch market order data"); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| return data; | ||
| }; | ||
|
|
||
| export const syncMarketOrderAPI = async (market: string): Promise<MarketOrderResponse[]> => { | ||
| const response = await fetch("/api/market-order", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ market }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| console.error("Sync failed:", response.status, response.statusText); | ||
| throw new Error("Failed to fetch sync market order data"); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| return data; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from "./useGetMarketOrder"; | ||
| export * from "./useMarketOrderRealtime"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { useQuery } from "@tanstack/react-query"; | ||
|
|
||
| import { marketOrderAPI } from "../apis"; | ||
|
|
||
| export const useGetMarketOrder = (markets: string) => { | ||
| return useQuery({ | ||
| queryKey: ["market-order", markets], | ||
| queryFn: () => marketOrderAPI(markets), | ||
|
|
||
| refetchInterval: 1000, | ||
| }); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useState } from "react"; | ||
|
|
||
| import { createClient } from "@/shared"; | ||
|
|
||
| import { OrderbookUnit } from "../types"; | ||
|
|
||
| export interface MarketOrderRealtimeData { | ||
| market: string; | ||
| orderbook_units: OrderbookUnit[]; | ||
| timestamp: number; | ||
| } | ||
|
|
||
| export const useMarketOrderRealtime = (market: string) => { | ||
| const [data, setData] = useState<MarketOrderRealtimeData | null>(null); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const [error, setError] = useState<Error | null>(null); | ||
|
|
||
| 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, | ||
| }); | ||
|
Comment on lines
+38
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 데이터베이스에서 가져온 PR 설명에서도 언급하셨듯이, Zod와 같은 라이브러리를 사용하여 런타임에 데이터의 유효성을 검사하고 파싱하는 것이 더 안전한 방법입니다. 이를 통해 타입 안정성을 높이고 잠재적인 버그를 예방할 수 있습니다. 실시간으로 수신되는 |
||
| } | ||
| setIsLoading(false); | ||
| } catch (err) { | ||
| setError(err as Error); | ||
| setIsLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| fetchInitialData(); | ||
|
Comment on lines
+20
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 초기 데이터를 가져오는 이 문제를 해결하려면, 예시: .subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await fetchInitialData();
}
});
// fetchInitialData(); // 이 위치에서 호출 제거 |
||
|
|
||
| // Realtime 구독 설정 | ||
| const channel = supabase | ||
| .channel(`market_orders:${market}`) | ||
| .on( | ||
| "postgres_changes", | ||
| { | ||
| event: "*", | ||
| schema: "public", | ||
| table: "market_orders", | ||
| filter: `market=eq.${market}`, | ||
| }, | ||
| (payload) => { | ||
| if (payload.eventType === "INSERT" || payload.eventType === "UPDATE") { | ||
| const newData = payload.new as { | ||
| market: string; | ||
| orderbook_units: unknown; | ||
| timestamp: number; | ||
| }; | ||
|
|
||
| setData({ | ||
| market: newData.market, | ||
| orderbook_units: newData.orderbook_units as OrderbookUnit[], | ||
| timestamp: newData.timestamp, | ||
| }); | ||
| } | ||
| }, | ||
| ) | ||
| .subscribe(); | ||
|
|
||
| // Cleanup | ||
| return () => { | ||
| supabase.removeChannel(channel); | ||
| }; | ||
| }, [market]); | ||
|
|
||
| return { data, isLoading, error }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| export * from "./apis"; | ||
| export * from "./types"; | ||
| export * from "./hooks"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from "./market-info.type"; | ||
| export * from "./orderbook-units.type"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| export type OrderbookUnit = { | ||
| ask_price: number; | ||
| bid_price: number; | ||
| ask_size: number; | ||
| bid_size: number; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API 라우트에서 Supabase에 데이터를
upsert할 때 클라이언트용createClient()를 사용하고 있습니다. 이 함수는 익명(anon) 키를 사용하는 브라우저 클라이언트를 생성하므로,market_orders테이블의 RLS(행 수준 보안) 정책에 의해 쓰기 작업이 차단됩니다.서버 환경에서는 RLS를 우회하기 위해 서비스 역할(service role) 키를 사용하는 서버용 클라이언트를 생성해야 합니다. PR 설명에 언급하신 것처럼
@supabase/supabase-js의createClient를 서비스 키와 함께 사용하도록 수정해야 합니다.