Skip to content
Open
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
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080
570 changes: 378 additions & 192 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@stomp/stompjs": "^7.2.1",
"@tanstack/react-query": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand All @@ -56,6 +57,7 @@
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
"sockjs-client": "^1.6.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
Expand All @@ -68,6 +70,7 @@
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/sockjs-client": "^1.5.4",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
Expand Down
210 changes: 207 additions & 3 deletions src/components/StockChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { createChart, ColorType, IChartApi, UTCTimestamp } from "lightweight-charts";
import { cn } from "@/lib/utils";
import { chartService } from "@/services/chartService";
import { websocketService, StockExecutionData, StockAskBidData } from "@/services/websocketService";
import { ChartData } from "@/types/chart";

interface StockChartProps {
Expand Down Expand Up @@ -54,6 +55,7 @@ const StockChart = ({ stockCode, basePrice, change }: StockChartProps) => {
const [chartData, setChartData] = useState<ChartData[]>([]);
const [loading, setLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [shortCode, setShortCode] = useState<string | null>(null);
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartInstanceRef = useRef<IChartApi | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -63,6 +65,8 @@ const StockChart = ({ stockCode, basePrice, change }: StockChartProps) => {
const isLoadingRef = useRef(false);
const shouldLoadMoreRef = useRef(false);
const isInitialLoadRef = useRef(true); // 초기 로드 여부 추적
const lastUpdateTimeRef = useRef<string | null>(null); // 마지막 실시간 업데이트 시간
const realtimeCandleRef = useRef<Map<string, ChartData>>(new Map()); // 실시간 캔들 데이터 저장

const isPositive = change >= 0;
const selectedOption = periodGroups[selectedGroupIndex].options[selectedOptionIndex];
Expand Down Expand Up @@ -148,6 +152,9 @@ const StockChart = ({ stockCode, basePrice, change }: StockChartProps) => {

setChartData(response.candles);
nextDateTimeRef.current = response.nextDateTime;

// stockCode가 실제로는 shortCode이므로 WebSocket 구독에 사용
setShortCode(response.stockCode);
} catch (error) {
console.error("Failed to fetch chart data:", error);
setChartData([]);
Expand All @@ -158,6 +165,9 @@ const StockChart = ({ stockCode, basePrice, change }: StockChartProps) => {
};

useEffect(() => {
// 차트 변경 시 실시간 캔들 데이터 초기화
realtimeCandleRef.current.clear();
lastUpdateTimeRef.current = null;
loadInitialData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stockCode, selectedGroupIndex, selectedOptionIndex]);
Expand Down Expand Up @@ -258,15 +268,15 @@ const StockChart = ({ stockCode, basePrice, change }: StockChartProps) => {
// 최대 줌 아웃 제한 (interval별로 다르게 설정)
let maxVisibleBars: number;
if (selectedOption.interval.startsWith('min:')) {
maxVisibleBars = 200; // 분봉: 최대 200개 캔들
maxVisibleBars = 300; // 분봉: 최대 300개 캔들
} else if (selectedOption.interval.startsWith('day:')) {
maxVisibleBars = 180; // 일봉: 최대 180개 캔들 (약 6개월)
} else if (selectedOption.interval.startsWith('week:')) {
maxVisibleBars = 104; // 주봉: 최대 104개 캔들 (약 2년)
} else if (selectedOption.interval.startsWith('month:')) {
maxVisibleBars = 60; // 월봉: 최대 60개 캔들 (약 5년)
maxVisibleBars = 110; // 월봉: 최대 110개 캔들 (약 9년)
} else {
maxVisibleBars = 20; // 년봉: 최대 20개 캔들
maxVisibleBars = 30; // 년봉: 최대 30개 캔들
}

if (rangeSize > maxVisibleBars) {
Expand Down Expand Up @@ -477,6 +487,200 @@ const StockChart = ({ stockCode, basePrice, change }: StockChartProps) => {
}
}, [chartData, selectedOption.interval, chartType]);

// WebSocket 구독 관리 (모든 차트 타입에서 실시간 데이터 구독)
useEffect(() => {
// shortCode가 없으면 구독하지 않음
if (!shortCode) {
return;
}

console.log('[StockChart] Subscribing to WebSocket for:', shortCode);

// 선택된 차트 간격 파싱
const [intervalType, intervalValueStr] = selectedOption.interval.split(':');
const intervalValue = parseInt(intervalValueStr);

// WebSocket 구독
websocketService.subscribe(
shortCode,
// onAskBid - 호가 데이터 (차트에는 사용하지 않음)
(askBidData: StockAskBidData) => {
console.log('[StockChart] AskBid data received:', askBidData);
// 호가 데이터는 차트에 반영하지 않음
},
// onExecution - 체결 데이터 (차트에 반영)
(executionData: StockExecutionData) => {
console.log('[StockChart] Execution data received:', executionData);

// businessDate(YYYYMMDD)와 executionTime(HHmmss)을 결합
const parseExecutionDateTime = (businessDate: string, executionTime: string): string => {
if (businessDate.length === 8 && executionTime.length === 6) {
const year = businessDate.substring(0, 4);
const month = businessDate.substring(4, 6);
const day = businessDate.substring(6, 8);
const hour = executionTime.substring(0, 2);
const minute = executionTime.substring(2, 4);
const second = executionTime.substring(4, 6);

return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
}

// 파싱 실패 시 현재 KST 시간 반환
const kstNow = new Date(Date.now() + 9 * 60 * 60 * 1000);
return kstNow.toISOString().slice(0, 19);
};
Comment on lines +516 to +531
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

타임스탬프 파싱 실패 시 에러 처리를 개선하세요.

Lines 528-530에서 파싱 실패 시 현재 KST 시간으로 폴백하는 로직이 데이터 이슈를 은폐할 수 있습니다. 잘못된 타임스탬프로 차트 데이터가 생성되면 시각화가 왜곡될 수 있습니다.

파싱 실패 시 명시적으로 에러를 로깅하거나, onError 콜백을 통해 상위로 전파하는 것을 고려하세요.

🔍 에러 로깅 추가 제안
        const parseExecutionDateTime = (businessDate: string, executionTime: string): string => {
          if (businessDate.length === 8 && executionTime.length === 6) {
            const year = businessDate.substring(0, 4);
            const month = businessDate.substring(4, 6);
            const day = businessDate.substring(6, 8);
            const hour = executionTime.substring(0, 2);
            const minute = executionTime.substring(2, 4);
            const second = executionTime.substring(4, 6);

            return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
          }

          // 파싱 실패 시 현재 KST 시간 반환
+         console.error('[StockChart] Failed to parse execution time:', { businessDate, executionTime });
          const kstNow = new Date(Date.now() + 9 * 60 * 60 * 1000);
          return kstNow.toISOString().slice(0, 19);
        };
🤖 Prompt for AI Agents
In @src/components/StockChart.tsx around lines 516 - 531, The
parseExecutionDateTime function currently falls back to the current KST
timestamp on parse failure which hides data issues; change
parseExecutionDateTime to stop silently returning KST and instead surface the
failure by either (a) accepting an optional onError callback parameter and
invoking onError(new Error(...)) with a descriptive message then returning null,
or (b) logging the error via console.error/processLogger and throwing a specific
Error; update all callers of parseExecutionDateTime to handle a null/exception
result (skip the datapoint or mark it invalid) so malformed timestamps are not
silently visualized.


const executionDateTime = parseExecutionDateTime(executionData.businessDate, executionData.executionTime);
console.log('[StockChart] Parsed execution dateTime:', executionDateTime);

// 차트 간격에 맞춰 시간을 정규화
const normalizeDateTime = (dateTimeStr: string, type: string, value: number): string => {
const date = new Date(dateTimeStr);

switch (type) {
case 'min': {
// 분봉: 분 단위로 정규화
const minutes = date.getMinutes();
const normalizedMinutes = Math.floor(minutes / value) * value;
date.setMinutes(normalizedMinutes);
date.setSeconds(0);
break;
}
case 'day': {
// 일봉: 날짜 단위로 정규화 (시간/분/초 = 0)
date.setHours(0, 0, 0, 0);
break;
}
case 'week': {
// 주봉: 주의 시작일(월요일)로 정규화
const dayOfWeek = date.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // 일요일이면 6일 전, 아니면 요일-1
date.setDate(date.getDate() - diff);
date.setHours(0, 0, 0, 0);
break;
}
case 'month': {
// 월봉: 월의 1일로 정규화
date.setDate(1);
date.setHours(0, 0, 0, 0);
break;
}
case 'year': {
// 년봉: 연도의 1월 1일로 정규화
date.setMonth(0, 1);
date.setHours(0, 0, 0, 0);
break;
}
}

const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
const second = String(date.getSeconds()).padStart(2, '0');

return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
};

const normalizedDateTime = normalizeDateTime(executionDateTime, intervalType, intervalValue);

if (!seriesRef.current) return;

const currentPrice = parseFloat(executionData.currentPrice);
const volume = parseInt(executionData.executionVolume);
const accumulatedAmount = parseFloat(executionData.accumulatedTradeAmount);

// timestamp로 변환
const parseKSTtoTimestamp = (dateTimeStr: string): UTCTimestamp => {
const [datePart, timePart] = dateTimeStr.split('T');
const [year, month, day] = datePart.split('-').map(Number);
const [hour, minute, second] = timePart.split(':').map(Number);
const timestamp = Date.UTC(year, month - 1, day, hour, minute, second);
return Math.floor(timestamp / 1000) as UTCTimestamp;
};

const timestamp = parseKSTtoTimestamp(normalizedDateTime);

// chartData에서 기존 캔들 찾기
const existingIndex = chartData.findIndex((item) => item.dateTime === normalizedDateTime);

let realtimeCandle: ChartData;
let updatedChartData: ChartData[];

if (existingIndex >= 0) {
// chartData에 이미 존재하는 캔들 업데이트
const existingCandle = chartData[existingIndex];
realtimeCandle = {
...existingCandle,
high: Math.max(existingCandle.high, currentPrice),
low: Math.min(existingCandle.low, currentPrice),
close: currentPrice,
volume: existingCandle.volume + volume,
accumulatedAmount: accumulatedAmount,
};

// chartData 업데이트 (불변성 유지)
updatedChartData = [...chartData];
updatedChartData[existingIndex] = realtimeCandle;
setChartData(updatedChartData);
} else {
// 새로운 캔들 생성
realtimeCandle = {
dateTime: normalizedDateTime,
base: basePrice,
open: currentPrice,
high: currentPrice,
low: currentPrice,
close: currentPrice,
volume: volume,
accumulatedAmount: accumulatedAmount,
};

// chartData에 추가 (불변성 유지)
updatedChartData = [...chartData, realtimeCandle];
updatedChartData.sort((a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime());
setChartData(updatedChartData);
}

// 실시간 캔들 저장
realtimeCandleRef.current.set(normalizedDateTime, realtimeCandle);

// 차트에 직접 update (setData가 아닌 update 사용)
if (chartType === 'candlestick') {
seriesRef.current.update({
time: timestamp,
open: realtimeCandle.open,
high: realtimeCandle.high,
low: realtimeCandle.low,
close: realtimeCandle.close,
});
} else {
seriesRef.current.update({
time: timestamp,
value: realtimeCandle.close,
});
}

lastUpdateTimeRef.current = normalizedDateTime;
},
// onReply
(response) => {
console.log('[StockChart] Subscription response:', response);
},
// onError
(error) => {
console.error('[StockChart] WebSocket error:', error);
}
);

// 컴포넌트 언마운트 또는 stockCode 변경 시 구독 해제
return () => {
console.log('[StockChart] Unsubscribing from WebSocket for:', shortCode);
websocketService.unsubscribe(shortCode);
};
}, [shortCode, selectedOption.interval, basePrice, chartType, chartData]);
Comment on lines +490 to +682
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

WebSocket 구독 effect의 의존성 배열이 재구독 루프를 발생시킵니다.

Line 682의 의존성 배열에 chartData가 포함되어 있어 심각한 문제가 발생합니다:

  1. 실시간 데이터가 도착하면 chartData가 업데이트됩니다 (lines 626, 643)
  2. chartData 변경으로 effect가 재실행됩니다
  3. 재실행 시 이전 구독이 해제되고 새로운 구독이 생성됩니다 (line 680)
  4. 이 과정이 모든 데이터 업데이트마다 반복됩니다

이는 불필요한 네트워크 오버헤드를 발생시키고, 구독 해제/재구독 과정에서 데이터 손실 가능성이 있습니다.

🐛 의존성 배열 수정 제안
-  }, [shortCode, selectedOption.interval, basePrice, chartType, chartData]);
+  }, [shortCode, selectedOption.interval, chartType]);

chartData를 의존성에서 제거하고, 대신 콜백 내부에서 setChartData의 함수형 업데이트를 사용하여 최신 상태에 접근하세요. basePrice는 새 캔들 생성 시에만 필요하므로 ref로 관리하거나 stock 정보에서 직접 조회하는 것을 고려하세요.

Committable suggestion skipped: line range outside the PR's diff.


const handleGroupChange = (groupIndex: number) => {
setSelectedGroupIndex(groupIndex);
setSelectedOptionIndex(0);
Expand Down
8 changes: 5 additions & 3 deletions src/pages/StockDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,11 @@ const StockDetail = () => {
</div>

{/* Chart */}
<div className="mb-6">
<StockChart basePrice={stock.price} change={stock.change} />
</div>
{code && (
<div className="mb-6">
<StockChart stockCode={code} basePrice={stock.price} change={stock.change} />
</div>
)}

{/* Info Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
Expand Down
2 changes: 1 addition & 1 deletion src/services/chartService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChartResponse, ApiResponse } from "@/types/chart";

const API_BASE_URL = import.meta.env.LOCAL_API_BASE_URL || "http://localhost:8080";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if VITE_API_BASE_URL is defined in production environment

echo "=== Listing all .env files ==="
fd -H "^\.env" --type f

echo "=== Checking VITE_API_BASE_URL in all .env files ==="
fd -H "^\.env" --type f --exec grep -H "VITE_API_BASE_URL" {}

Repository: SimuDAQ/Simudaq-client

Length of output: 225


프로덕션 환경 설정 파일 누락

.env.production 파일이 없어서 프로덕션 빌드 시 VITE_API_BASE_URL이 정의되지 않으며, 결과적으로 로컬호스트 폴백값(http://localhost:8080)이 사용됩니다. .env.production 파일을 생성하고 적절한 프로덕션 API 엔드포인트를 설정해야 합니다.

🤖 Prompt for AI Agents
In @src/services/chartService.ts at line 3, chartService.ts defines API_BASE_URL
from import.meta.env.VITE_API_BASE_URL falling back to localhost; create a
.env.production at the repo root and set VITE_API_BASE_URL to your production
API endpoint so the production build picks up the correct URL, then verify your
CI/build uses the .env.production and that the variable is available during the
Vite build (and avoid committing any sensitive secrets).

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for LOCAL_API_BASE_URL in all source files and config files
echo "=== Searching for LOCAL_API_BASE_URL in entire codebase ==="
rg -i "LOCAL_API_BASE_URL" --type-add 'env:*.env*' -t env -t ts -t tsx -t js -t jsx -t json -t yaml -t yml -C2

echo -e "\n=== Checking for VITE_API_BASE_URL definition ==="
rg "VITE_API_BASE_URL" --type-add 'env:*.env*' -t env -t json -C2

echo -e "\n=== Listing all .env files ==="
fd -e env -e "env.*" | head -20

Repository: SimuDAQ/Simudaq-client

Length of output: 310


🏁 Script executed:

#!/bin/bash
# List all .env files and their content
echo "=== All .env files in repository ==="
find . -maxdepth 2 -name ".env*" -type f 2>/dev/null | sort

echo -e "\n=== Content of .env files ==="
find . -maxdepth 2 -name ".env*" -type f 2>/dev/null | while read file; do
  echo "--- $file ---"
  cat "$file"
  echo ""
done

echo "=== Search for API_BASE_URL or api references ==="
rg -i "api.*url|base.*url" --type ts --type js --type json -C1 | head -50

echo "=== Check for LOCAL in entire codebase ==="
rg "LOCAL_API" . --type ts --type js 2>/dev/null

Repository: SimuDAQ/Simudaq-client

Length of output: 1859


VITE_API_BASE_URL 마이그레이션이 불완전합니다

src/services/stockService.ts에서 여전히 LOCAL_API_BASE_URL을 사용하고 있습니다. 이 파일도 VITE_API_BASE_URL로 변경해야 합니다. 추가로 프로덕션 환경 설정 파일(.env.production)이 없어서, 프로덕션 환경에서 VITE_API_BASE_URL이 정의되지 않으면 런타임 오류가 발생할 수 있습니다.

영향을 받는 파일
  • src/services/stockService.ts: LOCAL_API_BASE_URLVITE_API_BASE_URL로 변경 필요
  • .env.production (또는 프로덕션 환경 설정): VITE_API_BASE_URL 정의 추가 필요
🤖 Prompt for AI Agents
In @src/services/chartService.ts at line 3, The constant migration is
incomplete: replace the use of LOCAL_API_BASE_URL in
src/services/stockService.ts with import.meta.env.VITE_API_BASE_URL (matching
the pattern used in chartService.ts, e.g., set API_BASE_URL or equivalent to
import.meta.env.VITE_API_BASE_URL || "http://localhost:8080") and update any
references to LOCAL_API_BASE_URL to use that new variable; additionally add
VITE_API_BASE_URL to your production environment config (e.g., .env.production)
to ensure the variable is defined at runtime to avoid errors.


export interface GetChartParams {
stockCode: string;
Expand Down
Loading