Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3f0724e
chore: shadcn dialog 컴포넌트 추가
Dobbymin Nov 14, 2025
fdbc52b
fix: table 컴포넌트 포맷팅 오류 수정
Dobbymin Nov 14, 2025
4cc62b2
fix: 마켓 정보 조회 api 수정
Dobbymin Nov 17, 2025
0fd6538
feat: 프로필 생성 api 구현
Dobbymin Nov 17, 2025
97a765d
fix: 파일 이름 변경
Dobbymin Nov 17, 2025
35d8062
feat: real time 기능 hook 구현
Dobbymin Nov 17, 2025
4f970c2
feat: order table 기능 구현
Dobbymin Nov 17, 2025
d5126df
feat: 실시간 시세 반영 route handler 구현
Dobbymin Nov 17, 2025
ca99a96
fix: 회원가입 폼 수정
Dobbymin Nov 17, 2025
b8810b2
feat: 주문하기 폼 구현
Dobbymin Nov 17, 2025
801b289
feat: 지갑 관련 타입 작성
Dobbymin Nov 17, 2025
a0bc9fb
feat: 지갑 real time 관련 hook 구현
Dobbymin Nov 17, 2025
32866b6
feat: 실시간 시세 반영 ui 구현
Dobbymin Nov 17, 2025
930ffe0
fix: 코인 정보 가져오기 기능 수정사항 반영
Dobbymin Nov 17, 2025
5ee4e1f
feat: 지갑 및 거래 관련 api 구현
Dobbymin Nov 17, 2025
6ca6823
feat: 내 지갑 정보 가져오기 api 구현
Dobbymin Nov 17, 2025
c05196a
feat: 지갑 관련 store 코드 구현
Dobbymin Nov 17, 2025
0f2233b
feat: 내 보유 코인 보기 관련 util 함수 구현
Dobbymin Nov 17, 2025
c8b8950
feat: 내 거래 내역 보기 기능 추가
Dobbymin Nov 17, 2025
f1e1027
fix: 실시간 코인 시세 가져오기 hook 수정
Dobbymin Nov 17, 2025
a0a50f4
feat: 지갑 페이지 구현 및 컴포넌트 분리
Dobbymin Nov 17, 2025
cb7306c
deploy: ci 테스트에서 build 테스트 제거
Dobbymin Nov 17, 2025
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
4 changes: 1 addition & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,4 @@ jobs:

- name: Lint Code
run: pnpm lint

- name: Build Test
run: pnpm run build

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
Expand Down
61 changes: 61 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 19 additions & 3 deletions src/app/api/market/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";

import { marketInfoHandler } from "@/entities";
import { marketAllAPI, marketInfoHandler } from "@/entities";

export async function GET() {
/**
* 마켓 정보 조회 API
* GET /api/market - 마켓 정보 + 시세 (기본)
* GET /api/market?type=list - 마켓 목록만 조회 (KRW 마켓)
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const type = searchParams.get("type");

// type=list인 경우 마켓 목록만 반환
if (type === "list") {
const markets = await marketAllAPI();
const krwMarkets = markets.filter((market) => market.market.startsWith("KRW-"));
return NextResponse.json({ success: true, data: krwMarkets }, { status: 200 });
}

// 기본: 마켓 정보 + 시세
const data = await marketInfoHandler();
return NextResponse.json({ status: "success", data });
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions src/app/api/ticker/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";

export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const markets = searchParams.get("markets") || "KRW-WAXP";

// Upbit API로 현재가 조회
const response = await fetch(`https://api.upbit.com/v1/ticker?markets=${markets}`, {
headers: {
Accept: "application/json",
},
cache: "no-store",
});

if (!response.ok) {
throw new Error("Failed to fetch ticker from Upbit");
}

const data = await response.json();

return NextResponse.json({ success: true, data }, { status: 200 });
} catch (error) {
console.error("Ticker API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
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

API 응답 형식을 일관성 있게 유지하는 것이 좋습니다. 성공 시에는 { success: true, ... }를 반환하지만, 에러 발생 시에는 { error: "..." }를 반환하고 있습니다. 다른 API와 마찬가지로 { success: false, error: "..." }와 같은 형태로 통일하는 것을 권장합니다.

Suggested change
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });

}
}
137 changes: 133 additions & 4 deletions src/app/api/trade/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

import { createClient } from "@/shared";
import { createClient as createServerClient } from "@/shared/utils/supabase/server";

export async function POST(request: Request) {
try {
const supabase = await createClient();
const supabase = createServerClient(cookies());
const { data: userData, error: userError } = await supabase.auth.getUser();

if (userError || !userData.user) {
Expand All @@ -26,12 +27,140 @@ export async function POST(request: Request) {

// 총 거래 금액 계산
const total_krw = price * amount;
const userId = userData.user.id;
Comment on lines 26 to +30
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

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

Missing input validation for coin_id, price, and amount parameters. The code should validate that:

  • coin_id is in the expected format (e.g., "KRW-XXX")
  • price and amount are positive numbers
  • The values are within reasonable ranges to prevent potential exploits or errors

Copilot uses AI. Check for mistakes.

// 매수/매도에 따른 지갑 잔고 확인 및 업데이트
if (trade_type === "buy") {
// 매수: KRW 차감, 코인 증액
// 1. KRW 잔고 확인
const { data: krwWallet } = await supabase
.from("wallet")
.select("*")
.eq("user_id", userId)
.eq("coin_id", "KRW")
.single();

if (!krwWallet || krwWallet.amount < total_krw) {
return NextResponse.json({ error: "KRW 잔고가 부족합니다." }, { status: 400 });
}

// 2. KRW 차감
const { error: krwUpdateError } = await supabase
.from("wallet")
.update({ amount: krwWallet.amount - total_krw })
.eq("id", krwWallet.id);

if (krwUpdateError) {
console.error("KRW update error:", krwUpdateError);
return NextResponse.json({ error: krwUpdateError.message }, { status: 500 });
}

Comment on lines +36 to +57
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

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

Race condition: The wallet balance checks for buy/sell operations are not atomic. Between checking the balance and updating it, another concurrent request could modify the balance, potentially allowing overdrafts. Consider using database-level constraints or row-level locking to prevent this.

Suggested change
const { data: krwWallet } = await supabase
.from("wallet")
.select("*")
.eq("user_id", userId)
.eq("coin_id", "KRW")
.single();
if (!krwWallet || krwWallet.amount < total_krw) {
return NextResponse.json({ error: "KRW 잔고가 부족합니다." }, { status: 400 });
}
// 2. KRW 차감
const { error: krwUpdateError } = await supabase
.from("wallet")
.update({ amount: krwWallet.amount - total_krw })
.eq("id", krwWallet.id);
if (krwUpdateError) {
console.error("KRW update error:", krwUpdateError);
return NextResponse.json({ error: krwUpdateError.message }, { status: 500 });
}
// Atomic KRW balance check and update using a Postgres function
// You must create the following function in your database:
// CREATE OR REPLACE FUNCTION atomic_wallet_update(user_id uuid, coin_id text, amount numeric)
// RETURNS BOOLEAN AS $$
// DECLARE
// current_amount numeric;
// BEGIN
// SELECT amount INTO current_amount FROM wallet WHERE user_id = atomic_wallet_update.user_id AND coin_id = atomic_wallet_update.coin_id FOR UPDATE;
// IF current_amount < atomic_wallet_update.amount THEN
// RETURN FALSE;
// END IF;
// UPDATE wallet SET amount = current_amount - atomic_wallet_update.amount WHERE user_id = atomic_wallet_update.user_id AND coin_id = atomic_wallet_update.coin_id;
// RETURN TRUE;
// END;
// $$ LANGUAGE plpgsql;
const { data: krwUpdateResult, error: krwUpdateError } = await supabase
.rpc("atomic_wallet_update", {
user_id: userId,
coin_id: "KRW",
amount: total_krw,
});
if (krwUpdateError) {
console.error("KRW update error:", krwUpdateError);
return NextResponse.json({ error: krwUpdateError.message }, { status: 500 });
}
if (!krwUpdateResult) {
return NextResponse.json({ error: "KRW 잔고가 부족합니다." }, { status: 400 });
}

Copilot uses AI. Check for mistakes.
// 3. 코인 증액 (없으면 생성)
const { data: coinWallet } = await supabase
.from("wallet")
.select("*")
.eq("user_id", userId)
.eq("coin_id", coin_id)
.single();

if (coinWallet) {
// 기존 코인 지갑 증액
const { error: coinUpdateError } = await supabase
.from("wallet")
.update({ amount: coinWallet.amount + amount })
.eq("id", coinWallet.id);

if (coinUpdateError) {
console.error("Coin update error:", coinUpdateError);
return NextResponse.json({ error: coinUpdateError.message }, { status: 500 });
}
} else {
// 새 코인 지갑 생성
// coins 테이블에 코인이 없으면 생성
const { error: coinSelectError } = await supabase
.from("coins")
.select("market_id")
.eq("market_id", coin_id)
.single();

if ((coinSelectError as { code?: string }).code === "PGRST116") {
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

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

The error object is being cast to check for the code property using (coinSelectError as { code?: string }). This approach is not type-safe and could lead to runtime errors. Consider using a proper type guard or checking the error structure more safely with optional chaining, e.g., coinSelectError && 'code' in coinSelectError && coinSelectError.code === "PGRST116".

Suggested change
if ((coinSelectError as { code?: string }).code === "PGRST116") {
if (coinSelectError && 'code' in coinSelectError && coinSelectError.code === "PGRST116") {

Copilot uses AI. Check for mistakes.
const { error: coinInsertError } = await supabase
.from("coins")
.insert({ market_id: coin_id, korean_name: coin_id, english_name: coin_id });
if (coinInsertError) {
console.error("Coin insert error:", coinInsertError);
return NextResponse.json({ error: coinInsertError.message }, { status: 500 });
}
}
Comment on lines +86 to +94
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

새로운 코인을 coins 테이블에 추가할 때, korean_nameenglish_namecoin_id를 그대로 사용하고 있습니다. 이는 부정확한 데이터를 생성할 수 있습니다. Upbit API 등에서 정확한 코인 이름을 조회하여 채워 넣거나, 이것이 어렵다면 임시 데이터임을 명확히 알리는 주석과 함께 추후 수정할 수 있도록 TODO를 남겨두는 것이 좋습니다.

Suggested change
if ((coinSelectError as { code?: string }).code === "PGRST116") {
const { error: coinInsertError } = await supabase
.from("coins")
.insert({ market_id: coin_id, korean_name: coin_id, english_name: coin_id });
if (coinInsertError) {
console.error("Coin insert error:", coinInsertError);
return NextResponse.json({ error: coinInsertError.message }, { status: 500 });
}
}
if ((coinSelectError as { code?: string }).code === "PGRST116") {
// TODO: 추후 Upbit API 등을 통해 정확한 코인 이름 조회 로직 추가 필요
const { error: coinInsertError } = await supabase
.from("coins")
.insert({ market_id: coin_id, korean_name: coin_id, english_name: coin_id });
if (coinInsertError) {
console.error("Coin insert error:", coinInsertError);
return NextResponse.json({ error: coinInsertError.message }, { status: 500 });
}
}


const { error: walletInsertError } = await supabase.from("wallet").insert({ user_id: userId, coin_id, amount });

if (walletInsertError) {
console.error("Wallet insert error:", walletInsertError);
return NextResponse.json({ error: walletInsertError.message }, { status: 500 });
}
}
} else if (trade_type === "sell") {
// 매도: 코인 차감, KRW 증액
// 1. 코인 잔고 확인
const { data: coinWallet } = await supabase
.from("wallet")
.select("*")
.eq("user_id", userId)
.eq("coin_id", coin_id)
.single();

if (!coinWallet || coinWallet.amount < amount) {
return NextResponse.json({ error: "코인 잔고가 부족합니다." }, { status: 400 });
}

// 2. 코인 차감
const { error: coinUpdateError } = await supabase
.from("wallet")
.update({ amount: coinWallet.amount - amount })
.eq("id", coinWallet.id);

if (coinUpdateError) {
console.error("Coin update error:", coinUpdateError);
return NextResponse.json({ error: coinUpdateError.message }, { status: 500 });
}

// 3. KRW 증액
const { data: krwWallet } = await supabase
.from("wallet")
.select("*")
.eq("user_id", userId)
.eq("coin_id", "KRW")
.single();

if (krwWallet) {
const { error: krwUpdateError } = await supabase
.from("wallet")
.update({ amount: krwWallet.amount + total_krw })
.eq("id", krwWallet.id);

if (krwUpdateError) {
console.error("KRW update error:", krwUpdateError);
return NextResponse.json({ error: krwUpdateError.message }, { status: 500 });
}
} else {
// KRW 지갑이 없으면 생성 (일반적으로 있어야 하지만 예외 처리)
const { error: krwInsertError } = await supabase
.from("wallet")
.insert({ user_id: userId, coin_id: "KRW", amount: total_krw });

if (krwInsertError) {
console.error("KRW insert error:", krwInsertError);
return NextResponse.json({ error: krwInsertError.message }, { status: 500 });
}
}
}
Comment on lines +32 to +157
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

매수/매도 로직이 여러 개의 await를 포함한 DB 호출로 이루어져 있어 원자성(atomicity)을 보장하지 않습니다. 예를 들어, 매수 시 KRW 잔고를 차감한 후 코인 수량을 늘리는 데 실패하면 사용자의 자산에 불일치가 발생합니다. 이는 심각한 버그로 이어질 수 있습니다. 이와 같은 여러 단계의 DB 업데이트는 반드시 단일 트랜잭션으로 처리되어야 합니다. Supabase의 RPC(PostgreSQL 함수)를 사용하여 전체 거래 로직을 데이터베이스 내에서 원자적으로 실행하도록 리팩터링하는 것을 강력히 권장합니다.

Comment on lines +33 to +157
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

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

The buy/sell operations lack transaction safety. If any step fails after KRW is deducted (e.g., coin wallet update fails), the user loses money without receiving coins. These operations should be wrapped in a database transaction to ensure atomicity, or implement proper rollback logic on errors.

Copilot uses AI. Check for mistakes.

// 거래 데이터 삽입
const { data: tradeData, error: tradeError } = await supabase
.from("trade")
.insert({
user_id: userData.user.id,
user_id: userId,
coin_id,
price,
amount,
Comment on lines 159 to 166
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

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

The trade insertion occurs after all wallet updates, meaning if it fails, the wallet balances have already been modified but no trade record exists. This creates data inconsistency. The trade record insertion should either be part of the same transaction or handled before wallet updates to maintain data integrity.

Copilot uses AI. Check for mistakes.
Expand All @@ -57,7 +186,7 @@ export async function POST(request: Request) {
// 사용자의 거래 내역 조회
export async function GET(request: Request) {
try {
const supabase = await createClient();
const supabase = createServerClient(cookies());
const { data: userData, error: userError } = await supabase.auth.getUser();

if (userError || !userData.user) {
Expand Down
Loading
Loading