-
-
GET STARTED
-
- 가입부터 자동 실행까지
-
- 딱 3단계면 충분합니다
-
-
-
- 어려운 설정 화면 대신, 따라 하기 쉬운 단계로 바로 시작할 수 있게 구성했습니다.
+ {/* ========== BRAND TONE SECTION (움직이는 글자) ========== */}
+
+
+ {/* ========== SIMPLE STEPS SECTION ========== */}
+
+
+
+
+ 설계부터 실행까지
+
+ 단 3단계면 끝.
+
+
+ 복잡한 계산과 감시는 JOORIN-E가 대신할게요.
+
+ 당신은 가벼운 마음으로 '시작' 버튼만 누르세요.
-
+
{START_STEPS.map((item) => (
-
-
{item.step}
-
{item.title}
-
{item.description}
+
+
+ {item.step}
+
+
+
+ {item.title}
+
+
+ {item.description}
+
+
))}
-
- {/* ========== CTA SECTION ========== */}
-
-
-
-
-
-
+ {/* 보안 안심 문구 (사용자 요청 반영) */}
+
+
+
-
수익의 시작은 빠를수록 좋습니다
-
- 지금 가입하고,
+
+ 내 계좌 정보, 서버에 저장되지 않나요?
+
+
+
+ 네, 절대 저장하지 않으니 안심하세요.
+
- 내 전략을 오늘부터 자동 실행하세요
-
-
- 주린이가 첫 설정부터 실행까지 함께 안내합니다.
+ JOORIN-E는 여러분의 계좌 비밀번호와 API 키를 서버로 전송하지
+ 않습니다.
+
+ 모든 중요 정보는 여러분의 기기(브라우저)에만 암호화되어
+ 저장되며,
+
+ 매매 실행 시에만 증권사와 직접 통신하는 데 사용됩니다.
+
+
+
+ {/* ========== FINAL CTA SECTION ========== */}
+
+
+
+ 더 이상 미루지 마세요.
+
+ 지금 바로 경험해보세요.
+
+
-
- 지금 시작하기
-
-
+ {primaryCtaLabel}
+
+
+ © 2026 POPUP STUDIO. All rights reserved.
+
diff --git a/app/api/kis/_session.ts b/app/api/kis/_session.ts
new file mode 100644
index 0000000..b464d90
--- /dev/null
+++ b/app/api/kis/_session.ts
@@ -0,0 +1,18 @@
+import { createClient } from "@/utils/supabase/server";
+
+/**
+ * @description KIS API 라우트 접근 전에 Supabase 로그인 세션을 검증합니다.
+ * @returns 로그인 세션 존재 여부
+ * @remarks UI 흐름: 클라이언트 요청 -> KIS API route -> hasKisApiSession -> (실패 시 401, 성공 시 KIS 호출)
+ * @see app/api/kis/domestic/balance/route.ts 잔고 API 세션 가드
+ * @see app/api/kis/validate/route.ts 인증 검증 API 세션 가드
+ */
+export async function hasKisApiSession() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser();
+
+ return Boolean(!error && user);
+}
diff --git a/app/api/kis/domestic/_shared.ts b/app/api/kis/domestic/_shared.ts
index 595106d..a749302 100644
--- a/app/api/kis/domestic/_shared.ts
+++ b/app/api/kis/domestic/_shared.ts
@@ -26,7 +26,7 @@ export function readKisCredentialsFromHeaders(headers: Headers): KisCredentialIn
}
/**
- * @description 요청 헤더(또는 서버 환경변수)에서 계좌번호(8-2)를 읽어옵니다.
+ * @description 요청 헤더에서 계좌번호(8-2)를 읽어옵니다.
* @param headers 요청 헤더
* @returns 계좌번호 파트(8 + 2) 또는 null
* @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱
@@ -35,11 +35,5 @@ export function readKisAccountParts(headers: Headers) {
const headerAccountNo = headers.get("x-kis-account-no");
const headerAccountProductCode = headers.get("x-kis-account-product-code");
- const envAccountNo = process.env.KIS_ACCOUNT_NO;
- const envAccountProductCode = process.env.KIS_ACCOUNT_PRODUCT_CODE;
-
- return (
- parseKisAccountParts(headerAccountNo, headerAccountProductCode) ??
- parseKisAccountParts(envAccountNo, envAccountProductCode)
- );
+ return parseKisAccountParts(headerAccountNo, headerAccountProductCode);
}
diff --git a/app/api/kis/domestic/activity/route.ts b/app/api/kis/domestic/activity/route.ts
index 85ecc46..b21dbb7 100644
--- a/app/api/kis/domestic/activity/route.ts
+++ b/app/api/kis/domestic/activity/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
@@ -20,6 +21,11 @@ import {
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 섹션 렌더링
*/
export async function GET(request: Request) {
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
+ }
+
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
diff --git a/app/api/kis/domestic/balance/route.ts b/app/api/kis/domestic/balance/route.ts
index a63dbea..9557899 100644
--- a/app/api/kis/domestic/balance/route.ts
+++ b/app/api/kis/domestic/balance/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
@@ -18,6 +19,11 @@ import {
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
*/
export async function GET(request: Request) {
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
+ }
+
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
diff --git a/app/api/kis/domestic/chart/route.ts b/app/api/kis/domestic/chart/route.ts
index ab3999b..c3ed8ac 100644
--- a/app/api/kis/domestic/chart/route.ts
+++ b/app/api/kis/domestic/chart/route.ts
@@ -6,6 +6,7 @@ import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
+import { hasKisApiSession } from "@/app/api/kis/_session";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
@@ -20,6 +21,11 @@ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
*/
export async function GET(request: NextRequest) {
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
+ }
+
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
const timeframe = (
diff --git a/app/api/kis/domestic/indices/route.ts b/app/api/kis/domestic/indices/route.ts
index c7ebce8..2525c52 100644
--- a/app/api/kis/domestic/indices/route.ts
+++ b/app/api/kis/domestic/indices/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
@@ -15,6 +16,11 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다.
*/
export async function GET(request: Request) {
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
+ }
+
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
diff --git a/app/api/kis/domestic/order-cash/route.ts b/app/api/kis/domestic/order-cash/route.ts
index 236f3d2..949e985 100644
--- a/app/api/kis/domestic/order-cash/route.ts
+++ b/app/api/kis/domestic/order-cash/route.ts
@@ -4,6 +4,7 @@ import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import {
KisCredentialInput,
hasKisConfig,
@@ -17,12 +18,25 @@ import {
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
+ const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
+
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json(
+ {
+ ok: false,
+ tradingEnv,
+ message: "로그인이 필요합니다.",
+ },
+ { status: 401 },
+ );
+ }
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
- tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
+ tradingEnv,
message: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
@@ -42,7 +56,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
ok: false,
- tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
+ tradingEnv,
message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
},
@@ -65,7 +79,7 @@ export async function POST(request: NextRequest) {
const response: DashboardStockCashOrderResponse = {
ok: true,
- tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
+ tradingEnv,
message: "주문이 전송되었습니다.",
orderNo: output.ODNO,
orderTime: output.ORD_TMD,
@@ -81,7 +95,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
ok: false,
- tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
+ tradingEnv,
message,
},
{ status: 500 },
diff --git a/app/api/kis/domestic/orderbook/route.ts b/app/api/kis/domestic/orderbook/route.ts
index 7b60e3d..91f76f1 100644
--- a/app/api/kis/domestic/orderbook/route.ts
+++ b/app/api/kis/domestic/orderbook/route.ts
@@ -4,6 +4,7 @@ import {
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import {
KisCredentialInput,
hasKisConfig,
@@ -20,6 +21,11 @@ import {
*/
export async function GET(request: NextRequest) {
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
+ }
+
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
diff --git a/app/api/kis/domestic/overview/route.ts b/app/api/kis/domestic/overview/route.ts
index 1656acb..9add374 100644
--- a/app/api/kis/domestic/overview/route.ts
+++ b/app/api/kis/domestic/overview/route.ts
@@ -2,6 +2,7 @@ import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import {
@@ -20,6 +21,11 @@ import {
* @returns 대시보드 상세 모델
*/
export async function GET(request: NextRequest) {
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
+ }
+
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
diff --git a/app/api/kis/domestic/search/route.ts b/app/api/kis/domestic/search/route.ts
index 6df3c5e..49a43ef 100644
--- a/app/api/kis/domestic/search/route.ts
+++ b/app/api/kis/domestic/search/route.ts
@@ -4,6 +4,7 @@ import type {
DashboardStockSearchResponse,
KoreanStockIndexItem,
} from "@/features/trade/types/trade.types";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import { NextRequest, NextResponse } from "next/server";
const SEARCH_LIMIT = 10;
@@ -26,6 +27,11 @@ const SEARCH_LIMIT = 10;
* @see features/trade/components/dashboard-main.tsx 검색 폼에서 호출합니다.
*/
export async function GET(request: NextRequest) {
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
+ }
+
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
const { searchParams } = new URL(request.url);
const query = (searchParams.get("q") ?? "").trim();
diff --git a/app/api/kis/revoke/route.ts b/app/api/kis/revoke/route.ts
index 00d9522..81dcc38 100644
--- a/app/api/kis/revoke/route.ts
+++ b/app/api/kis/revoke/route.ts
@@ -5,6 +5,7 @@ import {
validateKisCredentialInput,
} from "@/lib/kis/request";
import { revokeKisAccessToken } from "@/lib/kis/token";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -20,6 +21,18 @@ export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json(
+ {
+ ok: false,
+ tradingEnv,
+ message: "로그인이 필요합니다.",
+ } satisfies DashboardKisRevokeResponse,
+ { status: 401 },
+ );
+ }
+
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
diff --git a/app/api/kis/validate-profile/route.ts b/app/api/kis/validate-profile/route.ts
index 6f69d0e..bf54353 100644
--- a/app/api/kis/validate-profile/route.ts
+++ b/app/api/kis/validate-profile/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import { parseKisAccountParts } from "@/lib/kis/account";
import { kisGet } from "@/lib/kis/client";
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
@@ -43,6 +44,22 @@ const BALANCE_VALIDATION_PRESETS: BalanceValidationPreset[] = [
* @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수
*/
export async function POST(request: NextRequest) {
+ const fallbackTradingEnv = normalizeTradingEnv(
+ request.headers.get("x-kis-trading-env") ?? undefined,
+ );
+
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json(
+ {
+ ok: false,
+ tradingEnv: fallbackTradingEnv,
+ message: "로그인이 필요합니다.",
+ } satisfies Pick
,
+ { status: 401 },
+ );
+ }
+
let body: KisProfileValidateRequestBody = {};
try {
@@ -51,7 +68,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
ok: false,
- tradingEnv: "mock",
+ tradingEnv: fallbackTradingEnv,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
} satisfies Pick,
{ status: 400 },
diff --git a/app/api/kis/validate/route.ts b/app/api/kis/validate/route.ts
index 4b29660..9c51c45 100644
--- a/app/api/kis/validate/route.ts
+++ b/app/api/kis/validate/route.ts
@@ -5,6 +5,7 @@ import {
validateKisCredentialInput,
} from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -20,6 +21,18 @@ export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json(
+ {
+ ok: false,
+ tradingEnv,
+ message: "로그인이 필요합니다.",
+ } satisfies DashboardKisValidateResponse,
+ { status: 401 },
+ );
+ }
+
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
diff --git a/app/api/kis/ws/approval/route.ts b/app/api/kis/ws/approval/route.ts
index da7443e..e62410b 100644
--- a/app/api/kis/ws/approval/route.ts
+++ b/app/api/kis/ws/approval/route.ts
@@ -1,4 +1,5 @@
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
+import { hasKisApiSession } from "@/app/api/kis/_session";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
@@ -20,6 +21,18 @@ export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
+ const hasSession = await hasKisApiSession();
+ if (!hasSession) {
+ return NextResponse.json(
+ {
+ ok: false,
+ tradingEnv,
+ message: "로그인이 필요합니다.",
+ } satisfies DashboardKisWsApprovalResponse,
+ { status: 401 },
+ );
+ }
+
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
diff --git a/app/layout.tsx b/app/layout.tsx
index c6a25fd..c9927ec 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -33,9 +33,9 @@ const outfit = Outfit({
});
export const metadata: Metadata = {
- title: "Jurini - 감이 아닌 전략으로 시작하는 자동매매",
+ title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
description:
- "주린이를 위한 자동매매 파트너 Jurini. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
+ "주린이를 위한 자동매매 파트너 JOORIN-E. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
};
/**
diff --git a/components/ui/animated-brand-tone.tsx b/components/ui/animated-brand-tone.tsx
new file mode 100644
index 0000000..13e10c7
--- /dev/null
+++ b/components/ui/animated-brand-tone.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { cn } from "@/lib/utils";
+
+const TONE_PHRASES = [
+ { q: "주식이 너무 어려워요...", a: "걱정하지 마. JOORIN-E가 다 해줄게." },
+ {
+ q: "내 돈, 정말 안전할까?",
+ a: "안심해도 돼. 금융권 수준 보안키로 지키니까.",
+ },
+ {
+ q: "손실 날까 봐 불안해요...",
+ a: "걱정하지 마. 안전 장치가 24시간 작동해.",
+ },
+ {
+ q: "복잡한 건 딱 질색인데..",
+ a: "몰라도 돼. 클릭 몇 번이면 바로 시작이야.",
+ },
+];
+
+/**
+ * @description 프리미엄한 텍스트 리빌 효과를 제공하는 브랜드 톤 컴포넌트
+ */
+export function AnimatedBrandTone() {
+ const [index, setIndex] = useState(0);
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setIndex((prev) => (prev + 1) % TONE_PHRASES.length);
+ }, 5000);
+
+ return () => clearInterval(timer);
+ }, []);
+
+ return (
+
+
+
+ {/* 질문 (Q) */}
+
+ “{TONE_PHRASES[index].q}”
+
+
+ {/* 답변 (A) - 타이핑 효과 */}
+
+
+
+ {TONE_PHRASES[index].a.split("").map((char, i) => (
+
+ {char === " " ? "\u00A0" : char}
+
+ ))}
+ {/* 깜빡이는 커서 */}
+
+
+
+
+
+
+
+ {/* 인디케이터 - 유휴 상태 표시 및 선택 기능 */}
+
+ {TONE_PHRASES.map((_, i) => (
+ setIndex(i)}
+ className={cn(
+ "h-1.5 transition-all duration-500 rounded-full",
+ i === index
+ ? "w-10 bg-brand-500 shadow-[0_0_20px_rgba(20,184,166,0.3)]"
+ : "w-2 bg-white/10 hover:bg-white/20",
+ )}
+ aria-label={`Go to slide ${i + 1}`}
+ />
+ ))}
+
+
+ );
+}
diff --git a/features/dashboard/components/StockDetailPreview.tsx b/features/dashboard/components/StockDetailPreview.tsx
index 743eabc..b3a1fc2 100644
--- a/features/dashboard/components/StockDetailPreview.tsx
+++ b/features/dashboard/components/StockDetailPreview.tsx
@@ -19,6 +19,7 @@ import {
import { useRouter } from "next/navigation";
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
+import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
import {
formatCurrency,
formatPercent,
@@ -45,6 +46,9 @@ export function StockDetailPreview({
totalAmount,
}: StockDetailPreviewProps) {
const router = useRouter();
+ const setPendingTarget = useTradeNavigationStore(
+ (state) => state.setPendingTarget,
+ );
// [State/Hook] 실시간 가격 변동 애니메이션 상태 관리
// @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달
const currentPrice = holding?.currentPrice ?? 0;
@@ -92,11 +96,14 @@ export function StockDetailPreview({
- router.push(
- `/trade?symbol=${holding.symbol}&name=${encodeURIComponent(holding.name)}`,
- )
- }
+ onClick={() => {
+ setPendingTarget({
+ symbol: holding.symbol,
+ name: holding.name,
+ market: holding.market,
+ });
+ router.push("/trade");
+ }}
className={cn(
"group flex items-center gap-1.5 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5",
"text-sm font-bold text-brand-700 transition-all cursor-pointer",
diff --git a/features/layout/components/Logo.tsx b/features/layout/components/Logo.tsx
index 37ccd45..c591e16 100644
--- a/features/layout/components/Logo.tsx
+++ b/features/layout/components/Logo.tsx
@@ -20,7 +20,7 @@ export function Logo({
return (
- 대시보드
+ 시작하기
)}
@@ -132,7 +132,7 @@ export function Header({
: "",
)}
>
- 시작하기
+ 회원가입
)}
diff --git a/features/settings/components/KisAuthForm.tsx b/features/settings/components/KisAuthForm.tsx
index 37625eb..6e8070c 100644
--- a/features/settings/components/KisAuthForm.tsx
+++ b/features/settings/components/KisAuthForm.tsx
@@ -162,23 +162,21 @@ export function KisAuthForm() {
)}
- {isKisVerified && (
-
- {isRevoking ? (
- "해제 중"
- ) : (
-
-
- 연결 해제
-
- )}
-
- )}
+
+ {isRevoking ? (
+ "해제 중"
+ ) : (
+
+
+ 연결 해제(토큰 폐기)
+
+ )}
+
),
status: (
diff --git a/features/settings/components/SettingsCard.tsx b/features/settings/components/SettingsCard.tsx
index c37ba34..527751e 100644
--- a/features/settings/components/SettingsCard.tsx
+++ b/features/settings/components/SettingsCard.tsx
@@ -49,7 +49,7 @@ export function SettingsCard({
className,
)}
>
-
+
{/* ========== CARD HEADER ========== */}
diff --git a/features/settings/components/SettingsContainer.tsx b/features/settings/components/SettingsContainer.tsx
index 7f68056..21ee8a0 100644
--- a/features/settings/components/SettingsContainer.tsx
+++ b/features/settings/components/SettingsContainer.tsx
@@ -31,7 +31,8 @@ export function SettingsContainer() {
return (
{/* ========== SETTINGS OVERVIEW ========== */}
-
+
+
@@ -43,8 +44,8 @@ export function SettingsContainer() {
- 진행 순서: 1) 앱키 연결 확인 {"->"} 2) 계좌 인증 {"->"} 3)
- 거래 화면 사용
+ 진행 순서: 1) 앱키 연결 확인 {"->"} 2) 계좌 인증 {"->"} 3) 거래
+ 화면 사용
diff --git a/features/trade/components/TradeContainer.tsx b/features/trade/components/TradeContainer.tsx
index 499f300..bf25779 100644
--- a/features/trade/components/TradeContainer.tsx
+++ b/features/trade/components/TradeContainer.tsx
@@ -1,7 +1,7 @@
"use client";
import { type FormEvent, useCallback, useEffect, useState } from "react";
-import { useSearchParams } from "next/navigation";
+import { useRouter } from "next/navigation";
import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
@@ -14,6 +14,7 @@ import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocke
import { useStockOverview } from "@/features/trade/hooks/useStockOverview";
import { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice";
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
+import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
import type {
DashboardStockOrderBookResponse,
DashboardStockSearchItem,
@@ -27,9 +28,10 @@ import type {
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/
export function TradeContainer() {
- const searchParams = useSearchParams();
- const symbolParam = searchParams.get("symbol");
- const nameParam = searchParams.get("name");
+ const router = useRouter();
+ const consumePendingTarget = useTradeNavigationStore(
+ (state) => state.consumePendingTarget,
+ );
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
@@ -60,28 +62,47 @@ export function TradeContainer() {
useStockOverview();
/**
- * [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드
- * 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다.
+ * [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다.
+ * 과거 링크/브라우저 히스토리로 유입되는 query 오염을 제거하기 위한 방어 로직입니다.
*/
useEffect(() => {
- if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) {
- // 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행
- if (selectedStock?.symbol !== symbolParam) {
- setKeyword(nameParam || symbolParam);
- appendSearchHistory({
- symbol: symbolParam,
- name: nameParam || symbolParam,
- market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨
- });
- loadOverview(symbolParam, verifiedCredentials);
- }
+ if (typeof window === "undefined") return;
+ if (!window.location.search) return;
+ router.replace("/trade");
+ }, [router]);
+
+ /**
+ * [Effect] Dashboard에서 넘긴 종목을 1회 소비해 자동 로드합니다.
+ * @remarks UI 흐름: Dashboard 종목 클릭 -> useTradeNavigationStore.setPendingTarget -> /trade -> consumePendingTarget -> loadOverview
+ */
+ useEffect(() => {
+ if (!isKisVerified || !verifiedCredentials || !_hasHydrated) {
+ return;
}
+
+ const pendingTarget = consumePendingTarget();
+ if (!pendingTarget) return;
+
+ if (selectedStock?.symbol === pendingTarget.symbol) {
+ return;
+ }
+
+ setKeyword(pendingTarget.name || pendingTarget.symbol);
+ appendSearchHistory({
+ symbol: pendingTarget.symbol,
+ name: pendingTarget.name || pendingTarget.symbol,
+ market: pendingTarget.market,
+ });
+ loadOverview(
+ pendingTarget.symbol,
+ verifiedCredentials,
+ pendingTarget.market,
+ );
}, [
- symbolParam,
- nameParam,
isKisVerified,
verifiedCredentials,
_hasHydrated,
+ consumePendingTarget,
selectedStock?.symbol,
loadOverview,
setKeyword,
diff --git a/features/trade/hooks/useStockSearch.ts b/features/trade/hooks/useStockSearch.ts
index b07fc45..60c15f6 100644
--- a/features/trade/hooks/useStockSearch.ts
+++ b/features/trade/hooks/useStockSearch.ts
@@ -6,7 +6,7 @@ import type {
DashboardStockSearchItem,
} from "@/features/trade/types/trade.types";
-const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1";
+const SEARCH_HISTORY_STORAGE_KEY = "joorine:stock-search-history:v1";
const SEARCH_HISTORY_LIMIT = 12;
interface StoredSearchHistory {
@@ -39,7 +39,10 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
version: 1,
items,
};
- window.localStorage.setItem(SEARCH_HISTORY_STORAGE_KEY, JSON.stringify(payload));
+ window.localStorage.setItem(
+ SEARCH_HISTORY_STORAGE_KEY,
+ JSON.stringify(payload),
+ );
}
/**
@@ -50,14 +53,16 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
export function useStockSearch() {
// ========== SEARCH STATE ==========
const [keyword, setKeyword] = useState("");
- const [searchResults, setSearchResults] = useState
([]);
+ const [searchResults, setSearchResults] = useState<
+ DashboardStockSearchItem[]
+ >([]);
const [error, setError] = useState(null);
const [isSearching, setIsSearching] = useState(false);
// ========== SEARCH HISTORY STATE ==========
- const [searchHistory, setSearchHistory] = useState(
- () => readSearchHistory(),
- );
+ const [searchHistory, setSearchHistory] = useState<
+ DashboardStockSearchHistoryItem[]
+ >(() => readSearchHistory());
// 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러
const abortRef = useRef(null);
@@ -142,7 +147,9 @@ export function useStockSearch() {
*/
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
setSearchHistory((prev) => {
- const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol);
+ const deduped = prev.filter(
+ (historyItem) => historyItem.symbol !== item.symbol,
+ );
const nextItems: DashboardStockSearchHistoryItem[] = [
{ ...item, savedAt: Date.now() },
...deduped,
diff --git a/features/trade/store/use-trade-navigation-store.ts b/features/trade/store/use-trade-navigation-store.ts
new file mode 100644
index 0000000..e21568d
--- /dev/null
+++ b/features/trade/store/use-trade-navigation-store.ts
@@ -0,0 +1,56 @@
+"use client";
+
+import { create } from "zustand";
+import type { DashboardStockSearchItem } from "@/features/trade/types/trade.types";
+
+/**
+ * @file features/trade/store/use-trade-navigation-store.ts
+ * @description 대시보드 -> 트레이드 이동 시 URL 쿼리 없이 종목 선택 상태를 1회 전달합니다.
+ */
+
+export interface TradeNavigationTarget {
+ symbol: string;
+ name: string;
+ market: DashboardStockSearchItem["market"];
+ requestedAt: number;
+}
+
+interface TradeNavigationStoreState {
+ pendingTarget: TradeNavigationTarget | null;
+}
+
+interface TradeNavigationStoreActions {
+ setPendingTarget: (target: Omit) => void;
+ consumePendingTarget: () => TradeNavigationTarget | null;
+ clearPendingTarget: () => void;
+}
+
+/**
+ * @description 트레이드 화면 진입 시 사용할 종목 이동 상태 store
+ * @remarks UI 흐름: Dashboard 종목 클릭 -> setPendingTarget -> /trade 이동 -> TradeContainer consumePendingTarget -> 종목 로드
+ * @see features/dashboard/components/StockDetailPreview.tsx setPendingTarget 호출
+ * @see features/trade/components/TradeContainer.tsx consumePendingTarget 호출
+ */
+export const useTradeNavigationStore = create<
+ TradeNavigationStoreState & TradeNavigationStoreActions
+>()((set, get) => ({
+ pendingTarget: null,
+
+ setPendingTarget: (target) =>
+ set({
+ pendingTarget: {
+ ...target,
+ requestedAt: Date.now(),
+ },
+ }),
+
+ consumePendingTarget: () => {
+ const target = get().pendingTarget;
+ if (!target) return null;
+
+ set({ pendingTarget: null });
+ return target;
+ },
+
+ clearPendingTarget: () => set({ pendingTarget: null }),
+}));
diff --git a/lib/kis/config.ts b/lib/kis/config.ts
index 5e7b903..d74e25a 100644
--- a/lib/kis/config.ts
+++ b/lib/kis/config.ts
@@ -33,14 +33,6 @@ export function normalizeTradingEnv(value?: string): KisTradingEnv {
return (value ?? "mock").toLowerCase() === "real" ? "real" : "mock";
}
-/**
- * 현재 거래 환경을 반환합니다.
- * @returns real | mock
- */
-export function getKisTradingEnv() {
- return normalizeTradingEnv(process.env.KIS_TRADING_ENV);
-}
-
/**
* KIS 웹소켓 URL을 반환합니다.
* @param tradingEnvInput 거래 모드(real/mock)
@@ -50,10 +42,10 @@ export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) {
const tradingEnv = normalizeTradingEnv(tradingEnvInput);
if (tradingEnv === "real") {
- return process.env.KIS_WS_URL_REAL ?? DEFAULT_KIS_REAL_WS_URL;
+ return DEFAULT_KIS_REAL_WS_URL;
}
- return process.env.KIS_WS_URL_MOCK ?? DEFAULT_KIS_MOCK_WS_URL;
+ return DEFAULT_KIS_MOCK_WS_URL;
}
/**
@@ -62,14 +54,7 @@ export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) {
* @returns 사용 가능 여부
*/
export function hasKisConfig(input?: KisCredentialInput) {
- if (input?.appKey && input?.appSecret) return true;
-
- const env = getKisTradingEnv();
- if (env === "real") {
- return Boolean(process.env.KIS_APP_KEY_REAL && process.env.KIS_APP_SECRET_REAL);
- }
-
- return Boolean(process.env.KIS_APP_KEY_MOCK && process.env.KIS_APP_SECRET_MOCK);
+ return Boolean(input?.appKey?.trim() && input?.appSecret?.trim());
}
/**
@@ -78,7 +63,7 @@ export function hasKisConfig(input?: KisCredentialInput) {
* @returns tradingEnv/appKey/appSecret/baseUrl
*/
export function getKisConfig(input?: KisCredentialInput): KisConfig {
- if (input?.appKey && input?.appSecret) {
+ if (input?.appKey?.trim() && input?.appSecret?.trim()) {
const tradingEnv = normalizeTradingEnv(input.tradingEnv);
const baseUrl =
input.baseUrl ??
@@ -86,37 +71,13 @@ export function getKisConfig(input?: KisCredentialInput): KisConfig {
return {
tradingEnv,
- appKey: input.appKey,
- appSecret: input.appSecret,
+ appKey: input.appKey.trim(),
+ appSecret: input.appSecret.trim(),
baseUrl,
};
}
- const tradingEnv = getKisTradingEnv();
-
- if (tradingEnv === "real") {
- const appKey = process.env.KIS_APP_KEY_REAL;
- const appSecret = process.env.KIS_APP_SECRET_REAL;
- const baseUrl = process.env.KIS_BASE_URL_REAL ?? DEFAULT_KIS_REAL_BASE_URL;
-
- if (!appKey || !appSecret) {
- throw new Error(
- "KIS 실전투자 키가 없습니다. KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL 환경변수를 설정하세요.",
- );
- }
-
- return { tradingEnv, appKey, appSecret, baseUrl };
- }
-
- const appKey = process.env.KIS_APP_KEY_MOCK;
- const appSecret = process.env.KIS_APP_SECRET_MOCK;
- const baseUrl = process.env.KIS_BASE_URL_MOCK ?? DEFAULT_KIS_MOCK_BASE_URL;
-
- if (!appKey || !appSecret) {
- throw new Error(
- "KIS 모의투자 키가 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 환경변수를 설정하세요.",
- );
- }
-
- return { tradingEnv, appKey, appSecret, baseUrl };
+ throw new Error(
+ "KIS API 키가 없습니다. 설정 화면에서 앱 키와 앱 시크릿을 먼저 입력해 주세요.",
+ );
}
diff --git a/lib/kis/token.ts b/lib/kis/token.ts
index 3fd7e24..5e5174e 100644
--- a/lib/kis/token.ts
+++ b/lib/kis/token.ts
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
-import type { KisCredentialInput } from "@/lib/kis/config";
+import type { KisConfig, KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config";
/**
@@ -11,8 +11,9 @@ import { getKisConfig } from "@/lib/kis/config";
interface KisTokenResponse {
access_token?: string;
access_token_token_expired?: string;
+ token_type?: string;
access_token_expired?: string;
- expires_in?: number;
+ expires_in?: number | string;
msg1?: string;
msg_cd?: string;
error?: string;
@@ -33,16 +34,39 @@ interface KisRevokeResponse {
const tokenCacheMap = new Map();
const tokenIssueInFlightMap = new Map>();
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
+const TOKEN_DEFAULT_TTL_MS = 23 * 60 * 60 * 1000;
+const KIS_TOKEN_ISSUE_PATH = "/oauth2/tokenP";
+const KIS_TOKEN_REVOKE_PATH = "/oauth2/revokeP";
+const KIS_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
+const KIS_TOKEN_TYPE_BEARER = "bearer";
+/**
+ * @description 토큰 캐시 식별용 해시를 생성합니다.
+ * @param value 해시 입력값
+ * @returns sha256 hex 문자열
+ * @see getTokenCacheKey 앱키 원문을 직접 저장하지 않기 위한 보조 함수
+ */
function hashKey(value: string) {
return createHash("sha256").update(value).digest("hex");
}
+/**
+ * @description 거래환경+앱키 기준 토큰 캐시 키를 생성합니다.
+ * @param credentials 사용자 입력 KIS 인증정보
+ * @returns 캐시 키
+ * @see getKisAccessToken 토큰 캐시 조회/갱신 키로 사용
+ */
function getTokenCacheKey(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials);
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
}
+/**
+ * @description 토큰 응답 문자열을 안전하게 JSON 파싱합니다.
+ * @param rawText 응답 원문
+ * @returns 파싱된 토큰 응답 객체
+ * @see issueKisToken 토큰 발급 응답 파싱 단계
+ */
function tryParseTokenResponse(rawText: string): KisTokenResponse {
try {
return JSON.parse(rawText) as KisTokenResponse;
@@ -53,6 +77,12 @@ function tryParseTokenResponse(rawText: string): KisTokenResponse {
}
}
+/**
+ * @description 토큰 폐기 응답 문자열을 안전하게 JSON 파싱합니다.
+ * @param rawText 응답 원문
+ * @returns 파싱된 토큰 폐기 응답 객체
+ * @see revokeKisAccessToken 토큰 폐기 응답 파싱 단계
+ */
function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
try {
return JSON.parse(rawText) as KisRevokeResponse;
@@ -63,19 +93,62 @@ function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
}
}
-function parseTokenExpiryText(value?: string) {
- if (!value) return null;
+/**
+ * @description number 또는 숫자 문자열을 안전하게 숫자로 변환합니다.
+ * @param value 원본 값
+ * @returns 숫자값 또는 null
+ * @see resolveTokenExpiry expires_in 파싱 단계
+ */
+function parseNumericSeconds(value?: number | string) {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
- const normalized = value.includes("T") ? value : value.replace(" ", "T");
+ if (typeof value === "string" && value.trim() !== "") {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * @description KIS 만료일시 문자열을 epoch(ms)로 변환합니다.
+ * @param value 만료일시 문자열 (예: 2023-12-22 08:16:59)
+ * @returns epoch(ms) 또는 null
+ * @see resolveTokenExpiry access_token_token_expired 파싱 단계
+ */
+function parseTokenExpiryText(value?: string) {
+ if (!value?.trim()) return null;
+ const trimmed = value.trim();
+
+ // 명세 샘플("YYYY-MM-DD HH:mm:ss")은 한국시간(KST)으로 해석해 UTC 서버에서도 오차를 줄입니다.
+ if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}$/.test(trimmed)) {
+ const parsedKst = Date.parse(`${trimmed.replace(" ", "T")}+09:00`);
+ if (!Number.isNaN(parsedKst)) {
+ return parsedKst;
+ }
+ }
+
+ const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
const parsed = Date.parse(normalized);
if (Number.isNaN(parsed)) return null;
return parsed;
}
+/**
+ * @description 토큰 만료시각을 계산합니다.
+ * @param payload KIS 토큰 발급 응답
+ * @returns 만료시각 epoch(ms)
+ * @see issueKisToken 토큰 캐시 저장 만료시간 계산
+ */
function resolveTokenExpiry(payload: KisTokenResponse) {
- if (typeof payload.expires_in === "number" && payload.expires_in > 0) {
- return Date.now() + payload.expires_in * 1000;
+ const expiresInSeconds = parseNumericSeconds(payload.expires_in);
+ if (expiresInSeconds && expiresInSeconds > 0) {
+ return Date.now() + expiresInSeconds * 1000;
}
const absoluteExpiry =
@@ -87,7 +160,7 @@ function resolveTokenExpiry(payload: KisTokenResponse) {
}
// 예외 상황 기본값: 23시간
- return Date.now() + 23 * 60 * 60 * 1000;
+ return Date.now() + TOKEN_DEFAULT_TTL_MS;
}
/**
@@ -111,24 +184,58 @@ function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") {
return " | 점검: API 서비스 상태와 거래 환경(real/mock)을 확인해 주세요.";
}
+/**
+ * @description 토큰 발급/폐기 공통 헤더를 생성합니다.
+ * @returns OAuth 요청 헤더
+ * @see issueKisToken 접근토큰발급(P) 호출
+ * @see revokeKisAccessToken 접근토큰폐기(P) 호출
+ */
+function buildOauthHeaders(): HeadersInit {
+ return {
+ "content-type": "application/json; charset=utf-8",
+ accept: "application/json, text/plain, */*",
+ };
+}
+
+/**
+ * @description 접근토큰발급(P) 요청 바디를 생성합니다.
+ * @param config KIS 설정
+ * @returns tokenP 요청 바디
+ * @see 접근토큰발급(P)[인증-001].xlsx grant_type/appkey/appsecret 명세
+ */
+function buildTokenIssueBody(config: KisConfig) {
+ return {
+ grant_type: KIS_GRANT_TYPE_CLIENT_CREDENTIALS,
+ appkey: config.appKey,
+ appsecret: config.appSecret,
+ };
+}
+
+/**
+ * @description 토큰 응답 실패 상세 메시지를 조합합니다.
+ * @param payload KIS 토큰 응답
+ * @returns 상세 메시지
+ * @see issueKisToken 토큰 발급 실패 에러 메시지 구성
+ */
+function buildTokenIssueDetail(payload: KisTokenResponse) {
+ return [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
+ .filter(Boolean)
+ .join(" / ");
+}
+
/**
* @description KIS 액세스 토큰을 발급합니다.
* @see app/api/kis/validate/route.ts
+ * @see C:/Users/이지훈/Downloads/접근토큰발급(P)[인증-001].xlsx 접근토큰발급(P) 명세
*/
async function issueKisToken(credentials?: KisCredentialInput): Promise {
const config = getKisConfig(credentials);
- const tokenUrl = `${config.baseUrl}/oauth2/tokenP`;
+ const tokenUrl = `${config.baseUrl}${KIS_TOKEN_ISSUE_PATH}`;
const response = await fetch(tokenUrl, {
method: "POST",
- headers: {
- "content-type": "application/json; charset=utf-8",
- },
- body: JSON.stringify({
- grant_type: "client_credentials",
- appkey: config.appKey,
- appsecret: config.appSecret,
- }),
+ headers: buildOauthHeaders(),
+ body: JSON.stringify(buildTokenIssueBody(config)),
cache: "no-store",
});
@@ -136,9 +243,7 @@ async function issueKisToken(credentials?: KisCredentialInput): Promise