From f650d51f68580634bf5e03648d02c3fbf410934d Mon Sep 17 00:00:00 2001 From: "jihoon87.lee" Date: Wed, 11 Feb 2026 15:27:03 +0900 Subject: [PATCH] =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .tmp/kis-token-cache.json | 2 +- app/api/kis/revoke/route.ts | 46 ++- app/api/kis/validate/route.ts | 49 ++- app/api/kis/ws/approval/route.ts | 48 ++- features/dashboard/apis/kis-auth.api.ts | 102 +++---- .../dashboard/components/auth/KisAuthForm.tsx | 282 +++++++++++------- .../components/orderbook/AnimatedQuantity.tsx | 48 +-- .../components/orderbook/OrderBook.tsx | 12 +- .../dashboard/hooks/useKisTradeWebSocket.ts | 24 +- .../dashboard/store/use-kis-runtime-store.ts | 149 ++++----- features/layout/components/Logo.tsx | 108 +++++++ features/layout/components/header.tsx | 44 ++- lib/kis/approval.ts | 35 +-- lib/kis/request.ts | 42 +++ lib/kis/token.ts | 181 ++++++----- 15 files changed, 667 insertions(+), 505 deletions(-) create mode 100644 features/layout/components/Logo.tsx create mode 100644 lib/kis/request.ts diff --git a/.tmp/kis-token-cache.json b/.tmp/kis-token-cache.json index 7fe2f37..31c1d03 100644 --- a/.tmp/kis-token-cache.json +++ b/.tmp/kis-token-cache.json @@ -1 +1 @@ -{"mock:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000},"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000}} \ No newline at end of file +{"mock:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000},"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6IjQ1ZTBmYTczLWI3ZmEtNDg5Mi1iYmZkLTJkYzdlNWQ2YTFhOCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDg3NDg1NywiaWF0IjoxNzcwNzg4NDU3LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.f4XsiK4WgzzBNbGEP5bNnJ9r4yAfGBb8SOwEZ-D0knygsFqSOGsj1QfjjVIBo7lG5AxAwyrIUdoC-rjqIVCc3A","expiresAt":1770874857000}} \ No newline at end of file diff --git a/app/api/kis/revoke/route.ts b/app/api/kis/revoke/route.ts index 69d431a..1069a56 100644 --- a/app/api/kis/revoke/route.ts +++ b/app/api/kis/revoke/route.ts @@ -1,35 +1,32 @@ import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types"; -import type { KisCredentialInput } from "@/lib/kis/config"; -import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; +import { normalizeTradingEnv } from "@/lib/kis/config"; +import { + parseKisCredentialRequest, + validateKisCredentialInput, +} from "@/lib/kis/request"; import { revokeKisAccessToken } from "@/lib/kis/token"; import { NextRequest, NextResponse } from "next/server"; /** * @file app/api/kis/revoke/route.ts - * @description 사용자 입력 KIS API 키 기반 접근토큰 폐기 라우트 + * @description 사용자 입력 KIS API 키로 액세스 토큰을 폐기합니다. */ /** - * KIS API 접근토큰 폐기 - * @param request appKey/appSecret/tradingEnv JSON 본문 - * @returns 폐기 성공/실패 정보 - * @see features/dashboard/components/dashboard-main.tsx handleRevokeKis - 접근 폐기 버튼 클릭 이벤트 + * @description KIS 액세스 토큰 폐기 + * @see features/dashboard/components/auth/KisAuthForm.tsx */ export async function POST(request: NextRequest) { - const body = (await request.json()) as Partial; + const credentials = await parseKisCredentialRequest(request); + const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); - const credentials: KisCredentialInput = { - appKey: body.appKey?.trim(), - appSecret: body.appSecret?.trim(), - tradingEnv: normalizeTradingEnv(body.tradingEnv), - }; - - if (!hasKisConfig(credentials)) { + const invalidMessage = validateKisCredentialInput(credentials); + if (invalidMessage) { return NextResponse.json( { ok: false, - tradingEnv: normalizeTradingEnv(credentials.tradingEnv), - message: "앱 키와 앱 시크릿을 모두 입력해 주세요.", + tradingEnv, + message: invalidMessage, } satisfies DashboardKisRevokeResponse, { status: 400 }, ); @@ -40,25 +37,22 @@ export async function POST(request: NextRequest) { return NextResponse.json({ ok: true, - tradingEnv: normalizeTradingEnv(credentials.tradingEnv), + tradingEnv, message, } satisfies DashboardKisRevokeResponse); } catch (error) { - const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다."; + const message = + error instanceof Error + ? error.message + : "API 토큰 폐기 중 오류가 발생했습니다."; return NextResponse.json( { ok: false, - tradingEnv: normalizeTradingEnv(credentials.tradingEnv), + tradingEnv, message, } satisfies DashboardKisRevokeResponse, { status: 401 }, ); } } - -interface DashboardKisRevokeRequest { - appKey: string; - appSecret: string; - tradingEnv: "real" | "mock"; -} diff --git a/app/api/kis/validate/route.ts b/app/api/kis/validate/route.ts index dcef284..9df871a 100644 --- a/app/api/kis/validate/route.ts +++ b/app/api/kis/validate/route.ts @@ -1,65 +1,58 @@ -import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types"; -import type { KisCredentialInput } from "@/lib/kis/config"; -import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; +import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types"; +import { normalizeTradingEnv } from "@/lib/kis/config"; +import { + parseKisCredentialRequest, + validateKisCredentialInput, +} from "@/lib/kis/request"; import { getKisAccessToken } from "@/lib/kis/token"; import { NextRequest, NextResponse } from "next/server"; /** * @file app/api/kis/validate/route.ts - * @description 사용자 입력 KIS API 키 검증 라우트 + * @description 사용자 입력 KIS API 키를 검증합니다. */ /** - * KIS API 키 검증 - * @param request appKey/appSecret/tradingEnv JSON 본문 - * @returns 검증 성공/실패 정보 - * @see features/dashboard/components/dashboard-main.tsx handleValidateKis - 검증 버튼 클릭 시 호출 + * @description 액세스 토큰 발급 성공 여부로 API 키를 검증합니다. + * @see features/dashboard/components/auth/KisAuthForm.tsx */ export async function POST(request: NextRequest) { - const body = (await request.json()) as Partial; + const credentials = await parseKisCredentialRequest(request); + const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); - const credentials: KisCredentialInput = { - appKey: body.appKey?.trim(), - appSecret: body.appSecret?.trim(), - tradingEnv: normalizeTradingEnv(body.tradingEnv), - }; - - if (!hasKisConfig(credentials)) { + const invalidMessage = validateKisCredentialInput(credentials); + if (invalidMessage) { return NextResponse.json( { ok: false, - tradingEnv: normalizeTradingEnv(credentials.tradingEnv), - message: "앱 키와 앱 시크릿을 모두 입력해 주세요.", + tradingEnv, + message: invalidMessage, } satisfies DashboardKisValidateResponse, { status: 400 }, ); } try { - // 검증 단계는 토큰 발급 성공 여부만 확인합니다. await getKisAccessToken(credentials); return NextResponse.json({ ok: true, - tradingEnv: normalizeTradingEnv(credentials.tradingEnv), + tradingEnv, message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)", } satisfies DashboardKisValidateResponse); } catch (error) { - const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다."; + const message = + error instanceof Error + ? error.message + : "API 키 검증 중 오류가 발생했습니다."; return NextResponse.json( { ok: false, - tradingEnv: normalizeTradingEnv(credentials.tradingEnv), + tradingEnv, message, } satisfies DashboardKisValidateResponse, { status: 401 }, ); } } - -interface DashboardKisValidateRequest { - appKey: string; - appSecret: string; - tradingEnv: "real" | "mock"; -} diff --git a/app/api/kis/ws/approval/route.ts b/app/api/kis/ws/approval/route.ts index 67c2737..407a920 100644 --- a/app/api/kis/ws/approval/route.ts +++ b/app/api/kis/ws/approval/route.ts @@ -1,35 +1,32 @@ import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types"; -import type { KisCredentialInput } from "@/lib/kis/config"; -import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval"; +import { normalizeTradingEnv } from "@/lib/kis/config"; +import { + parseKisCredentialRequest, + validateKisCredentialInput, +} from "@/lib/kis/request"; import { NextRequest, NextResponse } from "next/server"; /** * @file app/api/kis/ws/approval/route.ts - * @description KIS 웹소켓 approval key 발급 라우트 + * @description KIS 웹소켓 승인키와 WS URL을 발급합니다. */ /** - * 실시간 웹소켓 승인키 발급 - * @param request appKey/appSecret/tradingEnv JSON 본문 - * @returns approval key + ws url - * @see features/dashboard/components/dashboard-main.tsx connectKisRealtimePrice - 실시간 체결가 구독 진입점 + * @description 실시간 웹소켓 연결 정보를 발급합니다. + * @see features/dashboard/hooks/useKisTradeWebSocket.ts */ export async function POST(request: NextRequest) { - const body = (await request.json()) as Partial; + const credentials = await parseKisCredentialRequest(request); + const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); - const credentials: KisCredentialInput = { - appKey: body.appKey?.trim(), - appSecret: body.appSecret?.trim(), - tradingEnv: normalizeTradingEnv(body.tradingEnv), - }; - - if (!hasKisConfig(credentials)) { + const invalidMessage = validateKisCredentialInput(credentials); + if (invalidMessage) { return NextResponse.json( { ok: false, - tradingEnv: normalizeTradingEnv(credentials.tradingEnv), - message: "앱 키와 앱 시크릿을 모두 입력해 주세요.", + tradingEnv, + message: invalidMessage, } satisfies DashboardKisWsApprovalResponse, { status: 400 }, ); @@ -41,27 +38,24 @@ export async function POST(request: NextRequest) { return NextResponse.json({ ok: true, - tradingEnv: normalizeTradingEnv(credentials.tradingEnv), + tradingEnv, approvalKey, wsUrl, - message: "KIS 실시간 웹소켓 승인키 발급이 완료되었습니다.", + message: "웹소켓 승인키 발급이 완료되었습니다.", } satisfies DashboardKisWsApprovalResponse); } catch (error) { - const message = error instanceof Error ? error.message : "웹소켓 승인키 발급 중 오류가 발생했습니다."; + const message = + error instanceof Error + ? error.message + : "웹소켓 승인키 발급 중 오류가 발생했습니다."; return NextResponse.json( { ok: false, - tradingEnv: normalizeTradingEnv(credentials.tradingEnv), + tradingEnv, message, } satisfies DashboardKisWsApprovalResponse, { status: 401 }, ); } } - -interface DashboardKisWsApprovalRequest { - appKey: string; - appSecret: string; - tradingEnv: "real" | "mock"; -} diff --git a/features/dashboard/apis/kis-auth.api.ts b/features/dashboard/apis/kis-auth.api.ts index eb3368e..e858458 100644 --- a/features/dashboard/apis/kis-auth.api.ts +++ b/features/dashboard/apis/kis-auth.api.ts @@ -5,77 +5,77 @@ import type { DashboardKisWsApprovalResponse, } from "@/features/dashboard/types/dashboard.types"; +interface KisApiBaseResponse { + ok: boolean; + message: string; +} + +async function postKisAuthApi( + endpoint: string, + credentials: KisRuntimeCredentials, + fallbackErrorMessage: string, +): Promise { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(credentials), + cache: "no-store", + }); + + const payload = (await response.json()) as T; + + if (!response.ok || !payload.ok) { + throw new Error(payload.message || fallbackErrorMessage); + } + + return payload; +} + /** - * KIS API 키 검증 요청 - * @param credentials 검증할 키 정보 + * @description KIS API 키를 검증합니다. + * @see app/api/kis/validate/route.ts */ export async function validateKisCredentials( credentials: KisRuntimeCredentials, ): Promise { - const response = await fetch("/api/kis/validate", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(credentials), - cache: "no-store", - }); - - const payload = (await response.json()) as DashboardKisValidateResponse; - - if (!response.ok || !payload.ok) { - throw new Error(payload.message || "API 키 검증에 실패했습니다."); - } - - return payload; + return postKisAuthApi( + "/api/kis/validate", + credentials, + "API 키 검증에 실패했습니다.", + ); } /** - * KIS 접근토큰 폐기 요청 - * @param credentials 폐기할 키 정보 + * @description KIS 액세스 토큰을 폐기합니다. + * @see app/api/kis/revoke/route.ts */ export async function revokeKisCredentials( credentials: KisRuntimeCredentials, ): Promise { - const response = await fetch("/api/kis/revoke", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(credentials), - cache: "no-store", - }); - - const payload = (await response.json()) as DashboardKisRevokeResponse; - - if (!response.ok || !payload.ok) { - throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다."); - } - - return payload; + return postKisAuthApi( + "/api/kis/revoke", + credentials, + "API 토큰 폐기에 실패했습니다.", + ); } /** - * KIS 실시간 웹소켓 승인키 발급 요청 - * @param credentials 인증 정보 + * @description 웹소켓 승인키와 WS URL을 조회합니다. + * @see app/api/kis/ws/approval/route.ts */ export async function fetchKisWebSocketApproval( credentials: KisRuntimeCredentials, ): Promise { - const response = await fetch("/api/kis/ws/approval", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(credentials), - cache: "no-store", - }); + const payload = await postKisAuthApi( + "/api/kis/ws/approval", + credentials, + "웹소켓 승인키 발급에 실패했습니다.", + ); - const payload = (await response.json()) as DashboardKisWsApprovalResponse; - if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) { - throw new Error( - payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.", - ); + if (!payload.approvalKey || !payload.wsUrl) { + throw new Error(payload.message || "웹소켓 연결 정보가 누락되었습니다."); } return payload; diff --git a/features/dashboard/components/auth/KisAuthForm.tsx b/features/dashboard/components/auth/KisAuthForm.tsx index 716fbc8..feaa666 100644 --- a/features/dashboard/components/auth/KisAuthForm.tsx +++ b/features/dashboard/components/auth/KisAuthForm.tsx @@ -2,23 +2,29 @@ import { useState, useTransition } from "react"; import { useShallow } from "zustand/react/shallow"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store"; import { revokeKisCredentials, validateKisCredentials, } from "@/features/dashboard/apis/kis-auth.api"; +import { + KeyRound, + Shield, + CheckCircle2, + XCircle, + Lock, + Sparkles, + Zap, + Activity, +} from "lucide-react"; +import { InlineSpinner } from "@/components/ui/loading-spinner"; /** - * @description KIS 인증 입력 폼 - * @see features/dashboard/store/use-kis-runtime-store.ts 인증 입력값/검증 상태를 저장합니다. + * @description KIS 인증 입력 폼 (Minimal Redesign v4) + * - User Feedback: "입력창이 너무 길어", "파란색/하늘색 제거해" + * - Compact Width: max-w-lg + mx-auto + * - Monochrome Mock Mode: Blue -> Zinc/Gray */ export function KisAuthForm() { const { @@ -54,6 +60,11 @@ export function KisAuthForm() { const [isValidating, startValidateTransition] = useTransition(); const [isRevoking, startRevokeTransition] = useTransition(); + // 입력 필드 Focus 상태 관리를 위한 State + const [focusedField, setFocusedField] = useState< + "appKey" | "appSecret" | null + >(null); + function handleValidate() { startValidateTransition(async () => { try { @@ -67,7 +78,6 @@ export function KisAuthForm() { throw new Error("App Key와 App Secret을 모두 입력해 주세요."); } - // 주문 기능에서 계좌번호가 필요할 수 있어 구조는 유지하되, 인증 단계에서는 입력받지 않습니다. const credentials = { appKey, appSecret, @@ -114,123 +124,191 @@ export function KisAuthForm() { } return ( - - - KIS API 키 연결 - - 대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요. - 검증에 성공해야 시세 조회가 동작합니다. - - - - - {/* ========== CREDENTIAL INPUTS ========== */} -
-
- -
- - + +

+ 한국투자증권에서 발급받은 API 키를 입력해 주세요. +

-
- + {/* Trading Mode Switch (Segmented Control - Compact) */} +
+ + +
+
+ + {/* Input Fields Section (Compact Stacked Layout) */} +
+ {/* App Key Input */} +
+
+ +
+
+ App Key +
setKisAppKeyInput(e.target.value)} + onFocus={() => setFocusedField("appKey")} + onBlur={() => setFocusedField(null)} placeholder="App Key 입력" + className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600" autoComplete="off" />
-
- + {/* App Secret Input */} +
+
+ +
+
+ Secret +
setKisAppSecretInput(e.target.value)} + onFocus={() => setFocusedField("appSecret")} + onBlur={() => setFocusedField(null)} placeholder="App Secret 입력" + className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600" autoComplete="off" />
- {/* ========== ACTIONS ========== */} -
- + {/* Action & Status Section */} +
+
+ - + {isKisVerified && ( + + )} +
- {isKisVerified ? ( - - - 연결됨 ({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"}) - - ) : ( - 미연결 - )} + {/* Status Messages - Compact */} +
+ {errorMessage && ( +

+ + {errorMessage} +

+ )} + {statusMessage && ( +

+ + {statusMessage} +

+ )} + {!errorMessage && !statusMessage && !isKisVerified && ( +

+ + 미연결 +

+ )} +
- - {errorMessage && ( -
{errorMessage}
- )} - {statusMessage && ( -
{statusMessage}
- )} - - +
+
); } diff --git a/features/dashboard/components/orderbook/AnimatedQuantity.tsx b/features/dashboard/components/orderbook/AnimatedQuantity.tsx index 2de010d..ca134aa 100644 --- a/features/dashboard/components/orderbook/AnimatedQuantity.tsx +++ b/features/dashboard/components/orderbook/AnimatedQuantity.tsx @@ -8,6 +8,8 @@ interface AnimatedQuantityProps { className?: string; /** 값 변동 시 배경 깜빡임 */ useColor?: boolean; + /** 정렬 방향 (ask: 우측 정렬/왼쪽으로 확장, bid: 좌측 정렬/오른쪽으로 확장) */ + side?: "ask" | "bid"; } /** @@ -18,6 +20,7 @@ export function AnimatedQuantity({ format = (v) => v.toLocaleString(), className, useColor = false, + side = "bid", }: AnimatedQuantityProps) { const prevRef = useRef(value); const [diff, setDiff] = useState(null); @@ -59,32 +62,41 @@ export function AnimatedQuantity({ transition={{ duration: 1 }} className={cn( "absolute inset-0 z-0 rounded-sm", - flash === "up" ? "bg-red-200/50" : "bg-brand-200/50", + flash === "up" ? "bg-red-200/50" : "bg-blue-200/50", )} /> )} + {/* 매도(Ask)일 경우 Diff가 먼저 와야 텍스트가 우측 정렬된 상태에서 흔들리지 않음 */} + {side === "ask" && } + {/* 수량 값 */} {format(value)} - {/* ±diff (인라인 표시) */} - - {diff != null && diff !== 0 && ( - 0 ? "text-red-500" : "text-brand-600", - )} - > - {diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()} - - )} - + {/* 매수(Bid)일 경우 Diff가 뒤에 와야 텍스트가 좌측 정렬된 상태에서 흔들리지 않음 */} + {side !== "ask" && } ); } + +function DiffChange({ diff }: { diff: number | null }) { + return ( + + {diff != null && diff !== 0 && ( + 0 ? "text-red-500" : "text-blue-600 dark:text-blue-400", + )} + > + {diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()} + + )} + + ); +} diff --git a/features/dashboard/components/orderbook/OrderBook.tsx b/features/dashboard/components/orderbook/OrderBook.tsx index b811ce3..484b55b 100644 --- a/features/dashboard/components/orderbook/OrderBook.tsx +++ b/features/dashboard/components/orderbook/OrderBook.tsx @@ -309,6 +309,7 @@ function BookSideRows({ value={row.size} format={fmt} useColor + side="ask" className="relative z-10" /> @@ -323,7 +324,11 @@ function BookSideRows({ "border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20", )} > - + {row.price > 0 ? fmt(row.price) : "-"} @@ -451,7 +457,9 @@ function Row({ }) { return (
- {label} + + {label} + { const timerId = window.setInterval(() => { const nextSession = resolveSessionInClient(); @@ -93,13 +92,12 @@ export function useKisTradeWebSocket( return () => window.clearInterval(timerId); }, []); - // 연결은 되었는데 체결이 오래 안 들어오는 경우 안내합니다. useEffect(() => { if (!isConnected || lastTickAt) return; const timer = window.setTimeout(() => { setError( - "실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간) 여부를 확인해 주세요.", + "실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.", ); }, 8000); @@ -134,22 +132,18 @@ export function useKisTradeWebSocket( setError(null); setIsConnected(false); - const approvalKey = await useKisRuntimeStore + const wsConnection = await useKisRuntimeStore .getState() - .getOrFetchApprovalKey(); + .getOrFetchWsConnection(); - if (!approvalKey) { + if (!wsConnection) { throw new Error("웹소켓 승인키 발급에 실패했습니다."); } if (disposed) return; - approvalKeyRef.current = approvalKey; + approvalKeyRef.current = wsConnection.approvalKey; - const wsBase = - process.env.NEXT_PUBLIC_KIS_WS_URL || - "ws://ops.koreainvestment.com:21000"; - - socket = new WebSocket(`${wsBase}/tryitout/${tradeTrId}`); + socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`); socketRef.current = socket; socket.onopen = () => { diff --git a/features/dashboard/store/use-kis-runtime-store.ts b/features/dashboard/store/use-kis-runtime-store.ts index 1a6f9a9..399ade8 100644 --- a/features/dashboard/store/use-kis-runtime-store.ts +++ b/features/dashboard/store/use-kis-runtime-store.ts @@ -1,15 +1,15 @@ "use client"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; -import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types"; import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api"; +import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { create } from "zustand"; /** * @file features/dashboard/store/use-kis-runtime-store.ts - * @description KIS 키 입력/검증 상태를 zustand로 관리하고 새로고침 시 복원합니다. + * @description Stores KIS input, verification, and websocket connection state. + * @see features/dashboard/hooks/useKisTradeWebSocket.ts */ - export interface KisRuntimeCredentials { appKey: string; appSecret: string; @@ -17,73 +17,37 @@ export interface KisRuntimeCredentials { accountNo: string; } +interface KisWsConnection { + approvalKey: string; + wsUrl: string; +} + interface KisRuntimeStoreState { - // [State] 입력 폼 상태 kisTradingEnvInput: KisTradingEnv; kisAppKeyInput: string; kisAppSecretInput: string; kisAccountNoInput: string; - // [State] 검증/연동 상태 verifiedCredentials: KisRuntimeCredentials | null; isKisVerified: boolean; tradingEnv: KisTradingEnv; - // [State] 웹소켓 승인키 wsApprovalKey: string | null; + wsUrl: string | null; } interface KisRuntimeStoreActions { - /** - * 거래 모드 입력값을 변경하고 기존 검증 상태를 무효화합니다. - * @param tradingEnv 거래 모드 - * @see features/dashboard/components/dashboard-main.tsx 거래 모드 버튼 onClick 이벤트 - */ setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void; - /** - * 앱 키 입력값을 변경하고 기존 검증 상태를 무효화합니다. - * @param appKey 앱 키 - * @see features/dashboard/components/dashboard-main.tsx App Key onChange 이벤트 - */ setKisAppKeyInput: (appKey: string) => void; - /** - * 앱 시크릿 입력값을 변경하고 기존 검증 상태를 무효화합니다. - * @param appSecret 앱 시크릿 - * @see features/dashboard/components/dashboard-main.tsx App Secret onChange 이벤트 - */ setKisAppSecretInput: (appSecret: string) => void; - /** - * 계좌번호 입력값을 변경하고 기존 검증 상태를 무효화합니다. - * @param accountNo 계좌번호 (8자리-2자리) - */ setKisAccountNoInput: (accountNo: string) => void; - /** - * 검증 성공 상태를 저장합니다. - * @param credentials 검증 완료된 키 - * @param tradingEnv 현재 연동 모드 - * @see features/dashboard/components/dashboard-main.tsx handleValidateKis - */ setVerifiedKisSession: ( credentials: KisRuntimeCredentials, tradingEnv: KisTradingEnv, ) => void; - /** - * 검증 실패 또는 입력 변경 시 검증 상태만 초기화합니다. - * @see features/dashboard/components/dashboard-main.tsx handleValidateKis catch - */ invalidateKisVerification: () => void; - /** - * 접근 폐기 시 입력값/검증값을 모두 제거합니다. - * @param tradingEnv 표시용 모드 - * @see features/dashboard/components/dashboard-main.tsx handleRevokeKis - */ clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void; - - /** - * 웹소켓 승인키를 가져오거나 없으면 발급받습니다. - * @returns approvalKey - */ - getOrFetchApprovalKey: () => Promise; + getOrFetchWsConnection: () => Promise; } const INITIAL_STATE: KisRuntimeStoreState = { @@ -95,11 +59,22 @@ const INITIAL_STATE: KisRuntimeStoreState = { isKisVerified: false, tradingEnv: "real", wsApprovalKey: null, + wsUrl: null, }; -// 동시 요청 방지를 위한 모듈 스코프 변수 -let approvalPromise: Promise | null = null; +const RESET_VERIFICATION_STATE = { + verifiedCredentials: null, + isKisVerified: false, + wsApprovalKey: null, + wsUrl: null, +}; +let wsConnectionPromise: Promise | null = null; + +/** + * @description Runtime store for KIS session. + * @see features/dashboard/components/auth/KisAuthForm.tsx + */ export const useKisRuntimeStore = create< KisRuntimeStoreState & KisRuntimeStoreActions >()( @@ -110,33 +85,25 @@ export const useKisRuntimeStore = create< setKisTradingEnvInput: (tradingEnv) => set({ kisTradingEnvInput: tradingEnv, - verifiedCredentials: null, - isKisVerified: false, - wsApprovalKey: null, + ...RESET_VERIFICATION_STATE, }), setKisAppKeyInput: (appKey) => set({ kisAppKeyInput: appKey, - verifiedCredentials: null, - isKisVerified: false, - wsApprovalKey: null, + ...RESET_VERIFICATION_STATE, }), setKisAppSecretInput: (appSecret) => set({ kisAppSecretInput: appSecret, - verifiedCredentials: null, - isKisVerified: false, - wsApprovalKey: null, + ...RESET_VERIFICATION_STATE, }), setKisAccountNoInput: (accountNo) => set({ kisAccountNoInput: accountNo, - verifiedCredentials: null, - isKisVerified: false, - wsApprovalKey: null, + ...RESET_VERIFICATION_STATE, }), setVerifiedKisSession: (credentials, tradingEnv) => @@ -144,15 +111,13 @@ export const useKisRuntimeStore = create< verifiedCredentials: credentials, isKisVerified: true, tradingEnv, - // 인증이 바뀌면 승인키도 초기화 wsApprovalKey: null, + wsUrl: null, }), invalidateKisVerification: () => set({ - verifiedCredentials: null, - isKisVerified: false, - wsApprovalKey: null, + ...RESET_VERIFICATION_STATE, }), clearKisRuntimeSession: (tradingEnv) => @@ -161,48 +126,52 @@ export const useKisRuntimeStore = create< kisAppKeyInput: "", kisAppSecretInput: "", kisAccountNoInput: "", - verifiedCredentials: null, - isKisVerified: false, + ...RESET_VERIFICATION_STATE, tradingEnv, - wsApprovalKey: null, }), - getOrFetchApprovalKey: async () => { - const { wsApprovalKey, verifiedCredentials } = get(); + getOrFetchWsConnection: async () => { + const { wsApprovalKey, wsUrl, verifiedCredentials } = get(); - // 1. 이미 키가 있으면 반환 - if (wsApprovalKey) { - return wsApprovalKey; + if (wsApprovalKey && wsUrl) { + return { approvalKey: wsApprovalKey, wsUrl }; } - // 2. 인증 정보가 없으면 실패 if (!verifiedCredentials) { return null; } - // 3. 이미 진행 중인 요청이 있다면 해당 Promise 반환 (Deduping) - if (approvalPromise) { - return approvalPromise; + if (wsConnectionPromise) { + return wsConnectionPromise; } - // 4. API 호출 - approvalPromise = (async () => { + wsConnectionPromise = (async () => { try { const data = await fetchKisWebSocketApproval(verifiedCredentials); - if (data.approvalKey) { - set({ wsApprovalKey: data.approvalKey }); - return data.approvalKey; + if (!data.approvalKey || !data.wsUrl) { + return null; } - return null; + + const nextConnection = { + approvalKey: data.approvalKey, + wsUrl: data.wsUrl, + } satisfies KisWsConnection; + + set({ + wsApprovalKey: nextConnection.approvalKey, + wsUrl: nextConnection.wsUrl, + }); + + return nextConnection; } catch (error) { console.error(error); return null; } finally { - approvalPromise = null; + wsConnectionPromise = null; } })(); - return approvalPromise; + return wsConnectionPromise; }, }), { @@ -216,13 +185,7 @@ export const useKisRuntimeStore = create< verifiedCredentials: state.verifiedCredentials, isKisVerified: state.isKisVerified, tradingEnv: state.tradingEnv, - // wsApprovalKey도 로컬 스토리지에 저장하여 새로고침 후에도 유지 (선택사항이나 유지하는 게 유리) - // 단, 승인키 유효기간 문제가 있을 수 있으나 API 실패 시 재발급 로직을 넣거나, - // 현재 로직상 인증 정보가 바뀌면 초기화되므로 저장해도 무방. - // 하지만 유효기간 만료 처리가 없으므로 일단 저장하지 않는 게 안전할 수도 있음. - // 사용자가 "새로고침"을 하는 빈도보다 "일반적인 사용"이 많으므로 저장하지 않음 (partialize에서 제외) - // -> 코드를 보니 여기 포함시키지 않으면 저장이 안 됨. - // 유효기간 처리가 없으니 승인키는 메모리에만 유지하도록 함 (새로고침 시 재발급) + // wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive). }), }, ), diff --git a/features/layout/components/Logo.tsx b/features/layout/components/Logo.tsx new file mode 100644 index 0000000..37ccd45 --- /dev/null +++ b/features/layout/components/Logo.tsx @@ -0,0 +1,108 @@ +import { cn } from "@/lib/utils"; + +interface LogoProps { + className?: string; + variant?: "symbol" | "full"; + /** 배경과 섞이는 모드 (홈 화면 등). 로고가 흰색으로 표시됩니다. */ + blendWithBackground?: boolean; +} + +export function Logo({ + className, + variant = "full", + blendWithBackground = false, +}: LogoProps) { + // 색상 클래스 정의 + const mainColorClass = blendWithBackground + ? "fill-brand-500 stroke-brand-500" // 배경 혼합 모드에서도 심볼은 브랜드 컬러 유지 + : "fill-brand-600 stroke-brand-600 dark:fill-brand-500 dark:stroke-brand-500"; + + return ( +
+ + + {/* Mask for the cutout effect around the arrow */} + + + + {/* Arrow Head Cutout */} + + + + + {/* ========== BARS (Masked) ========== */} + + {/* Bar 1 (Left, Short) */} + + {/* Bar 2 (Middle, Medium) */} + + {/* Bar 3 (Right, Tall) */} + + + + {/* ========== ARROW (Foreground) ========== */} + + {/* Arrow Path */} + + {/* Arrow Head */} + + + + + {/* ========== TEXT (Optional) ========== */} + {variant === "full" && ( + + JOORIN-E + + )} +
+ ); +} diff --git a/features/layout/components/header.tsx b/features/layout/components/header.tsx index 1444f53..cecfc2e 100644 --- a/features/layout/components/header.tsx +++ b/features/layout/components/header.tsx @@ -11,6 +11,7 @@ import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; import { SessionTimer } from "@/features/auth/components/session-timer"; import { cn } from "@/lib/utils"; +import { Logo } from "@/features/layout/components/Logo"; interface HeaderProps { /** 현재 로그인 사용자 정보(null 가능) */ @@ -59,40 +60,29 @@ export function Header({ )} > {/* ========== LEFT: LOGO SECTION ========== */} - -
-
-
- - Jurini - + {/* ========== LEFT: LOGO SECTION ========== */} + + {/* ========== RIGHT: ACTION SECTION ========== */} -
{user ? ( @@ -107,7 +97,7 @@ export function Header({ className={cn( "hidden font-medium sm:inline-flex", blendWithBackground - ? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:!text-white" + ? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:text-white!" : "", )} > @@ -126,7 +116,7 @@ export function Header({ className={cn( "hidden sm:inline-flex", blendWithBackground - ? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 hover:!text-white" + ? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md hover:bg-black/65 hover:text-white!" : "", )} > diff --git a/lib/kis/approval.ts b/lib/kis/approval.ts index 79e74a4..12ad503 100644 --- a/lib/kis/approval.ts +++ b/lib/kis/approval.ts @@ -4,7 +4,7 @@ import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config"; /** * @file lib/kis/approval.ts - * @description KIS 웹소켓 approval key 발급/캐시 관리 + * @description KIS 웹소켓 승인키 생명주기를 관리합니다. */ interface KisApprovalResponse { @@ -34,12 +34,12 @@ function getApprovalCacheKey(credentials?: KisCredentialInput) { } /** - * KIS 웹소켓 approval key 발급 - * @param credentials 사용자 입력 키(선택) - * @returns approval key + expiresAt - * @see app/api/kis/ws/approval/route.ts POST - 실시간 차트 연결 시 호출 + * @description 웹소켓 승인키를 발급합니다. + * @see app/api/kis/ws/approval/route.ts */ -async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise { +async function issueKisApprovalKey( + credentials?: KisCredentialInput, +): Promise { const config = getKisConfig(credentials); const response = await fetch(`${config.baseUrl}/oauth2/Approval`, { @@ -50,6 +50,7 @@ async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise { approvalIssueInFlightMap.delete(cacheKey); }); @@ -122,9 +118,8 @@ export async function getKisApprovalKey(credentials?: KisCredentialInput) { } /** - * 거래 모드에 맞는 KIS 웹소켓 URL을 반환합니다. - * @param credentials 사용자 입력 키(선택) - * @returns websocket url + * @description 거래 환경에 맞는 웹소켓 URL을 반환합니다. + * @see app/api/kis/ws/approval/route.ts */ export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) { const config = getKisConfig(credentials); @@ -132,8 +127,8 @@ export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) { } /** - * 승인키 캐시를 제거합니다. - * @param credentials 사용자 입력 키(선택) + * @description 승인키 캐시를 제거합니다. + * @see lib/kis/token.ts */ export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) { const cacheKey = getApprovalCacheKey(credentials); diff --git a/lib/kis/request.ts b/lib/kis/request.ts new file mode 100644 index 0000000..6348351 --- /dev/null +++ b/lib/kis/request.ts @@ -0,0 +1,42 @@ +import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config"; +import type { NextRequest } from "next/server"; + +interface KisCredentialRequestBody { + appKey?: string; + appSecret?: string; + tradingEnv?: string; +} + +/** + * @description 요청 본문에서 KIS 인증 정보를 파싱합니다. + * @see app/api/kis/validate/route.ts + */ +export async function parseKisCredentialRequest( + request: NextRequest, +): Promise { + let body: KisCredentialRequestBody = {}; + + try { + body = (await request.json()) as KisCredentialRequestBody; + } catch { + // 빈 본문 또는 JSON 파싱 실패는 아래 필수값 검증에서 처리합니다. + } + + return { + appKey: body.appKey?.trim(), + appSecret: body.appSecret?.trim(), + tradingEnv: normalizeTradingEnv(body.tradingEnv), + }; +} + +/** + * @description 인증키 필수값을 검증합니다. + * @see app/api/kis/revoke/route.ts + */ +export function validateKisCredentialInput(credentials: KisCredentialInput) { + if (!credentials.appKey || !credentials.appSecret) { + return "앱 키와 앱 시크릿을 모두 입력해 주세요."; + } + + return null; +} diff --git a/lib/kis/token.ts b/lib/kis/token.ts index 1a9dd75..a9b3dbe 100644 --- a/lib/kis/token.ts +++ b/lib/kis/token.ts @@ -1,18 +1,19 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { KisCredentialInput } from "@/lib/kis/config"; import { clearKisApprovalKeyCache } from "@/lib/kis/approval"; +import type { KisCredentialInput } from "@/lib/kis/config"; import { getKisConfig } from "@/lib/kis/config"; /** * @file lib/kis/token.ts - * @description KIS access token 발급/캐시 관리(실전/모의 공통) + * @description KIS 액세스 토큰 발급/폐기/캐시를 관리합니다. */ interface KisTokenResponse { access_token?: string; access_token_token_expired?: string; + access_token_expired?: string; expires_in?: number; msg1?: string; msg_cd?: string; @@ -25,6 +26,10 @@ interface KisTokenCache { expiresAt: number; } +interface PersistedTokenCache { + [cacheKey: string]: KisTokenCache; +} + interface KisRevokeResponse { code?: number | string; message?: string; @@ -45,10 +50,6 @@ function getTokenCacheKey(credentials?: KisCredentialInput) { return `${config.tradingEnv}:${hashKey(config.appKey)}`; } -interface PersistedTokenCache { - [cacheKey: string]: KisTokenCache; -} - async function readPersistedTokenCache() { try { const raw = await readFile(TOKEN_CACHE_FILE_PATH, "utf8"); @@ -66,6 +67,7 @@ async function writePersistedTokenCache(next: PersistedTokenCache) { async function getPersistedToken(cacheKey: string) { const cache = await readPersistedTokenCache(); const token = cache[cacheKey]; + if (!token) return null; if (token.expiresAt - TOKEN_REFRESH_BUFFER_MS <= Date.now()) { @@ -93,7 +95,7 @@ async function clearPersistedToken(cacheKey: string) { try { await unlink(TOKEN_CACHE_FILE_PATH); } catch { - // ignore + // ignore when file does not exist } return; } @@ -101,11 +103,77 @@ async function clearPersistedToken(cacheKey: string) { await writePersistedTokenCache(cache); } +function tryParseTokenResponse(rawText: string): KisTokenResponse { + try { + return JSON.parse(rawText) as KisTokenResponse; + } catch { + return { + msg1: rawText.slice(0, 200), + }; + } +} + +function tryParseRevokeResponse(rawText: string): KisRevokeResponse { + try { + return JSON.parse(rawText) as KisRevokeResponse; + } catch { + return { + message: rawText.slice(0, 200), + }; + } +} + +function parseTokenExpiryText(value?: string) { + if (!value) return null; + + const normalized = value.includes("T") ? value : value.replace(" ", "T"); + const parsed = Date.parse(normalized); + + if (Number.isNaN(parsed)) return null; + return parsed; +} + +function resolveTokenExpiry(payload: KisTokenResponse) { + if (typeof payload.expires_in === "number" && payload.expires_in > 0) { + return Date.now() + payload.expires_in * 1000; + } + + const absoluteExpiry = + parseTokenExpiryText(payload.access_token_token_expired) ?? + parseTokenExpiryText(payload.access_token_expired); + + if (absoluteExpiry) { + return absoluteExpiry; + } + + // 예외 상황 기본값: 23시간 + return Date.now() + 23 * 60 * 60 * 1000; +} + /** - * KIS access token 발급 - * @param credentials 사용자 입력 키(선택) - * @returns token + expiresAt - * @see app/api/kis/validate/route.ts POST - 사용자 키 검증 시 토큰 발급 경로 + * @description 토큰 발급 실패 원인 점검 문구를 만듭니다. + * @see https://github.com/koreainvestment/open-trading-api + */ +function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") { + const lower = detail.toLowerCase(); + + const keyError = + lower.includes("appkey") || + lower.includes("appsecret") || + lower.includes("secret") || + lower.includes("invalid") || + lower.includes("auth"); + + if (keyError) { + return ` | 점검: ${tradingEnv === "real" ? "실전" : "모의"} 앱 키/시크릿 쌍을 확인해 주세요.`; + } + + return " | 점검: API 서비스 상태와 거래 환경(real/mock)을 확인해 주세요."; +} + +/** + * @description KIS 액세스 토큰을 발급합니다. + * @see app/api/kis/validate/route.ts */ async function issueKisToken(credentials?: KisCredentialInput): Promise { const config = getKisConfig(credentials); @@ -147,68 +215,8 @@ async function issueKisToken(credentials?: KisCredentialInput): Promise 0) { - return Date.now() + payload.expires_in * 1000; - } - - return Date.now() + 23 * 60 * 60 * 1000; -} - -/** - * access token 반환(환경/키 단위 메모리 캐시) - * @param credentials 사용자 입력 키(선택) - * @returns access token - * @see lib/kis/domestic.ts getDomesticOverview - 현재가/일봉 병렬 조회 시 공용 토큰 사용 + * @description 캐시된 토큰을 반환하거나 새로 발급합니다. + * @see lib/kis/domestic.ts */ export async function getKisAccessToken(credentials?: KisCredentialInput) { const cacheKey = getTokenCacheKey(credentials); @@ -224,7 +232,6 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) { return persisted.token; } - // 같은 키로 동시에 요청이 들어오면 토큰 발급을 1회로 합칩니다. const inFlight = tokenIssueInFlightMap.get(cacheKey); if (inFlight) { const shared = await inFlight; @@ -233,6 +240,7 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) { const nextPromise = issueKisToken(credentials); tokenIssueInFlightMap.set(cacheKey, nextPromise); + const next = await nextPromise.finally(() => { tokenIssueInFlightMap.delete(cacheKey); }); @@ -243,10 +251,8 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) { } /** - * KIS access token 폐기 요청 - * @param credentials 사용자 입력 키(선택) - * @returns 폐기 응답 메시지 - * @see app/api/kis/revoke/route.ts POST - 대시보드 접근 폐기 버튼 처리 + * @description 현재 KIS 액세스 토큰을 폐기합니다. + * @see app/api/kis/revoke/route.ts */ export async function revokeKisAccessToken(credentials?: KisCredentialInput) { const config = getKisConfig(credentials); @@ -273,6 +279,7 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) { if (!response.ok || !isSuccessCode) { const detail = [payload.message, payload.msg1].filter(Boolean).join(" / "); + throw new Error( detail ? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}` @@ -285,21 +292,5 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) { await clearPersistedToken(cacheKey); clearKisApprovalKeyCache(credentials); - return payload.message ?? "접근토큰 폐기에 성공하였습니다."; -} - -/** - * 토큰 폐기 응답 문자열을 안전하게 JSON으로 변환합니다. - * @param rawText fetch 응답 원문 - * @returns KisRevokeResponse - * @see https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/revokeP - */ -function tryParseRevokeResponse(rawText: string): KisRevokeResponse { - try { - return JSON.parse(rawText) as KisRevokeResponse; - } catch { - return { - message: rawText.slice(0, 200), - }; - } + return payload.message ?? "액세스 토큰 폐기가 완료되었습니다."; }