Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/app/api/market-order/route.ts
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();
Copy link
Copy Markdown

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를 서비스 키와 함께 사용하도록 수정해야 합니다.

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.

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 that need to bypass RLS policies, you should use the Service Role Key with createClient() from @supabase/supabase-js directly, or create a server-specific client helper. This is mentioned in the PR description's troubleshooting section as a solution for RLS policy violations.

Copilot uses AI. Check for mistakes.
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);
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.
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();
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 { 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 });
}
}
1 change: 1 addition & 0 deletions src/entities/market/model/apis/index.ts
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";
39 changes: 39 additions & 0 deletions src/entities/market/model/apis/market-order.api.ts
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);
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.
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;
};
2 changes: 2 additions & 0 deletions src/entities/market/model/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./useGetMarketOrder";
export * from "./useMarketOrderRealtime";
12 changes: 12 additions & 0 deletions src/entities/market/model/hooks/useGetMarketOrder.ts
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,
});
};
89 changes: 89 additions & 0 deletions src/entities/market/model/hooks/useMarketOrderRealtime.ts
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
Copy link
Copy Markdown

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행)에도 동일하게 적용하는 것을 권장합니다.

}
setIsLoading(false);
} catch (err) {
setError(err as Error);
setIsLoading(false);
}
};

fetchInitialData();
Comment on lines +20 to +51
Copy link
Copy Markdown

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(); // 이 위치에서 호출 제거


// 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 };
};
1 change: 1 addition & 0 deletions src/entities/market/model/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./apis";
export * from "./types";
export * from "./hooks";
1 change: 1 addition & 0 deletions src/entities/market/model/types/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./market-info.type";
export * from "./orderbook-units.type";
6 changes: 6 additions & 0 deletions src/entities/market/model/types/orderbook-units.type.ts
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;
};
Loading
Loading