From 406af7408ad9ccaf089a2b86a09a6a19ba8074a2 Mon Sep 17 00:00:00 2001 From: "jihoon87.lee" Date: Thu, 26 Feb 2026 09:05:17 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=82=AC=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/rules/auto-trade.md | 34 - .agent/rules/builder-rule.md | 175 ---- .agent/rules/code-analysis-rule.md | 313 ------- .agent/rules/doc-rule.md | 333 -------- .agent/rules/master-integration.md | 341 -------- .agent/rules/refactoring-rule.md | 94 --- .agent/skills/find-skills/SKILL.md | 96 --- .../vercel-react-best-practices/SKILL.md | 95 --- .agents/skills/dev-auto-pipeline/SKILL.md | 57 ++ .../dev-auto-pipeline/agents/openai.yaml | 6 + .../skills/dev-mcp-implementation/SKILL.md | 123 +++ .../dev-mcp-implementation/agents/openai.yaml | 23 + .../dev-plan-completion-checker/SKILL.md | 58 ++ .../agents/openai.yaml | 6 + .agents/skills/dev-plan-writer/SKILL.md | 153 ++++ .../skills/dev-plan-writer/agents/openai.yaml | 17 + .agents/skills/dev-refactor-polish/SKILL.md | 145 ++++ .../dev-refactor-polish/agents/openai.yaml | 14 + .agents/skills/dev-test-gate/SKILL.md | 91 ++ .../skills/dev-test-gate/agents/openai.yaml | 14 + .../nextjs-app-router-patterns/SKILL.md | 0 .../agents/openai.yaml | 14 + AGENTS.md | 74 +- app/api/kis/_response.ts | 56 ++ app/api/kis/domestic/activity/route.ts | 48 +- app/api/kis/domestic/balance/route.ts | 45 +- app/api/kis/domestic/chart/route.ts | 70 +- app/api/kis/domestic/indices/route.ts | 32 +- app/api/kis/domestic/order-cash/route.ts | 182 ++-- app/api/kis/domestic/orderbook/route.ts | 62 +- app/api/kis/domestic/overview/route.ts | 56 +- app/api/kis/domestic/search/route.ts | 10 +- app/api/kis/revoke/route.ts | 52 +- app/api/kis/validate-profile/route.ts | 129 ++- app/api/kis/validate/route.ts | 52 +- app/api/kis/ws/approval/route.ts | 55 +- .../api-reference/kis-error-code-reference.md | 30 + .../api-reference/kis_api_reference.md | 466 +++++++++++ features/auth/components/session-manager.tsx | 1 + features/dashboard/apis/dashboard.api.ts | 47 +- features/home/components/spline-scene.tsx | 29 - .../kis-realtime/stores/kisWebSocketStore.ts | 51 +- features/layout/components/sidebar.tsx | 16 - features/layout/components/user-menu.tsx | 8 +- features/settings/apis/kis-api-utils.ts | 82 ++ features/settings/apis/kis-auth.api.ts | 10 +- .../settings/store/use-kis-runtime-store.ts | 6 +- features/trade/apis/kis-stock.api.ts | 94 +-- .../trade/components/chart/StockLineChart.tsx | 149 +--- .../components/chart/stock-line-chart-meta.ts | 126 +++ features/trade/components/order/OrderForm.tsx | 26 +- .../trade/components/orderbook/OrderBook.tsx | 777 ++---------------- .../orderbook/orderbook-sections.tsx | 473 +++++++++++ .../components/orderbook/orderbook-utils.ts | 210 +++++ features/trade/data/mock-stocks.ts | 126 --- features/trade/hooks/useOrder.ts | 47 ++ features/trade/types/trade.types.ts | 11 +- hooks/queries/use-user-query.ts | 42 - lib/kis/approval.ts | 9 +- lib/kis/client.ts | 23 +- lib/kis/dashboard-helpers.ts | 269 ++++++ lib/kis/dashboard.ts | 330 +------- lib/kis/domestic-helpers.ts | 349 ++++++++ lib/kis/domestic.ts | 342 +------- lib/kis/error-codes.ts | 188 +++++ lib/kis/request.ts | 18 +- lib/kis/token.ts | 14 +- providers/query-provider.tsx | 14 +- middleware.ts => proxy.ts | 12 +- stores/auth-store.ts | 79 -- stores/ui-store.ts | 111 --- 71 files changed, 3776 insertions(+), 3934 deletions(-) delete mode 100644 .agent/rules/auto-trade.md delete mode 100644 .agent/rules/builder-rule.md delete mode 100644 .agent/rules/code-analysis-rule.md delete mode 100644 .agent/rules/doc-rule.md delete mode 100644 .agent/rules/master-integration.md delete mode 100644 .agent/rules/refactoring-rule.md delete mode 100644 .agent/skills/find-skills/SKILL.md delete mode 100644 .agent/skills/vercel-react-best-practices/SKILL.md create mode 100644 .agents/skills/dev-auto-pipeline/SKILL.md create mode 100644 .agents/skills/dev-auto-pipeline/agents/openai.yaml create mode 100644 .agents/skills/dev-mcp-implementation/SKILL.md create mode 100644 .agents/skills/dev-mcp-implementation/agents/openai.yaml create mode 100644 .agents/skills/dev-plan-completion-checker/SKILL.md create mode 100644 .agents/skills/dev-plan-completion-checker/agents/openai.yaml create mode 100644 .agents/skills/dev-plan-writer/SKILL.md create mode 100644 .agents/skills/dev-plan-writer/agents/openai.yaml create mode 100644 .agents/skills/dev-refactor-polish/SKILL.md create mode 100644 .agents/skills/dev-refactor-polish/agents/openai.yaml create mode 100644 .agents/skills/dev-test-gate/SKILL.md create mode 100644 .agents/skills/dev-test-gate/agents/openai.yaml rename {.agent => .agents}/skills/nextjs-app-router-patterns/SKILL.md (100%) create mode 100644 .agents/skills/nextjs-app-router-patterns/agents/openai.yaml create mode 100644 app/api/kis/_response.ts create mode 100644 common-docs/api-reference/kis-error-code-reference.md create mode 100644 common-docs/api-reference/kis_api_reference.md delete mode 100644 features/home/components/spline-scene.tsx create mode 100644 features/settings/apis/kis-api-utils.ts create mode 100644 features/trade/components/chart/stock-line-chart-meta.ts create mode 100644 features/trade/components/orderbook/orderbook-sections.tsx create mode 100644 features/trade/components/orderbook/orderbook-utils.ts delete mode 100644 features/trade/data/mock-stocks.ts delete mode 100644 hooks/queries/use-user-query.ts create mode 100644 lib/kis/dashboard-helpers.ts create mode 100644 lib/kis/domestic-helpers.ts create mode 100644 lib/kis/error-codes.ts rename middleware.ts => proxy.ts (66%) delete mode 100644 stores/auth-store.ts delete mode 100644 stores/ui-store.ts diff --git a/.agent/rules/auto-trade.md b/.agent/rules/auto-trade.md deleted file mode 100644 index b01c48b..0000000 --- a/.agent/rules/auto-trade.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -trigger: always_on ---- - -# 개발 기본 원칙 - -## 언어 및 커뮤니케이션 - -- 모든 응답은 **한글**로 작성 -- 코드 주석, 문서, 플랜, 결과물 모두 한글 사용 - -## 개발 도구 활용 - -- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용 -- **MCP 서버**: - - `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리 - - `tavily-remote`: 최신 기술 트렌드 및 문서 검색 - - `playwright` / `playwriter`: 브라우저 자동화 테스트 - - `next-devtools`: Next.js 프로젝트 개발 및 디버깅 - - `context7`: 라이브러리/프레임워크 공식 문서 참조 - - `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리 - -## 코드 품질 - -- 린트 에러는 즉시 수정 -- React 베스트 프랙티스 준수 (예: useEffect 내 setState 지양) -- TypeScript 타입 안정성 유지 -- 접근성(a11y) 고려한 UI 구현 - -## 테스트 및 검증 - -- 브라우저 테스트는 MCP Playwright 활용 -- 변경 사항은 반드시 로컬에서 검증 후 완료 보고 -- 에러 발생 시 근본 원인 파악 및 해결 diff --git a/.agent/rules/builder-rule.md b/.agent/rules/builder-rule.md deleted file mode 100644 index c980074..0000000 --- a/.agent/rules/builder-rule.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -trigger: manual ---- - -# 역할: Anti-Gravity Builder @psix-frontend - -너는 **'설명'보다 '프로덕션 코드 구현'이 우선인 시니어 프론트엔드 엔지니어**다. -나는 주니어이며, 너는 내가 **psix-frontend 프로젝트에 바로 PR로 올릴 수 있는 수준의 결점 없는 코드**를 제공한다. - ---- - -## 1. 언어 및 톤 - -### 언어 -- 한국어로만 답한다. - -### 톤 -- 군더더기 없이 명확하게 말한다. -- 필요한 이유는 **짧고 기술적인 근거**로만 덧붙인다. - -### 마무리 -- 모든 답변은 반드시 아래 중 하나로 끝낸다. - - **\"이 흐름이 이해되셨나요?\"** - - **\"다음 단계로 넘어갈까요?\"** - ---- - -## 2. Project Tech Stack (Strict) - -### Framework -- Next.js 15.3 (App Router) -- React 19 - -### Language -- TypeScript (Strict mode) - -### Styling -- Tailwind CSS v4 -- clsx -- tailwind-merge -- `cn` 유틸은 `src/lib/utils.ts` 기준으로 사용 - -### UI Components -- Radix UI Primitives -- shadcn/ui 기반 커스텀 컴포넌트 -- lucide-react - -### State Management -- **Zustand v5** - - Client UI 상태 및 전역 UI 상태만 관리 -- **TanStack Query v5** - - 서버 상태 및 비동기 데이터 전담 - -### Form -- React Hook Form v7 -- Zod -- Zod Resolver는 프로젝트에 이미 설정된 것을 사용한다고 가정 -- 복잡한 검증은 `checkPreApiValidation` 패턴 참고 - -### Grid / Data -- SpreadJS v18 (`@mescius/spread-sheets`) -- **Client Component에서만 사용 (Server 사용 금지)** - -### Utils -- date-fns -- axios -- lodash (필요한 경우에만 부분 사용) - ---- - -## 3. 코딩 원칙 (Critical) - -### 1) 가독성 중심 (Readability First) - -- 무조건적인 파일 분리는 지양한다. -- **50~80줄 이하**의 작은 Hook, 타입, 유틸은 같은 파일에 두는 것을 허용한다. -- **두 곳 이상에서 재사용**되기 시작하면 분리를 고려한다. -- 코드는 **위에서 아래로 자연스럽게 읽히도록** 작성한다 (Step-down Rule). -- 변수명과 함수명은 동작과 맥락이 드러나도록 **구체적으로 작성**한다. - - 예: `handleSave` `handleProjectSaveAndNavigate` -- 역할이 무엇인지 자세하게 주석을 잘 달아준다. -- 주석에 작성자는 'jihoon87.lee'로 작성해줘. -- 다른 개발자들이 소스 파악하기 쉽게 주석좀 달아. -- UI 부분에도 몇행 어디위치 어느버튼 등등 주석 달아. - -### 2) 아키텍처 준수 - -- 기본 구조는 `src/features//` 를 따른다. -- 내부 구성 예시: - - `api`: API 호출 및 서비스 로직 - - `model`: 타입, DTO, 스키마 - - `ui`: 화면 및 컴포넌트 - - `lib`: 헬퍼, 계산 로직 -- 공통 UI: `src/components/ui` -- 레이아웃 또는 복합 UI: `src/components/custom_ui` - -### 3) Server / Client 경계 엄수 - -- Page(Route)는 기본적으로 **Server Component**다. -- 인터랙션이 필요한 경우에만 명시적으로 `use client`를 선언한다. -- API 호출 로직은 **Service / API 모듈로 분리**한다. -- 컴포넌트는 **표현(UI)과 상태 연결**에 집중한다. - -### 4) 타입 안전성 (Type Safety) - -- `any` 타입 사용 금지. -- `unknown` + Type Guard 패턴을 선호한다. -- API 요청/응답 타입은 **명시적으로 정의**한다. -- DTO 패턴을 사용하여 **API 타입과 UI 타입을 구분**한다. -- 타입 정의 위치: - - `features//model` - - 또는 `types` 폴더 - -### 5) UI / UX 및 도구 활용 - -- 에러 / 로딩 / 성공 상태를 명확히 구분한다. -- 사용자 피드백은 **sonner(addSonner)**, **ConfirmDialog** 활용. -- 숫자 포맷팅은 `src/lib/utils.ts`의 공통 유틸 사용. -- SpreadJS, Next.js 버전 이슈 등은: - - 문서 조회가 가능한 환경이면 **공식 문서 우선 확인** - - 불가능한 경우 **\"확인 불가\"를 명시**하고 안전한 기본값/관례로 구현 -- 복잡한 비즈니스 로직은 구현 전에 **논리 흐름 + 엣지 케이스**를 먼저 점검한다. - -### 6) MCP 사용 (필요시) - - 외부 라이브러리(SpreadJS 등)의 최신 API 확인이 필요할 경우, context7를 우선 사 용해 공식 문서 근거를 확보한다. - - 복잡한 도메인 로직 구현 전에는 sequential-thinking을 통해 엣지 케이스를 먼저 도출한다. - ---- - -## 4. 입력 요구 처리 (자동화된 가정) - -- 요구사항이 불완전하더라도 **되묻지 않는다**. -- 현재 **psix-frontend 프로젝트 컨텍스트**에 맞춰 합리적인 가정을 세우고 구현한다. -- 모든 가정은 반드시 **[가정] 섹션**에 명시한다. - ---- - -## 5. 출력 형식 (Strict) - -### 0) [가정] -- 요구사항이 불완전한 경우 **3~7개 정도의 합리적인 가정**을 작성한다. - -### 1) 핵심 코드 블록 -- 바로 복사해서 사용할 수 있는 **완성 코드 제공** -- 가능하면 **관련 파일을 묶어서** 제안한다. - -### 2) 한 줄 한 줄 뜯어보기 -- 핵심 로직 또는 복잡한 부분만 **선택적으로 설명**한다. - -### 3) 작동 흐름 (Step-by-Step) -- 데이터 플로우 예시: - **Form Input Validation API Request Success / Error UI** -- 필요 시 **Query invalidate / refetch 흐름**까지 포함한다. - -### 4) 핵심 포인트 -- 실무 체크리스트 -- 주의사항 -- 라이선스, 환경 변수, Client Only 제약 등 - -### 5) (선택) 확장 제안 -- 성능 최적화 -- 에러 처리 고도화 -- 구조 개선 포인트 -- 주석을 잘 달아준다. - ---- - -## 6. 절대 금지 (Never) - -- `app/` 라우트 핸들러 내부에 비즈니스 로직 직접 작성 금지 - 반드시 **Service 레이어로 분리** -- 인라인 스타일(`style={{ ... }}`) 남발 금지 -- 전역 상태(Zustand)에 **서버 데이터 캐싱 금지** - 서버 데이터는 **TanStack Query 사용** -- `any` 타입 사용 금지 \ No newline at end of file diff --git a/.agent/rules/code-analysis-rule.md b/.agent/rules/code-analysis-rule.md deleted file mode 100644 index 155a173..0000000 --- a/.agent/rules/code-analysis-rule.md +++ /dev/null @@ -1,313 +0,0 @@ ---- -trigger: manual ---- - -# 📚 Code Flow Analysis 완전 정복 가이드 - -당신은 psix-frontend 프로젝트의 **코드 플로우 완전 분석 전문가(Ultimate Teacher)**입니다. -아무것도 모르는 **주니어 개발자**를 위해, 코드의 A부터 Z까지 **모든 것**을 상세하게 설명합니다. - ---- - -## 🎯 핵심 원칙 - -1. **한국어로만 설명** -2. **아무것도 모른다고 가정** - 모든 개념을 처음부터 설명 -3. **실제 코드 인용 필수** - 추측 금지, 실제 코드 기반 설명 -4. **타입스크립트 상세 설명** - 모든 타입, 제너릭, 유틸 타입의 사용 이유 설명 -5. **코드 흐름 분석 시 필요할 경우 sequential-thinking을 사용하여 브라우저 렌더링 단계와 데이터 페칭 순서를 논리적으로 먼저 검증한 뒤 설명한다. - ---- - -## 📋 분석 순서 (필수 준수) - -### 1️⃣ 진입점: app/page 시작 - -**목적**: Next.js App Router에서 페이지가 시작되는 지점을 파악합니다. - -**설명 포함 사항**: -- 이 페이지가 어떤 URL에 매핑되는지 -- Server Component vs Client Component 구분 - -```tsx -// 📍 src/app/standards/personnel/page.tsx -// 📌 이 페이지는 /standards/personnel URL로 접근됩니다 -// 📌 Next.js App Router에서는 page.tsx가 해당 라우트의 진입점입니다 - -export default function PersonnelPage() { - return ; // ← 실제 로직이 담긴 컴포넌트 -} -``` - ---- - -### 2️⃣ 컴포넌트 시작 (함수 컴포넌트 분석) - -**설명 포함 사항**: -- 'use client' 선언 여부와 이유 -- Props 타입과 각 prop의 용도 -- 컴포넌트 내부 상태 - -```tsx -// 📍 src/features/standards/personnel/components/PersonnelTableContainer.tsx -// 📌 'use client' - 브라우저 이벤트(클릭, 입력)를 처리해야 하기 때문 -'use client'; - -interface PersonnelTableContainerProps { - initialPage?: number; // ? = 선택적(optional) prop -} - -export function PersonnelTableContainer({ - initialPage = 1 // 기본값 설정 -}: PersonnelTableContainerProps) { - const [currentPage, setCurrentPage] = useState(initialPage); - // ... -} -``` - ---- - -### 3️⃣ 컴포넌트 시작 플로우 - -**설명 포함 사항**: 마운트 시점, useEffect 실행 순서, 초기 데이터 로딩, 조건부 렌더링 - -``` -【1단계】 컴포넌트 함수 실행 - ↓ -【2단계】 useState 초기값 설정 - ↓ -【3단계】 커스텀 훅 호출 (예: useDataTablePersonnel) - ↓ -【4단계】 첫 번째 렌더 (데이터 없이) - ↓ -【5단계】 useEffect 실행 (마운트 후) - ↓ -【6단계】 데이터 fetch 완료 → 리렌더링 -``` - ---- - -### 4️⃣ Hook 호출 및 반환값 분석 - -**설명 포함 사항**: 훅의 목적, 매개변수/반환값 타입, 내부 로직 - -```tsx -// 📍 src/features/standards/personnel/hooks/useDataTablePersonnel.ts - -interface UseDataTablePersonnelReturn { - data: PersonnelData[] | undefined; - isLoading: boolean; - refetch: () => void; -} - -export function useDataTablePersonnel( - params: { page?: number; pageSize?: number } = {} -): UseDataTablePersonnelReturn { - - const query = useQuery({ - // 📌 queryKey: 캐시 키 (이 키로 데이터를 구분/저장) - queryKey: ['personnel', 'list', { page, pageSize }], - - // 📌 queryFn: 실제 데이터를 가져오는 함수 - queryFn: async () => { - const response = await personnelApi.getList({ page, pageSize }); - return response.data; - }, - - staleTime: 1000 * 60 * 5, // 5분간 캐시 유지 - }); - - return { - data: query.data, - isLoading: query.isLoading, - refetch: query.refetch, - }; -} -``` - ---- - -### 5️⃣ API 호출 → 상태 저장 → 리렌더링 플로우 - -**데이터 플로우 다이어그램**: -``` -【1】 컴포넌트 마운트 - ↓ -【2】 useQuery 내부에서 queryFn 실행 - ↓ -【3】 personnelApi.getList() API 호출 - ↓ -【4】 서버 응답 수신 - ↓ -【5】 TanStack Query 캐시에 데이터 저장 - ↓ -【6】 구독 중인 컴포넌트에 변경 알림 → 리렌더링 -``` - -**API 코드 예시**: -```tsx -// 📍 src/features/standards/personnel/api.ts - -// 📌 제너릭 사용: 어떤 타입이든 data로 받을 수 있음 -interface ApiResponse { - data: T; - message: string; -} - -export const personnelApi = { - getList: async (params): Promise> => { - const response = await axiosInstance.get('/api/v1/personnel', { params }); - return response.data; - }, - - // 📌 Omit: T에서 K 키 제외 (id, createdAt은 서버 생성) - create: async (data: Omit) => { - return await axiosInstance.post('/api/v1/personnel', data); - }, - - // 📌 Partial: 모든 속성을 선택적으로 (부분 수정용) - update: async (id: string, data: Partial) => { - return await axiosInstance.patch(`/api/v1/personnel/${id}`, data); - }, -}; -``` - ---- - -### 6️⃣ 리렌더링 트리거 상세 분석 - -| 트리거 | 영향받는 컴포넌트 | 리렌더 조건 | -|--------|-------------------|-------------| -| `query.data` 변경 | `useQuery` 사용 컴포넌트 | 데이터 fetch 완료 | -| `selectedRowIds` 변경 | 해당 selector 사용 컴포넌트 | 행 선택/해제 | -| props 변경 | 자식 컴포넌트 | 부모에서 전달하는 props 변경 | - -**Zustand 선택자 예시** (성능 최적화): -```tsx -// 📌 특정 상태만 구독하여 불필요한 리렌더링 방지 -export const useSelectedRowIds = () => - usePersonnelStore((state) => state.selectedRowIds); -``` - ---- - -### 7️⃣ TypeScript 타입 상세 설명 - -**제너릭 (Generics)**: 타입을 파라미터처럼 전달 -```tsx -function getFirst(arr: T[]): T | undefined { - return arr[0]; -} -const firstNumber = getFirst([1, 2, 3]); // number | undefined -``` - -**주요 유틸리티 타입**: -```tsx -interface Person { id: string; name: string; age: number; createdAt: Date; } - -// Partial - 모든 속성을 선택적으로 (부분 업데이트용) -type PartialPerson = Partial; - -// Pick - 특정 속성만 선택 -type PersonName = Pick; - -// Omit - 특정 속성 제외 (생성 시 서버 자동 생성 필드 제외) -type PersonWithoutId = Omit; - -// Record - 키-값 쌍의 객체 타입 -type Filters = Record; -``` - -**타입 가드 (Type Guards)**: -```tsx -// 커스텀 타입 가드 (is 키워드) -function isSuccess(response: SuccessResponse | ErrorResponse): response is SuccessResponse { - return response.success === true; -} - -if (isSuccess(response)) { - console.log(response.data); // SuccessResponse로 타입 좁혀짐 -} -``` - ---- - -## 🎯 분석 체크리스트 - -### ✅ 필수 포함 사항 -- 파일 경로와 라인 번호 명시 -- 모든 타입 정의 상세 설명 -- 제너릭/유틸리티 타입 사용 이유 설명 -- 데이터 플로우 다이어그램 포함 -- 리렌더링 조건 표로 정리 -- **주석은 한글로** 상세하게 - -### ❌ 금지 사항 -- 추측으로 설명하기 -- 코드 없이 설명만 하기 -- 타입 설명 생략하기 - ---- - -## 📊 응답 템플릿 - -```markdown -# 🔍 [기능명] 완전 분석 - -## 1️⃣ 진입점: app/page -[코드 + 상세 주석] - -## 2️⃣ 컴포넌트 시작 -[코드 + 상세 주석] - -## 3️⃣ 컴포넌트 시작 플로우 -[플로우 다이어그램 + 코드] - -## 4️⃣ Hook 호출 및 반환값 -[훅 코드 + 타입 설명 + 각 반환값 기능] - -## 5️⃣ API 호출 → 상태 저장 → 리렌더링 -[전체 플로우 다이어그램] - -## 6️⃣ 리렌더링 트리거 -[리렌더 조건 표] - -## 7️⃣ TypeScript 타입 분석 -[제너릭/유틸리티 타입 사용 이유] -``` - ---- - -## 🔧 프로젝트 기술 스택 - -| 분류 | 기술 | 버전 | -|------|------|------| -| 프레임워크 | Next.js (App Router) | 15.3 | -| UI 라이브러리 | React | 19 | -| 언어 | TypeScript | strict mode | -| 서버 상태 | TanStack Query | v5 | -| UI 상태 | Zustand | v5 | -| 폼 관리 | React Hook Form + Zod | v7 | - ---- - -## 📁 프로젝트 구조 - -``` -src/ -├── app/ # Next.js App Router 라우트 -│ └── [route]/page.tsx # 페이지 컴포넌트 -├── components/ -│ ├── ui/ # 기본 UI (shadcn 기반) -│ └── custom_ui/ # 복합/레이아웃 컴포넌트 -├── features/ # 도메인별 기능 모듈 -│ └── [domain]/ -│ ├── api.ts # 도메인 API 서비스 -│ ├── types.ts # 타입 정의 -│ ├── hooks/ # 커스텀 훅 -│ ├── components/ # 도메인 컴포넌트 -│ └── store/ # Zustand 스토어 -├── hooks/ # 공통 훅 -├── lib/ # 유틸리티 -└── stores/ # 공통 스토어 -``` \ No newline at end of file diff --git a/.agent/rules/doc-rule.md b/.agent/rules/doc-rule.md deleted file mode 100644 index 9d4e8e0..0000000 --- a/.agent/rules/doc-rule.md +++ /dev/null @@ -1,333 +0,0 @@ ---- -trigger: manual ---- - -# 역할 - -시니어 프론트엔드 엔지니어이자 "문서화 전문가". -목표: **코드 변경 없이 주석만 추가**하여 신규 개발자가 파일을 열었을 때 5분 내에 '사용자 행동 흐름'과 '데이터 흐름'을 파악할 수 있게 만든다. - -# 기술 스택 - -- TypeScript + React/Next.js -- TanStack Query (React Query) -- Zustand -- React Hook Form + Zod -- shadcn/ui - -# 출력 규칙 (절대 준수) - -1. **코드 로직 변경 금지**: 타입/런타임/동작/변수명/import 변경 절대 금지 -2. **주석만 추가**: TSDoc/라인주석/블록주석만 삽입 -3. **충분한 인라인 주석**: state, handler, JSX 각 영역에 주석 추가 (과하지 않게 적당히) -4. **한국어 사용**: 딱딱한 한자어 대신 쉬운 일상 용어 사용 - -──────────────────────────────────────────────────────── - -# 1) 파일 상단 TSDoc (모든 주요 파일 필수) - -**형식:** - -```typescript -/** - * @file <파일명> - * @description <1-2줄로 파일 목적 설명> - * @remarks - * - [레이어] Infrastructure/Hooks/Components/Core 중 하나 - * - [사용자 행동] 검색 -> 목록 -> 상세 -> 편집 (1줄) - * - [데이터 흐름] UI -> Service -> API -> DTO -> Adapter -> UI (1줄) - * - [연관 파일] types.ts, useXXXQuery.ts (주요 파일만) - * - [주의사항] 필드 매핑/권한/캐시 무효화 등 (있을 때만) - * @example - * // 핵심 사용 예시 2-3줄 - */ -``` - -**원칙:** - -- @remarks는 총 5줄 이내로 간결하게 -- 당연한 내용 제외 (예: "에러는 전역 처리") -- 단순 re-export 파일은 @description만 - -──────────────────────────────────────────────────────── - -# 2) 함수/타입 TSDoc (export 대상) - -**필수 대상:** - -- Query Key factory -- API 함수 (Service) -- Adapter 함수 -- Zustand store/actions -- React Hook Form schema/handler -- Container/Modal 컴포넌트 (모두) - -**형식:** - -````typescript -/** - * <1줄 설명 (무엇을 하는지)> - * @param <파라미터명> <설명> - * @returns <반환값 설명> - * @remarks <핵심 주의사항 1줄> (선택) - * @see <연관 파일명.tsx의 함수명 - 어떤 역할로 호출하는지> - */ - -## 2-1. 복잡한 로직/Server Action 추가 규칙 (권장) -데이터 흐름이나 로직이 복잡한 함수(특히 Server Action)는 **처리 과정**을 명시하여 흐름을 한눈에 파악할 수 있게 한다. - -**형식:** -```typescript -/** - * [함수명] - * - * <상세 설명> - * - * 처리 과정: - * 1. <데이터 추출/준비> - * 2. <검증 로직> - * 3. <외부 API/DB 호출> - * 4. <분기 처리 (성공/실패)> - * 5. <결과 반환/리다이렉트> - * - * @param ... - */ -```` - -```` - -## ⭐ @see 강화 규칙 (필수) - -모든 함수/컴포넌트에 **@see를 적극적으로 추가**하여 호출 관계를 명확히 한다. - -**@see 작성 패턴:** - -```typescript -/** - * @see OpptyDetailHeader.tsx - handleApprovalClick()에서 다이얼로그 열기 - * @see useContractApproval.ts - onConfirm 콜백으로 날짜 전달 - */ - -/** - * @see LeadDetailPage.tsx - 리드 상세 페이지에서 목록 조회 - * @see LeadSearchForm.tsx - 검색 폼 제출 시 호출 - */ -```` - -**@see 필수 포함 정보:** - -1. **파일명** - 어떤 파일에서 호출하는지 -2. **함수/이벤트명** - 어떤 함수나 이벤트에서 호출하는지 -3. **호출 목적** - 왜 호출하는지 (간단히) - -**예시:** - -```typescript -/** - * 리드 목록 조회 API (검색/필터/정렬/페이징) - * @param params 조회 조건 - * @returns 목록, 페이지정보, 통계 - * @remarks 정렬 필드는 sortFieldMap으로 프론트↔백엔드 변환 - * @see useMainLeads.ts - useQuery의 queryFn으로 호출 - * @see LeadTableContainer.tsx - 테이블 데이터 소스로 사용 - */ -``` - -**DTO/Interface:** - -```typescript -/** - * 리드 생성 요청 데이터 구조 (DTO) - * @see LeadCreateModal.tsx - 리드 생성 폼 제출 시 사용 - */ -export interface CreateLeadRequest { ... } -``` - -**Query Key Factory:** - -```typescript -/** - * 리드 Query Key Factory - * React Query 캐싱/무효화를 위한 키 구조 - * @returns ['leads', { entity: 'mainLeads', page, ... }] 형태 - * @see useLeadsQuery.ts - queryKey로 사용 - * @see useLeadMutations.ts - invalidateQueries 대상 - */ -export const leadKeys = { ... } - -/** 메인 리드 목록 키 */ -mainLeads: (...) => [...], -``` - -──────────────────────────────────────────────────────── - -# 3) 인라인 주석 (적극 활용) - -## 3-1. State 주석 (필수) - -모든 useState/useRef에 역할 주석 추가 - -```typescript -// [State] 선택된 날짜 (기본값: 오늘) -const [selectedDate, setSelectedDate] = useState(new Date()); - -// [State] 캘린더 팝오버 열림 상태 -const [isCalendarOpen, setIsCalendarOpen] = useState(false); - -// [Ref] 파일 input 참조 (프로그래밍 방식 클릭용) -const fileInputRef = useRef(null); -``` - -## 3-2. Handler/함수 주석 (필수) - -이벤트 핸들러에 Step 주석 추가 - -```typescript -/** - * 작성 확인 버튼 클릭 핸들러 - * @see OpptyDetailHeader.tsx - handleConfirm prop으로 전달 - */ -const handleConfirm = () => { - // [Step 1] 선택된 날짜를 부모 컴포넌트로 전달 - onConfirm(selectedDate); - // [Step 2] 다이얼로그 닫기 - onClose(); -}; -``` - -## 3-3. JSX 영역 주석 (필수) - -UI 구조를 파악하기 쉽게 영역별 주석 추가 - -```tsx -return ( - - {/* ========== 헤더 영역 ========== */} - - 제목 - - - {/* ========== 본문: 날짜 선택 영역 ========== */} -
- {/* 날짜 선택 Popover */} - - {/* 트리거 버튼: 현재 선택된 날짜 표시 */} - ... - {/* 캘린더 컨텐츠: 한국어 로케일 */} - ... - -
- - {/* ========== 하단: 액션 버튼 영역 ========== */} -
- - -
-
-); -``` - -**JSX 주석 규칙:** - -- `{/* ========== 영역명 ========== */}` - 큰 섹션 구분 -- `{/* 설명 */}` - 개별 요소 설명 -- 스크롤 없이 UI 구조 파악 가능하게 - -──────────────────────────────────────────────────────── - -# 4) 함수 내부 Step 주석 - -**대상:** -조건/분기/데이터 변환/API 호출/상태 변경이 있는 함수 - -**형식:** - -```typescript -// [Step 1] <무엇을 하는지 간결하게> -// [Step 2] <다음 단계> -// [Step 3] <최종 단계> -``` - -**규칙:** - -- 각 Step은 1줄로 -- 반드시 1번부터 순차적으로 -- "무엇을", "왜"를 명확하게 - -**예시:** - -```typescript -export const getMainLeads = async (params) => { - // [Step 1] UI 정렬 필드를 백엔드 컬럼명으로 매핑 - const mappedField = sortFieldMap[sortField] || sortField; - - // [Step 2] API 요청 파라미터 구성 - const requestParams = { ... }; - - // [Step 3] 리드 목록 조회 API 호출 - const { data } = await axiosInstance.get(...); - - // [Step 4] 응답 데이터 검증 및 기본값 설정 - let dataList = data?.data?.list || []; - - // [Step 5] UI 모델로 변환 및 결과 반환 - return { list: dataList.map(convertToRow), ... }; -} -``` - -──────────────────────────────────────────────────────── - -# 5) 레이어별 특수 규칙 - -## 5-1. Service/API - -- **Step 주석**: API 호출 흐름을 단계별로 명시 -- **@see**: 이 API를 호출하는 모든 훅/컴포넌트 명시 - -## 5-2. Hooks (TanStack Query) - -- **Query Key**: 반환 구조 예시 필수 -- **캐시 전략**: invalidateQueries/setQueryData 사용 이유 -- **@see**: 이 훅을 사용하는 모든 컴포넌트 명시 - -## 5-3. Adapters - -- **간단한 변환**: 주석 불필요 -- **복잡한 변환**: 입력→출력 1줄 설명 + 변환 규칙 - -## 5-4. Components (Container/Modal) - -- **상태 관리**: 어떤 state가 어떤 이벤트로 변경되는지 -- **Dialog/Modal**: open 상태 소유자, 닫힘 조건 -- **Table**: 인라인 편집, 스켈레톤 범위 -- **JSX 영역 주석**: UI 구조 파악용 영역 구분 주석 필수 - -## 5-5. Zustand Store - -- **왜 store인지**: 페이지 이동/모달 간 상태 유지 이유 -- **reset 조건**: 언제 초기화되는지 -- **서버 캐시와 역할 분담**: React Query와의 경계 - -──────────────────────────────────────────────────────── - -# 6) 작업 순서 - -1. 파일 레이어 판별 (Infrastructure/Hooks/UI/Core) -2. 파일 상단 TSDoc 추가 (@see 포함) -3. export 대상에 TSDoc 추가 (@see 필수) -4. State/Ref에 인라인 주석 추가 -5. Handler 함수에 TSDoc + Step 주석 추가 -6. JSX 영역별 구분 주석 추가 -7. Query Key Factory에 반환 구조 예시 추가 - -# 제약사항 - -- **@author는 jihoon87.lee 고정** -- **@see는 필수**: 호출 관계 명확히 -- **Step 주석은 1줄**: 간결하게 -- **JSX 주석 필수**: UI 구조 파악용 -- **@see는 파일명 + 함수명 + 역할**: 전체 경로 불필요 - -# 지금부터 작업 - -내가 주는 코드를 위 규칙에 맞게 "주석만" 보강하라. diff --git a/.agent/rules/master-integration.md b/.agent/rules/master-integration.md deleted file mode 100644 index e4c1d21..0000000 --- a/.agent/rules/master-integration.md +++ /dev/null @@ -1,341 +0,0 @@ ---- -trigger: manual ---- - -# 🎯 Anti-Gravity 통합 작업 지침서 - -이 문서는 `.agent/rules/`의 커스텀 룰과 `.agent/skills/`의 Skill들을 **상황별로 자동 조합**하여 최적의 결과를 도출하기 위한 마스터 가이드입니다. -주식 예제는 공식 한국투자증권에서 제공하는 예제를 항상 이용해서 파이선 예제 코드를 참고하여 작성합니다. -공식예제경로: .tmp\open-trading-api -공식 사이트 무조건참고해서 수정해 공식사이트는 여기야 'https://github.com/koreainvestment/open-trading-api' - ---- - -## 📋 작업 유형별 룰+Skill 조합표 - -| 작업 유형 | 주 룰(Primary) | 보조 룰(Secondary) | 활용 Skill | MCP 도구 | -| ------------------- | ----------------------- | -------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------- | -| **새 기능 개발** | `builder-rule.md` | - | `nextjs-app-router-patterns`
`vercel-react-best-practices` | `sequential-thinking` (복잡한 로직)
`context7` (라이브러리 확인) | -| **코드 분석/이해** | `code-analysis-rule.md` | - | `nextjs-app-router-patterns` (구조 이해) | `sequential-thinking` (플로우 분석) | -| **주석 추가** | `doc-rule.md` | `code-analysis-rule.md` | - | - | -| **리팩토링** | `refactoring-rule.md` | `builder-rule.md` (재구현) | `vercel-react-best-practices` (성능 개선) | `sequential-thinking` (의존성 분석)
`context7` (최신 패턴 확인) | -| **성능 최적화** | `builder-rule.md` | `refactoring-rule.md` | `vercel-react-best-practices` | `context7` (최신 최적화 기법) | -| **주석 + 리팩토링** | `refactoring-rule.md` | `doc-rule.md` | `vercel-react-best-practices` | `sequential-thinking` | - ---- - -## 🔄 작업 흐름별 세부 가이드 - -### 1️⃣ 새 기능 개발 (Feature Development) - -**트리거 키워드**: "새로운 기능", "컴포넌트 추가", "API 연동", "페이지 생성" - -**작업 순서**: - -``` -[1단계] builder-rule.md 기준으로 구조 설계 - ↓ -[2단계] nextjs-app-router-patterns로 Next.js App Router 패턴 확인 - ↓ (복잡한 로직이 있다면) -[2-1] sequential-thinking으로 로직 검증 - ↓ (SpreadJS 등 외부 라이브러리 사용 시) -[2-2] context7로 공식 문서 조회 - ↓ -[3단계] vercel-react-best-practices로 성능 최적화 패턴 적용 - ↓ -[4단계] builder-rule.md의 출력 형식대로 코드 제공 - - [가정] 섹션 - - 핵심 코드 블록 - - 한 줄 한 줄 뜯어보기 - - 작동 흐름 - - 핵심 포인트 -``` - -**구체적 통합 예시**: - -```markdown -# [예시] CreateLeadDialog 컴포넌트 개발 - -## [1단계] builder-rule.md 적용 - -- Tech Stack 확인: Next.js 15.3, React 19, TanStack Query v5, Zustand v5 -- 폴더 구조: `src/features/leads/components/CreateLeadDialog.tsx` -- 'use client' 필요 (Form 인터랙션) - -## [2단계] nextjs-app-router-patterns 참고 - -- Client Component는 'use client' 선언 -- Server Action 사용 시 "use server" 분리 -- Suspense 경계 설정 - -## [2-1] sequential-thinking (복잡한 검증 로직이 있는 경우) - -- Form 제출 → 사전 검증 → API 호출 → 성공/실패 처리 -- 엣지 케이스: 중복 제출, 네트워크 오류, 필수값 누락 - -## [3단계] vercel-react-best-practices 적용 - -- `rerender-memo`: 무거운 Form 로직은 memo로 감싸기 -- `client-swr-dedup`: TanStack Query로 중복 요청 방지 -- `rendering-conditional-render`: 조건부 렌더링은 삼항 연산자 사용 - -## [4단계] 최종 코드 출력 - -- builder-rule.md의 출력 형식 준수 -- 주석은 한글로, 작성자 'jihoon87.lee' -``` - ---- - -### 2️⃣ 코드 분석/이해 (Code Analysis) - -**트리거 키워드**: "코드 분석", "흐름 설명", "어떻게 작동", "플로우 파악" - -**작업 순서**: - -``` -[1단계] code-analysis-rule.md의 분석 순서 준수 - - 진입점 (app/page) - - 컴포넌트 시작 - - Hook 호출 - - API → 상태 → 리렌더 - - TypeScript 타입 설명 - ↓ (복잡한 흐름인 경우) -[1-1] sequential-thinking으로 논리적 단계 검증 - ↓ -[2단계] nextjs-app-router-patterns로 Next.js 구조 매핑 - - Server Component vs Client Component - - Parallel Routes, Intercepting Routes 등 - ↓ -[3단계] code-analysis-rule.md 응답 템플릿 사용 - - 한글 주석 - - 플로우 다이어그램 - - 리렌더링 조건 표 -``` - -**통합 예시**: - -```markdown -# [예시] PersonnelTableContainer 분석 - -## [적용 룰] - -- code-analysis-rule.md: 분석 순서 및 템플릿 -- nextjs-app-router-patterns: Server/Client 구분 -- sequential-thinking: 데이터 페칭 순서 검증 - -## [분석 결과] - -### 1️⃣ 진입점 - -- URL: /standards/personnel -- Server Component (page.tsx) → Client Component (PersonnelTableContainer) - -### 3️⃣ 컴포넌트 시작 플로우 (sequential-thinking 검증) - -【1단계】useState 초기값 설정 -【2단계】useDataTablePersonnel 훅 호출 -【3단계】TanStack Query가 queryFn 실행 -【4단계】personnelApi.getList() 호출 -【5단계】응답 데이터를 Query 캐시에 저장 -【6단계】컴포넌트 리렌더링 -``` - ---- - -### 3️⃣ 주석 추가 (Documentation) - -**트리거 키워드**: "주석 추가", "문서화", "JSDoc 작성" - -**작업 순서**: - -``` -[1단계] code-analysis-rule.md로 코드 흐름 파악 - ↓ -[2단계] doc-rule.md 규칙 적용 - - 파일 상단 TSDoc - - 함수/타입 TSDoc - - Step 주석 (복잡한 함수만) - ↓ -[3단계] 코드 변경 없이 주석만 추가 -``` - -**통합 예시**: - -```typescript -/** - * @file PersonnelTableContainer.tsx - * @description 인사 기준정보 목록 조회 및 관리 컨테이너 - * @author jihoon87.lee - * @remarks - * - [레이어] Components (UI) - * - [사용자 행동] 목록 조회 → 검색/필터 → 상세 → 편집/삭제 - * - [데이터 흐름] UI → useDataTablePersonnel → personnelApi → TanStack Query 캐시 → UI - * - [연관 파일] useDataTablePersonnel.ts, personnelApi.ts - */ -"use client"; - -// (code-analysis-rule로 분석 → doc-rule로 주석 추가) -``` - ---- - -### 4️⃣ 리팩토링 (Refactoring) - -**트리거 키워드**: "리팩토링", "구조 개선", "폴더 정리", "성능 개선" - -**작업 순서**: - -``` -[1단계] refactoring-rule.md의 워크플로우 적용 - ↓ -[1-1] sequential-thinking으로 의존성 지도 작성 - - 파일 이동 전 영향 범위 분석 - - import 경로 변경 목록 작성 - ↓ -[1-2] context7로 최신 폴더 구조 패턴 확인 - - TanStack Query v5 권장 구조 - - Next.js 15 App Router 최적화 - ↓ -[2단계] refactoring-rule.md의 표준 구조로 재구성 - - apis/, hooks/, types/, stores/, components/ - ↓ -[3단계] vercel-react-best-practices로 성능 최적화 - - bundle-barrel-imports: 직접 import - - rerender-memo: 불필요한 리렌더 방지 - ↓ -[4단계] builder-rule.md로 재구현 (필요 시) -``` - -**통합 예시**: - -```markdown -# [예시] work-execution 기능 리팩토링 - -## [1단계] sequential-thinking 의존성 분석 - -- 현재 파일: workExecutionOld.tsx (800줄, 단일 파일) -- 의존하는 외부 파일: app/standards/work-execution/page.tsx -- 영향받는 import: 3개 파일 - -## [1-2] context7 최신 패턴 조회 - -- TanStack Query v5: queryKeys를 별도 파일로 분리 권장 -- Next.js 15: Parallel Routes로 loading 상태 분리 가능 - -## [2단계] refactoring-rule 표준 구조 적용 - -src/features/standards/work-execution/ -├── apis/ -│ ├── workExecution.api.ts -│ └── workExecutionForm.adapter.ts -├── hooks/ -│ ├── queryKeys.ts -│ └── useWorkExecutionList.ts -├── types/ -│ └── workExecution.types.ts -├── stores/ -│ └── workExecutionStore.ts -└── components/ -├── WorkExecutionContainer.tsx -└── WorkExecutionModal.tsx - -## [3단계] vercel-react-best-practices 적용 - -- bundle-barrel-imports: index.ts 제거, 직접 경로 사용 -- rerender-memo: WorkExecutionModal을 React.memo로 감싸기 -- async-parallel: API 호출을 Promise.all로 병렬화 -``` - ---- - -## 🛠️ MCP 도구 활용 가이드 - -### Sequential Thinking 사용 시점 - -1. **복잡한 비즈니스 로직 구현 전** - - 예: 다단계 Form 검증, 복잡한 상태 머신 - - 목적: 엣지 케이스 사전 도출 - -2. **리팩토링 시 의존성 분석** - - 예: 파일 이동 시 영향 범위 파악 - - 목적: Broken Import 방지 - -3. **코드 분석 시 데이터 플로우 검증** - - 예: 브라우저 렌더링 단계, 데이터 페칭 순서 - - 목적: 논리적 흐름 명확화 - -### Context7 사용 시점 - -1. **외부 라이브러리 최신 API 확인** - - 예: SpreadJS v18, TanStack Query v5 - - 목적: 공식 문서 기반 정확한 구현 - -2. **리팩토링 시 최신 패턴 확인** - - 예: Next.js 15 App Router 권장 구조 - - 목적: 최신 표준과 프로젝트 룰 결합 - -3. **성능 최적화 검증** - - 예: React 19 신규 Hook 활용법 - - 목적: 최신 기법 적용 - ---- - -## 📌 실전 적용 예시 - -### 예시 1: 새 기능 개발 요청 - -**사용자 요청**: "리드 생성 모달을 만들어줘" - -**AI 작업 프로세스**: - -1. `builder-rule.md` 로드 → Tech Stack 확인 -2. `nextjs-app-router-patterns` 참고 → Client Component 패턴 확인 -3. `vercel-react-best-practices` 적용 → `rerender-memo`, `rendering-conditional-render` -4. `builder-rule.md` 출력 형식으로 코드 제공 - -### 예시 2: 코드 플로우 분석 요청 - -**사용자 요청**: "PersonnelTableContainer가 어떻게 작동하는지 설명해줘" - -**AI 작업 프로세스**: - -1. `code-analysis-rule.md` 로드 → 분석 순서 준수 -2. `sequential-thinking` 사용 → 데이터 페칭 순서 검증 -3. `nextjs-app-router-patterns` 참고 → Server/Client 구분 설명 -4. `code-analysis-rule.md` 템플릿으로 결과 출력 - -### 예시 3: 리팩토링 요청 - -**사용자 요청**: "work-execution 폴더를 정리해줘" - -**AI 작업 프로세스**: - -1. `refactoring-rule.md` 로드 → 워크플로우 확인 -2. `sequential-thinking` 사용 → 의존성 지도 작성 -3. `context7` 조회 → TanStack Query v5 권장 구조 확인 -4. `refactoring-rule.md` 표준 구조로 재구성 -5. `vercel-react-best-practices` 적용 → `bundle-barrel-imports`, `rerender-memo` - ---- - -## ✅ 체크리스트 - -작업 시작 전 항상 확인: - -- [ ] 작업 유형이 무엇인가? (개발/분석/주석/리팩토링) -- [ ] 주 룰(Primary)과 보조 룰(Secondary)은? -- [ ] 어떤 Skill을 참고해야 하는가? -- [ ] MCP 도구(sequential-thinking, context7)가 필요한가? -- [ ] 출력 형식은 어떤 룰을 따르는가? - ---- - -## 🎓 학습 자료 - -- **builder-rule.md**: Tech Stack, 코딩 원칙, 출력 형식 -- **code-analysis-rule.md**: 분석 순서, TypeScript 타입 설명 -- **doc-rule.md**: TSDoc 형식, Step 주석 규칙 -- **refactoring-rule.md**: 폴더 구조, 워크플로우 -- **nextjs-app-router-patterns**: Next.js 패턴, Server/Client 구분 -- **vercel-react-best-practices**: 성능 최적화 57개 룰 diff --git a/.agent/rules/refactoring-rule.md b/.agent/rules/refactoring-rule.md deleted file mode 100644 index 11eb577..0000000 --- a/.agent/rules/refactoring-rule.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -trigger: manual ---- - -# 역할: Anti-Gravity Refactoring Specialist -당신은 psix-frontend 프로젝트의 **구조적 개선 및 리팩토링 전문가**입니다. -기존 스파게티 코드나 레거시 구조를 **모던하고 유지보수 가능한 표준 구조**로 재설계합니다. - ---- - -## 📥 입력 (Input) -- **FEATURE_ROOT**: 리팩토링할 기능의 루트 폴더 경로 - - 예) `src/features/standards/work-execution` - ---- - -## 🎯 목표 (Goal) -1. **표준 폴더 구조 지향**: `apis`, `components`, `hooks`, `stores`, `types` 5대 폴더를 기본으로 구성한다. -2. **유연성 허용**: 필요에 따라 `utils`(유틸리티), `lib`(라이브러리 래퍼), `constants`(상수) 등 보조 폴더 생성을 허용한다. -3. **단일 파일 분해**: 거대한 파일은 기능 단위로 쪼개야 함 -4. **배럴 파일 제거**: `index.ts`를 사용한 re-export 패턴을 제거하고 직접 경로(`.../components/MyComponent`)를 사용 - ---- - -## 🛠️ MCP 도구 활용 (분석 및 설계 지침) - -### 1. Sequential Thinking 활용 (의존성 및 리스크 분석) -- **적용 시점**: `1) FEATURE_ROOT의 기존 구조와 import 의존성 분석` 단계에서 필수 사용 -- **수행 작업**: - - 실제 파일을 옮기기 전, `sequential-thinking`을 사용하여 이동할 파일들의 의존성 지도(Dependency Map)를 먼저 그린다. - - 파일 이동 시 영향받는 외부 파일(page.tsx 등)의 리스트를 미리 확보한다. - - 수정해야 할 import 경로가 많은 경우, 논리적 순차 단계를 설정하여 하나씩 해결함으로써 경로 오류(Broken Import)를 방지한다. - -### 2. Context7 활용 (기술 표준 검증) -- **적용 시점**: 폴더 구조 재구성 중 최신 라이브러리 패턴이 가이드와 충돌하거나 모호할 때 사용 -- **수행 작업**: - - TanStack Query의 최신 v5 권장 폴더 구조나 Next.js 15의 App Router 최적화 기법이 필요할 경우 `context7`을 통해 공식 문서를 조회한다. - - 조회된 최신 표준과 본 룰의 구조(`apis`, `hooks`, `types` 등)를 결합하여 개발자가 유지보수하기 가장 편한 최적의 경로를 도출한다. - ---- - -## 📋 작업 지시 (Workflow) - -1. **분석**: `FEATURE_ROOT` 내의 기존 파일 구조와 외부 의존성(import)을 파악한다. (MCP 활용) -2. **구조 설계**: - - **기본 폴더**: `apis`, `hooks`, `types`, `stores`, `components` - - **선택 폴더**: `utils` (순수 함수), `lib` (설정/래퍼), `constants` (상수 데이터) - - 위 기준에 맞춰 파일 분류 계획을 세운다. -3. **이동 및 생성**: 파일을 계획된 폴더로 이동하거나 분리 생성한다. -4. **경로 수정**: 이동된 파일에 맞춰 모든 `import` 경로를 업데이트한다. -5. **청소**: 불필요해진 폴더(구조상 매핑되지 않는 옛 폴더)와 `index.ts` 파일을 삭제한다. -6. **진입점 갱신**: `page.tsx` 등 외부에서 해당 기능을 사용하는 곳의 import 경로를 수정한다. - ---- - -## 🏗️ 권장 파일 구조 (Standard Structure) - -```text -/ -├── apis/ -│ ├── apiError.ts -│ ├── .api.ts # API 호출 로직 -│ ├── Form.adapter.ts # Form <-> API 변환 -│ └── List.adapter.ts # List <-> API 변환 -├── hooks/ -│ ├── queryKeys.ts # Query Key Factory -│ ├── useList.ts # 목록 조회 Hooks -│ ├── useMutations.ts # CUD Hooks -│ └── useForm.ts # Form Logic Hooks -├── types/ -│ ├── api.types.ts # 공통 API 응답 규격 -│ ├── .types.ts # 도메인 Entity -│ └── selectOption.types.ts # 공통 Select Option -├── stores/ -│ └── Store.ts # Zustand Store -├── components/ -│ ├── Container.tsx # 메인 컨테이너 -│ └── Modal.tsx # 모달 컴포넌트 -├── utils/ # (Optional) -│ └── Utils.ts # 순수 헬퍼 함수 -└── constants/ # (Optional) - └── .constants.ts # 상수 (components 내부에 둬도 무방) -``` - ---- - -## ⚠️ 규칙 (Rules) - -1. **로직 변경 금지**: 오직 파일 위치와 구조만 변경하며, 비즈니스 로직은 건드리지 않는다. -2. **Naming Convention**: - - 파일명은 **ASCII 영문**만 사용 (한글 금지) - - UI 컴포넌트 파일: `PascalCase` (예: `WorkExecutionContainer.tsx`) - - Hooks 및 일반 파일: `camelCase` (예: `useWorkExecutionList.ts`) -3. **Clean Import**: import 시 불필요한 별칭(alias)보다는 명확한 상대/절대 경로를 사용한다. \ No newline at end of file diff --git a/.agent/skills/find-skills/SKILL.md b/.agent/skills/find-skills/SKILL.md deleted file mode 100644 index 1a301cb..0000000 --- a/.agent/skills/find-skills/SKILL.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: find-skills -description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", or "is there a skill that can help with X". ---- - -# Find Skills - -This skill helps you discover and install skills from the open agent skills ecosystem. - -## When to Use This Skill - -Use this skill when the user: - -- Asks "how do I do X" where X might be a common task with an existing skill -- Says "find a skill for X" or "is there a skill for X" -- Asks "can you do X" where X is a specialized capability -- Expresses interest in extending agent capabilities -- Wants to search for tools, templates, or workflows -- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.) - -## What is the Skills CLI? - -The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools. - -**Key commands:** - -- `npx skills find [query]` - Search for skills interactively or by keyword -- `npx skills add` - Install a skill from GitHub or other sources -- `npx skills check` - Check for skill updates -- `npx skills update` - Update all installed skills - -**Browse skills at:** - -## How to Help Users Find Skills - -### Step 1: Understand What They Need - -When a user asks for help with something, identify: - -1. The domain (e.g., React, testing, design, deployment) -2. The specific task (e.g., writing tests, creating animations, reviewing PRs) -3. Whether this is a common enough task that a skill likely exists - -### Step 2: Search for Skills - -Run the find command with a relevant query: - -```bash -npx skills find [query] -``` - -For example: - -- User asks "how do I make my React app faster?" → `npx skills find react performance` -- User asks "can you help me with PR reviews?" → `npx skills find pr review` -- User asks "I need to create a changelog" → `npx skills find changelog` - -### Step 3: Present Recommendations - -When you find relevant skills, present them to the user with: - -1. The skill name and what it does -2. The installation command -3. A link to the skill's page - -**Example response:** - -> I found a skill that might help! -> -> **vercel-react-best-practices** -> Vercel's official React performance guidelines for AI agents. -> -> To install it: -> `npx skills add vercel-labs/agent-skills@vercel-react-best-practices` -> -> Learn more: - -If the user wants to proceed, you can install the skill for them: - -```bash -npx skills add vercel-labs/agent-skills@vercel-react-best-practices -``` - -### Step 4: Verify Installation (Optional) - -After installing, you can verify it was installed correctly: - -```bash -npx skills list -``` - -## When No Skills Are Found - -1. Try a broader search term -2. Check the [skills.sh](https://skills.sh/) website manually if you suspect a network issue -3. Suggest the user could create their own skill with `npx skills init` diff --git a/.agent/skills/vercel-react-best-practices/SKILL.md b/.agent/skills/vercel-react-best-practices/SKILL.md deleted file mode 100644 index 32cdea6..0000000 --- a/.agent/skills/vercel-react-best-practices/SKILL.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -name: vercel-react-best-practices -description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements. -license: MIT -metadata: - author: vercel - version: "1.0.0" ---- - -# Vercel React Best Practices - -Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation. - -## When to Apply - -Reference these guidelines when: - -- Writing new React components or Next.js pages -- Implementing data fetching (client or server-side) -- Reviewing code for performance issues -- Refactoring existing React/Next.js code -- Optimizing bundle size or load times - -## Rule Categories by Priority - -| Priority | Category | Impact | Prefix | -| -------- | ------------------------- | ----------- | ------------ | -| 1 | Eliminating Waterfalls | CRITICAL | `async-` | -| 2 | Bundle Size Optimization | CRITICAL | `bundle-` | -| 3 | Server-Side Performance | HIGH | `server-` | -| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` | -| 5 | Re-render Optimization | MEDIUM | `rerender-` | -| 6 | Rendering Performance | MEDIUM | `rendering-` | -| 7 | JavaScript Performance | LOW-MEDIUM | `js-` | -| 8 | Advanced Patterns | LOW | `advanced-` | - -## Quick Reference - -### 1. Eliminating Waterfalls (CRITICAL) - -- `async-defer-await` - Move await into branches where actually used -- `async-parallel` - Use Promise.all() for independent operations -- `async-dependencies` - Use better-all for partial dependencies -- `async-api-routes` - Start promises early, await late in API routes -- `async-suspense-boundaries` - Use Suspense to stream content - -### 2. Bundle Size Optimization (CRITICAL) - -- `bundle-barrel-imports` - Avoid large barrel files; use direct imports -- `bundle-large-libraries` - Optimize heavy deps (e.g. framer-motion, lucide) -- `bundle-conditional` - Lazy load conditional components -- `bundle-route-split` - Split huge page components -- `bundle-dynamic-imports` - Use next/dynamic for heavy client components - -### 3. Server-Side Performance (HIGH) - -- `server-cache-react` - Use React.cache() for per-request deduplication -- `server-cache-next` - Use unstable_cache for data coaching -- `server-only-utils` - Mark server-only code with 'server-only' package -- `server-component-boundaries` - Keep client components at leaves -- `server-image-optimization` - Use next/image with proper sizing - -### 4. Client-Side Data Fetching (MEDIUM-HIGH) - -- `client-use-swr` - Use SWR/TanStack Query for client-side data -- `client-no-effect-fetch` - Avoid fetching in useEffect without caching libraries -- `client-prefetch-link` - Use next/link prefetching -- `client-caching-headers` - Respect cache-control headers - -### 5. Re-render Optimization (MEDIUM) - -- `rerender-memo-props` - Memoize complex props -- `rerender-dependencies` - Use primitive dependencies in effects -- `rerender-functional-setstate` - Use functional setState for stable callbacks -- `rerender-context-split` - Split context to avoid wide re-renders - -### 6. Rendering Performance (MEDIUM) - -- `rendering-image-priority` - Priority load LCP images -- `rendering-list-virtualization` - Virtualize long lists -- `rendering-content-visibility` - Use content-visibility for long lists -- `rendering-hoist-jsx` - Extract static JSX outside components -- `rendering-hydration-no-flicker` - Use inline script for client-only data - -### 7. JavaScript Performance (LOW-MEDIUM) - -- `js-batch-dom-css` - Group CSS changes -- `js-index-maps` - Build Map for repeated lookups -- `js-combine-iterations` - Combine multiple filter/map into one loop -- `js-set-map-lookups` - Use Set/Map for O(1) lookups - -### 8. Advanced Patterns (LOW) - -- `advanced-event-handler-refs` - Store event handlers in refs -- `advanced-init-once` - Initialize app once per app load diff --git a/.agents/skills/dev-auto-pipeline/SKILL.md b/.agents/skills/dev-auto-pipeline/SKILL.md new file mode 100644 index 0000000..c5342da --- /dev/null +++ b/.agents/skills/dev-auto-pipeline/SKILL.md @@ -0,0 +1,57 @@ +--- +name: dev-auto-pipeline +description: 기능 개발/버그 수정/리팩토링 같은 구현 요청에서 계획→구현→리팩토링→테스트→완료체크를 순서대로 실행하는 상위 오케스트레이션 스킬. 단순 설명/문서 요약/잡담에는 사용하지 않는다. +--- + +# Dev Auto Pipeline + +## 목표 + +- 개발 요청을 표준 5단계로 자동 처리한다. +- 각 단계 결과를 다음 단계 입력으로 넘겨 누락을 줄인다. + +## 실행 단계 (고정) + +1. `dev-plan-writer` +2. `dev-mcp-implementation` +3. `dev-refactor-polish` +4. `dev-test-gate` +5. `dev-plan-completion-checker` + +## 단계 연결 규칙 + +1. 계획 단계에서 생성한 계획 문서(`common-docs/improvement/plans/*.md`)를 이후 단계의 기준 문서로 사용한다. +2. 구현/리팩토링에서 변경된 파일 목록을 테스트 단계 입력으로 전달한다. +3. 테스트 결과를 완료체크 단계 입력으로 전달한다. +4. 완료체크는 계획 대비 `완료/부분 완료/미완료`와 최종 판정을 반드시 남긴다. + +## common-docs 기준 + +- 사용 문서: + - `common-docs/api-reference/openapi_all.xlsx` + - `common-docs/api-reference/kis_api_reference.md` + - `common-docs/api-reference/kis-error-code-reference.md` + - `common-docs/features/trade-stock-sync.md` + - `common-docs/ui/GLOBAL_ALERT_SYSTEM.md` +- 제외 문서: + - `common-docs/features-autotrade-design.md` + +## 최종 보고 형식 + +```md +[1. 계획] +- ... + +[2. 구현] +- ... + +[3. 리팩토링/성능/가독성] +- ... + +[4. 테스트] +- ... + +[5. 계획 대비 완료체크] +- 완료/부분 완료/미완료 +- 최종 판정: 배포 가능/보완 필요 +``` diff --git a/.agents/skills/dev-auto-pipeline/agents/openai.yaml b/.agents/skills/dev-auto-pipeline/agents/openai.yaml new file mode 100644 index 0000000..edff3c1 --- /dev/null +++ b/.agents/skills/dev-auto-pipeline/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Dev Auto Pipeline" + short_description: "Run end-to-end development pipeline" + default_prompt: "Use $dev-auto-pipeline to execute plan, implement, refactor, test, and completion checks." +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/dev-mcp-implementation/SKILL.md b/.agents/skills/dev-mcp-implementation/SKILL.md new file mode 100644 index 0000000..e925d9f --- /dev/null +++ b/.agents/skills/dev-mcp-implementation/SKILL.md @@ -0,0 +1,123 @@ +--- +name: dev-mcp-implementation +description: 구현 단계에서 MCP와 기존 스킬을 활용해 근거 기반으로 코드를 작성하는 스킬. 계획 문서가 확정된 뒤 실제 코드 변경이 필요할 때 사용하며, 단순 계획 작성/완료 판정 단계에는 사용하지 않는다. +--- + +# Dev MCP Implementation + +## 목표 + +- 추측 구현을 줄이고 공식 문서/런타임 진단 기반으로 구현한다. +- 구현 결과를 나중에 리팩토링/테스트 단계로 넘기기 쉬운 형태로 만든다. + +## 기본 구현 원칙 (AGENTS 반영) + +1. 모든 코드/주석/설명은 한국어 기준으로 작성한다. +2. 기술 스택 기준을 지킨다. + - Next.js 16 App Router, React 19, TypeScript + - Zustand(클라이언트 UI 상태), Supabase, react-hook-form + zod + - Tailwind CSS v4, Radix UI +3. 사이드이펙트가 예상되면 영향 범위를 먼저 확인하고 구현한다. +4. 불필요한 삭제는 하지 않는다. 삭제가 필요하면 영향 검증 후 진행한다. + +## 구현 순서 + +1. `dev-plan-writer` 결과를 읽고 구현 범위를 고정한다. +2. Next.js 프로젝트면 `next-devtools`로 현재 라우트/에러 상태를 먼저 확인한다. +3. 외부 라이브러리 API가 모호하면 `context7`로 공식 문서를 확인한다. +4. 복잡한 로직은 `sequential-thinking`으로 엣지 케이스(경계 상황)를 먼저 정리한다. +5. DB/권한/SQL 변경은 `supabase-mcp-server`로 안전하게 반영한다. +6. 코드 수정 후 최소 동작 확인(`lint`/핵심 UI 실행)을 진행한다. + +## 리팩토링 구현 규칙 (refactoring-rule 반영) + +1. 리팩토링 요청이면 `FEATURE_ROOT` 기준으로 작업한다. +2. 아래 기본 구조를 우선 사용한다. + - `apis`, `components`, `hooks`, `stores`, `types` +3. 필요 시 선택 구조를 사용한다. + - `utils`, `lib`, `constants` +4. 대형 파일은 책임 단위로 분해하고, 로직은 보존한다. +5. `index.ts` 배럴 export 의존을 줄이고 직접 경로 import로 전환한다. +6. 파일 이동 후 외부 진입점(`page.tsx` 등) import까지 함께 갱신한다. + +## 필수 적용 스킬 + +- `nextjs-app-router-patterns`: Server/Client 경계 검증 +- `vercel-react-best-practices`: 렌더링/번들/데이터 요청 최적화 + +## MCP 활용 맵 (AGENTS 반영) + +- `next-devtools`: Next.js 라우트/컴파일/런타임 오류 점검 +- `playwright`: 브라우저 상호작용/스모크 검증 +- `playwriter`: Chrome 확장 기반 상세 디버깅 +- `context7`: 라이브러리/프레임워크 공식 문서 조회 +- `supabase-mcp-server`: DB/SQL/함수 작업 +- `tavily-remote`: 최신 자료/기술 검색 +- `sequential-thinking`: 복잡 로직 단계화 +- `figma`: 디자인 파일 레이아웃/스타일/에셋 확인 +- `mcp:kis-code-assistant-mcp`: 한국투자증권 API 검색/소스 확인 + +## 코드/주석 규칙 (문서화 전문가 기준) + +1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다. +2. 주석은 쉬운 한글로 작성하고 "사용처"와 "데이터 흐름"을 먼저 보이게 쓴다. +3. 함수/API/Query 주석은 아래 3가지를 중심으로 작성한다. + - `[목적]` + - `[사용처]` + - `[데이터 흐름]` +4. 상태(`useState`, `useRef`, store)에는 "값이 바뀌면 화면이 어떻게 변하는지" 한 줄 주석을 단다. +5. 복잡한 로직/이벤트 핸들러는 `1, 2, 3...` 단계 주석으로 흐름을 나눈다. +6. 긴 JSX는 화면 구역별 주석으로 시각적으로 분리한다. + - 예: `{/* ===== 1. 상단: 제목/액션 ===== */}` +7. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다. + +## UI/브랜드/문구 규칙 + +1. 새 UI는 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰을 사용한다. +2. 기본 액션 색은 `primary`를 우선한다. +3. 색상 톤 변경은 컴포넌트 개별 수정보다 `app/globals.css` 토큰 조정을 우선 검토한다. +4. 사용자 문구는 불안을 줄이고 확신을 주는 친근한 톤을 사용한다. + +## common-docs 구현 규칙 + +1. KIS API 구현 기준: + - `openapi_all.xlsx`를 1순위 스펙으로 본다. + - 문서 확인 순서: `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp/open-trading-api` -> `kis_api_reference.md` + - 차이가 크면 사용자에게 최신 파일 재확인을 요청한다. +2. 에러코드 처리 기준: + - `kis-error-code-reference.md`를 따라 `msg_cd + 문구` 형태를 유지한다. + - `lib/kis/error-codes.ts`의 `buildKisErrorDetail`/`getKisErrorGuide` 사용 패턴을 유지한다. +3. 종목 마스터 데이터 기준: + - `features/trade/data/korean-stocks.json`은 수동 편집하지 않는다. + - `trade-stock-sync.md` 기준으로 `npm run sync:stocks` / `npm run sync:stocks:check`를 사용한다. +4. 전역 알림 UI 기준: + - `GLOBAL_ALERT_SYSTEM.md` 기준으로 `useGlobalAlert` 패턴을 우선 사용한다. + - 로컬 임시 Alert/Confirm 구현보다 전역 시스템(`GlobalAlertModal`) 연동을 우선한다. +5. 제외 문서: + - `features-autotrade-design.md`는 현 구현 기준에서 제외한다. + +## 출력 템플릿 + +```md +[구현 결과] +- ... + +[사용한 MCP/Skills] +- MCP: ... +- Skills: ... + +[변경 파일] +- ... + +[핵심 데이터 흐름] +- 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영 + +[남은 이슈] +- ... +``` + +## 규칙 + +- 필요 없는 파일/코드는 남기지 않는다. +- 불확실한 라이브러리 API는 문서 근거 없이 단정하지 않는다. +- 구현 단계에서 성능에 큰 악영향이 보이면 즉시 메모(기록)하고 다음 단계에서 정리한다. diff --git a/.agents/skills/dev-mcp-implementation/agents/openai.yaml b/.agents/skills/dev-mcp-implementation/agents/openai.yaml new file mode 100644 index 0000000..39e6326 --- /dev/null +++ b/.agents/skills/dev-mcp-implementation/agents/openai.yaml @@ -0,0 +1,23 @@ +interface: + display_name: "Dev MCP Implementation" + short_description: "Implement features with MCP workflows" + default_prompt: "Use $dev-mcp-implementation to build code using MCP-first verification." +dependencies: + tools: + - type: "mcp" + value: "next-devtools" + description: "Next.js route and runtime diagnostics" + - type: "mcp" + value: "context7" + description: "Official framework and library docs" + - type: "mcp" + value: "supabase-mcp-server" + description: "Supabase SQL and function operations" + - type: "mcp" + value: "playwright" + description: "Browser smoke verification" + - type: "mcp" + value: "kis-code-assistant-mcp" + description: "KIS API lookup and source references" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/dev-plan-completion-checker/SKILL.md b/.agents/skills/dev-plan-completion-checker/SKILL.md new file mode 100644 index 0000000..10edefe --- /dev/null +++ b/.agents/skills/dev-plan-completion-checker/SKILL.md @@ -0,0 +1,58 @@ +--- +name: dev-plan-completion-checker +description: 구현 완료 후 계획 문서와 실제 변경·테스트 근거를 대조해 완료 상태를 판정하는 스킬. 최종 점검 단계에서 사용하며, 계획 작성/구현/테스트 실행 단계를 대신하지 않는다. +--- + +# Dev Plan Completion Checker + +## 목표 + +- 계획대로 구현이 수행됐는지 객관적으로 확인한다. +- 누락/부분 완료 항목을 마지막에 명확히 남긴다. + +## 입력 + +1. 계획 문서 경로 (`common-docs/improvement/plans/*.md`) +2. 변경 파일 목록 +3. 테스트 결과 (`lint`, `build`, `playwright smoke`, 추가 검증) + +## 작업 순서 + +1. 계획 문서의 체크 항목을 읽는다. + - 구현 단계 체크박스 + - 검증 계획 체크박스 +2. 변경 파일/테스트 결과를 근거로 각 항목 상태를 판정한다. + - 완료: 근거가 충분함 + - 부분 완료: 일부 근거만 있음 + - 미완료: 근거가 없음 +3. 누락 항목에 대해 바로 실행 가능한 후속 작업을 작성한다. +4. 최종 완료 판정(`배포 가능` / `보완 필요`)을 내린다. + +## 판정 규칙 + +1. 구현 단계에 미완료가 1개 이상이면 `보완 필요` +2. 검증 계획에 미완료가 있으면 `보완 필요` +3. 테스트 생략 항목은 사유와 대체 검증이 있으면 `부분 완료`로 인정 가능 + +## 출력 템플릿 + +```md +[계획 문서] +- 경로: ... + +[완료 체크 결과] +- 완료: ... +- 부분 완료: ... +- 미완료: ... + +[근거] +- 변경 파일: ... +- 테스트 결과: ... + +[보완 필요 항목] +1. ... +2. ... + +[최종 판정] +- 배포 가능/보완 필요 +``` diff --git a/.agents/skills/dev-plan-completion-checker/agents/openai.yaml b/.agents/skills/dev-plan-completion-checker/agents/openai.yaml new file mode 100644 index 0000000..d733e0e --- /dev/null +++ b/.agents/skills/dev-plan-completion-checker/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Dev Completion Checker" + short_description: "Check plan completion against evidence" + default_prompt: "Use $dev-plan-completion-checker to compare plan checklists with changed files and test results." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/dev-plan-writer/SKILL.md b/.agents/skills/dev-plan-writer/SKILL.md new file mode 100644 index 0000000..69e0df2 --- /dev/null +++ b/.agents/skills/dev-plan-writer/SKILL.md @@ -0,0 +1,153 @@ +--- +name: dev-plan-writer +description: 구현 전에 실행 가능한 계획 문서를 만드는 스킬. 기능 추가/버그 수정/구조 변경 요청에서 범위·영향·작업 순서·검증 기준을 먼저 고정할 때 사용하며, 실제 코드 대량 구현 단계에서는 단독 사용하지 않는다. +--- + +# Dev Plan Writer + +## 목표 + +- 구현 전에 계획부터 확정하여 누락(빠뜨림)을 줄인다. +- 주니어 개발자도 바로 따라갈 수 있게 단계를 단순하게 작성한다. + +## 언어/소통 규칙 + +1. 모든 계획과 설명을 한국어로 작성한다. +2. 어려운 용어는 짧은 괄호 설명을 붙인다. +3. 요청이 모호하면 질문 1~3개로 범위를 먼저 고정한다. + +## 프로젝트 기본 컨텍스트 + +- 기술 스택: Next.js 16 App Router, React 19, TypeScript, Zustand, Supabase, react-hook-form, zod, Tailwind CSS v4, Radix UI +- 기본 명령어: `npm run dev`(포트 3001), `npm run lint`, `npm run build`, `npm run start` + +## 안전 계획 규칙 + +1. 수정/추가/삭제 파일을 분리해서 영향 범위를 먼저 적는다. +2. 삭제/이동/계약 변경(입출력 규칙 변경)은 사전 확인 질문을 남긴다. +3. "진짜 필요 없는 코드만 제거" 원칙으로 계획을 세운다. +4. 사이드이펙트(옆 영향) 가능성이 있으면 검증 단계를 계획에 반드시 넣는다. + +## 작업 순서 + +1. 요구사항을 3줄 이내로 요약한다. +2. 모호한 부분이 있으면 질문 1~3개로 범위를 먼저 고정한다. +3. 영향 파일(수정/추가/삭제)을 먼저 찾고, 사이드이펙트(옆 영향)를 표시한다. +4. 사용할 MCP/Skills를 단계별로 고른다. +5. 구현 단계를 순서대로 작성한다. +6. 검증 단계를 구현 단계와 1:1로 매핑한다. + +## 계획 문서 저장 규칙 (필수) + +1. 저장 위치: `common-docs/improvement/plans/` +2. 파일명 규칙: `dev-plan-YYYY-MM-DD-<작업슬러그>.md` + - 예: `dev-plan-2026-02-25-order-validation.md` +3. 하나의 개발 요청은 하나의 계획 파일을 기준으로 끝까지 추적한다. +4. 구현이 시작되면 같은 파일에 진행/완료 상태를 계속 갱신한다. + +## 계획 상태 관리 규칙 + +1. 구현 단계/검증 계획을 체크박스 형식으로 작성한다. +2. 각 체크 항목 옆에 근거(변경 파일, 테스트 결과)를 짧게 남긴다. +3. 완료 판단은 마지막에 `dev-plan-completion-checker`가 수행한다. + +## 리팩토링 요청 전용 계획 규칙 (refactoring-rule 반영) + +1. 입력값으로 `FEATURE_ROOT`를 명시한다. +2. 목표에 아래 4가지를 반드시 넣는다. + - 표준 폴더 구조(`apis/components/hooks/stores/types`) + - 선택 폴더 허용(`utils/lib/constants`) + - 대형 파일 분해 + - 배럴 파일 제거 및 직접 import +3. 작업 지시는 6단계로 고정해 계획한다. + - 분석 -> 구조 설계 -> 이동/생성 -> 경로 수정 -> 청소 -> 진입점 갱신 +4. 계획 문서에 "권장 파일 구조 트리"를 포함한다. + +## 도구 선택 기준 + +- Next.js 런타임/라우트 점검: `next-devtools` +- 라이브러리 공식 문서 확인: `context7` +- 복잡 로직 분해: `sequential-thinking` +- Supabase SQL/함수 작업: `supabase-mcp-server` +- 브라우저 동작 검증: `playwright` +- Chrome 확장 기반 디버깅: `playwriter` +- 최신 기술/레퍼런스 검색: `tavily-remote` +- Figma 레이아웃/스타일 확인: `figma` + +## common-docs 계획 반영 규칙 + +1. `common-docs` 기준 문서를 계획 단계에서 먼저 지정한다. + - `common-docs/api-reference/openapi_all.xlsx` + - `common-docs/api-reference/kis_api_reference.md` + - `common-docs/api-reference/kis-error-code-reference.md` + - `common-docs/features/trade-stock-sync.md` + - `common-docs/ui/GLOBAL_ALERT_SYSTEM.md` +2. 아래 문서는 계획에서 제외한다. + - `common-docs/features-autotrade-design.md` (향후 기획 문서) +3. KIS 연동 작업이면 스펙 확인 순서를 계획에 명시한다. + - `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp/open-trading-api` -> `kis_api_reference.md` +4. 종목 코드/마스터 데이터 변경이면 `trade-stock-sync.md` 기준으로 자동 동기화 명령을 계획에 넣는다. +5. 사용자 알림/확인 모달 변경이면 `GLOBAL_ALERT_SYSTEM.md` 기준으로 전역 알림 시스템 유지 계획을 넣는다. + +## 출력 템플릿 + +```md +[계획 문서 경로] +- common-docs/improvement/plans/dev-plan-YYYY-MM-DD-<작업슬러그>.md + +[요구사항 요약] +- ... + +[확인 질문(필요 시 1~3개)] +- ... + +[가정] +- ... + +[영향 범위] +- 수정: ... +- 추가: ... +- 삭제: ... + +[구현 단계] +- [ ] 1. ... +- [ ] 2. ... +- [ ] 3. ... + +[사용할 MCP/Skills] +- MCP: ... +- Skills: ... + +[참조 문서(common-docs)] +- ... + +[주석/문서 반영 계획] +- 함수 주석: [목적]/[사용처]/[데이터 흐름] +- 상태 주석: 값 변경 시 화면 영향 한 줄 설명 +- 복잡 로직/핸들러: 1, 2, 3 단계 주석 +- JSX 구역 주석: 화면 구조가 보이게 분리 +- TSDoc 딱딱한 태그(`@param`, `@see`, `@remarks`) 강제 없음 + +[리팩토링 구조 계획(리팩토링 요청 시)] +- FEATURE_ROOT: ... +- 목표(표준 구조/선택 구조/대형파일 분해/배럴 제거): ... +- Workflow 6단계: ... +- 권장 구조 트리: ... + +[리스크/회귀 포인트] +- ... + +[검증 계획] +- [ ] 1. ... +- [ ] 2. ... +- [ ] 3. ... + +[진행 로그] +- 2026-..-..: ... +``` + +## 규칙 + +- 계획 승인 전에 실제 구현 코드를 대량 작성하지 않는다. +- 파일 삭제는 반드시 필요성/대체 경로를 확인한 뒤 진행한다. +- 동작 변경과 리팩토링을 섞지 않는다. diff --git a/.agents/skills/dev-plan-writer/agents/openai.yaml b/.agents/skills/dev-plan-writer/agents/openai.yaml new file mode 100644 index 0000000..2306a2b --- /dev/null +++ b/.agents/skills/dev-plan-writer/agents/openai.yaml @@ -0,0 +1,17 @@ +interface: + display_name: "Dev Plan Writer" + short_description: "Write implementation plans with checks" + default_prompt: "Use $dev-plan-writer to create a tracked implementation plan file." +dependencies: + tools: + - type: "mcp" + value: "next-devtools" + description: "Next.js runtime and route diagnostics" + - type: "mcp" + value: "context7" + description: "Official library documentation lookup" + - type: "mcp" + value: "sequential-thinking" + description: "Step-by-step reasoning for complex planning" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/dev-refactor-polish/SKILL.md b/.agents/skills/dev-refactor-polish/SKILL.md new file mode 100644 index 0000000..f21b70d --- /dev/null +++ b/.agents/skills/dev-refactor-polish/SKILL.md @@ -0,0 +1,145 @@ +--- +name: dev-refactor-polish +description: 구현 완료 직후 가독성·데이터 흐름·성능을 다듬는 후처리 리팩토링 스킬. 핵심 동작을 바꾸지 않는 정리 단계에서 사용하며, 신규 기능의 본 구현 단계 대체 용도로는 사용하지 않는다. +--- + +# Dev Refactor Polish + +## 목표 + +- 핵심 비즈니스 동작은 유지하고 코드 품질을 높인다. +- 읽기 쉬운 구조와 명확한 데이터 흐름 설명을 만든다. +- 사용자 혼란을 줄이는 작은 UX 개선(문구, 버튼 라벨, 피드백 표시)은 허용한다. + +## 리팩토링 목표 (refactoring-rule 반영) + +1. 표준 폴더 구조를 지향한다. + - 기본: `apis`, `components`, `hooks`, `stores`, `types` +2. 필요 시 보조 폴더를 유연하게 허용한다. + - 선택: `utils`, `lib`, `constants` +3. 거대한 단일 파일은 기능 단위로 분해한다. +4. 배럴 파일(`index.ts` re-export) 의존을 줄이고 직접 import 경로를 사용한다. + +## 리팩토링 기본 원칙 + +1. 설명과 주석은 한국어로 쉽게 쓴다. 어려운 용어는 괄호로 짧게 풀어쓴다. +2. 사이드이펙트 위험이 있으면 영향 범위를 먼저 확인하고 수정한다. +3. 불필요한 코드만 제거하고, 영향 가능성이 있으면 검증 후 반영한다. + +## 리팩토링 순서 + +1. 핵심 동작 변경 없이 중복 코드를 줄인다. +2. 함수/변수 이름을 역할이 드러나는 이름으로 정리한다. +3. 복잡한 JSX는 섹션 주석으로 나눈다. +4. 상태/이벤트/복잡 로직에 인라인 주석을 보강한다. +5. 함수/API/Query에 쉬운 설명 주석을 보강한다. +6. 데이터 흐름을 `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영`으로 명시한다. +7. `vercel-react-best-practices` 기준으로 불필요한 리렌더/요청을 줄인다. + +## 작업 지시 (Workflow, refactoring-rule 반영) + +1. 분석: `FEATURE_ROOT` 내부 파일 구조와 외부 import 의존성을 파악한다. +2. 구조 설계: 기본/선택 폴더 기준으로 파일 분류 계획을 세운다. +3. 이동/생성: 파일을 이동하거나 책임 단위로 분리 생성한다. +4. 경로 수정: 이동된 파일에 맞춰 import 경로를 일괄 수정한다. +5. 청소: 옛 폴더와 불필요한 `index.ts`를 정리한다. +6. 진입점 갱신: `page.tsx` 등 외부 진입 파일 import를 최종 갱신한다. + +## 권장 파일 구조 (Standard Structure) + +```text +/ +├── apis/ +│ ├── apiError.ts +│ ├── .api.ts +│ ├── Form.adapter.ts +│ └── List.adapter.ts +├── hooks/ +│ ├── queryKeys.ts +│ ├── useList.ts +│ ├── useMutations.ts +│ └── useForm.ts +├── types/ +│ ├── api.types.ts +│ ├── .types.ts +│ └── selectOption.types.ts +├── stores/ +│ └── Store.ts +├── components/ +│ ├── Container.tsx +│ └── Modal.tsx +├── utils/ # Optional +│ └── Utils.ts +├── lib/ # Optional +│ └── Lib.ts +└── constants/ # Optional + └── .constants.ts +``` + +## 의존성/리스크 분석 규칙 + +1. 파일 이동 전 `sequential-thinking`으로 의존성 지도를 먼저 만든다. +2. 최신 구조 기준이 모호하면 `context7`로 공식 문서를 확인한다. + +## common-docs 리팩토링 반영 규칙 + +1. KIS 연동 리팩토링 시 아래 기준을 유지한다. + - 스펙 기준: `common-docs/api-reference/openapi_all.xlsx` + - 보조 확인: `kis_api_reference.md`, `kis-error-code-reference.md` +2. 에러 처리 리팩토링 시 `lib/kis/error-codes.ts` 중심 구조(`msg_cd + 문구`)를 깨지 않게 유지한다. +3. 종목 데이터 리팩토링 시 `korean-stocks.json`을 수동 편집 대상으로 옮기지 않는다. + - 동기화 흐름은 `trade-stock-sync.md` 기준으로 유지한다. +4. 알림 UI 리팩토링 시 전역 알림 구조(`useGlobalAlert`, `GlobalAlertModal`)를 우선 유지한다. +5. `features-autotrade-design.md`는 리팩토링 기준 문서로 사용하지 않는다. + +## 주석 규칙 (문서화 전문가 기준) + +1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다. +2. 함수/API/쿼리 주석은 `[목적]`, `[사용처]`, `[데이터 흐름]` 중심으로 쉽게 쓴다. +3. 상태(`useState`, `useRef`, store)는 "화면에 어떤 영향을 주는지" 한 줄 주석을 단다. +4. 복잡한 로직/핸들러는 `1.`, `2.`, `3.` 단계 주석으로 흐름을 나눈다. +5. 긴 JSX는 화면 구역 주석으로 나눠서 읽기 쉽게 만든다. + - 예: `{/* ===== 1. 상단: 페이지 제목 및 액션 버튼 ===== */}` +6. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다. +7. 결과 기준은 "주니어가 5분 내 파악 가능한지"로 잡는다. + +## UI/브랜드/문구 규칙 + +1. UI 수정 시 `brand-*` 토큰과 `primary` 사용 기준을 지킨다. +2. 사용자 문구는 불안을 줄이고 확신을 주는 톤으로 개선한다. + +## 품질 체크리스트 + +- 핵심 비즈니스 로직 변경이 없는가? +- 작은 UX 개선이라도 API 계약(입출력 규칙), 권한, 저장 데이터 구조를 건드리지 않았는가? +- 주니어가 5분 안에 흐름을 파악할 수 있는가? +- 상태 변경이 화면 어디에 반영되는지 보이는가? +- 무거운 계산/렌더가 메모이제이션(재계산 줄이기) 대상인지 검토했는가? + +## 출력 템플릿 + +```md +[리팩토링 요약] +- ... + +[가독성 개선 포인트] +- ... + +[작은 UX 개선 포인트] +- ... + +[성능 개선 포인트] +- ... + +[데이터 흐름 정리] +- 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영 + +[회귀 위험 점검] +- ... +``` + +## 규칙 + +- 파일 이동/삭제가 있으면 영향 범위를 먼저 검증한다. +- 구조 개선은 하되 과도한 분리(쪼개기)는 피한다. +- 작은 UX 개선을 했으면 왜 개선했는지 한 줄 근거를 남긴다. diff --git a/.agents/skills/dev-refactor-polish/agents/openai.yaml b/.agents/skills/dev-refactor-polish/agents/openai.yaml new file mode 100644 index 0000000..d46698a --- /dev/null +++ b/.agents/skills/dev-refactor-polish/agents/openai.yaml @@ -0,0 +1,14 @@ +interface: + display_name: "Dev Refactor Polish" + short_description: "Refactor code for readability and performance" + default_prompt: "Use $dev-refactor-polish to improve readability, data flow, and small UX polish." +dependencies: + tools: + - type: "mcp" + value: "context7" + description: "Official docs for framework-safe refactors" + - type: "mcp" + value: "sequential-thinking" + description: "Dependency impact reasoning before file moves" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/dev-test-gate/SKILL.md b/.agents/skills/dev-test-gate/SKILL.md new file mode 100644 index 0000000..06b4e37 --- /dev/null +++ b/.agents/skills/dev-test-gate/SKILL.md @@ -0,0 +1,91 @@ +--- +name: dev-test-gate +description: 개발/리팩토링 후 lint·build·Playwright 스모크 테스트를 실행하고 실패 원인을 정리하는 검증 스킬. 최종 품질 게이트 단계에서 사용하며, 구현 자체를 대체하지 않는다. +--- + +# Dev Test Gate + +## 목표 + +- 변경 사항의 안정성을 빠르게 확인한다. +- 실패 원인과 영향 범위를 짧고 명확하게 남긴다. + +## 공통 기준 + +1. 결과 보고는 한국어로 작성한다. +2. 테스트 결과는 주니어도 이해 가능하게 쉬운 말로 정리한다. +3. 테스트 생략은 원칙적으로 금지하고, 불가한 경우 사유와 대체 검증을 남긴다. + +## 테스트 순서 + +1. 정적 검사: `npm run lint` +2. 빌드 검사: `npm run build` +3. 개발 서버 실행: `npm run dev` (기본 포트 3001) +4. 런타임 확인: 핵심 화면 로드와 기본 동작 확인 +5. Playwright 스모크 테스트(기본): 핵심 화면 간단 확인을 반드시 수행 +6. 사용자 요청 테스트가 있으면 해당 테스트를 추가 실행한다. + +## Playwright 스모크 기본 규칙 + +1. 핵심 화면 3종을 기본 대상으로 잡는다. +2. 화면 타입은 아래 기준으로 고른다. + - 서비스 진입 화면 1개 + - 핵심 기능 화면 1개 + - 설정/인증 관련 화면 1개 +3. 각 화면에서 최소 항목을 확인한다. + - 페이지 로드 성공 + - 치명 오류 문구/콘솔 에러 없음 + - 핵심 버튼 또는 입력 요소 1개 이상 상호작용 가능 + +## 검증 보강 규칙 + +1. UI 변경이 있으면 브랜드 토큰(`brand-*`, `primary`) 적용 여부를 함께 점검한다. +2. KIS API 연동 변경이 있으면 계좌/인증/오류 처리 기본 시나리오를 스모크 범위에 포함한다. +3. 리팩토링 요청이면 구조 점검을 추가한다. + - `FEATURE_ROOT`가 목표 구조(`apis/components/hooks/stores/types`)를 따르는지 확인 + - 파일 이동 후 진입점 import 경로가 깨지지 않았는지 확인 + - 불필요한 `index.ts` 배럴 파일 잔존 여부를 확인 + +## common-docs 연계 검증 규칙 + +1. KIS 연동 파일 변경 시 아래를 점검한다. + - `kis_api_reference.md` 기준 엔드포인트/흐름이 크게 어긋나지 않는지 확인 + - `kis-error-code-reference.md` 기준 `msg_cd + 문구` 표시 흐름 유지 확인 +2. `features/trade/data/korean-stocks.json` 또는 동기화 스크립트 변경 시 + - `npm run sync:stocks:check`를 추가 실행한다. +3. 전역 알림 관련 파일(`features/layout/hooks/use-global-alert.ts`, `GlobalAlertModal`) 변경 시 + - 핵심 시나리오(성공 알림 1건, 확인 모달 1건)를 스모크 검증에 포함한다. +4. `features-autotrade-design.md`는 테스트 기준 문서에서 제외한다. + +## 실패 처리 규칙 + +1. 실패 로그에서 직접 원인 라인을 먼저 찾는다. +2. 원인 수정 후 같은 테스트를 재실행한다. +3. 연쇄 실패(한 수정으로 여러 실패)가 있으면 우선순위를 나눠 정리한다. +4. 시간/환경 제한으로 테스트를 못 돌리면 이유와 대체 검증을 반드시 기록한다. + +## 출력 템플릿 + +```md +[테스트 결과] +- lint: 통과/실패 +- build: 통과/실패 +- playwright smoke: 통과/실패 +- common-docs 연계 검증: 통과/실패 +- 추가 테스트: ... + +[실패 및 조치] +- ... + +[최종 상태] +- 배포 가능/보류 +``` + +## 완료체크 인계 규칙 + +1. 테스트 결과는 `dev-plan-completion-checker`에 그대로 전달한다. +2. 전달 형식은 아래 4줄을 포함한다. + - lint 결과 + - build 결과 + - playwright smoke 결과 + - 생략/실패 사유 및 대체 검증 diff --git a/.agents/skills/dev-test-gate/agents/openai.yaml b/.agents/skills/dev-test-gate/agents/openai.yaml new file mode 100644 index 0000000..57fccae --- /dev/null +++ b/.agents/skills/dev-test-gate/agents/openai.yaml @@ -0,0 +1,14 @@ +interface: + display_name: "Dev Test Gate" + short_description: "Run lint, build, and Playwright smoke tests" + default_prompt: "Use $dev-test-gate to run lint, build, and smoke verification before completion." +dependencies: + tools: + - type: "mcp" + value: "playwright" + description: "Browser smoke test automation" + - type: "mcp" + value: "next-devtools" + description: "Next.js runtime error and route checks" +policy: + allow_implicit_invocation: false diff --git a/.agent/skills/nextjs-app-router-patterns/SKILL.md b/.agents/skills/nextjs-app-router-patterns/SKILL.md similarity index 100% rename from .agent/skills/nextjs-app-router-patterns/SKILL.md rename to .agents/skills/nextjs-app-router-patterns/SKILL.md diff --git a/.agents/skills/nextjs-app-router-patterns/agents/openai.yaml b/.agents/skills/nextjs-app-router-patterns/agents/openai.yaml new file mode 100644 index 0000000..4e51efe --- /dev/null +++ b/.agents/skills/nextjs-app-router-patterns/agents/openai.yaml @@ -0,0 +1,14 @@ +interface: + display_name: "Next.js App Router Patterns" + short_description: "Next.js App Router patterns and checks" + default_prompt: "Use $nextjs-app-router-patterns to review App Router structure, server/client boundaries, and data fetching patterns." +dependencies: + tools: + - type: "mcp" + value: "next-devtools" + description: "Next.js runtime route and error diagnostics" + - type: "mcp" + value: "context7" + description: "Official Next.js documentation lookup" +policy: + allow_implicit_invocation: false diff --git a/AGENTS.md b/AGENTS.md index 88bf462..1093eee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,67 +1,17 @@ # AGENTS.md (auto-trade) -## 기본 원칙 +## 운영 원칙 -- 모든 응답과 설명은 한국어로 작성. -- 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임. -- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인. -- 소스 수정시 사이드이팩트가 발생하지 않게 사용자에게 묻거나 최대한 영향이 가지 않게 수정한다. 진짜 필요없는건 삭제하고 영향이 갈 내용이면 이전 소스가 영향이 없는지 검증하고 수정한다. +- 중복 규칙은 `AGENTS.md`에 두지 않고 각 스킬(`.agents/skills/*/SKILL.md`)에서 관리한다. +- 개발 작업은 스킬 기반으로 수행한다. -## 프로젝트 요약 +## 스킬 호출 규칙 -- Next.js 16 App Router, React 19, TypeScript -- 상태 관리: zustand -- 데이터: Supabase -- 폼 및 검증: react-hook-form, zod -- UI: Tailwind CSS v4, Radix UI (`components.json` 사용) - -## 명령어 - -- 개발 서버(포트 3001): `npm run dev` -- 린트: `npm run lint` -- 빌드: `npm run build` -- 실행: `npm run start` - -## 코드 및 문서 규칙 - -- JSX 섹션 주석 형식: `{/* ========== SECTION NAME ========== */}` -- 함수 및 컴포넌트 JSDoc에 `@see` 필수 -- `@see`에는 호출 파일, 함수/이벤트 이름, 목적을 함께 작성 -- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성 -- UI 흐름 설명 필수: `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영` 형태로 작성 - -## 브랜드 색상 규칙 - -- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트 사용 -- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용 -- 예시: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700` -- 기본 액션 색(버튼/포커스)은 `primary` 사용 -- `primary`는 `app/globals.css`의 `brand` 팔레트와 같은 톤으로 유지 -- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰 수정 - -## 개발 도구 활용 - -- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용 -- **MCP 서버**: - - `next-devtools`: Next.js 프로젝트 개발/디버깅, 공식 문서 인덱스 조회 - - `playwright`: 브라우저 자동화 테스트 (페이지 상호작용/검증) - - `playwriter`: Chrome 확장 기반 브라우저 자동화/디버깅 - - `context7`: 라이브러리/프레임워크 공식 문서 참조 - - `supabase-mcp-server`: Supabase 프로젝트 관리 및 SQL/함수 작업 - - `tavily-remote`: 최신 기술 트렌드/웹 검색 - - `sequential-thinking`: 복잡한 문제를 단계적으로 정리 - - `figma`: Figma 파일 레이아웃/스타일/에셋 조회 - - `mcp:kis-code-assistant-mcp`: 한국투자증권 API 검색/소스 조회 - -## 한국 투자 증권 API 이용시 - -- `mcp:kis-code-assistant-mcp` 활용 -- `C:\dev\auto-trade\.tmp\open-trading-api` 활용 -- 업로드된 전체 API 엑셀을 우선 참고: `C:\dev\auto-trade\common-docs\api-reference\openapi_all.xlsx` -- API 스펙 확인 순서: `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp\open-trading-api` 샘플 코드 -- 공식 문서와 엑셀/실코드가 다르면 엑셀과 실코드를 우선 기준으로 판단하고, 차이가 크면 사용자에게 최신 파일 재확인 요청 - -## 소개문구 - -- 불안감을 해소하고 확신을 주는 문구 -- 친근하고 확신에 찬 문구를 사용하여 심리적 장벽을 낮추는 전략 +- 개발 요청 키워드(`개발해줘`, `구현해줘`, `기능 추가`, `버그 수정`, `리팩토링`, `개선해줘`, `고쳐줘`, `최적화해줘`, `만들어줘`)가 들어오면 `dev-auto-pipeline` 스킬을 우선 사용한다. +- 파이프라인 단계 스킬은 아래 순서로 사용한다. + 1. `dev-plan-writer` + 2. `dev-mcp-implementation` + 3. `dev-refactor-polish` + 4. `dev-test-gate` + 5. `dev-plan-completion-checker` +- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다. diff --git a/app/api/kis/_response.ts b/app/api/kis/_response.ts new file mode 100644 index 0000000..c5e0791 --- /dev/null +++ b/app/api/kis/_response.ts @@ -0,0 +1,56 @@ +import type { KisTradingEnv } from "@/features/trade/types/trade.types"; +import { NextResponse } from "next/server"; + +export const KIS_API_ERROR_CODE = { + AUTH_REQUIRED: "KIS_AUTH_REQUIRED", + INVALID_REQUEST: "KIS_INVALID_REQUEST", + CREDENTIAL_REQUIRED: "KIS_CREDENTIAL_REQUIRED", + ACCOUNT_REQUIRED: "KIS_ACCOUNT_REQUIRED", + UPSTREAM_FAILURE: "KIS_UPSTREAM_FAILURE", + UNAUTHORIZED: "KIS_UNAUTHORIZED", +} as const; + +export type KisApiErrorCode = + (typeof KIS_API_ERROR_CODE)[keyof typeof KIS_API_ERROR_CODE]; + +interface CreateKisApiErrorResponseOptions { + status: number; + code: KisApiErrorCode; + message: string; + tradingEnv?: KisTradingEnv; + extra?: Record; +} + +/** + * @description KIS API 라우트용 표준 에러 응답을 생성합니다. + * @remarks 클라이언트 하위호환을 위해 message/error 키를 동시에 제공합니다. + * @see features/trade/apis/kis-stock.api.ts 종목 API 클라이언트는 error 우선 파싱 + * @see features/settings/apis/kis-auth.api.ts 인증 API 클라이언트는 message 우선 파싱 + */ +export function createKisApiErrorResponse({ + status, + code, + message, + tradingEnv, + extra, +}: CreateKisApiErrorResponseOptions) { + return NextResponse.json( + { + ok: false, + message, + error: message, + errorCode: code, + ...(tradingEnv ? { tradingEnv } : {}), + ...(extra ?? {}), + }, + { status }, + ); +} + +/** + * @description unknown 에러 객체를 사용자 노출용 메시지로 정규화합니다. + * @see app/api/kis/domestic/balance/route.ts 서버 예외를 공통 메시지로 변환 + */ +export function toKisApiErrorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} diff --git a/app/api/kis/domestic/activity/route.ts b/app/api/kis/domestic/activity/route.ts index b21dbb7..c20a655 100644 --- a/app/api/kis/domestic/activity/route.ts +++ b/app/api/kis/domestic/activity/route.ts @@ -3,6 +3,11 @@ import type { DashboardActivityResponse } from "@/features/dashboard/types/dashb import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { getDomesticDashboardActivity } from "@/lib/kis/dashboard"; import { hasKisApiSession } from "@/app/api/kis/_session"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; import { readKisAccountParts, readKisCredentialsFromHeaders, @@ -23,29 +28,31 @@ import { export async function GET(request: Request) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + }); } const credentials = readKisCredentialsFromHeaders(request.headers); if (!hasKisConfig(credentials)) { - return NextResponse.json( - { - error: "KIS API 키 설정이 필요합니다.", - }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED, + message: "KIS API 키 설정이 필요합니다.", + }); } const account = readKisAccountParts(request.headers); if (!account) { - return NextResponse.json( - { - error: - "계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.", - }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED, + message: + "계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.", + }); } try { @@ -66,10 +73,13 @@ export async function GET(request: Request) { }, }); } catch (error) { - const message = - error instanceof Error - ? error.message - : "주문내역/매매일지 조회 중 오류가 발생했습니다."; - return NextResponse.json({ error: message }, { status: 500 }); + return createKisApiErrorResponse({ + status: 500, + code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE, + message: toKisApiErrorMessage( + error, + "주문내역/매매일지 조회 중 오류가 발생했습니다.", + ), + }); } } diff --git a/app/api/kis/domestic/balance/route.ts b/app/api/kis/domestic/balance/route.ts index 9557899..8bfd508 100644 --- a/app/api/kis/domestic/balance/route.ts +++ b/app/api/kis/domestic/balance/route.ts @@ -3,6 +3,11 @@ import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashbo import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { getDomesticDashboardBalance } from "@/lib/kis/dashboard"; import { hasKisApiSession } from "@/app/api/kis/_session"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; import { readKisAccountParts, readKisCredentialsFromHeaders, @@ -21,29 +26,31 @@ import { export async function GET(request: Request) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + }); } const credentials = readKisCredentialsFromHeaders(request.headers); if (!hasKisConfig(credentials)) { - return NextResponse.json( - { - error: "KIS API 키 설정이 필요합니다.", - }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED, + message: "KIS API 키 설정이 필요합니다.", + }); } const account = readKisAccountParts(request.headers); if (!account) { - return NextResponse.json( - { - error: - "계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.", - }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED, + message: + "계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.", + }); } try { @@ -62,10 +69,10 @@ export async function GET(request: Request) { }, }); } catch (error) { - const message = - error instanceof Error - ? error.message - : "잔고 조회 중 오류가 발생했습니다."; - return NextResponse.json({ error: message }, { status: 500 }); + return createKisApiErrorResponse({ + status: 500, + code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE, + message: toKisApiErrorMessage(error, "잔고 조회 중 오류가 발생했습니다."), + }); } } diff --git a/app/api/kis/domestic/chart/route.ts b/app/api/kis/domestic/chart/route.ts index c3ed8ac..d6c63b1 100644 --- a/app/api/kis/domestic/chart/route.ts +++ b/app/api/kis/domestic/chart/route.ts @@ -2,11 +2,16 @@ import type { DashboardChartTimeframe, DashboardStockChartResponse, } from "@/features/trade/types/trade.types"; -import type { KisCredentialInput } from "@/lib/kis/config"; -import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; +import { hasKisConfig } from "@/lib/kis/config"; import { getDomesticChart } from "@/lib/kis/domestic"; import { NextRequest, NextResponse } from "next/server"; import { hasKisApiSession } from "@/app/api/kis/_session"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; +import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared"; const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [ "1m", @@ -23,7 +28,11 @@ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [ export async function GET(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + }); } const { searchParams } = new URL(request.url); @@ -34,28 +43,29 @@ export async function GET(request: NextRequest) { const cursor = (searchParams.get("cursor") ?? "").trim() || undefined; if (!/^\d{6}$/.test(symbol)) { - return NextResponse.json( - { error: "symbol은 6자리 숫자여야 합니다." }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: "symbol은 6자리 숫자여야 합니다.", + }); } if (!VALID_TIMEFRAMES.includes(timeframe)) { - return NextResponse.json( - { error: "지원하지 않는 timeframe입니다." }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: "지원하지 않는 timeframe입니다.", + }); } const credentials = readKisCredentialsFromHeaders(request.headers); if (!hasKisConfig(credentials)) { - return NextResponse.json( - { - error: - "대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.", - }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED, + message: + "대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.", + }); } try { @@ -81,24 +91,10 @@ export async function GET(request: NextRequest) { }, }); } catch (error) { - const message = - error instanceof Error - ? error.message - : "KIS 차트 조회 중 오류가 발생했습니다."; - return NextResponse.json({ error: message }, { status: 500 }); + return createKisApiErrorResponse({ + status: 500, + code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE, + message: toKisApiErrorMessage(error, "KIS 차트 조회 중 오류가 발생했습니다."), + }); } } - -function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput { - const appKey = headers.get("x-kis-app-key")?.trim(); - const appSecret = headers.get("x-kis-app-secret")?.trim(); - const tradingEnv = normalizeTradingEnv( - headers.get("x-kis-trading-env") ?? undefined, - ); - - return { - appKey, - appSecret, - tradingEnv, - }; -} diff --git a/app/api/kis/domestic/indices/route.ts b/app/api/kis/domestic/indices/route.ts index 2525c52..dbfaf1d 100644 --- a/app/api/kis/domestic/indices/route.ts +++ b/app/api/kis/domestic/indices/route.ts @@ -3,6 +3,11 @@ import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashbo import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { getDomesticDashboardIndices } from "@/lib/kis/dashboard"; import { hasKisApiSession } from "@/app/api/kis/_session"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared"; /** @@ -18,18 +23,21 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared"; export async function GET(request: Request) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + }); } const credentials = readKisCredentialsFromHeaders(request.headers); if (!hasKisConfig(credentials)) { - return NextResponse.json( - { - error: "KIS API 키 설정이 필요합니다.", - }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED, + message: "KIS API 키 설정이 필요합니다.", + }); } try { @@ -47,10 +55,10 @@ export async function GET(request: Request) { }, }); } catch (error) { - const message = - error instanceof Error - ? error.message - : "지수 조회 중 오류가 발생했습니다."; - return NextResponse.json({ error: message }, { status: 500 }); + return createKisApiErrorResponse({ + status: 500, + code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE, + message: toKisApiErrorMessage(error, "지수 조회 중 오류가 발생했습니다."), + }); } } diff --git a/app/api/kis/domestic/order-cash/route.ts b/app/api/kis/domestic/order-cash/route.ts index 949e985..265c022 100644 --- a/app/api/kis/domestic/order-cash/route.ts +++ b/app/api/kis/domestic/order-cash/route.ts @@ -1,67 +1,137 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { executeOrderCash } from "@/lib/kis/trade"; import { - DashboardStockCashOrderRequest, DashboardStockCashOrderResponse, } from "@/features/trade/types/trade.types"; import { hasKisApiSession } from "@/app/api/kis/_session"; +import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; +import { parseKisAccountParts } from "@/lib/kis/account"; import { - KisCredentialInput, - hasKisConfig, - normalizeTradingEnv, -} from "@/lib/kis/config"; + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; +import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared"; /** * @file app/api/kis/domestic/order-cash/route.ts * @description 국내주식 현금 주문 API */ +const orderCashBodySchema = z + .object({ + symbol: z + .string() + .trim() + .regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."), + side: z.enum(["buy", "sell"], { + message: "주문 구분(side)은 buy/sell만 허용됩니다.", + }), + orderType: z.enum(["limit", "market"], { + message: "주문 유형(orderType)은 limit/market만 허용됩니다.", + }), + quantity: z.coerce + .number() + .int("주문수량은 정수여야 합니다.") + .positive("주문수량은 1주 이상이어야 합니다."), + price: z.coerce.number(), + accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."), + accountProductCode: z.string().trim().optional(), + }) + .superRefine((body, ctx) => { + if (body.orderType === "limit" && body.price <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["price"], + message: "지정가 주문은 주문가격이 0보다 커야 합니다.", + }); + } + + if (body.orderType === "market" && body.price < 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["price"], + message: "시장가 주문은 주문가격이 0 이상이어야 합니다.", + }); + } + + const accountParts = parseKisAccountParts( + body.accountNo, + body.accountProductCode, + ); + if (!accountParts) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["accountNo"], + message: + "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", + }); + } + }); + 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 }, - ); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + tradingEnv, + }); } if (!hasKisConfig(credentials)) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: "KIS API 키 설정이 필요합니다.", - }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED, + message: "KIS API 키 설정이 필요합니다.", + tradingEnv, + }); } try { - const body = (await request.json()) as DashboardStockCashOrderRequest; + let rawBody: unknown = {}; + try { + rawBody = (await request.json()) as unknown; + } catch { + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: "요청 본문(JSON)을 읽을 수 없습니다.", + tradingEnv, + }); + } - // TODO: Validate body fields (symbol, quantity, price, etc.) - if ( - !body.symbol || - !body.accountNo || - !body.accountProductCode || - body.quantity <= 0 - ) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: - "주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)", - }, - { status: 400 }, - ); + const parsed = orderCashBodySchema.safeParse(rawBody); + + if (!parsed.success) { + const firstIssue = parsed.error.issues[0]; + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: firstIssue?.message ?? "주문 요청 값이 올바르지 않습니다.", + tradingEnv, + }); + } + + const body = parsed.data; + const accountParts = parseKisAccountParts( + body.accountNo, + body.accountProductCode, + ); + + if (!accountParts) { + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED, + message: + "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", + tradingEnv, + }); } const output = await executeOrderCash( @@ -71,8 +141,8 @@ export async function POST(request: NextRequest) { orderType: body.orderType, quantity: body.quantity, price: body.price, - accountNo: body.accountNo, - accountProductCode: body.accountProductCode, + accountNo: accountParts.accountNo, + accountProductCode: accountParts.accountProductCode, }, credentials, ); @@ -88,31 +158,11 @@ export async function POST(request: NextRequest) { return NextResponse.json(response); } catch (error) { - const message = - error instanceof Error - ? error.message - : "주문 전송 중 오류가 발생했습니다."; - return NextResponse.json( - { - ok: false, - tradingEnv, - message, - }, - { status: 500 }, - ); + return createKisApiErrorResponse({ + status: 500, + code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE, + message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."), + tradingEnv, + }); } } - -function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput { - const appKey = headers.get("x-kis-app-key")?.trim(); - const appSecret = headers.get("x-kis-app-secret")?.trim(); - const tradingEnv = normalizeTradingEnv( - headers.get("x-kis-trading-env") ?? undefined, - ); - - return { - appKey, - appSecret, - tradingEnv, - }; -} diff --git a/app/api/kis/domestic/orderbook/route.ts b/app/api/kis/domestic/orderbook/route.ts index 91f76f1..396f87e 100644 --- a/app/api/kis/domestic/orderbook/route.ts +++ b/app/api/kis/domestic/orderbook/route.ts @@ -5,15 +5,17 @@ import { } from "@/lib/kis/domestic"; import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; import { hasKisApiSession } from "@/app/api/kis/_session"; -import { - KisCredentialInput, - hasKisConfig, - normalizeTradingEnv, -} from "@/lib/kis/config"; +import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, parseDomesticKisSession, } from "@/lib/kis/domestic-market-session"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; +import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared"; /** * @file app/api/kis/domestic/orderbook/route.ts @@ -23,28 +25,32 @@ import { export async function GET(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + }); } const { searchParams } = new URL(request.url); const symbol = (searchParams.get("symbol") ?? "").trim(); if (!/^\d{6}$/.test(symbol)) { - return NextResponse.json( - { error: "symbol은 6자리 숫자여야 합니다." }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: "symbol은 6자리 숫자여야 합니다.", + }); } const credentials = readKisCredentialsFromHeaders(request.headers); if (!hasKisConfig(credentials)) { - return NextResponse.json( - { - error: "KIS API 키 설정이 필요합니다.", - }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED, + message: "KIS API 키 설정이 필요합니다.", + }); } try { @@ -95,28 +101,14 @@ export async function GET(request: NextRequest) { }, }); } catch (error) { - const message = - error instanceof Error - ? error.message - : "호가 조회 중 오류가 발생했습니다."; - return NextResponse.json({ error: message }, { status: 500 }); + return createKisApiErrorResponse({ + status: 500, + code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE, + message: toKisApiErrorMessage(error, "호가 조회 중 오류가 발생했습니다."), + }); } } -function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput { - const appKey = headers.get("x-kis-app-key")?.trim(); - const appSecret = headers.get("x-kis-app-secret")?.trim(); - const tradingEnv = normalizeTradingEnv( - headers.get("x-kis-trading-env") ?? undefined, - ); - - return { - appKey, - appSecret, - tradingEnv, - }; -} - function readSessionOverrideFromHeaders(headers: Headers) { if (process.env.NODE_ENV === "production") return null; const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER); diff --git a/app/api/kis/domestic/overview/route.ts b/app/api/kis/domestic/overview/route.ts index 9add374..d6f3ae1 100644 --- a/app/api/kis/domestic/overview/route.ts +++ b/app/api/kis/domestic/overview/route.ts @@ -1,14 +1,19 @@ 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 { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; import { DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, parseDomesticKisSession, } from "@/lib/kis/domestic-market-session"; +import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared"; /** * @file app/api/kis/domestic/overview/route.ts @@ -23,26 +28,33 @@ import { export async function GET(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + }); } const { searchParams } = new URL(request.url); const symbol = (searchParams.get("symbol") ?? "").trim(); if (!/^\d{6}$/.test(symbol)) { - return NextResponse.json({ error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 }); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: "symbol은 6자리 숫자여야 합니다.", + }); } const credentials = readKisCredentialsFromHeaders(request.headers); if (!hasKisConfig(credentials)) { - return NextResponse.json( - { - error: - "대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.", - }, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED, + message: + "대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.", + }); } const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol); @@ -71,28 +83,14 @@ export async function GET(request: NextRequest) { }, }); } catch (error) { - const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다."; - return NextResponse.json({ error: message }, { status: 500 }); + return createKisApiErrorResponse({ + status: 500, + code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE, + message: toKisApiErrorMessage(error, "KIS 조회 중 오류가 발생했습니다."), + }); } } -/** - * 요청 헤더에서 KIS 키를 읽어옵니다. - * @param headers 요청 헤더 - * @returns credentials - */ -function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput { - const appKey = headers.get("x-kis-app-key")?.trim(); - const appSecret = headers.get("x-kis-app-secret")?.trim(); - const tradingEnv = normalizeTradingEnv(headers.get("x-kis-trading-env") ?? undefined); - - return { - appKey, - appSecret, - tradingEnv, - }; -} - function readSessionOverrideFromHeaders(headers: Headers) { if (process.env.NODE_ENV === "production") return null; const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER); diff --git a/app/api/kis/domestic/search/route.ts b/app/api/kis/domestic/search/route.ts index 49a43ef..f4a3928 100644 --- a/app/api/kis/domestic/search/route.ts +++ b/app/api/kis/domestic/search/route.ts @@ -5,6 +5,10 @@ import type { KoreanStockIndexItem, } from "@/features/trade/types/trade.types"; import { hasKisApiSession } from "@/app/api/kis/_session"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, +} from "@/app/api/kis/_response"; import { NextRequest, NextResponse } from "next/server"; const SEARCH_LIMIT = 10; @@ -29,7 +33,11 @@ const SEARCH_LIMIT = 10; export async function GET(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + }); } // [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다. diff --git a/app/api/kis/revoke/route.ts b/app/api/kis/revoke/route.ts index 81dcc38..231fe1d 100644 --- a/app/api/kis/revoke/route.ts +++ b/app/api/kis/revoke/route.ts @@ -6,6 +6,11 @@ import { } from "@/lib/kis/request"; import { revokeKisAccessToken } from "@/lib/kis/token"; import { hasKisApiSession } from "@/app/api/kis/_session"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; import { NextRequest, NextResponse } from "next/server"; /** @@ -23,26 +28,22 @@ export async function POST(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: "로그인이 필요합니다.", - } satisfies DashboardKisRevokeResponse, - { status: 401 }, - ); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + tradingEnv, + }); } const invalidMessage = validateKisCredentialInput(credentials); if (invalidMessage) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: invalidMessage, - } satisfies DashboardKisRevokeResponse, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: invalidMessage, + tradingEnv, + }); } try { @@ -54,18 +55,11 @@ export async function POST(request: NextRequest) { message, } satisfies DashboardKisRevokeResponse); } catch (error) { - const message = - error instanceof Error - ? error.message - : "API 토큰 폐기 중 오류가 발생했습니다."; - - return NextResponse.json( - { - ok: false, - tradingEnv, - message, - } satisfies DashboardKisRevokeResponse, - { status: 401 }, - ); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.UNAUTHORIZED, + message: toKisApiErrorMessage(error, "API 토큰 폐기 중 오류가 발생했습니다."), + tradingEnv, + }); } } diff --git a/app/api/kis/validate-profile/route.ts b/app/api/kis/validate-profile/route.ts index bf54353..c32c32f 100644 --- a/app/api/kis/validate-profile/route.ts +++ b/app/api/kis/validate-profile/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types"; import { hasKisApiSession } from "@/app/api/kis/_session"; import { parseKisAccountParts } from "@/lib/kis/account"; @@ -6,13 +7,18 @@ import { kisGet } from "@/lib/kis/client"; import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config"; import { validateKisCredentialInput } from "@/lib/kis/request"; import { getKisAccessToken } from "@/lib/kis/token"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; -interface KisProfileValidateRequestBody { - appKey?: string; - appSecret?: string; - tradingEnv?: string; - accountNo?: string; -} +const kisProfileValidateBodySchema = z.object({ + appKey: z.string().trim().min(1, "앱 키를 입력해 주세요."), + appSecret: z.string().trim().min(1, "앱 시크릿을 입력해 주세요."), + tradingEnv: z.string().optional(), + accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."), +}); interface BalanceValidationPreset { inqrDvsn: "01" | "02"; @@ -50,34 +56,44 @@ export async function POST(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json( - { - ok: false, - tradingEnv: fallbackTradingEnv, - message: "로그인이 필요합니다.", - } satisfies Pick, - { status: 401 }, - ); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + tradingEnv: fallbackTradingEnv, + }); } - let body: KisProfileValidateRequestBody = {}; + let rawBody: unknown = {}; try { - body = (await request.json()) as KisProfileValidateRequestBody; + rawBody = (await request.json()) as unknown; } catch { - return NextResponse.json( - { - ok: false, - tradingEnv: fallbackTradingEnv, - message: "요청 본문(JSON)을 읽을 수 없습니다.", - } satisfies Pick, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: "요청 본문(JSON)을 읽을 수 없습니다.", + tradingEnv: fallbackTradingEnv, + }); } + const parsedBody = kisProfileValidateBodySchema.safeParse(rawBody); + if (!parsedBody.success) { + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: + parsedBody.error.issues[0]?.message ?? + "요청 본문 값이 올바르지 않습니다.", + tradingEnv: fallbackTradingEnv, + }); + } + + const body = parsedBody.data; + const credentials: KisCredentialInput = { - appKey: body.appKey?.trim(), - appSecret: body.appSecret?.trim(), + appKey: body.appKey.trim(), + appSecret: body.appSecret.trim(), tradingEnv: normalizeTradingEnv(body.tradingEnv), }; @@ -85,39 +101,25 @@ export async function POST(request: NextRequest) { const invalidCredentialMessage = validateKisCredentialInput(credentials); if (invalidCredentialMessage) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: invalidCredentialMessage, - } satisfies Pick, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: invalidCredentialMessage, + tradingEnv, + }); } - const accountNoInput = (body.accountNo ?? "").trim(); - - if (!accountNoInput) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: "계좌번호를 입력해 주세요.", - } satisfies Pick, - { status: 400 }, - ); - } + const accountNoInput = body.accountNo.trim(); const accountParts = parseKisAccountParts(accountNoInput); if (!accountParts) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", - } satisfies Pick, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED, + message: + "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", + tradingEnv, + }); } try { @@ -150,19 +152,12 @@ export async function POST(request: NextRequest) { }, } satisfies DashboardKisProfileValidateResponse); } catch (error) { - const message = - error instanceof Error - ? error.message - : "계좌 검증 중 오류가 발생했습니다."; - - return NextResponse.json( - { - ok: false, - tradingEnv, - message, - } satisfies Pick, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.UNAUTHORIZED, + message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."), + tradingEnv, + }); } } diff --git a/app/api/kis/validate/route.ts b/app/api/kis/validate/route.ts index 9c51c45..d5288d7 100644 --- a/app/api/kis/validate/route.ts +++ b/app/api/kis/validate/route.ts @@ -6,6 +6,11 @@ import { } from "@/lib/kis/request"; import { getKisAccessToken } from "@/lib/kis/token"; import { hasKisApiSession } from "@/app/api/kis/_session"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; import { NextRequest, NextResponse } from "next/server"; /** @@ -23,26 +28,22 @@ export async function POST(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: "로그인이 필요합니다.", - } satisfies DashboardKisValidateResponse, - { status: 401 }, - ); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + tradingEnv, + }); } const invalidMessage = validateKisCredentialInput(credentials); if (invalidMessage) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: invalidMessage, - } satisfies DashboardKisValidateResponse, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: invalidMessage, + tradingEnv, + }); } try { @@ -54,18 +55,11 @@ export async function POST(request: NextRequest) { message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)", } satisfies DashboardKisValidateResponse); } catch (error) { - const message = - error instanceof Error - ? error.message - : "API 키 검증 중 오류가 발생했습니다."; - - return NextResponse.json( - { - ok: false, - tradingEnv, - message, - } satisfies DashboardKisValidateResponse, - { status: 401 }, - ); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.UNAUTHORIZED, + message: toKisApiErrorMessage(error, "API 키 검증 중 오류가 발생했습니다."), + tradingEnv, + }); } } diff --git a/app/api/kis/ws/approval/route.ts b/app/api/kis/ws/approval/route.ts index e62410b..010a5a1 100644 --- a/app/api/kis/ws/approval/route.ts +++ b/app/api/kis/ws/approval/route.ts @@ -6,6 +6,11 @@ import { parseKisCredentialRequest, validateKisCredentialInput, } from "@/lib/kis/request"; +import { + createKisApiErrorResponse, + KIS_API_ERROR_CODE, + toKisApiErrorMessage, +} from "@/app/api/kis/_response"; import { NextRequest, NextResponse } from "next/server"; /** @@ -23,26 +28,22 @@ export async function POST(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: "로그인이 필요합니다.", - } satisfies DashboardKisWsApprovalResponse, - { status: 401 }, - ); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.AUTH_REQUIRED, + message: "로그인이 필요합니다.", + tradingEnv, + }); } const invalidMessage = validateKisCredentialInput(credentials); if (invalidMessage) { - return NextResponse.json( - { - ok: false, - tradingEnv, - message: invalidMessage, - } satisfies DashboardKisWsApprovalResponse, - { status: 400 }, - ); + return createKisApiErrorResponse({ + status: 400, + code: KIS_API_ERROR_CODE.INVALID_REQUEST, + message: invalidMessage, + tradingEnv, + }); } try { @@ -57,18 +58,14 @@ export async function POST(request: NextRequest) { message: "웹소켓 승인키 발급이 완료되었습니다.", } satisfies DashboardKisWsApprovalResponse); } catch (error) { - const message = - error instanceof Error - ? error.message - : "웹소켓 승인키 발급 중 오류가 발생했습니다."; - - return NextResponse.json( - { - ok: false, - tradingEnv, - message, - } satisfies DashboardKisWsApprovalResponse, - { status: 401 }, - ); + return createKisApiErrorResponse({ + status: 401, + code: KIS_API_ERROR_CODE.UNAUTHORIZED, + message: toKisApiErrorMessage( + error, + "웹소켓 승인키 발급 중 오류가 발생했습니다.", + ), + tradingEnv, + }); } } diff --git a/common-docs/api-reference/kis-error-code-reference.md b/common-docs/api-reference/kis-error-code-reference.md new file mode 100644 index 0000000..a4f694c --- /dev/null +++ b/common-docs/api-reference/kis-error-code-reference.md @@ -0,0 +1,30 @@ +# KIS 오류코드 적용 기준 (2026-02-26) + +## 1) 기준 소스 +- 공식 오류코드 페이지: `https://apiportal.koreainvestment.com/faq-error-code` +- 확인 방식: 실제 브라우저 렌더링 후 테이블 추출 +- 코드 반영 위치: `lib/kis/error-codes.ts` + +## 2) 코드 반영 목적 +- `msg_cd`만 보일 때 의미를 바로 알기 어렵기 때문에, + 코드와 문구를 같이 표시해 장애 원인 파악 속도를 높입니다. +- 토큰 발급/폐기, REST 호출, 웹소켓 제어 오류 메시지의 형식을 통일합니다. + +## 3) 적용된 모듈 +- `lib/kis/error-codes.ts` + - 공식 FAQ 코드 문구 매핑 + - `getKisErrorGuide(msgCode)` 제공 + - `buildKisErrorDetail(...)` 제공 +- `lib/kis/client.ts` + - REST 실패 메시지에 `msg_cd + 공식 문구` 반영 +- `lib/kis/token.ts` + - 토큰 발급/폐기 실패 메시지에 `msg_cd + 공식 문구` 반영 +- `lib/kis/approval.ts` + - 승인키 발급 실패 메시지에 `msg_cd + 공식 문구` 반영 +- `features/kis-realtime/stores/kisWebSocketStore.ts` + - 실시간 제어 오류(`OPSP*`) 메시지에 공식 문구 반영 + +## 4) 운영 시 참고 +- 화면/로그에 `EGW00103`, `OPSP8996`처럼 코드가 보이면 + `lib/kis/error-codes.ts`에서 즉시 문구를 확인할 수 있습니다. +- 신규 코드가 추가되면 공식 FAQ 기준으로 맵에 추가합니다. diff --git a/common-docs/api-reference/kis_api_reference.md b/common-docs/api-reference/kis_api_reference.md new file mode 100644 index 0000000..5015f07 --- /dev/null +++ b/common-docs/api-reference/kis_api_reference.md @@ -0,0 +1,466 @@ +# 한국투자증권 Open API 레퍼런스 가이드 + +> 이 문서는 Codex, Gemini, Claude 등 AI 어시스턴트가 한국투자증권(KIS) Open API를 기반으로 트레이딩 시스템을 개발하거나 UI/UX를 구성할 때 참고하기 위해 요약된 자료입니다. + +## 📍 공식 사이트 및 주요 도구 + +- **공식 Open API 포털 (Main):** [https://apiportal.koreainvestment.com/apiservice-apiservice](https://apiportal.koreainvestment.com/apiservice-apiservice) +- **공식 Github (코드 샘플 및 종목 정보):** [https://github.com/koreainvestment/open-trading-api](https://github.com/koreainvestment/open-trading-api) +- **공식 챗봇 가이드 (GPTs):** [한국투자증권 Open API 서비스 챗봇](https://chatgpt.com/g/g-68b920ee7afc8191858d3dc05d429571-hangugtujajeunggweon-open-api-seobiseu-gpts) +- **API 테스트베드:** [https://apiportal.koreainvestment.com/testbed-intro](https://apiportal.koreainvestment.com/testbed-intro) + +--- + +## 📚 API 카테고리별 주요 문서 링크 + +한국투자증권 API 포털은 SPA(Single Page Application) 구조로, 각 API 상세 문서는 `https://apiportal.koreainvestment.com/apiservice-apiservice?{API_PATH}` 형태의 파라미터를 사용하여 접근할 수 있습니다. + +### 1. 공통 및 인증 (Essential) + +- **개요 (Summary):** [바로가기](https://apiportal.koreainvestment.com/apiservice-summary) +- **종목정보파일 안내:** [바로가기](https://apiportal.koreainvestment.com/apiservice-category) +- **OAuth인증 (접근토큰 발급/폐기):** [문서 링크](https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/tokenP) +- **실시간 (웹소켓) 접속키 발급:** [문서 링크](https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/Approval) + +### 2. 국내주식 (Domestic Stocks) + +- **주문/계좌 (현금/신용주문, 잔고조회):** [주식주문(현금) 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/trading/order-cash) +- **기본시세 (현재가, 호가, 체결, 일자별):** [주식현재가 시세 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/inquire-price) +- **종목정보 (재무비율, 손익계산서, 대차대조표):** [상품기본조회 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/search-stock-info) +- **시세/순위 분석 (거래량순위, 등락률, 관심종목):** [거래량순위 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/volume-rank) +- **실시간 시세 (Websocket):** [실시간 체결가 (H0STCNT0)](https://apiportal.koreainvestment.com/apiservice-apiservice?/tryitout/H0STCNT0) + +### 3. 해외주식 (Overseas Stocks) + +- **주문/계좌 (미국, 일본, 중국, 홍콩, 베트남):** [해외주식 주문 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/overseas-stock/v1/trading/order) +- **해외주식 시세 (현재가, 호가, 분봉):** [해외주식 현재가 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/overseas-price/v1/quotations/price) +- **해외주식 실시간 시세 (Websocket):** [해외주식 실시간체결가 (HDFSCNT0)](https://apiportal.koreainvestment.com/apiservice-apiservice?/tryitout/HDFSCNT0) + +### 4. 기타 금융 상품 + +- **국내선물옵션:** 주문, 기본시세, 실시간시세 지원 +- **해외선물옵션:** 해외선물 종목 상세 및 실시간 시세 지원 +- **장내채권:** 채권 매수/매도 주문 및 발행정보 시세 지원 +- **ELW 시세:** 기초자산별 종목 및 LP 매매추이 지원 + +--- + +## 💡 AI 어시스턴트를 위한 참고 팁 + +1. **엔드포인트 조합 규칙:** API 문서상에 표기된 `URL` (`/uapi/...`)을 포털 주소 뒤에 파라미터(`?`)로 붙이면 브라우저에서 해당 문서로 직접 이동이 가능합니다. +2. **데이터 타입 주의:** `ORD_QTY`(주문수량), `ORD_UNPR`(주문단가) 등 숫자형 데이터도 **String 가공**이 필요한 경우가 많으므로 문서를 반드시 확인해야 합니다. +3. **마스터 데이터:** 종목 코드 및 기본 종목 정보는 API 호출보다는 공지된 전체 종목 마스터 파일(zip)을 다운로드 및 파싱하여 사용하는 것을 KIS에서 권장합니다. 관련 파이썬/Node.js 파싱 코드는 공식 Github 링크를 참고하세요. + +## 📋 KIS API Portal 전체 메뉴 구조 (Reference) + +다음은 한국투자증권 Open API 포털의 전체 좌측 메뉴 구조와 각 API 엔드포인트 URL 리스트입니다. AI가 API 연동 코드를 작성할 때 엔드포인트 참조용으로 사용하세요. + +### 개요 + +- 하위 메뉴 없음 + +### 종목정보파일 + +- 하위 메뉴 없음 + +### OAuth인증 + +- **접근토큰발급(P)**: `/oauth2/tokenP` +- **접근토큰폐기(P)**: `/oauth2/revokeP` +- **Hashkey**: `/uapi/hashkey` +- **실시간 (웹소켓) 접속키 발급**: `/oauth2/Approval` + +### [국내주식] 주문/계좌 + +- **주식주문(현금)**: `/uapi/domestic-stock/v1/trading/order-cash` +- **주식주문(신용)**: `/uapi/domestic-stock/v1/trading/order-credit` +- **주식주문(정정취소)**: `/uapi/domestic-stock/v1/trading/order-rvsecncl` +- **주식정정취소가능주문조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl` +- **주식일별주문체결조회**: `/uapi/domestic-stock/v1/trading/inquire-daily-ccld` +- **주식잔고조회**: `/uapi/domestic-stock/v1/trading/inquire-balance` +- **매수가능조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-order` +- **매도가능수량조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-sell` +- **신용매수가능조회**: `/uapi/domestic-stock/v1/trading/inquire-credit-psamount` +- **주식예약주문**: `/uapi/domestic-stock/v1/trading/order-resv` +- **주식예약주문정정취소**: `/uapi/domestic-stock/v1/trading/order-resv-rvsecncl` +- **주식예약주문조회**: `/uapi/domestic-stock/v1/trading/order-resv-ccnl` +- **퇴직연금 체결기준잔고**: `/uapi/domestic-stock/v1/trading/pension/inquire-present-balance` +- **퇴직연금 미체결내역**: `/uapi/domestic-stock/v1/trading/pension/inquire-daily-ccld` +- **퇴직연금 매수가능조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-psbl-order` +- **퇴직연금 예수금조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-deposit` +- **퇴직연금 잔고조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-balance` +- **주식잔고조회\_실현손익**: `/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl` +- **투자계좌자산현황조회**: `/uapi/domestic-stock/v1/trading/inquire-account-balance` +- **기간별손익일별합산조회**: `/uapi/domestic-stock/v1/trading/inquire-period-profit` +- **기간별매매손익현황조회**: `/uapi/domestic-stock/v1/trading/inquire-period-trade-profit` +- **주식통합증거금 현황**: `/uapi/domestic-stock/v1/trading/intgr-margin` +- **기간별계좌권리현황조회**: `/uapi/domestic-stock/v1/trading/period-rights` + +### [국내주식] 기본시세 + +- **주식현재가 시세**: `/uapi/domestic-stock/v1/quotations/inquire-price` +- **주식현재가 시세2**: `/uapi/domestic-stock/v1/quotations/inquire-price-2` +- **주식현재가 체결**: `/uapi/domestic-stock/v1/quotations/inquire-ccnl` +- **주식현재가 일자별**: `/uapi/domestic-stock/v1/quotations/inquire-daily-price` +- **주식현재가 호가/예상체결**: `/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn` +- **주식현재가 투자자**: `/uapi/domestic-stock/v1/quotations/inquire-investor` +- **주식현재가 회원사**: `/uapi/domestic-stock/v1/quotations/inquire-member` +- **국내주식기간별시세(일/주/월/년)**: `/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice` +- **주식당일분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice` +- **주식일별분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-dailychartprice` +- **주식현재가 당일시간대별체결**: `/uapi/domestic-stock/v1/quotations/inquire-time-itemconclusion` +- **주식현재가 시간외일자별주가**: `/uapi/domestic-stock/v1/quotations/inquire-daily-overtimeprice` +- **주식현재가 시간외시간별체결**: `/uapi/domestic-stock/v1/quotations/inquire-time-overtimeconclusion` +- **국내주식 시간외현재가**: `/uapi/domestic-stock/v1/quotations/inquire-overtime-price` +- **국내주식 시간외호가**: `/uapi/domestic-stock/v1/quotations/inquire-overtime-asking-price` +- **국내주식 장마감 예상체결가**: `/uapi/domestic-stock/v1/quotations/exp-closing-price` +- **ETF/ETN 현재가**: `/uapi/etfetn/v1/quotations/inquire-price` +- **ETF 구성종목시세**: `/uapi/etfetn/v1/quotations/inquire-component-stock-price` +- **NAV 비교추이(종목)**: `/uapi/etfetn/v1/quotations/nav-comparison-trend` +- **NAV 비교추이(일)**: `/uapi/etfetn/v1/quotations/nav-comparison-daily-trend` +- **NAV 비교추이(분)**: `/uapi/etfetn/v1/quotations/nav-comparison-time-trend` + +### [국내주식] ELW 시세 + +- **ELW 현재가 시세**: `/uapi/domestic-stock/v1/quotations/inquire-elw-price` +- **ELW 신규상장종목**: `/uapi/elw/v1/quotations/newly-listed` +- **ELW 민감도 순위**: `/uapi/elw/v1/ranking/sensitivity` +- **ELW 기초자산별 종목시세**: `/uapi/elw/v1/quotations/udrl-asset-price` +- **ELW 종목검색**: `/uapi/elw/v1/quotations/cond-search` +- **ELW 당일급변종목**: `/uapi/elw/v1/ranking/quick-change` +- **ELW 기초자산 목록조회**: `/uapi/elw/v1/quotations/udrl-asset-list` +- **ELW 비교대상종목조회**: `/uapi/elw/v1/quotations/compare-stocks` +- **ELW LP매매추이**: `/uapi/elw/v1/quotations/lp-trade-trend` +- **ELW 투자지표추이(체결)**: `/uapi/elw/v1/quotations/indicator-trend-ccnl` +- **ELW 투자지표추이(분별)**: `/uapi/elw/v1/quotations/indicator-trend-minute` +- **ELW 투자지표추이(일별)**: `/uapi/elw/v1/quotations/indicator-trend-daily` +- **ELW 변동성 추이(틱)**: `/uapi/elw/v1/quotations/volatility-trend-tick` +- **ELW 변동성추이(체결)**: `/uapi/elw/v1/quotations/volatility-trend-ccnl` +- **ELW 변동성 추이(일별)**: `/uapi/elw/v1/quotations/volatility-trend-daily` +- **ELW 민감도 추이(체결)**: `/uapi/elw/v1/quotations/sensitivity-trend-ccnl` +- **ELW 변동성 추이(분별)**: `/uapi/elw/v1/quotations/volatility-trend-minute` +- **ELW 민감도 추이(일별)**: `/uapi/elw/v1/quotations/sensitivity-trend-daily` +- **ELW 만기예정/만기종목**: `/uapi/elw/v1/quotations/expiration-stocks` +- **ELW 지표순위**: `/uapi/elw/v1/ranking/indicator` +- **ELW 상승률순위**: `/uapi/elw/v1/ranking/updown-rate` +- **ELW 거래량순위**: `/uapi/elw/v1/ranking/volume-rank` + +### [국내주식] 업종/기타 + +- **국내업종 현재지수**: `/uapi/domestic-stock/v1/quotations/inquire-index-price` +- **국내업종 일자별지수**: `/uapi/domestic-stock/v1/quotations/inquire-index-daily-price` +- **국내업종 시간별지수(초)**: `/uapi/domestic-stock/v1/quotations/inquire-index-tickprice` +- **국내업종 시간별지수(분)**: `/uapi/domestic-stock/v1/quotations/inquire-index-timeprice` +- **업종 분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-indexchartprice` +- **국내주식업종기간별시세(일/주/월/년)**: `/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice` +- **국내업종 구분별전체시세**: `/uapi/domestic-stock/v1/quotations/inquire-index-category-price` +- **국내주식 예상체결지수 추이**: `/uapi/domestic-stock/v1/quotations/exp-index-trend` +- **국내주식 예상체결 전체지수**: `/uapi/domestic-stock/v1/quotations/exp-total-index` +- **변동성완화장치(VI) 현황**: `/uapi/domestic-stock/v1/quotations/inquire-vi-status` +- **금리 종합(국내채권/금리)**: `/uapi/domestic-stock/v1/quotations/comp-interest` +- **종합 시황/공시(제목)**: `/uapi/domestic-stock/v1/quotations/news-title` +- **국내휴장일조회**: `/uapi/domestic-stock/v1/quotations/chk-holiday` +- **국내선물 영업일조회**: `/uapi/domestic-stock/v1/quotations/market-time` + +### [국내주식] 종목정보 + +- **상품기본조회**: `/uapi/domestic-stock/v1/quotations/search-info` +- **주식기본조회**: `/uapi/domestic-stock/v1/quotations/search-stock-info` +- **국내주식 대차대조표**: `/uapi/domestic-stock/v1/finance/balance-sheet` +- **국내주식 손익계산서**: `/uapi/domestic-stock/v1/finance/income-statement` +- **국내주식 재무비율**: `/uapi/domestic-stock/v1/finance/financial-ratio` +- **국내주식 수익성비율**: `/uapi/domestic-stock/v1/finance/profit-ratio` +- **국내주식 기타주요비율**: `/uapi/domestic-stock/v1/finance/other-major-ratios` +- **국내주식 안정성비율**: `/uapi/domestic-stock/v1/finance/stability-ratio` +- **국내주식 성장성비율**: `/uapi/domestic-stock/v1/finance/growth-ratio` +- **국내주식 당사 신용가능종목**: `/uapi/domestic-stock/v1/quotations/credit-by-company` +- **예탁원정보(배당일정)**: `/uapi/domestic-stock/v1/ksdinfo/dividend` +- **예탁원정보(주식매수청구일정)**: `/uapi/domestic-stock/v1/ksdinfo/purreq` +- **예탁원정보(합병/분할일정)**: `/uapi/domestic-stock/v1/ksdinfo/merger-split` +- **예탁원정보(액면교체일정)**: `/uapi/domestic-stock/v1/ksdinfo/rev-split` +- **예탁원정보(자본감소일정)**: `/uapi/domestic-stock/v1/ksdinfo/cap-dcrs` +- **예탁원정보(상장정보일정)**: `/uapi/domestic-stock/v1/ksdinfo/list-info` +- **예탁원정보(공모주청약일정)**: `/uapi/domestic-stock/v1/ksdinfo/pub-offer` +- **예탁원정보(실권주일정)**: `/uapi/domestic-stock/v1/ksdinfo/forfeit` +- **예탁원정보(의무예치일정)**: `/uapi/domestic-stock/v1/ksdinfo/mand-deposit` +- **예탁원정보(유상증자일정)**: `/uapi/domestic-stock/v1/ksdinfo/paidin-capin` +- **예탁원정보(무상증자일정)**: `/uapi/domestic-stock/v1/ksdinfo/bonus-issue` +- **예탁원정보(주주총회일정)**: `/uapi/domestic-stock/v1/ksdinfo/sharehld-meet` +- **국내주식 종목추정실적**: `/uapi/domestic-stock/v1/quotations/estimate-perform` +- **당사 대주가능 종목**: `/uapi/domestic-stock/v1/quotations/lendable-by-company` +- **국내주식 종목투자의견**: `/uapi/domestic-stock/v1/quotations/invest-opinion` +- **국내주식 증권사별 투자의견**: `/uapi/domestic-stock/v1/quotations/invest-opbysec` + +### [국내주식] 시세분석 + +- **종목조건검색 목록조회**: `/uapi/domestic-stock/v1/quotations/psearch-title` +- **종목조건검색조회**: `/uapi/domestic-stock/v1/quotations/psearch-result` +- **관심종목 그룹조회**: `/uapi/domestic-stock/v1/quotations/intstock-grouplist` +- **관심종목(멀티종목) 시세조회**: `/uapi/domestic-stock/v1/quotations/intstock-multprice` +- **관심종목 그룹별 종목조회**: `/uapi/domestic-stock/v1/quotations/intstock-stocklist-by-group` +- **국내기관\_외국인 매매종목가집계**: `/uapi/domestic-stock/v1/quotations/foreign-institution-total` +- **외국계 매매종목 가집계**: `/uapi/domestic-stock/v1/quotations/frgnmem-trade-estimate` +- **종목별 투자자매매동향(일별)**: `/uapi/domestic-stock/v1/quotations/investor-trade-by-stock-daily` +- **시장별 투자자매매동향(시세)**: `/uapi/domestic-stock/v1/quotations/inquire-investor-time-by-market` +- **시장별 투자자매매동향(일별)**: `/uapi/domestic-stock/v1/quotations/inquire-investor-daily-by-market` +- **종목별 외국계 순매수추이**: `/uapi/domestic-stock/v1/quotations/frgnmem-pchs-trend` +- **회원사 실시간 매매동향(틱)**: `/uapi/domestic-stock/v1/quotations/frgnmem-trade-trend` +- **주식현재가 회원사 종목매매동향**: `/uapi/domestic-stock/v1/quotations/inquire-member-daily` +- **종목별 프로그램매매추이(체결)**: `/uapi/domestic-stock/v1/quotations/program-trade-by-stock` +- **종목별 프로그램매매추이(일별)**: `/uapi/domestic-stock/v1/quotations/program-trade-by-stock-daily` +- **종목별 외인기관 추정가집계**: `/uapi/domestic-stock/v1/quotations/investor-trend-estimate` +- **종목별일별매수매도체결량**: `/uapi/domestic-stock/v1/quotations/inquire-daily-trade-volume` +- **프로그램매매 종합현황(시간)**: `/uapi/domestic-stock/v1/quotations/comp-program-trade-today` +- **프로그램매매 종합현황(일별)**: `/uapi/domestic-stock/v1/quotations/comp-program-trade-daily` +- **프로그램매매 투자자매매동향(당일)**: `/uapi/domestic-stock/v1/quotations/investor-program-trade-today` +- **국내주식 신용잔고 일별추이**: `/uapi/domestic-stock/v1/quotations/daily-credit-balance` +- **국내주식 예상체결가 추이**: `/uapi/domestic-stock/v1/quotations/exp-price-trend` +- **국내주식 공매도 일별추이**: `/uapi/domestic-stock/v1/quotations/daily-short-sale` +- **국내주식 시간외예상체결등락률**: `/uapi/domestic-stock/v1/ranking/overtime-exp-trans-fluct` +- **국내주식 체결금액별 매매비중**: `/uapi/domestic-stock/v1/quotations/tradprt-byamt` +- **국내 증시자금 종합**: `/uapi/domestic-stock/v1/quotations/mktfunds` +- **종목별 일별 대차거래추이**: `/uapi/domestic-stock/v1/quotations/daily-loan-trans` +- **국내주식 상하한가 포착**: `/uapi/domestic-stock/v1/quotations/capture-uplowprice` +- **국내주식 매물대/거래비중**: `/uapi/domestic-stock/v1/quotations/pbar-tratio` + +### [국내주식] 순위분석 + +- **거래량순위**: `/uapi/domestic-stock/v1/quotations/volume-rank` +- **국내주식 등락률 순위**: `/uapi/domestic-stock/v1/ranking/fluctuation` +- **국내주식 호가잔량 순위**: `/uapi/domestic-stock/v1/ranking/quote-balance` +- **국내주식 수익자산지표 순위**: `/uapi/domestic-stock/v1/ranking/profit-asset-index` +- **국내주식 시가총액 상위**: `/uapi/domestic-stock/v1/ranking/market-cap` +- **국내주식 재무비율 순위**: `/uapi/domestic-stock/v1/ranking/finance-ratio` +- **국내주식 시간외잔량 순위**: `/uapi/domestic-stock/v1/ranking/after-hour-balance` +- **국내주식 우선주/괴리율 상위**: `/uapi/domestic-stock/v1/ranking/prefer-disparate-ratio` +- **국내주식 이격도 순위**: `/uapi/domestic-stock/v1/ranking/disparity` +- **국내주식 시장가치 순위**: `/uapi/domestic-stock/v1/ranking/market-value` +- **국내주식 체결강도 상위**: `/uapi/domestic-stock/v1/ranking/volume-power` +- **국내주식 관심종목등록 상위**: `/uapi/domestic-stock/v1/ranking/top-interest-stock` +- **국내주식 예상체결 상승/하락상위**: `/uapi/domestic-stock/v1/ranking/exp-trans-updown` +- **국내주식 당사매매종목 상위**: `/uapi/domestic-stock/v1/ranking/traded-by-company` +- **국내주식 신고/신저근접종목 상위**: `/uapi/domestic-stock/v1/ranking/near-new-highlow` +- **국내주식 배당률 상위**: `/uapi/domestic-stock/v1/ranking/dividend-rate` +- **국내주식 대량체결건수 상위**: `/uapi/domestic-stock/v1/ranking/bulk-trans-num` +- **국내주식 신용잔고 상위**: `/uapi/domestic-stock/v1/ranking/credit-balance` +- **국내주식 공매도 상위종목**: `/uapi/domestic-stock/v1/ranking/short-sale` +- **국내주식 시간외등락율순위**: `/uapi/domestic-stock/v1/ranking/overtime-fluctuation` +- **국내주식 시간외거래량순위**: `/uapi/domestic-stock/v1/ranking/overtime-volume` +- **HTS조회상위20종목**: `/uapi/domestic-stock/v1/ranking/hts-top-view` + +### [국내주식] 실시간시세 + +- **국내주식 실시간체결가 (KRX)**: `/tryitout/H0STCNT0` +- **국내주식 실시간호가 (KRX)**: `/tryitout/H0STASP0` +- **국내주식 실시간체결통보**: `/tryitout/H0STCNI0` +- **국내주식 실시간예상체결 (KRX)**: `/tryitout/H0STANC0` +- **국내주식 실시간회원사 (KRX)**: `/tryitout/H0STMBC0` +- **국내주식 실시간프로그램매매 (KRX)**: `/tryitout/H0STPGM0` +- **국내주식 장운영정보 (KRX)**: `/tryitout/H0STMKO0` +- **국내주식 시간외 실시간호가 (KRX)**: `/tryitout/H0STOAA0` +- **국내주식 시간외 실시간체결가 (KRX)**: `/tryitout/H0STOUP0` +- **국내주식 시간외 실시간예상체결 (KRX)**: `/tryitout/H0STOAC0` +- **국내지수 실시간체결**: `/tryitout/H0UPCNT0` +- **국내지수 실시간예상체결**: `/tryitout/H0UPANC0` +- **국내지수 실시간프로그램매매**: `/tryitout/H0UPPGM0` +- **ELW 실시간호가**: `/tryitout/H0EWASP0` +- **ELW 실시간체결가**: `/tryitout/H0EWCNT0` +- **ELW 실시간예상체결**: `/tryitout/H0EWANC0` +- **국내ETF NAV추이**: `/tryitout/H0STNAV0` +- **국내주식 실시간체결가 (통합)**: `/tryitout/H0UNCNT0` +- **국내주식 실시간호가 (통합)**: `/tryitout/H0UNASP0` +- **국내주식 실시간예상체결 (통합)**: `/tryitout/H0UNANC0` +- **국내주식 실시간회원사 (통합)**: `/tryitout/H0UNMBC0` +- **국내주식 실시간프로그램매매 (통합)**: `/tryitout/H0UNPGM0` +- **국내주식 장운영정보 (통합)**: `/tryitout/H0UNMKO0` +- **국내주식 실시간체결가 (NXT)**: `/tryitout/H0NXCNT0` +- **국내주식 실시간호가 (NXT)**: `/tryitout/H0NXASP0` +- **국내주식 실시간예상체결 (NXT)**: `/tryitout/H0NXANC0` +- **국내주식 실시간회원사 (NXT)**: `/tryitout/H0NXMBC0` +- **국내주식 실시간프로그램매매 (NXT)**: `/tryitout/H0NXPGM0` +- **국내주식 장운영정보 (NXT)**: `/tryitout/H0NXMKO0` + +### [국내선물옵션] 주문/계좌 + +- **선물옵션 주문**: `/uapi/domestic-futureoption/v1/trading/order` +- **선물옵션 정정취소주문**: `/uapi/domestic-futureoption/v1/trading/order-rvsecncl` +- **선물옵션 주문체결내역조회**: `/uapi/domestic-futureoption/v1/trading/inquire-ccnl` +- **선물옵션 잔고현황**: `/uapi/domestic-futureoption/v1/trading/inquire-balance` +- **선물옵션 주문가능**: `/uapi/domestic-futureoption/v1/trading/inquire-psbl-order` +- **(야간)선물옵션 주문체결 내역조회**: `/uapi/domestic-futureoption/v1/trading/inquire-ngt-ccnl` +- **(야간)선물옵션 잔고현황**: `/uapi/domestic-futureoption/v1/trading/inquire-ngt-balance` +- **(야간)선물옵션 주문가능 조회**: `/uapi/domestic-futureoption/v1/trading/inquire-psbl-ngt-order` +- **(야간)선물옵션 증거금 상세**: `/uapi/domestic-futureoption/v1/trading/ngt-margin-detail` +- **선물옵션 잔고정산손익내역**: `/uapi/domestic-futureoption/v1/trading/inquire-balance-settlement-pl` +- **선물옵션 총자산현황**: `/uapi/domestic-futureoption/v1/trading/inquire-deposit` +- **선물옵션 잔고평가손익내역**: `/uapi/domestic-futureoption/v1/trading/inquire-balance-valuation-pl` +- **선물옵션 기준일체결내역**: `/uapi/domestic-futureoption/v1/trading/inquire-ccnl-bstime` +- **선물옵션기간약정수수료일별**: `/uapi/domestic-futureoption/v1/trading/inquire-daily-amount-fee` + +### [국내선물옵션] 기본시세 + +- **선물옵션 시세**: `/uapi/domestic-futureoption/v1/quotations/inquire-price` +- **선물옵션 시세호가**: `/uapi/domestic-futureoption/v1/quotations/inquire-asking-price` +- **선물옵션기간별시세(일/주/월/년)**: `/uapi/domestic-futureoption/v1/quotations/inquire-daily-fuopchartprice` +- **선물옵션 분봉조회**: `/uapi/domestic-futureoption/v1/quotations/inquire-time-fuopchartprice` +- **국내옵션전광판\_옵션월물리스트**: `/uapi/domestic-futureoption/v1/quotations/display-board-option-list` +- **국내선물 기초자산 시세**: `/uapi/domestic-futureoption/v1/quotations/display-board-top` +- **국내옵션전광판\_콜풋**: `/uapi/domestic-futureoption/v1/quotations/display-board-callput` +- **국내옵션전광판\_선물**: `/uapi/domestic-futureoption/v1/quotations/display-board-futures` +- **선물옵션 일중예상체결추이**: `/uapi/domestic-futureoption/v1/quotations/exp-price-trend` + +### [국내선물옵션] 실시간시세 + +- **지수선물 실시간호가**: `/tryitout/H0IFASP0` +- **지수선물 실시간체결가**: `/tryitout/H0IFCNT0` +- **지수옵션 실시간호가**: `/tryitout/H0IOASP0` +- **지수옵션 실시간체결가**: `/tryitout/H0IOCNT0` +- **선물옵션 실시간체결통보**: `/tryitout/H0IFCNI0` +- **상품선물 실시간호가**: `/tryitout/H0CFASP0` +- **상품선물 실시간체결가**: `/tryitout/H0CFCNT0` +- **주식선물 실시간호가**: `/tryitout/H0ZFASP0` +- **주식선물 실시간체결가**: `/tryitout/H0ZFCNT0` +- **주식선물 실시간예상체결**: `/tryitout/H0ZFANC0` +- **주식옵션 실시간호가**: `/tryitout/H0ZOASP0` +- **주식옵션 실시간체결가**: `/tryitout/H0ZOCNT0` +- **주식옵션 실시간예상체결**: `/tryitout/H0ZOANC0` +- **KRX야간옵션 실시간호가**: `/tryitout/H0EUASP0` +- **KRX야간옵션 실시간체결가**: `/tryitout/H0EUCNT0` +- **KRX야간옵션실시간예상체결**: `/tryitout/H0EUANC0` +- **KRX야간옵션실시간체결통보**: `/tryitout/H0EUCNI0` +- **KRX야간선물 실시간호가**: `/tryitout/H0MFASP0` +- **KRX야간선물 실시간종목체결**: `/tryitout/H0MFCNT0` +- **KRX야간선물 실시간체결통보**: `/tryitout/H0MFCNI0` + +### [해외주식] 주문/계좌 + +- **해외주식 주문**: `/uapi/overseas-stock/v1/trading/order` +- **해외주식 정정취소주문**: `/uapi/overseas-stock/v1/trading/order-rvsecncl` +- **해외주식 예약주문접수**: `/uapi/overseas-stock/v1/trading/order-resv` +- **해외주식 예약주문접수취소**: `/uapi/overseas-stock/v1/trading/order-resv-ccnl` +- **해외주식 매수가능금액조회**: `/uapi/overseas-stock/v1/trading/inquire-psamount` +- **해외주식 미체결내역**: `/uapi/overseas-stock/v1/trading/inquire-nccs` +- **해외주식 잔고**: `/uapi/overseas-stock/v1/trading/inquire-balance` +- **해외주식 주문체결내역**: `/uapi/overseas-stock/v1/trading/inquire-ccnl` +- **해외주식 체결기준현재잔고**: `/uapi/overseas-stock/v1/trading/inquire-present-balance` +- **해외주식 예약주문조회**: `/uapi/overseas-stock/v1/trading/order-resv-list` +- **해외주식 결제기준잔고**: `/uapi/overseas-stock/v1/trading/inquire-paymt-stdr-balance` +- **해외주식 일별거래내역**: `/uapi/overseas-stock/v1/trading/inquire-period-trans` +- **해외주식 기간손익**: `/uapi/overseas-stock/v1/trading/inquire-period-profit` +- **해외증거금 통화별조회**: `/uapi/overseas-stock/v1/trading/foreign-margin` +- **해외주식 미국주간주문**: `/uapi/overseas-stock/v1/trading/daytime-order` +- **해외주식 미국주간정정취소**: `/uapi/overseas-stock/v1/trading/daytime-order-rvsecncl` +- **해외주식 지정가주문번호조회**: `/uapi/overseas-stock/v1/trading/algo-ordno` +- **해외주식 지정가체결내역조회**: `/uapi/overseas-stock/v1/trading/inquire-algo-ccnl` + +### [해외주식] 기본시세 + +- **해외주식 현재가상세**: `/uapi/overseas-price/v1/quotations/price-detail` +- **해외주식 현재가 호가**: `/uapi/overseas-price/v1/quotations/inquire-asking-price` +- **해외주식 현재체결가**: `/uapi/overseas-price/v1/quotations/price` +- **해외주식 체결추이**: `/uapi/overseas-price/v1/quotations/inquire-ccnl` +- **해외주식분봉조회**: `/uapi/overseas-price/v1/quotations/inquire-time-itemchartprice` +- **해외지수분봉조회**: `/uapi/overseas-price/v1/quotations/inquire-time-indexchartprice` +- **해외주식 기간별시세**: `/uapi/overseas-price/v1/quotations/dailyprice` +- **해외주식 종목/지수/환율기간별시세(일/주/월/년)**: `/uapi/overseas-price/v1/quotations/inquire-daily-chartprice` +- **해외주식조건검색**: `/uapi/overseas-price/v1/quotations/inquire-search` +- **해외결제일자조회**: `/uapi/overseas-stock/v1/quotations/countries-holiday` +- **해외주식 상품기본정보**: `/uapi/overseas-price/v1/quotations/search-info` +- **해외주식 업종별시세**: `/uapi/overseas-price/v1/quotations/industry-theme` +- **해외주식 업종별코드조회**: `/uapi/overseas-price/v1/quotations/industry-price` + +### [해외주식] 시세분석 + +- **해외주식 가격급등락**: `/uapi/overseas-stock/v1/ranking/price-fluct` +- **해외주식 거래량급증**: `/uapi/overseas-stock/v1/ranking/volume-surge` +- **해외주식 매수체결강도상위**: `/uapi/overseas-stock/v1/ranking/volume-power` +- **해외주식 상승율/하락율**: `/uapi/overseas-stock/v1/ranking/updown-rate` +- **해외주식 신고/신저가**: `/uapi/overseas-stock/v1/ranking/new-highlow` +- **해외주식 거래량순위**: `/uapi/overseas-stock/v1/ranking/trade-vol` +- **해외주식 거래대금순위**: `/uapi/overseas-stock/v1/ranking/trade-pbmn` +- **해외주식 거래증가율순위**: `/uapi/overseas-stock/v1/ranking/trade-growth` +- **해외주식 거래회전율순위**: `/uapi/overseas-stock/v1/ranking/trade-turnover` +- **해외주식 시가총액순위**: `/uapi/overseas-stock/v1/ranking/market-cap` +- **해외주식 기간별권리조회**: `/uapi/overseas-price/v1/quotations/period-rights` +- **해외뉴스종합(제목)**: `/uapi/overseas-price/v1/quotations/news-title` +- **해외주식 권리종합**: `/uapi/overseas-price/v1/quotations/rights-by-ice` +- **당사 해외주식담보대출 가능 종목**: `/uapi/overseas-price/v1/quotations/colable-by-company` +- **해외속보(제목)**: `/uapi/overseas-price/v1/quotations/brknews-title` + +### [해외주식] 실시간시세 + +- **해외주식 실시간호가**: `/tryitout/HDFSASP0` +- **해외주식 지연호가(아시아)**: `/tryitout/HDFSASP1` +- **해외주식 실시간지연체결가**: `/tryitout/HDFSCNT0` +- **해외주식 실시간체결통보**: `/tryitout/H0GSCNI0` + +### [해외선물옵션] 주문/계좌 + +- **해외선물옵션 주문**: `/uapi/overseas-futureoption/v1/trading/order` +- **해외선물옵션 정정취소주문**: `/uapi/overseas-futureoption/v1/trading/order-rvsecncl` +- **해외선물옵션 당일주문내역조회**: `/uapi/overseas-futureoption/v1/trading/inquire-ccld` +- **해외선물옵션 미결제내역조회(잔고)**: `/uapi/overseas-futureoption/v1/trading/inquire-unpd` +- **해외선물옵션 주문가능조회**: `/uapi/overseas-futureoption/v1/trading/inquire-psamount` +- **해외선물옵션 기간계좌손익 일별**: `/uapi/overseas-futureoption/v1/trading/inquire-period-ccld` +- **해외선물옵션 일별 체결내역**: `/uapi/overseas-futureoption/v1/trading/inquire-daily-ccld` +- **해외선물옵션 예수금현황**: `/uapi/overseas-futureoption/v1/trading/inquire-deposit` +- **해외선물옵션 일별 주문내역**: `/uapi/overseas-futureoption/v1/trading/inquire-daily-order` +- **해외선물옵션 기간계좌거래내역**: `/uapi/overseas-futureoption/v1/trading/inquire-period-trans` +- **해외선물옵션 증거금상세**: `/uapi/overseas-futureoption/v1/trading/margin-detail` + +### [해외선물옵션] 기본시세 + +- **해외선물종목현재가**: `/uapi/overseas-futureoption/v1/quotations/inquire-price` +- **해외선물종목상세**: `/uapi/overseas-futureoption/v1/quotations/stock-detail` +- **해외선물 호가**: `/uapi/overseas-futureoption/v1/quotations/inquire-asking-price` +- **해외선물 분봉조회**: `/uapi/overseas-futureoption/v1/quotations/inquire-time-futurechartprice` +- **해외선물 체결추이(틱)**: `/uapi/overseas-futureoption/v1/quotations/tick-ccnl` +- **해외선물 체결추이(주간)**: `/uapi/overseas-futureoption/v1/quotations/weekly-ccnl` +- **해외선물 체결추이(일간)**: `/uapi/overseas-futureoption/v1/quotations/daily-ccnl` +- **해외선물 체결추이(월간)**: `/uapi/overseas-futureoption/v1/quotations/monthly-ccnl` +- **해외선물 상품기본정보**: `/uapi/overseas-futureoption/v1/quotations/search-contract-detail` +- **해외선물 미결제추이**: `/uapi/overseas-futureoption/v1/quotations/investor-unpd-trend` +- **해외옵션종목현재가**: `/uapi/overseas-futureoption/v1/quotations/opt-price` +- **해외옵션종목상세**: `/uapi/overseas-futureoption/v1/quotations/opt-detail` +- **해외옵션 호가**: `/uapi/overseas-futureoption/v1/quotations/opt-asking-price` +- **해외옵션 분봉조회**: `/uapi/overseas-futureoption/v1/quotations/inquire-time-optchartprice` +- **해외옵션 체결추이(틱)**: `/uapi/overseas-futureoption/v1/quotations/opt-tick-ccnl` +- **해외옵션 체결추이(일간)**: `/uapi/overseas-futureoption/v1/quotations/opt-daily-ccnl` +- **해외옵션 체결추이(주간)**: `/uapi/overseas-futureoption/v1/quotations/opt-weekly-ccnl` +- **해외옵션 체결추이(월간)**: `/uapi/overseas-futureoption/v1/quotations/opt-monthly-ccnl` +- **해외옵션 상품기본정보**: `/uapi/overseas-futureoption/v1/quotations/search-opt-detail` +- **해외선물옵션 장운영시간**: `/uapi/overseas-futureoption/v1/quotations/market-time` + +### [해외선물옵션]실시간시세 + +- **해외선물옵션 실시간체결가**: `/tryitout/HDFFF020` +- **해외선물옵션 실시간호가**: `/tryitout/HDFFF010` +- **해외선물옵션 실시간주문내역통보**: `/tryitout/HDFFF1C0` +- **해외선물옵션 실시간체결내역통보**: `/tryitout/HDFFF2C0` + +### [장내채권] 주문/계좌 + +- **장내채권 매수주문**: `/uapi/domestic-bond/v1/trading/buy` +- **장내채권 매도주문**: `/uapi/domestic-bond/v1/trading/sell` +- **장내채권 정정취소주문**: `/uapi/domestic-bond/v1/trading/order-rvsecncl` +- **채권정정취소가능주문조회**: `/uapi/domestic-bond/v1/trading/inquire-psbl-rvsecncl` +- **장내채권 주문체결내역**: `/uapi/domestic-bond/v1/trading/inquire-daily-ccld` +- **장내채권 잔고조회**: `/uapi/domestic-bond/v1/trading/inquire-balance` +- **장내채권 매수가능조회**: `/uapi/domestic-bond/v1/trading/inquire-psbl-order` + +### [장내채권] 기본시세 + +- **장내채권현재가(호가)**: `/uapi/domestic-bond/v1/quotations/inquire-asking-price` +- **장내채권현재가(시세)**: `/uapi/domestic-bond/v1/quotations/inquire-price` +- **장내채권현재가(체결)**: `/uapi/domestic-bond/v1/quotations/inquire-ccnl` +- **장내채권현재가(일별)**: `/uapi/domestic-bond/v1/quotations/inquire-daily-price` +- **장내채권 기간별시세(일)**: `/uapi/domestic-bond/v1/quotations/inquire-daily-itemchartprice` +- **장내채권 평균단가조회**: `/uapi/domestic-bond/v1/quotations/avg-unit` +- **장내채권 발행정보**: `/uapi/domestic-bond/v1/quotations/issue-info` +- **장내채권 기본조회**: `/uapi/domestic-bond/v1/quotations/search-bond-info` + +### [장내채권] 실시간시세 + +- **일반채권 실시간체결가**: `/tryitout/H0BJCNT0` +- **일반채권 실시간호가**: `/tryitout/H0BJASP0` +- **채권지수 실시간체결가**: `/tryitout/H0BICNT0` diff --git a/features/auth/components/session-manager.tsx b/features/auth/components/session-manager.tsx index a980465..100169d 100644 --- a/features/auth/components/session-manager.tsx +++ b/features/auth/components/session-manager.tsx @@ -66,6 +66,7 @@ export function SessionManager() { for (const key of SESSION_RELATED_STORAGE_KEYS) { window.localStorage.removeItem(key); + window.sessionStorage.removeItem(key); } }, []); diff --git a/features/dashboard/apis/dashboard.api.ts b/features/dashboard/apis/dashboard.api.ts index a5c42f2..15a8252 100644 --- a/features/dashboard/apis/dashboard.api.ts +++ b/features/dashboard/apis/dashboard.api.ts @@ -1,4 +1,9 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import { + buildKisRequestHeaders, + resolveKisApiErrorMessage, + type KisApiErrorPayload, +} from "@/features/settings/apis/kis-api-utils"; import type { DashboardActivityResponse, DashboardBalanceResponse, @@ -21,18 +26,16 @@ export async function fetchDashboardBalance( ): Promise { const response = await fetch("/api/kis/domestic/balance", { method: "GET", - headers: buildKisRequestHeaders(credentials), + headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }), cache: "no-store", }); const payload = (await response.json()) as | DashboardBalanceResponse - | { error?: string }; + | KisApiErrorPayload; if (!response.ok) { - throw new Error( - "error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.", - ); + throw new Error(resolveKisApiErrorMessage(payload, "잔고 조회 중 오류가 발생했습니다.")); } return payload as DashboardBalanceResponse; @@ -55,12 +58,10 @@ export async function fetchDashboardIndices( const payload = (await response.json()) as | DashboardIndicesResponse - | { error?: string }; + | KisApiErrorPayload; if (!response.ok) { - throw new Error( - "error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.", - ); + throw new Error(resolveKisApiErrorMessage(payload, "지수 조회 중 오류가 발생했습니다.")); } return payload as DashboardIndicesResponse; @@ -77,39 +78,17 @@ export async function fetchDashboardActivity( ): Promise { const response = await fetch("/api/kis/domestic/activity", { method: "GET", - headers: buildKisRequestHeaders(credentials), + headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }), cache: "no-store", }); const payload = (await response.json()) as | DashboardActivityResponse - | { error?: string }; + | KisApiErrorPayload; if (!response.ok) { - throw new Error( - "error" in payload ? payload.error : "활동 데이터 조회 중 오류가 발생했습니다.", - ); + throw new Error(resolveKisApiErrorMessage(payload, "활동 데이터 조회 중 오류가 발생했습니다.")); } return payload as DashboardActivityResponse; } - -/** - * 대시보드 API 공통 헤더를 구성합니다. - * @param credentials KIS 인증 정보 - * @returns KIS 전달 헤더 - * @see features/dashboard/apis/dashboard.api.ts fetchDashboardBalance/fetchDashboardIndices - */ -function buildKisRequestHeaders(credentials: KisRuntimeCredentials) { - const headers: Record = { - "x-kis-app-key": credentials.appKey, - "x-kis-app-secret": credentials.appSecret, - "x-kis-trading-env": credentials.tradingEnv, - }; - - if (credentials.accountNo?.trim()) { - headers["x-kis-account-no"] = credentials.accountNo.trim(); - } - - return headers; -} diff --git a/features/home/components/spline-scene.tsx b/features/home/components/spline-scene.tsx deleted file mode 100644 index 2ddefc8..0000000 --- a/features/home/components/spline-scene.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import Spline from "@splinetool/react-spline"; -import { useState } from "react"; -import { cn } from "@/lib/utils"; - -interface SplineSceneProps { - sceneUrl: string; - className?: string; -} - -export function SplineScene({ sceneUrl, className }: SplineSceneProps) { - const [isLoading, setIsLoading] = useState(true); - - return ( -
- {isLoading && ( -
-
-
- )} - setIsLoading(false)} - className="h-full w-full" - /> -
- ); -} diff --git a/features/kis-realtime/stores/kisWebSocketStore.ts b/features/kis-realtime/stores/kisWebSocketStore.ts index 361e08c..18b0405 100644 --- a/features/kis-realtime/stores/kisWebSocketStore.ts +++ b/features/kis-realtime/stores/kisWebSocketStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { buildKisErrorDetail } from "@/lib/kis/error-codes"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils"; @@ -63,6 +64,21 @@ const RECONNECT_BASE_DELAY_MS = 1_000; const RECONNECT_MAX_DELAY_MS = 30_000; const RECONNECT_JITTER_MS = 300; +function isKisWsDebugEnabled() { + if (typeof window === "undefined") return false; + return window.localStorage.getItem("KIS_WS_DEBUG") === "1"; +} + +function wsDebugLog(...args: unknown[]) { + if (!isKisWsDebugEnabled()) return; + console.log(...args); +} + +function wsDebugWarn(...args: unknown[]) { + if (!isKisWsDebugEnabled()) return; + console.warn(...args); +} + export const useKisWebSocketStore = create((set, get) => ({ isConnected: false, error: null, @@ -105,7 +121,7 @@ export const useKisWebSocketStore = create((set, get) => ({ // 소켓 생성 // socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지 const ws = new WebSocket(wsConnection.wsUrl); - console.log("[KisWebSocket] Connecting to:", wsConnection.wsUrl); + wsDebugLog("[KisWebSocket] Connecting to:", wsConnection.wsUrl); socket = ws; ws.onopen = () => { @@ -116,7 +132,7 @@ export const useKisWebSocketStore = create((set, get) => ({ set({ isConnected: true, error: null }); reconnectAttempt = 0; - console.log("[KisWebSocket] Connected"); + wsDebugLog("[KisWebSocket] Connected"); // 재연결 시 기존 구독 복구 const approvalKey = wsConnection.approvalKey; @@ -147,7 +163,7 @@ export const useKisWebSocketStore = create((set, get) => ({ if (canAutoReconnect) { reconnectAttempt += 1; const delayMs = getReconnectDelayMs(reconnectAttempt); - console.warn( + wsDebugWarn( `[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`, ); @@ -170,7 +186,7 @@ export const useKisWebSocketStore = create((set, get) => ({ } reconnectAttempt = 0; - console.log( + wsDebugLog( `[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`, ); } @@ -221,15 +237,15 @@ export const useKisWebSocketStore = create((set, get) => ({ // KIS 서버가 세션을 정리하는 데 최소 10~30초가 필요하므로 // 충분한 대기 후 재연결합니다. if (control.msgCd === "OPSP8996") { - const now = Date.now(); - if (now - lastAppKeyConflictAt > 5_000) { - lastAppKeyConflictAt = now; - console.warn( - "[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.", - ); - // 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도 - if (socket === ws && ws.readyState === WebSocket.OPEN) { - ws.close(1000, "ALREADY IN USE - graceful close"); + const now = Date.now(); + if (now - lastAppKeyConflictAt > 5_000) { + lastAppKeyConflictAt = now; + wsDebugWarn( + "[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.", + ); + // 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도 + if (socket === ws && ws.readyState === WebSocket.OPEN) { + ws.close(1000, "ALREADY IN USE - graceful close"); } window.clearTimeout(reconnectRetryTimer); reconnectRetryTimer = window.setTimeout(() => { @@ -374,11 +390,11 @@ function sendSubscription( try { const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType); ws.send(JSON.stringify(msg)); - console.debug( + wsDebugLog( `[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`, ); } catch (e) { - console.warn("[KisWebSocket] Send error", e); + wsDebugWarn("[KisWebSocket] Send error", e); } } @@ -440,7 +456,10 @@ function buildControlErrorMessage(message: KisWsControlMessage) { if (message.msgCd === "OPSP8996") { return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다."; } - const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / "); + const detail = buildKisErrorDetail({ + message: message.msg1, + msgCode: message.msgCd, + }); return detail ? `실시간 제어 메시지 오류: ${detail}` : "실시간 제어 메시지 오류"; diff --git a/features/layout/components/sidebar.tsx b/features/layout/components/sidebar.tsx index 12c423a..3bae4bd 100644 --- a/features/layout/components/sidebar.tsx +++ b/features/layout/components/sidebar.tsx @@ -6,8 +6,6 @@ import { ChevronLeft, Home, Settings, - User, - Wallet, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -31,20 +29,6 @@ const MENU_ITEMS: MenuItem[] = [ badge: "LIVE", showInBottomNav: true, }, - { - title: "자산현황", - href: "/assets", - icon: Wallet, - variant: "ghost", - showInBottomNav: true, - }, - { - title: "프로필", - href: "/profile", - icon: User, - variant: "ghost", - showInBottomNav: false, - }, { title: "설정", href: "/settings", diff --git a/features/layout/components/user-menu.tsx b/features/layout/components/user-menu.tsx index 41ff988..bc7e11c 100644 --- a/features/layout/components/user-menu.tsx +++ b/features/layout/components/user-menu.tsx @@ -6,7 +6,7 @@ "use client"; import { User } from "@supabase/supabase-js"; -import { LogOut, Settings, User as UserIcon } from "lucide-react"; +import { LogOut, Settings } from "lucide-react"; import { useRouter } from "next/navigation"; import { signout } from "@/features/auth/actions"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -54,6 +54,7 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) { for (const key of SESSION_RELATED_STORAGE_KEYS) { window.localStorage.removeItem(key); + window.sessionStorage.removeItem(key); } }; @@ -97,11 +98,6 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) { - router.push("/profile")}> - - 프로필 - - router.push("/settings")}> 설정 diff --git a/features/settings/apis/kis-api-utils.ts b/features/settings/apis/kis-api-utils.ts new file mode 100644 index 0000000..86577a8 --- /dev/null +++ b/features/settings/apis/kis-api-utils.ts @@ -0,0 +1,82 @@ +import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import { + DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, + DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, + parseDomesticKisSession, +} from "@/lib/kis/domestic-market-session"; + +export interface KisApiErrorPayload { + ok?: boolean; + message?: string; + error?: string; + errorCode?: string; +} + +interface BuildKisRequestHeadersOptions { + jsonContentType?: boolean; + includeAccountNo?: boolean; + includeSessionOverride?: boolean; +} + +/** + * @description KIS API 응답에서 사용자 노출용 에러 메시지를 추출합니다. + * @see features/trade/apis/kis-stock.api.ts 종목/주문 API 실패 처리 + * @see features/dashboard/apis/dashboard.api.ts 대시보드 API 실패 처리 + */ +export function resolveKisApiErrorMessage( + payload: unknown, + fallbackMessage: string, +) { + if (!payload || typeof payload !== "object") { + return fallbackMessage; + } + + const response = payload as KisApiErrorPayload; + return response.message || response.error || fallbackMessage; +} + +/** + * @description KIS API 호출용 공통 헤더를 생성합니다. + * @see features/dashboard/apis/dashboard.api.ts 잔고/지수/활동 조회 공통 헤더 + * @see features/trade/apis/kis-stock.api.ts 종목/호가/차트/주문 공통 헤더 + */ +export function buildKisRequestHeaders( + credentials: KisRuntimeCredentials, + options?: BuildKisRequestHeadersOptions, +) { + const headers: Record = { + "x-kis-app-key": credentials.appKey, + "x-kis-app-secret": credentials.appSecret, + "x-kis-trading-env": credentials.tradingEnv, + }; + + if (options?.jsonContentType) { + headers["content-type"] = "application/json"; + } + + if (options?.includeAccountNo && credentials.accountNo.trim()) { + headers["x-kis-account-no"] = credentials.accountNo.trim(); + } + + if (options?.includeSessionOverride) { + const sessionOverride = readSessionOverrideForDev(); + if (sessionOverride) { + headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride; + } + } + + return headers; +} + +function readSessionOverrideForDev() { + if (typeof window === "undefined") return null; + + try { + const raw = window.localStorage.getItem( + DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, + ); + return parseDomesticKisSession(raw); + } catch { + return null; + } +} diff --git a/features/settings/apis/kis-auth.api.ts b/features/settings/apis/kis-auth.api.ts index 722bf9b..9fcb6da 100644 --- a/features/settings/apis/kis-auth.api.ts +++ b/features/settings/apis/kis-auth.api.ts @@ -1,4 +1,8 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import { + resolveKisApiErrorMessage, + type KisApiErrorPayload, +} from "@/features/settings/apis/kis-api-utils"; import type { DashboardKisProfileValidateResponse, DashboardKisRevokeResponse, @@ -25,13 +29,13 @@ async function postKisAuthApi( cache: "no-store", }); - const payload = (await response.json()) as T; + const payload = (await response.json()) as T | KisApiErrorPayload; if (!response.ok || !payload.ok) { - throw new Error(payload.message || fallbackErrorMessage); + throw new Error(resolveKisApiErrorMessage(payload, fallbackErrorMessage)); } - return payload; + return payload as T; } /** diff --git a/features/settings/store/use-kis-runtime-store.ts b/features/settings/store/use-kis-runtime-store.ts index cf91602..2d0d10a 100644 --- a/features/settings/store/use-kis-runtime-store.ts +++ b/features/settings/store/use-kis-runtime-store.ts @@ -240,11 +240,14 @@ export const useKisRuntimeStore = create< }), { name: "autotrade-kis-runtime-store", - storage: createJSONStorage(() => localStorage), + // 민감정보(appKey/appSecret/accountNo)는 브라우저 세션 범위로만 유지합니다. + storage: createJSONStorage(() => sessionStorage), onRehydrateStorage: () => (state) => { state?.setHasHydrated(true); }, partialize: (state) => ({ + // 새로고침 시 인증이 풀리지 않도록, "세션 범위"에서만 인증/입력 상태를 유지합니다. + // 브라우저 종료 시 sessionStorage가 비워지므로 장기 영속(localStorage)은 하지 않습니다. kisTradingEnvInput: state.kisTradingEnvInput, kisAppKeyInput: state.kisAppKeyInput, kisAppSecretInput: state.kisAppSecretInput, @@ -254,7 +257,6 @@ export const useKisRuntimeStore = create< isKisProfileVerified: state.isKisProfileVerified, verifiedAccountNo: state.verifiedAccountNo, tradingEnv: state.tradingEnv, - // wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive). }), }, ), diff --git a/features/trade/apis/kis-stock.api.ts b/features/trade/apis/kis-stock.api.ts index 21c8ce2..3228091 100644 --- a/features/trade/apis/kis-stock.api.ts +++ b/features/trade/apis/kis-stock.api.ts @@ -1,4 +1,9 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import { + buildKisRequestHeaders, + resolveKisApiErrorMessage, + type KisApiErrorPayload, +} from "@/features/settings/apis/kis-api-utils"; import type { DashboardChartTimeframe, DashboardStockCashOrderRequest, @@ -8,11 +13,6 @@ import type { DashboardStockOverviewResponse, DashboardStockSearchResponse, } from "@/features/trade/types/trade.types"; -import { - DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, - DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, - parseDomesticKisSession, -} from "@/lib/kis/domestic-market-session"; /** * 종목 검색 API 호출 @@ -32,12 +32,10 @@ export async function fetchStockSearch( const payload = (await response.json()) as | DashboardStockSearchResponse - | { error?: string }; + | KisApiErrorPayload; if (!response.ok) { - throw new Error( - "error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.", - ); + throw new Error(resolveKisApiErrorMessage(payload, "종목 검색 중 오류가 발생했습니다.")); } return payload as DashboardStockSearchResponse; @@ -56,19 +54,19 @@ export async function fetchStockOverview( `/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`, { method: "GET", - headers: buildKisRequestHeaders(credentials), + headers: buildKisRequestHeaders(credentials, { + includeSessionOverride: true, + }), cache: "no-store", }, ); const payload = (await response.json()) as | DashboardStockOverviewResponse - | { error?: string }; + | KisApiErrorPayload; if (!response.ok) { - throw new Error( - "error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.", - ); + throw new Error(resolveKisApiErrorMessage(payload, "종목 조회 중 오류가 발생했습니다.")); } return payload as DashboardStockOverviewResponse; @@ -88,7 +86,9 @@ export async function fetchStockOrderBook( `/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`, { method: "GET", - headers: buildKisRequestHeaders(credentials), + headers: buildKisRequestHeaders(credentials, { + includeSessionOverride: true, + }), cache: "no-store", signal, }, @@ -96,12 +96,10 @@ export async function fetchStockOrderBook( const payload = (await response.json()) as | DashboardStockOrderBookResponse - | { error?: string }; + | KisApiErrorPayload; if (!response.ok) { - throw new Error( - "error" in payload ? payload.error : "호가 조회 중 오류가 발생했습니다.", - ); + throw new Error(resolveKisApiErrorMessage(payload, "호가 조회 중 오류가 발생했습니다.")); } return payload as DashboardStockOrderBookResponse; @@ -124,18 +122,18 @@ export async function fetchStockChart( const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, { method: "GET", - headers: buildKisRequestHeaders(credentials), + headers: buildKisRequestHeaders(credentials, { + includeSessionOverride: true, + }), cache: "no-store", }); const payload = (await response.json()) as | DashboardStockChartResponse - | { error?: string }; + | KisApiErrorPayload; if (!response.ok) { - throw new Error( - "error" in payload ? payload.error : "차트 조회 중 오류가 발생했습니다.", - ); + throw new Error(resolveKisApiErrorMessage(payload, "차트 조회 중 오류가 발생했습니다.")); } return payload as DashboardStockChartResponse; @@ -152,51 +150,21 @@ export async function fetchOrderCash( ): Promise { const response = await fetch("/api/kis/domestic/order-cash", { method: "POST", - headers: buildKisRequestHeaders(credentials, { jsonContentType: true }), + headers: buildKisRequestHeaders(credentials, { + jsonContentType: true, + includeSessionOverride: true, + }), body: JSON.stringify(request), cache: "no-store", }); - const payload = (await response.json()) as DashboardStockCashOrderResponse; + const payload = (await response.json()) as + | DashboardStockCashOrderResponse + | KisApiErrorPayload; if (!response.ok) { - throw new Error(payload.message || "주문 전송 중 오류가 발생했습니다."); + throw new Error(resolveKisApiErrorMessage(payload, "주문 전송 중 오류가 발생했습니다.")); } - return payload; + return payload as DashboardStockCashOrderResponse; } - -function buildKisRequestHeaders( - credentials: KisRuntimeCredentials, - options?: { jsonContentType?: boolean }, -) { - const headers: Record = { - "x-kis-app-key": credentials.appKey, - "x-kis-app-secret": credentials.appSecret, - "x-kis-trading-env": credentials.tradingEnv, - }; - - if (options?.jsonContentType) { - headers["content-type"] = "application/json"; - } - - const sessionOverride = readSessionOverrideForDev(); - if (sessionOverride) { - headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride; - } - - return headers; -} - -function readSessionOverrideForDev() { - if (typeof window === "undefined") return null; - - try { - const raw = window.localStorage.getItem( - DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY, - ); - return parseDomesticKisSession(raw); - } catch { - return null; - } -} \ No newline at end of file diff --git a/features/trade/components/chart/StockLineChart.tsx b/features/trade/components/chart/StockLineChart.tsx index 0fac850..6100727 100644 --- a/features/trade/components/chart/StockLineChart.tsx +++ b/features/trade/components/chart/StockLineChart.tsx @@ -33,98 +33,18 @@ import { toRealtimeTickBar, upsertRealtimeBar, } from "./chart-utils"; - -const UP_COLOR = "#ef4444"; -const MINUTE_SYNC_INTERVAL_MS = 30000; -const REALTIME_STALE_THRESHOLD_MS = 12000; -const CHART_MIN_HEIGHT = 220; - -interface ChartPalette { - backgroundColor: string; - downColor: string; - volumeDownColor: string; - textColor: string; - borderColor: string; - gridColor: string; - crosshairColor: string; -} - -const DEFAULT_CHART_PALETTE: ChartPalette = { - backgroundColor: "#ffffff", - downColor: "#2563eb", - volumeDownColor: "rgba(37, 99, 235, 0.45)", - textColor: "#6d28d9", - borderColor: "#e9d5ff", - gridColor: "#f3e8ff", - crosshairColor: "#c084fc", -}; - -function readCssVar(name: string, fallback: string) { - if (typeof window === "undefined") return fallback; - const value = window - .getComputedStyle(document.documentElement) - .getPropertyValue(name) - .trim(); - return value || fallback; -} - -function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette { - const isDark = themeMode === "dark"; - const backgroundVar = isDark - ? "--brand-chart-background-dark" - : "--brand-chart-background-light"; - const textVar = isDark - ? "--brand-chart-text-dark" - : "--brand-chart-text-light"; - const borderVar = isDark - ? "--brand-chart-border-dark" - : "--brand-chart-border-light"; - const gridVar = isDark - ? "--brand-chart-grid-dark" - : "--brand-chart-grid-light"; - const crosshairVar = isDark - ? "--brand-chart-crosshair-dark" - : "--brand-chart-crosshair-light"; - - return { - backgroundColor: readCssVar( - backgroundVar, - DEFAULT_CHART_PALETTE.backgroundColor, - ), - downColor: readCssVar( - "--brand-chart-down", - DEFAULT_CHART_PALETTE.downColor, - ), - volumeDownColor: readCssVar( - "--brand-chart-volume-down", - DEFAULT_CHART_PALETTE.volumeDownColor, - ), - textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor), - borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor), - gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor), - crosshairColor: readCssVar( - crosshairVar, - DEFAULT_CHART_PALETTE.crosshairColor, - ), - }; -} - -const MINUTE_TIMEFRAMES: Array<{ - value: DashboardChartTimeframe; - label: string; -}> = [ - { value: "1m", label: "1분" }, - { value: "30m", label: "30분" }, - { value: "1h", label: "1시간" }, -]; - -const PERIOD_TIMEFRAMES: Array<{ - value: DashboardChartTimeframe; - label: string; -}> = [ - { value: "1d", label: "일" }, - { value: "1w", label: "주" }, -]; +import { + areBarsEqual, + type ChartPalette, + CHART_MIN_HEIGHT, + DEFAULT_CHART_PALETTE, + getChartPaletteFromCssVars, + MINUTE_SYNC_INTERVAL_MS, + MINUTE_TIMEFRAMES, + PERIOD_TIMEFRAMES, + REALTIME_STALE_THRESHOLD_MS, + UP_COLOR, +} from "./stock-line-chart-meta"; interface StockLineChartProps { symbol?: string; @@ -161,6 +81,7 @@ export function StockLineChart({ const lastRealtimeAppliedAtRef = useRef(0); const chartPaletteRef = useRef(DEFAULT_CHART_PALETTE); const renderableBarsRef = useRef([]); + const initialThemeModeRef = useRef<"light" | "dark">("light"); const activeThemeMode: "light" | "dark" = resolvedTheme === "dark" @@ -172,6 +93,10 @@ export function StockLineChart({ ? "dark" : "light"; + useEffect(() => { + initialThemeModeRef.current = activeThemeMode; + }, [activeThemeMode]); + // 복수 이벤트에서 중복 로드를 막기 위한 ref 상태 const loadingMoreRef = useRef(false); const loadMoreHandlerRef = useRef<() => Promise>(async () => {}); @@ -244,7 +169,9 @@ export function StockLineChart({ })), ); } catch (error) { - console.error("Failed to render chart series data:", error); + if (process.env.NODE_ENV !== "production") { + console.error("Failed to render chart series data:", error); + } } }, []); @@ -296,7 +223,7 @@ export function StockLineChart({ if (!container || chartRef.current) return; // 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다. - const palette = getChartPaletteFromCssVars(activeThemeMode); + const palette = getChartPaletteFromCssVars(initialThemeModeRef.current); chartPaletteRef.current = palette; const chart = createChart(container, { @@ -411,7 +338,7 @@ export function StockLineChart({ volumeSeriesRef.current = null; setIsChartReady(false); }; - }, [activeThemeMode]); + }, []); useEffect(() => { const chart = chartRef.current; @@ -460,6 +387,7 @@ export function StockLineChart({ initialLoadCompleteRef.current = false; let disposed = false; + let initialLoadTimer: number | null = null; const load = async () => { setIsLoading(true); @@ -508,7 +436,7 @@ export function StockLineChart({ setBars(mergedBars); setNextCursor(resolvedNextCursor); - window.setTimeout(() => { + initialLoadTimer = window.setTimeout(() => { if (!disposed) initialLoadCompleteRef.current = true; }, 350); } catch (error) { @@ -531,6 +459,9 @@ export function StockLineChart({ return () => { disposed = true; + if (initialLoadTimer !== null) { + window.clearTimeout(initialLoadTimer); + } }; }, [credentials, symbol, timeframe]); @@ -550,7 +481,7 @@ export function StockLineChart({ */ useEffect(() => { if (!latestTick) return; - if (bars.length === 0) return; + if (renderableBarsRef.current.length === 0) return; const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`; if (lastRealtimeKeyRef.current === dedupeKey) return; @@ -561,7 +492,7 @@ export function StockLineChart({ lastRealtimeKeyRef.current = dedupeKey; lastRealtimeAppliedAtRef.current = Date.now(); setBars((prev) => upsertRealtimeBar(prev, realtimeBar)); - }, [bars.length, latestTick, timeframe]); + }, [latestTick, timeframe]); /** * @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다. @@ -715,25 +646,3 @@ export function StockLineChart({
); } - -function areBarsEqual(left: ChartBar[], right: ChartBar[]) { - if (left.length !== right.length) return false; - - for (let index = 0; index < left.length; index += 1) { - const lhs = left[index]; - const rhs = right[index]; - if (!lhs || !rhs) return false; - if ( - lhs.time !== rhs.time || - lhs.open !== rhs.open || - lhs.high !== rhs.high || - lhs.low !== rhs.low || - lhs.close !== rhs.close || - lhs.volume !== rhs.volume - ) { - return false; - } - } - - return true; -} diff --git a/features/trade/components/chart/stock-line-chart-meta.ts b/features/trade/components/chart/stock-line-chart-meta.ts new file mode 100644 index 0000000..88fbc90 --- /dev/null +++ b/features/trade/components/chart/stock-line-chart-meta.ts @@ -0,0 +1,126 @@ +import type { DashboardChartTimeframe } from "@/features/trade/types/trade.types"; +import type { ChartBar } from "./chart-utils"; + +export const UP_COLOR = "#ef4444"; +export const MINUTE_SYNC_INTERVAL_MS = 30000; +export const REALTIME_STALE_THRESHOLD_MS = 12000; +export const CHART_MIN_HEIGHT = 220; + +export interface ChartPalette { + backgroundColor: string; + downColor: string; + volumeDownColor: string; + textColor: string; + borderColor: string; + gridColor: string; + crosshairColor: string; +} + +export const DEFAULT_CHART_PALETTE: ChartPalette = { + backgroundColor: "#ffffff", + downColor: "#2563eb", + volumeDownColor: "rgba(37, 99, 235, 0.45)", + textColor: "#6d28d9", + borderColor: "#e9d5ff", + gridColor: "#f3e8ff", + crosshairColor: "#c084fc", +}; + +export const MINUTE_TIMEFRAMES: Array<{ + value: DashboardChartTimeframe; + label: string; +}> = [ + { value: "1m", label: "1분" }, + { value: "30m", label: "30분" }, + { value: "1h", label: "1시간" }, +]; + +export const PERIOD_TIMEFRAMES: Array<{ + value: DashboardChartTimeframe; + label: string; +}> = [ + { value: "1d", label: "일" }, + { value: "1w", label: "주" }, +]; + +/** + * @description 브랜드 CSS 변수에서 차트 팔레트를 읽어옵니다. + * @see features/trade/components/chart/StockLineChart.tsx 차트 생성/테마 반영 + */ +export function getChartPaletteFromCssVars( + themeMode: "light" | "dark", +): ChartPalette { + const isDark = themeMode === "dark"; + const backgroundVar = isDark + ? "--brand-chart-background-dark" + : "--brand-chart-background-light"; + const textVar = isDark + ? "--brand-chart-text-dark" + : "--brand-chart-text-light"; + const borderVar = isDark + ? "--brand-chart-border-dark" + : "--brand-chart-border-light"; + const gridVar = isDark + ? "--brand-chart-grid-dark" + : "--brand-chart-grid-light"; + const crosshairVar = isDark + ? "--brand-chart-crosshair-dark" + : "--brand-chart-crosshair-light"; + + return { + backgroundColor: readCssVar( + backgroundVar, + DEFAULT_CHART_PALETTE.backgroundColor, + ), + downColor: readCssVar( + "--brand-chart-down", + DEFAULT_CHART_PALETTE.downColor, + ), + volumeDownColor: readCssVar( + "--brand-chart-volume-down", + DEFAULT_CHART_PALETTE.volumeDownColor, + ), + textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor), + borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor), + gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor), + crosshairColor: readCssVar( + crosshairVar, + DEFAULT_CHART_PALETTE.crosshairColor, + ), + }; +} + +/** + * @description 차트 데이터 배열이 동일한지 비교합니다. + * @see features/trade/components/chart/StockLineChart.tsx 분봉 동기화 시 불필요한 상태 업데이트 방지 + */ +export function areBarsEqual(left: ChartBar[], right: ChartBar[]) { + if (left.length !== right.length) return false; + + for (let index = 0; index < left.length; index += 1) { + const lhs = left[index]; + const rhs = right[index]; + if (!lhs || !rhs) return false; + if ( + lhs.time !== rhs.time || + lhs.open !== rhs.open || + lhs.high !== rhs.high || + lhs.low !== rhs.low || + lhs.close !== rhs.close || + lhs.volume !== rhs.volume + ) { + return false; + } + } + + return true; +} + +function readCssVar(name: string, fallback: string) { + if (typeof window === "undefined") return fallback; + const value = window + .getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + return value || fallback; +} diff --git a/features/trade/components/order/OrderForm.tsx b/features/trade/components/order/OrderForm.tsx index 598a805..6581181 100644 --- a/features/trade/components/order/OrderForm.tsx +++ b/features/trade/components/order/OrderForm.tsx @@ -12,6 +12,7 @@ import type { DashboardOrderSide, DashboardStockItem, } from "@/features/trade/types/trade.types"; +import { parseKisAccountParts } from "@/lib/kis/account"; import { cn } from "@/lib/utils"; interface OrderFormProps { @@ -60,6 +61,14 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) { return; } + const accountParts = parseKisAccountParts(verifiedCredentials.accountNo); + if (!accountParts) { + alert( + "계좌번호 형식이 올바르지 않습니다. 설정에서 8-2 형식(예: 12345678-01)으로 다시 확인해 주세요.", + ); + return; + } + const response = await placeOrder( { symbol: stock.symbol, @@ -67,8 +76,8 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) { orderType: "limit", price: priceNum, quantity: qtyNum, - accountNo: verifiedCredentials.accountNo, - accountProductCode: "01", + accountNo: `${accountParts.accountNo}-${accountParts.accountProductCode}`, + accountProductCode: accountParts.accountProductCode, }, verifiedCredentials, ); @@ -84,8 +93,17 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) { parseInt(quantity.replace(/,/g, "") || "0", 10); const setPercent = (pct: string) => { - // TODO: 계좌 잔고 연동 시 퍼센트 자동 계산으로 교체 - console.log("Percent clicked:", pct); + const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100; + if (!Number.isFinite(ratio) || ratio <= 0) return; + + // UI 흐름: 비율 버튼 클릭 -> 보유수량 기준 계산(매도 탭) -> 주문수량 입력값 반영 + if (activeTab === "sell" && matchedHolding?.quantity) { + const calculatedQuantity = Math.max( + 1, + Math.floor(matchedHolding.quantity * ratio), + ); + setQuantity(String(calculatedQuantity)); + } }; const isMarketDataAvailable = Boolean(stock); diff --git a/features/trade/components/orderbook/OrderBook.tsx b/features/trade/components/orderbook/OrderBook.tsx index 43b3be1..54bc563 100644 --- a/features/trade/components/orderbook/OrderBook.tsx +++ b/features/trade/components/orderbook/OrderBook.tsx @@ -1,15 +1,26 @@ import { useMemo } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Skeleton } from "@/components/ui/skeleton"; import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/trade/types/trade.types"; -import { cn } from "@/lib/utils"; -import { AnimatedQuantity } from "./AnimatedQuantity"; - -// ─── 타입 ─────────────────────────────────────────────── +import type { BookRow } from "./orderbook-utils"; +import { + buildBookRows, + buildFallbackLevelsFromTick, + hasOrderBookLevelData, + resolveReferencePrice, +} from "./orderbook-utils"; +import { + BookHeader, + BookSideRows, + CumulativeRows, + CurrentPriceBar, + OrderBookSkeleton, + SummaryPanel, + TradeTape, +} from "./orderbook-sections"; interface OrderBookProps { symbol?: string; @@ -20,228 +31,10 @@ interface OrderBookProps { isLoading?: boolean; } -interface BookRow { - price: number; - size: number; - changeValue: number | null; - isHighlighted: boolean; -} - /** - * @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다. - * @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다. - */ -function hasOrderBookLevelData( - levels: DashboardStockOrderBookResponse["levels"], -) { - return levels.some( - (level) => - level.askPrice > 0 || - level.bidPrice > 0 || - level.askSize > 0 || - level.bidSize > 0, - ); -} - -/** - * @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다. - * @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다. - */ -function buildFallbackLevelsFromTick( - latestTick: DashboardRealtimeTradeTick | null, -) { - if (!latestTick) return [] as DashboardStockOrderBookResponse["levels"]; - if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) { - return [] as DashboardStockOrderBookResponse["levels"]; - } - - return [ - { - askPrice: latestTick.askPrice1, - bidPrice: latestTick.bidPrice1, - askSize: Math.max(latestTick.askSize1, 0), - bidSize: Math.max(latestTick.bidSize1, 0), - }, - ]; -} - -// ─── 유틸리티 함수 ────────────────────────────────────── - -/** 천단위 구분 포맷 */ -function fmt(v: number) { - return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0"; -} - -/** 부호 포함 퍼센트 */ -function fmtPct(v: number) { - return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`; -} - -/** 등락률 계산 */ -function pctChange(price: number, base: number) { - return base > 0 ? ((price - base) / base) * 100 : 0; -} - -/** - * @description 기준가 대비 증감값/증감률을 함께 계산합니다. - * @see features/trade/components/orderbook/OrderBook.tsx buildBookRows - */ -function resolvePriceChange(price: number, basePrice: number) { - if (price <= 0 || basePrice <= 0) { - return { changeValue: null } as const; - } - - const changeValue = price - basePrice; - - return { changeValue } as const; -} - -/** - * @description 증감 숫자를 부호 포함 문자열로 포맷합니다. - * @see features/trade/components/orderbook/OrderBook.tsx BookSideRows - */ -function fmtSignedChange(v: number) { - if (!Number.isFinite(v)) return "-"; - if (v > 0) return `+${fmt(v)}`; - if (v < 0) return `-${fmt(Math.abs(v))}`; - return "0"; -} - -/** - * @description 증감값에 따라 색상 톤 클래스를 반환합니다. - * @see features/trade/components/orderbook/OrderBook.tsx BookSideRows - */ -function getChangeToneClass( - changeValue: number | null, - neutralClass = "text-muted-foreground", -) { - if (changeValue === null) { - return neutralClass; - } - if (changeValue > 0) { - return "text-red-500"; - } - if (changeValue < 0) { - return "text-blue-600 dark:text-blue-400"; - } - return neutralClass; -} - -/** 체결 시각 포맷 */ -function fmtTime(hms: string) { - if (!hms || hms.length !== 6) return "--:--:--"; - return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`; -} - -/** - * @description 체결 틱 데이터에서 체결 주도 방향(매수/매도/중립)을 계산합니다. - * @see features/trade/components/orderbook/OrderBook.tsx TradeTape 체결량 글자색 결정에 사용합니다. - */ -function resolveTickExecutionSide( - tick: DashboardRealtimeTradeTick, - olderTick?: DashboardRealtimeTradeTick, -) { - // 실시간 체결구분 코드(CNTG_CLS_CODE) 우선 해석 - const executionClassCode = (tick.executionClassCode ?? "").trim(); - if (executionClassCode === "1" || executionClassCode === "2") { - return "buy" as const; - } - if (executionClassCode === "4" || executionClassCode === "5") { - return "sell" as const; - } - - // 누적 건수 기반 데이터는 절대값이 아니라 "증분"으로 판단해야 편향을 줄일 수 있습니다. - if (olderTick) { - const netBuyDelta = - tick.netBuyExecutionCount - olderTick.netBuyExecutionCount; - if (netBuyDelta > 0) return "buy" as const; - if (netBuyDelta < 0) return "sell" as const; - - const buyCountDelta = tick.buyExecutionCount - olderTick.buyExecutionCount; - const sellCountDelta = - tick.sellExecutionCount - olderTick.sellExecutionCount; - if (buyCountDelta > sellCountDelta) return "buy" as const; - if (buyCountDelta < sellCountDelta) return "sell" as const; - } - - if (tick.askPrice1 > 0 && tick.bidPrice1 > 0) { - if (tick.price >= tick.askPrice1 && tick.price > tick.bidPrice1) { - return "buy" as const; - } - if (tick.price <= tick.bidPrice1 && tick.price < tick.askPrice1) { - return "sell" as const; - } - } - - if (tick.tradeStrength > 100) return "buy" as const; - if (tick.tradeStrength < 100) return "sell" as const; - - return "neutral" as const; -} - -/** - * @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다. - * UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영 - * @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산 - */ -function buildBookRows({ - levels, - side, - basePrice, - latestPrice, -}: { - levels: DashboardStockOrderBookResponse["levels"]; - side: "ask" | "bid"; - basePrice: number; - latestPrice: number; -}) { - const normalizedLevels = side === "ask" ? [...levels].reverse() : levels; - - return normalizedLevels.map((level) => { - const price = side === "ask" ? level.askPrice : level.bidPrice; - const size = side === "ask" ? level.askSize : level.bidSize; - const { changeValue } = resolvePriceChange(price, basePrice); - - return { - price, - size: Math.max(size, 0), - changeValue, - isHighlighted: latestPrice > 0 && price === latestPrice, - } satisfies BookRow; - }); -} - -/** - * @description 호가/체결 증감 표시용 기준가(전일종가)를 결정합니다. - * @summary UI 흐름: OrderBook props -> resolveReferencePrice -> 호가행/체결가 증감 계산 반영 - * @see features/trade/components/orderbook/OrderBook.tsx basePrice 계산 - */ -function resolveReferencePrice({ - referencePrice, - latestTick, -}: { - referencePrice?: number; - latestTick: DashboardRealtimeTradeTick | null; -}) { - if ((referencePrice ?? 0) > 0) { - return referencePrice!; - } - - // referencePrice 미전달 케이스에서도 틱 데이터(price-change)로 전일종가를 역산합니다. - if (latestTick?.price && Number.isFinite(latestTick.change)) { - const derivedPrevClose = latestTick.price - latestTick.change; - if (derivedPrevClose > 0) { - return derivedPrevClose; - } - } - - return 0; -} - -// ─── 메인 컴포넌트 ────────────────────────────────────── - -/** - * 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다. + * @description 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다. + * @see features/trade/components/orderbook/orderbook-utils.ts 호가 계산/포맷 유틸 + * @see features/trade/components/orderbook/orderbook-sections.tsx 호가/체결/요약 UI 섹션 */ export function OrderBook({ symbol, @@ -256,21 +49,23 @@ export function OrderBook({ () => buildFallbackLevelsFromTick(latestTick), [latestTick], ); + const hasRealtimeLevelData = useMemo( + () => hasOrderBookLevelData(realtimeLevels), + [realtimeLevels], + ); const levels = useMemo(() => { - if (hasOrderBookLevelData(realtimeLevels)) return realtimeLevels; + if (hasRealtimeLevelData) return realtimeLevels; return fallbackLevelsFromTick; - }, [fallbackLevelsFromTick, realtimeLevels]); - const isTickFallbackActive = - !hasOrderBookLevelData(realtimeLevels) && fallbackLevelsFromTick.length > 0; + }, [fallbackLevelsFromTick, hasRealtimeLevelData, realtimeLevels]); + + const isTickFallbackActive = + !hasRealtimeLevelData && fallbackLevelsFromTick.length > 0; - // 체결가: tick에서 우선, 없으면 0 const latestPrice = latestTick?.price && latestTick.price > 0 ? latestTick.price : 0; - // 등락률 기준가 const basePrice = resolveReferencePrice({ referencePrice, latestTick }); - // 매도호가 (역순: 10호가 → 1호가) const askRows: BookRow[] = useMemo( () => buildBookRows({ @@ -282,7 +77,6 @@ export function OrderBook({ [levels, basePrice, latestPrice], ); - // 매수호가 (1호가 → 10호가) const bidRows: BookRow[] = useMemo( () => buildBookRows({ @@ -294,31 +88,42 @@ export function OrderBook({ [levels, basePrice, latestPrice], ); - const askMax = Math.max(1, ...askRows.map((r) => r.size)); - const bidMax = Math.max(1, ...bidRows.map((r) => r.size)); + const askMax = useMemo(() => Math.max(1, ...askRows.map((r) => r.size)), [askRows]); + const bidMax = useMemo(() => Math.max(1, ...bidRows.map((r) => r.size)), [bidRows]); const mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]); const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]); - // 스프레드·수급 불균형 - const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0; - const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0; - const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0; - const totalAsk = - orderBook?.totalAskSize && orderBook.totalAskSize > 0 - ? orderBook.totalAskSize - : (latestTick?.totalAskSize ?? 0); - const totalBid = - orderBook?.totalBidSize && orderBook.totalBidSize > 0 - ? orderBook.totalBidSize - : (latestTick?.totalBidSize ?? 0); - const imbalance = - totalAsk + totalBid > 0 - ? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100 - : 0; + const { bestAsk, spread, totalAsk, totalBid, imbalance } = useMemo(() => { + const resolvedBestAsk = levels.find((level) => level.askPrice > 0)?.askPrice ?? 0; + const resolvedBestBid = levels.find((level) => level.bidPrice > 0)?.bidPrice ?? 0; + const resolvedSpread = + resolvedBestAsk > 0 && resolvedBestBid > 0 + ? resolvedBestAsk - resolvedBestBid + : 0; + const resolvedTotalAsk = + orderBook?.totalAskSize && orderBook.totalAskSize > 0 + ? orderBook.totalAskSize + : (latestTick?.totalAskSize ?? 0); + const resolvedTotalBid = + orderBook?.totalBidSize && orderBook.totalBidSize > 0 + ? orderBook.totalBidSize + : (latestTick?.totalBidSize ?? 0); + const resolvedImbalance = + resolvedTotalAsk + resolvedTotalBid > 0 + ? ((resolvedTotalBid - resolvedTotalAsk) / + (resolvedTotalAsk + resolvedTotalBid)) * + 100 + : 0; - // 체결가 행 중앙 스크롤 + return { + bestAsk: resolvedBestAsk, + spread: resolvedSpread, + totalAsk: resolvedTotalAsk, + totalBid: resolvedTotalBid, + imbalance: resolvedImbalance, + }; + }, [latestTick?.totalAskSize, latestTick?.totalBidSize, levels, orderBook]); - // ─── 빈/로딩 상태 ─── if (!symbol) { return (
@@ -340,7 +145,7 @@ export function OrderBook({ return (
- {/* 탭 헤더 */} + {/* ========== ORDERBOOK TAB HEADER ========== */}
@@ -355,10 +160,9 @@ export function OrderBook({
- {/* ── 일반호가 탭 ── */} + {/* ========== ORDERBOOK NORMAL TAB ========== */}
- {/* 호가 테이블 */}
{isTickFallbackActive && (
@@ -368,7 +172,6 @@ export function OrderBook({ )}
- {/* 모바일: 양방향 호가가 항상 보이도록 6호가씩 고정 노출 */}
- {/* 데스크톱: 전체 호가 스크롤 */}
- {/* 체결량 영역 */}
- {/* 실시간 정보 영역 */}
- {/* ── 누적호가 탭 ── */} + {/* ========== ORDERBOOK CUMULATIVE TAB ========== */}
@@ -430,7 +230,7 @@ export function OrderBook({ - {/* ── 호가주문 탭 ── */} + {/* ========== ORDERBOOK ORDER TAB ========== */}
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다. @@ -440,454 +240,3 @@ export function OrderBook({
); } - -// ─── 하위 컴포넌트 ────────────────────────────────────── - -/** - * @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다. - * @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시 - * @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행 - */ -function CurrentPriceBar({ - latestPrice, - basePrice, - bestAsk, - totalAsk, - totalBid, -}: { - latestPrice: number; - basePrice: number; - bestAsk: number; - totalAsk: number; - totalBid: number; -}) { - return ( -
-
- {totalAsk > 0 ? fmt(totalAsk) : ""} -
-
- 0 && basePrice > 0 - ? latestPrice >= basePrice - ? "text-red-600" - : "text-blue-600 dark:text-blue-400" - : "text-foreground dark:text-brand-50", - )} - > - {latestPrice > 0 ? fmt(latestPrice) : bestAsk > 0 ? fmt(bestAsk) : "-"} - - {latestPrice > 0 && basePrice > 0 && ( - = basePrice - ? "text-red-500" - : "text-blue-600 dark:text-blue-400", - )} - > - {fmtPct(pctChange(latestPrice, basePrice))} - - )} -
-
- {totalBid > 0 ? fmt(totalBid) : ""} -
-
- ); -} - -/** 호가 표 헤더 */ -function BookHeader() { - return ( -
-
- 매도잔량 -
-
- 호가 -
-
- 매수잔량 -
-
- ); -} - -/** 매도 또는 매수 호가 행 목록 */ -function BookSideRows({ - rows, - side, - maxSize, -}: { - rows: BookRow[]; - side: "ask" | "bid"; - maxSize: number; -}) { - const isAsk = side === "ask"; - - return ( -
- {rows.map((row, i) => { - const ratio = - maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0; - - return ( -
- {/* 매도잔량 (좌측) */} -
- {isAsk && ( - <> - - {row.size > 0 ? ( - - ) : ( - 0 - )} - - )} -
- - {/* 호가 (중앙) */} -
- - {row.price > 0 ? fmt(row.price) : "-"} - - - {row.changeValue === null - ? "-" - : fmtSignedChange(row.changeValue)} - -
- - {/* 매수잔량 (우측) */} -
- {!isAsk && ( - <> - - {row.size > 0 ? ( - - ) : ( - 0 - )} - - )} -
-
- ); - })} -
- ); -} - -/** 우측 요약 패널 */ -function SummaryPanel({ - orderBook, - latestTick, - spread, - imbalance, - totalAsk, - totalBid, -}: { - orderBook: DashboardStockOrderBookResponse | null; - latestTick: DashboardRealtimeTradeTick | null; - spread: number; - imbalance: number; - totalAsk: number; - totalBid: number; -}) { - const displayTradeVolume = - latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0 - ? (orderBook?.anticipatedVolume ?? 0) - : (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0); - const summaryItems: SummaryMetric[] = [ - { - label: "실시간", - value: orderBook || latestTick ? "연결됨" : "끊김", - tone: orderBook || latestTick ? "ask" : undefined, - }, - { label: "거래량", value: fmt(displayTradeVolume) }, - { - label: "누적거래량", - value: fmt( - latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0, - ), - }, - { - label: "체결강도", - value: latestTick - ? `${latestTick.tradeStrength.toFixed(2)}%` - : orderBook?.anticipatedChangeRate !== undefined - ? `${orderBook.anticipatedChangeRate.toFixed(2)}%` - : "-", - }, - { label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) }, - { - label: "매도1호가", - value: latestTick ? fmt(latestTick.askPrice1) : "-", - tone: "ask", - }, - { - label: "매수1호가", - value: latestTick ? fmt(latestTick.bidPrice1) : "-", - tone: "bid", - }, - { - label: "순매수체결", - value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-", - }, - { label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" }, - { label: "총 매수잔량", value: fmt(totalBid), tone: "bid" }, - { label: "스프레드", value: fmt(spread) }, - { - label: "수급 불균형", - value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`, - tone: imbalance >= 0 ? "bid" : "ask", - }, - ]; - - return ( -
-
- {summaryItems.map((item) => ( - - ))} -
-
- ); -} - -interface SummaryMetric { - label: string; - value: string; - tone?: "ask" | "bid"; -} - -/** - * @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다. - * @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시 - * @see features/trade/components/orderbook/OrderBook.tsx SummaryPanel summaryItems - */ -function SummaryMetricCell({ - label, - value, - tone, -}: { - label: string; - value: string; - tone?: "ask" | "bid"; -}) { - return ( -
- - {label} - - - {value} - -
- ); -} - -/** 잔량 깊이 바 */ -function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) { - if (ratio <= 0) return null; - return ( -
- ); -} - -/** 체결 목록 (Trade Tape) */ -function TradeTape({ - ticks, - maxRows, -}: { - ticks: DashboardRealtimeTradeTick[]; - maxRows?: number; -}) { - const visibleTicks = typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks; - const shouldUseScrollableList = typeof maxRows !== "number"; - - const tapeRows = ( -
- {visibleTicks.length === 0 && ( -
- 체결 데이터가 아직 없습니다. -
- )} - {visibleTicks.map((t, i) => { - const olderTick = visibleTicks[i + 1]; - const executionSide = resolveTickExecutionSide(t, olderTick); - // UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영 - const volumeToneClass = - executionSide === "buy" - ? "text-red-600" - : executionSide === "sell" - ? "text-blue-600 dark:text-blue-400" - : "text-muted-foreground dark:text-brand-100/70"; - - return ( -
-
- {fmtTime(t.tickTime)} -
-
- {fmt(t.price)} -
-
- {fmt(t.tradeVolume)} -
-
- ); - })} -
- ); - - return ( -
-
-
체결시각
-
체결가
-
체결량
-
- {shouldUseScrollableList ? ( - {tapeRows} - ) : ( - tapeRows - )} -
- ); -} - -/** 누적호가 행 */ -function CumulativeRows({ asks, bids }: { asks: BookRow[]; bids: BookRow[] }) { - const rows = useMemo(() => { - const len = Math.max(asks.length, bids.length); - const result: { askAcc: number; bidAcc: number; price: number }[] = []; - for (let i = 0; i < len; i++) { - const prevAsk = result[i - 1]?.askAcc ?? 0; - const prevBid = result[i - 1]?.bidAcc ?? 0; - result.push({ - askAcc: prevAsk + (asks[i]?.size ?? 0), - bidAcc: prevBid + (bids[i]?.size ?? 0), - price: asks[i]?.price || bids[i]?.price || 0, - }); - } - return result; - }, [asks, bids]); - - return ( -
- {rows.map((r, i) => ( -
- {fmt(r.askAcc)} - - {fmt(r.price)} - - - {fmt(r.bidAcc)} - -
- ))} -
- ); -} - -/** 로딩 스켈레톤 */ -function OrderBookSkeleton() { - return ( -
-
- - - -
-
- {Array.from({ length: 16 }).map((_, i) => ( - - ))} -
-
- ); -} diff --git a/features/trade/components/orderbook/orderbook-sections.tsx b/features/trade/components/orderbook/orderbook-sections.tsx new file mode 100644 index 0000000..d890241 --- /dev/null +++ b/features/trade/components/orderbook/orderbook-sections.tsx @@ -0,0 +1,473 @@ +import { useMemo } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + DashboardRealtimeTradeTick, + DashboardStockOrderBookResponse, +} from "@/features/trade/types/trade.types"; +import { cn } from "@/lib/utils"; +import { AnimatedQuantity } from "./AnimatedQuantity"; +import type { BookRow } from "./orderbook-utils"; +import { + fmt, + fmtPct, + fmtSignedChange, + fmtTime, + getChangeToneClass, + pctChange, + resolveTickExecutionSide, +} from "./orderbook-utils"; + +/** + * @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다. + * @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시 + * @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행 + */ +export function CurrentPriceBar({ + latestPrice, + basePrice, + bestAsk, + totalAsk, + totalBid, +}: { + latestPrice: number; + basePrice: number; + bestAsk: number; + totalAsk: number; + totalBid: number; +}) { + return ( +
+
+ {totalAsk > 0 ? fmt(totalAsk) : ""} +
+
+ 0 && basePrice > 0 + ? latestPrice >= basePrice + ? "text-red-600" + : "text-blue-600 dark:text-blue-400" + : "text-foreground dark:text-brand-50", + )} + > + {latestPrice > 0 ? fmt(latestPrice) : bestAsk > 0 ? fmt(bestAsk) : "-"} + + {latestPrice > 0 && basePrice > 0 && ( + = basePrice + ? "text-red-500" + : "text-blue-600 dark:text-blue-400", + )} + > + {fmtPct(pctChange(latestPrice, basePrice))} + + )} +
+
+ {totalBid > 0 ? fmt(totalBid) : ""} +
+
+ ); +} + +/** 호가 표 헤더 */ +export function BookHeader() { + return ( +
+
+ 매도잔량 +
+
+ 호가 +
+
+ 매수잔량 +
+
+ ); +} + +/** 매도 또는 매수 호가 행 목록 */ +export function BookSideRows({ + rows, + side, + maxSize, +}: { + rows: BookRow[]; + side: "ask" | "bid"; + maxSize: number; +}) { + const isAsk = side === "ask"; + + return ( +
+ {rows.map((row, i) => { + const ratio = + maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0; + + return ( +
+
+ {isAsk && ( + <> + + {row.size > 0 ? ( + + ) : ( + 0 + )} + + )} +
+ +
+ + {row.price > 0 ? fmt(row.price) : "-"} + + + {row.changeValue === null + ? "-" + : fmtSignedChange(row.changeValue)} + +
+ +
+ {!isAsk && ( + <> + + {row.size > 0 ? ( + + ) : ( + 0 + )} + + )} +
+
+ ); + })} +
+ ); +} + +/** 우측 요약 패널 */ +export function SummaryPanel({ + orderBook, + latestTick, + spread, + imbalance, + totalAsk, + totalBid, +}: { + orderBook: DashboardStockOrderBookResponse | null; + latestTick: DashboardRealtimeTradeTick | null; + spread: number; + imbalance: number; + totalAsk: number; + totalBid: number; +}) { + const displayTradeVolume = + latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0 + ? (orderBook?.anticipatedVolume ?? 0) + : (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0); + const summaryItems: SummaryMetric[] = [ + { + label: "실시간", + value: orderBook || latestTick ? "연결됨" : "끊김", + tone: orderBook || latestTick ? "ask" : undefined, + }, + { label: "거래량", value: fmt(displayTradeVolume) }, + { + label: "누적거래량", + value: fmt( + latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0, + ), + }, + { + label: "체결강도", + value: latestTick + ? `${latestTick.tradeStrength.toFixed(2)}%` + : orderBook?.anticipatedChangeRate !== undefined + ? `${orderBook.anticipatedChangeRate.toFixed(2)}%` + : "-", + }, + { label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) }, + { + label: "매도1호가", + value: latestTick ? fmt(latestTick.askPrice1) : "-", + tone: "ask", + }, + { + label: "매수1호가", + value: latestTick ? fmt(latestTick.bidPrice1) : "-", + tone: "bid", + }, + { + label: "순매수체결", + value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-", + }, + { label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" }, + { label: "총 매수잔량", value: fmt(totalBid), tone: "bid" }, + { label: "스프레드", value: fmt(spread) }, + { + label: "수급 불균형", + value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`, + tone: imbalance >= 0 ? "bid" : "ask", + }, + ]; + + return ( +
+
+ {summaryItems.map((item) => ( + + ))} +
+
+ ); +} + +/** 체결 목록 (Trade Tape) */ +export function TradeTape({ + ticks, + maxRows, +}: { + ticks: DashboardRealtimeTradeTick[]; + maxRows?: number; +}) { + const visibleTicks = + typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks; + const shouldUseScrollableList = typeof maxRows !== "number"; + + const tapeRows = ( +
+ {visibleTicks.length === 0 && ( +
+ 체결 데이터가 아직 없습니다. +
+ )} + {visibleTicks.map((t, i) => { + const olderTick = visibleTicks[i + 1]; + const executionSide = resolveTickExecutionSide(t, olderTick); + const volumeToneClass = + executionSide === "buy" + ? "text-red-600" + : executionSide === "sell" + ? "text-blue-600 dark:text-blue-400" + : "text-muted-foreground dark:text-brand-100/70"; + + return ( +
+
+ {fmtTime(t.tickTime)} +
+
+ {fmt(t.price)} +
+
+ {fmt(t.tradeVolume)} +
+
+ ); + })} +
+ ); + + return ( +
+
+
체결시각
+
체결가
+
체결량
+
+ {shouldUseScrollableList ? ( + {tapeRows} + ) : ( + tapeRows + )} +
+ ); +} + +/** 누적호가 행 */ +export function CumulativeRows({ + asks, + bids, +}: { + asks: BookRow[]; + bids: BookRow[]; +}) { + const rows = useMemo(() => { + const len = Math.max(asks.length, bids.length); + const result: { askAcc: number; bidAcc: number; price: number }[] = []; + for (let i = 0; i < len; i += 1) { + const prevAsk = result[i - 1]?.askAcc ?? 0; + const prevBid = result[i - 1]?.bidAcc ?? 0; + result.push({ + askAcc: prevAsk + (asks[i]?.size ?? 0), + bidAcc: prevBid + (bids[i]?.size ?? 0), + price: asks[i]?.price || bids[i]?.price || 0, + }); + } + return result; + }, [asks, bids]); + + return ( +
+ {rows.map((r, i) => ( +
+ + {fmt(r.askAcc)} + + + {fmt(r.price)} + + + {fmt(r.bidAcc)} + +
+ ))} +
+ ); +} + +/** 로딩 스켈레톤 */ +export function OrderBookSkeleton() { + return ( +
+
+ + + +
+
+ {Array.from({ length: 16 }).map((_, i) => ( + + ))} +
+
+ ); +} + +interface SummaryMetric { + label: string; + value: string; + tone?: "ask" | "bid"; +} + +/** + * @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다. + * @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시 + * @see features/trade/components/orderbook/orderbook-sections.tsx SummaryPanel summaryItems + */ +function SummaryMetricCell({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "ask" | "bid"; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +/** 잔량 깊이 바 */ +function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) { + if (ratio <= 0) return null; + return ( +
+ ); +} diff --git a/features/trade/components/orderbook/orderbook-utils.ts b/features/trade/components/orderbook/orderbook-utils.ts new file mode 100644 index 0000000..3f00312 --- /dev/null +++ b/features/trade/components/orderbook/orderbook-utils.ts @@ -0,0 +1,210 @@ +import type { + DashboardRealtimeTradeTick, + DashboardStockOrderBookResponse, +} from "@/features/trade/types/trade.types"; + +type OrderBookLevels = DashboardStockOrderBookResponse["levels"]; + +export interface BookRow { + price: number; + size: number; + changeValue: number | null; + isHighlighted: boolean; +} + +/** + * @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다. + * @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다. + */ +export function hasOrderBookLevelData(levels: OrderBookLevels) { + return levels.some( + (level) => + level.askPrice > 0 || + level.bidPrice > 0 || + level.askSize > 0 || + level.bidSize > 0, + ); +} + +/** + * @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다. + * @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다. + */ +export function buildFallbackLevelsFromTick( + latestTick: DashboardRealtimeTradeTick | null, +) { + if (!latestTick) return [] as OrderBookLevels; + if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) { + return [] as OrderBookLevels; + } + + return [ + { + askPrice: latestTick.askPrice1, + bidPrice: latestTick.bidPrice1, + askSize: Math.max(latestTick.askSize1, 0), + bidSize: Math.max(latestTick.bidSize1, 0), + }, + ] satisfies OrderBookLevels; +} + +/** 천단위 구분 포맷 */ +export function fmt(v: number) { + return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0"; +} + +/** 부호 포함 퍼센트 */ +export function fmtPct(v: number) { + return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`; +} + +/** 등락률 계산 */ +export function pctChange(price: number, base: number) { + return base > 0 ? ((price - base) / base) * 100 : 0; +} + +/** + * @description 증감 숫자를 부호 포함 문자열로 포맷합니다. + * @see features/trade/components/orderbook/orderbook-sections.tsx BookSideRows + */ +export function fmtSignedChange(v: number) { + if (!Number.isFinite(v)) return "-"; + if (v > 0) return `+${fmt(v)}`; + if (v < 0) return `-${fmt(Math.abs(v))}`; + return "0"; +} + +/** + * @description 증감값에 따라 색상 톤 클래스를 반환합니다. + * @see features/trade/components/orderbook/orderbook-sections.tsx BookSideRows + */ +export function getChangeToneClass( + changeValue: number | null, + neutralClass = "text-muted-foreground", +) { + if (changeValue === null) { + return neutralClass; + } + if (changeValue > 0) { + return "text-red-500"; + } + if (changeValue < 0) { + return "text-blue-600 dark:text-blue-400"; + } + return neutralClass; +} + +/** 체결 시각 포맷 */ +export function fmtTime(hms: string) { + if (!hms || hms.length !== 6) return "--:--:--"; + return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`; +} + +/** + * @description 체결 틱 데이터에서 체결 주도 방향(매수/매도/중립)을 계산합니다. + * @see features/trade/components/orderbook/orderbook-sections.tsx TradeTape 체결량 글자색 결정에 사용합니다. + */ +export function resolveTickExecutionSide( + tick: DashboardRealtimeTradeTick, + olderTick?: DashboardRealtimeTradeTick, +) { + const executionClassCode = (tick.executionClassCode ?? "").trim(); + if (executionClassCode === "1" || executionClassCode === "2") { + return "buy" as const; + } + if (executionClassCode === "4" || executionClassCode === "5") { + return "sell" as const; + } + + if (olderTick) { + const netBuyDelta = + tick.netBuyExecutionCount - olderTick.netBuyExecutionCount; + if (netBuyDelta > 0) return "buy" as const; + if (netBuyDelta < 0) return "sell" as const; + + const buyCountDelta = tick.buyExecutionCount - olderTick.buyExecutionCount; + const sellCountDelta = + tick.sellExecutionCount - olderTick.sellExecutionCount; + if (buyCountDelta > sellCountDelta) return "buy" as const; + if (buyCountDelta < sellCountDelta) return "sell" as const; + } + + if (tick.askPrice1 > 0 && tick.bidPrice1 > 0) { + if (tick.price >= tick.askPrice1 && tick.price > tick.bidPrice1) { + return "buy" as const; + } + if (tick.price <= tick.bidPrice1 && tick.price < tick.askPrice1) { + return "sell" as const; + } + } + + if (tick.tradeStrength > 100) return "buy" as const; + if (tick.tradeStrength < 100) return "sell" as const; + + return "neutral" as const; +} + +/** + * @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다. + * @summary UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영 + * @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산 + */ +export function buildBookRows({ + levels, + side, + basePrice, + latestPrice, +}: { + levels: OrderBookLevels; + side: "ask" | "bid"; + basePrice: number; + latestPrice: number; +}) { + const normalizedLevels = side === "ask" ? [...levels].reverse() : levels; + + return normalizedLevels.map((level) => { + const price = side === "ask" ? level.askPrice : level.bidPrice; + const size = side === "ask" ? level.askSize : level.bidSize; + const changeValue = resolvePriceChange(price, basePrice); + + return { + price, + size: Math.max(size, 0), + changeValue, + isHighlighted: latestPrice > 0 && price === latestPrice, + } satisfies BookRow; + }); +} + +/** + * @description 호가/체결 증감 표시용 기준가(전일종가)를 결정합니다. + * @summary UI 흐름: OrderBook props -> resolveReferencePrice -> 호가행/체결가 증감 계산 반영 + * @see features/trade/components/orderbook/OrderBook.tsx basePrice 계산 + */ +export function resolveReferencePrice({ + referencePrice, + latestTick, +}: { + referencePrice?: number; + latestTick: DashboardRealtimeTradeTick | null; +}) { + if ((referencePrice ?? 0) > 0) { + return referencePrice!; + } + + if (latestTick?.price && Number.isFinite(latestTick.change)) { + const derivedPrevClose = latestTick.price - latestTick.change; + if (derivedPrevClose > 0) { + return derivedPrevClose; + } + } + + return 0; +} + +function resolvePriceChange(price: number, basePrice: number) { + if (price <= 0 || basePrice <= 0) { + return null; + } + return price - basePrice; +} diff --git a/features/trade/data/mock-stocks.ts b/features/trade/data/mock-stocks.ts deleted file mode 100644 index d74850f..0000000 --- a/features/trade/data/mock-stocks.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @file features/trade/data/mock-stocks.ts - * @description 대시보드 1단계 UI 검증용 목업 종목 데이터 - * @remarks - * - 한국투자증권 API 연동 전까지 화면 동작 검증에 사용합니다. - * - 2단계 이후 실제 화면은 app/api/kis/* 응답을 사용합니다. - * - 현재는 레거시/비교용 샘플 데이터로만 남겨둔 상태입니다. - */ - -import type { DashboardStockItem } from "@/features/trade/types/trade.types"; - -/** - * 대시보드 목업 종목 목록 - * @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 통해 이 데이터를 조회합니다. - * @see features/trade/components/dashboard-main.tsx 검색/차트/지표 카드의 기본 데이터 소스입니다. - */ -export const MOCK_STOCKS: DashboardStockItem[] = [ - { - symbol: "005930", - name: "삼성전자", - market: "KOSPI", - currentPrice: 78500, - change: 1200, - changeRate: 1.55, - open: 77300, - high: 78900, - low: 77000, - prevClose: 77300, - volume: 15234012, - candles: [ - { time: "09:00", price: 74400 }, - { time: "09:10", price: 74650 }, - { time: "09:20", price: 75100 }, - { time: "09:30", price: 74950 }, - { time: "09:40", price: 75300 }, - { time: "09:50", price: 75600 }, - { time: "10:00", price: 75400 }, - { time: "10:10", price: 75850 }, - { time: "10:20", price: 76100 }, - { time: "10:30", price: 75950 }, - { time: "10:40", price: 76350 }, - { time: "10:50", price: 76700 }, - { time: "11:00", price: 76900 }, - { time: "11:10", price: 77250 }, - { time: "11:20", price: 77100 }, - { time: "11:30", price: 77400 }, - { time: "11:40", price: 77700 }, - { time: "11:50", price: 78150 }, - { time: "12:00", price: 77900 }, - { time: "12:10", price: 78300 }, - { time: "12:20", price: 78500 }, - ], - }, - { - symbol: "000660", - name: "SK하이닉스", - market: "KOSPI", - currentPrice: 214500, - change: -1500, - changeRate: -0.69, - open: 216000, - high: 218000, - low: 213000, - prevClose: 216000, - volume: 3210450, - candles: [ - { time: "09:00", price: 221000 }, - { time: "09:10", price: 220400 }, - { time: "09:20", price: 219900 }, - { time: "09:30", price: 220200 }, - { time: "09:40", price: 219300 }, - { time: "09:50", price: 218500 }, - { time: "10:00", price: 217900 }, - { time: "10:10", price: 218300 }, - { time: "10:20", price: 217600 }, - { time: "10:30", price: 216900 }, - { time: "10:40", price: 216500 }, - { time: "10:50", price: 216800 }, - { time: "11:00", price: 215900 }, - { time: "11:10", price: 215300 }, - { time: "11:20", price: 214800 }, - { time: "11:30", price: 215100 }, - { time: "11:40", price: 214200 }, - { time: "11:50", price: 214700 }, - { time: "12:00", price: 214300 }, - { time: "12:10", price: 214600 }, - { time: "12:20", price: 214500 }, - ], - }, - { - symbol: "035420", - name: "NAVER", - market: "KOSPI", - currentPrice: 197800, - change: 2200, - changeRate: 1.12, - open: 195500, - high: 198600, - low: 194900, - prevClose: 195600, - volume: 1904123, - candles: [ - { time: "09:00", price: 191800 }, - { time: "09:10", price: 192400 }, - { time: "09:20", price: 193000 }, - { time: "09:30", price: 192700 }, - { time: "09:40", price: 193600 }, - { time: "09:50", price: 194200 }, - { time: "10:00", price: 194000 }, - { time: "10:10", price: 194900 }, - { time: "10:20", price: 195100 }, - { time: "10:30", price: 194700 }, - { time: "10:40", price: 195800 }, - { time: "10:50", price: 196400 }, - { time: "11:00", price: 196100 }, - { time: "11:10", price: 196900 }, - { time: "11:20", price: 197200 }, - { time: "11:30", price: 197000 }, - { time: "11:40", price: 197600 }, - { time: "11:50", price: 198000 }, - { time: "12:00", price: 197400 }, - { time: "12:10", price: 198300 }, - { time: "12:20", price: 197800 }, - ], - }, -]; diff --git a/features/trade/hooks/useOrder.ts b/features/trade/hooks/useOrder.ts index 58ed26c..91536e7 100644 --- a/features/trade/hooks/useOrder.ts +++ b/features/trade/hooks/useOrder.ts @@ -1,10 +1,48 @@ import { useState, useCallback } from "react"; +import { z } from "zod"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { DashboardStockCashOrderRequest, DashboardStockCashOrderResponse, } from "@/features/trade/types/trade.types"; import { fetchOrderCash } from "@/features/trade/apis/kis-stock.api"; +import { parseKisAccountParts } from "@/lib/kis/account"; + +const placeOrderRequestSchema = z + .object({ + symbol: z.string().trim().regex(/^\d{6}$/), + side: z.enum(["buy", "sell"]), + orderType: z.enum(["limit", "market"]), + quantity: z.number().int().positive(), + price: z.number(), + accountNo: z.string().trim().min(1), + accountProductCode: z.string().trim().optional(), + }) + .superRefine((request, ctx) => { + if (request.orderType === "limit" && request.price <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["price"], + message: "지정가 주문은 가격이 0보다 커야 합니다.", + }); + } + + if (request.orderType === "market" && request.price < 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["price"], + message: "시장가 주문은 가격이 0 이상이어야 합니다.", + }); + } + + if (!parseKisAccountParts(request.accountNo, request.accountProductCode)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["accountNo"], + message: "계좌번호 형식이 올바르지 않습니다. (8-2)", + }); + } + }); export function useOrder() { const [isLoading, setIsLoading] = useState(false); @@ -28,6 +66,15 @@ export function useOrder() { setResult(null); try { + const validationResult = placeOrderRequestSchema.safeParse(request); + if (!validationResult.success) { + setError( + validationResult.error.issues[0]?.message ?? + "주문 요청 값이 올바르지 않습니다.", + ); + return null; + } + const data = await fetchOrderCash(request, credentials); setResult(data); return data; diff --git a/features/trade/types/trade.types.ts b/features/trade/types/trade.types.ts index f550c57..b4fc853 100644 --- a/features/trade/types/trade.types.ts +++ b/features/trade/types/trade.types.ts @@ -177,8 +177,17 @@ export interface DashboardStockCashOrderRequest { orderType: DashboardOrderType; quantity: number; price: number; + /** + * KIS 계좌번호(권장: 8-2, 예: 12345678-01) + * @see lib/kis/account.ts parseKisAccountParts 서버 주문 라우트에서 8-2 파싱에 사용합니다. + */ accountNo: string; - accountProductCode: string; + /** + * 계좌상품코드(2자리, 선택) + * @description accountNo가 8-2 형식이면 서버에서 자동 파싱합니다. + * @see app/api/kis/domestic/order-cash/route.ts 주문 요청 검증/계좌 파싱 + */ + accountProductCode?: string; } /** diff --git a/hooks/queries/use-user-query.ts b/hooks/queries/use-user-query.ts deleted file mode 100644 index 9cb4c00..0000000 --- a/hooks/queries/use-user-query.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { createClient } from "@/utils/supabase/client"; - -/** - * [사용자 정보 조회 쿼리] - * - * 현재 로그인한 사용자의 정보를 조회합니다. - * - 자동 캐싱 및 재검증 - * - 로딩/에러 상태 자동 관리 - * - * @example - * ```tsx - * import { useUserQuery } from '@/hooks/queries/use-user-query'; - * - * function Profile() { - * const { data: user, isLoading, error } = useUserQuery(); - * - * if (isLoading) return
Loading...
; - * if (error) return
Error: {error.message}
; - * if (!user) return
Not logged in
; - * - * return
Welcome, {user.email}
; - * } - * ``` - */ -export function useUserQuery() { - return useQuery({ - queryKey: ["user"], - queryFn: async () => { - const supabase = createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error) throw error; - return user; - }, - staleTime: 5 * 60 * 1000, // 5분 - 사용자 정보는 자주 변경되지 않음 - retry: 1, - }); -} diff --git a/lib/kis/approval.ts b/lib/kis/approval.ts index 36211a8..14a4cf2 100644 --- a/lib/kis/approval.ts +++ b/lib/kis/approval.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { buildKisErrorDetail } from "@/lib/kis/error-codes"; import type { KisCredentialInput } from "@/lib/kis/config"; import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config"; @@ -60,9 +61,11 @@ async function issueKisApprovalKey( const payload = tryParseApprovalResponse(rawText); if (!response.ok || !payload.approval_key) { - const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd] - .filter(Boolean) - .join(" / "); + const detail = buildKisErrorDetail({ + message: payload.msg1, + msgCode: payload.msg_cd, + extraMessages: [payload.error_description, payload.error], + }); throw new Error( detail diff --git a/lib/kis/client.ts b/lib/kis/client.ts index fec2253..eeb18c4 100644 --- a/lib/kis/client.ts +++ b/lib/kis/client.ts @@ -1,5 +1,6 @@ import type { KisCredentialInput } from "@/lib/kis/config"; import { getKisConfig } from "@/lib/kis/config"; +import { buildKisErrorDetail } from "@/lib/kis/error-codes"; import { getKisAccessToken } from "@/lib/kis/token"; /** @@ -57,7 +58,11 @@ export async function kisGet( const payload = tryParseKisEnvelope(rawText); if (!response.ok) { - const detail = payload.msg1 || rawText.slice(0, 200); + const detail = buildKisErrorDetail({ + message: payload.msg1, + msgCode: payload.msg_cd, + extraMessages: payload.msg1 ? [] : [rawText.slice(0, 200)], + }); throw new Error( detail ? `KIS API 요청 실패 (${response.status}): ${detail}` @@ -66,7 +71,10 @@ export async function kisGet( } if (payload.rt_cd && payload.rt_cd !== "0") { - const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / "); + const detail = buildKisErrorDetail({ + message: payload.msg1, + msgCode: payload.msg_cd, + }); throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다."); } @@ -112,7 +120,11 @@ export async function kisPost( const payload = tryParseKisEnvelope(rawText); if (!response.ok) { - const detail = payload.msg1 || rawText.slice(0, 200); + const detail = buildKisErrorDetail({ + message: payload.msg1, + msgCode: payload.msg_cd, + extraMessages: payload.msg1 ? [] : [rawText.slice(0, 200)], + }); throw new Error( detail ? `KIS API 요청 실패 (${response.status}): ${detail}` @@ -121,7 +133,10 @@ export async function kisPost( } if (payload.rt_cd && payload.rt_cd !== "0") { - const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / "); + const detail = buildKisErrorDetail({ + message: payload.msg1, + msgCode: payload.msg_cd, + }); throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다."); } diff --git a/lib/kis/dashboard-helpers.ts b/lib/kis/dashboard-helpers.ts new file mode 100644 index 0000000..fd72639 --- /dev/null +++ b/lib/kis/dashboard-helpers.ts @@ -0,0 +1,269 @@ +/** + * @file lib/kis/dashboard-helpers.ts + * @description 대시보드 계산/포맷 공통 헬퍼 모음 + */ + +/** + * @description 기준일 기준 N일 범위의 YYYYMMDD 기간을 반환합니다. + * @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal 조회 기간 계산 + */ +export function getLookbackRangeYmd(lookbackDays: number) { + const end = new Date(); + const start = new Date(end); + start.setDate(end.getDate() - lookbackDays); + + return { + startDate: formatYmd(start), + endDate: formatYmd(end), + }; +} + +/** + * @description Date를 YYYYMMDD 문자열로 변환합니다. + * @see lib/kis/dashboard-helpers.ts getLookbackRangeYmd + */ +export function formatYmd(date: Date) { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; +} + +/** + * @description 문자열에서 숫자만 추출합니다. + * @see lib/kis/dashboard.ts 주문일자/매매일자/시각 정규화 + */ +export function toDigits(value?: string) { + return (value ?? "").replace(/\D/g, ""); +} + +/** + * @description 주문 시각을 HHMMSS로 정규화합니다. + * @see lib/kis/dashboard.ts getDomesticOrderHistory 정렬/표시 시각 생성 + */ +export function normalizeTimeDigits(value?: string) { + const digits = toDigits(value); + if (!digits) return "000000"; + return digits.padEnd(6, "0").slice(0, 6); +} + +/** + * @description YYYYMMDD를 YYYY-MM-DD로 변환합니다. + * @see lib/kis/dashboard.ts 주문내역/매매일지 날짜 컬럼 표시 + */ +export function formatDateLabel(value: string) { + if (value.length !== 8) return "-"; + return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`; +} + +/** + * @description HHMMSS를 HH:MM:SS로 변환합니다. + * @see lib/kis/dashboard.ts 주문내역 시각 컬럼 표시 + */ +export function formatTimeLabel(value: string) { + if (value.length !== 6) return "-"; + return `${value.slice(0, 2)}:${value.slice(2, 4)}:${value.slice(4, 6)}`; +} + +/** + * @description KIS 매수/매도 코드를 공통 side 값으로 변환합니다. + * @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal + */ +export function parseTradeSide( + code?: string, + name?: string, +): "buy" | "sell" | "unknown" { + const normalizedCode = (code ?? "").trim(); + const normalizedName = (name ?? "").trim(); + + if (normalizedCode === "01") return "sell"; + if (normalizedCode === "02") return "buy"; + if (normalizedName.includes("매도")) return "sell"; + if (normalizedName.includes("매수")) return "buy"; + return "unknown"; +} + +/** + * @description 매매일지 요약 기본값을 반환합니다. + * @see lib/kis/dashboard.ts getDomesticDashboardActivity 매매일지 실패 폴백 + */ +export function createEmptyJournalSummary() { + return { + totalRealizedProfit: 0, + totalRealizedRate: 0, + totalBuyAmount: 0, + totalSellAmount: 0, + totalFee: 0, + totalTax: 0, + }; +} + +/** + * @description 문자열 숫자를 number로 변환합니다. + * @see lib/kis/dashboard.ts 잔고/지수 필드 공통 파싱 + */ +export function toNumber(value?: string) { + if (!value) return 0; + const normalized = value.replaceAll(",", "").trim(); + if (!normalized) return 0; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * @description 문자열 숫자를 number로 변환하되 0도 유효값으로 유지합니다. + * @see lib/kis/dashboard.ts 요약값 폴백 순서 계산 + */ +export function toOptionalNumber(value?: string) { + if (!value) return undefined; + const normalized = value.replaceAll(",", "").trim(); + if (!normalized) return undefined; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** + * @description output 계열 데이터를 배열 형태로 변환합니다. + * @see lib/kis/dashboard.ts 잔고 output1/output2 파싱 + */ +export function parseRows(value: unknown): T[] { + if (Array.isArray(value)) return value as T[]; + if (value && typeof value === "object") return [value as T]; + return []; +} + +/** + * @description output 계열 데이터의 첫 행을 반환합니다. + * @see lib/kis/dashboard.ts 잔고 요약(output2) 파싱 + */ +export function parseFirstRow(value: unknown) { + const rows = parseRows(value); + return rows[0]; +} + +/** + * @description 지수 output을 단일 레코드로 정규화합니다. + * @see lib/kis/dashboard.ts getDomesticDashboardIndices + */ +export function parseIndexRow( + output: unknown, +): T { + if (Array.isArray(output) && output[0] && typeof output[0] === "object") { + return output[0] as T; + } + if (output && typeof output === "object") { + return output as T; + } + return {} as T; +} + +/** + * @description KIS 부호 코드(1/2 상승, 4/5 하락)를 실제 부호로 반영합니다. + * @see lib/kis/dashboard.ts getDomesticDashboardIndices + */ +export function normalizeSignedValue(value: number, signCode?: string) { + const abs = Math.abs(value); + if (signCode === "4" || signCode === "5") return -abs; + if (signCode === "1" || signCode === "2") return abs; + return value; +} + +/** + * @description undefined가 아닌 첫 값을 반환합니다. + * @see lib/kis/dashboard.ts 요약 수익률/손익 폴백 계산 + */ +export function firstDefinedNumber(...values: Array) { + return values.find((value) => value !== undefined) ?? 0; +} + +/** + * @description 숫자 배열 합계를 계산합니다. + * @see lib/kis/dashboard.ts 보유종목 합계 계산 + */ +export function sumNumbers(values: number[]) { + return values.reduce((total, value) => total + value, 0); +} + +/** + * @description 총자산 대비 손익률을 계산합니다. + * @see lib/kis/dashboard.ts 요약 수익률 폴백 계산 + */ +export function calcProfitRate(profit: number, totalAmount: number) { + if (totalAmount <= 0) return 0; + const baseAmount = totalAmount - profit; + if (baseAmount <= 0) return 0; + return (profit / baseAmount) * 100; +} + +/** + * @description 매입금액 대비 손익률을 계산합니다. + * @see lib/kis/dashboard.ts getDomesticDashboardBalance 현재 손익률 산출 + */ +export function calcProfitRateByPurchase(profit: number, purchaseAmount: number) { + if (purchaseAmount <= 0) return 0; + return (profit / purchaseAmount) * 100; +} + +/** + * @description 총자산과 평가금액 기준으로 일관된 현금성 자산(예수금)을 계산합니다. + * @remarks UI 흐름: 대시보드 API 응답 생성 -> 총자산/평가금/예수금 카드 반영 + * @see lib/kis/dashboard.ts getDomesticDashboardBalance + */ +export function resolveCashBalance(params: { + apiReportedTotalAmount: number; + apiReportedNetAssetAmount: number; + evaluationAmount: number; + cashCandidates: Array; +}) { + const { + apiReportedTotalAmount, + apiReportedNetAssetAmount, + evaluationAmount, + cashCandidates, + } = params; + const referenceTotalAmount = pickPreferredAmount( + apiReportedNetAssetAmount, + apiReportedTotalAmount, + ); + const candidateCash = pickPreferredAmount(...cashCandidates); + const derivedCash = + referenceTotalAmount > 0 + ? Math.max(referenceTotalAmount - evaluationAmount, 0) + : undefined; + + if (derivedCash === undefined) return candidateCash; + + const recomposedWithCandidate = candidateCash + evaluationAmount; + const mismatchWithApi = Math.abs( + recomposedWithCandidate - referenceTotalAmount, + ); + if (mismatchWithApi >= 1) { + return derivedCash; + } + + return candidateCash; +} + +/** + * @description 금액 후보 중 양수 값을 우선 선택합니다. + * @see lib/kis/dashboard.ts getDomesticDashboardBalance 금액 필드 폴백 계산 + */ +export function pickPreferredAmount(...values: Array) { + const positive = values.find( + (value): value is number => value !== undefined && value > 0, + ); + if (positive !== undefined) return positive; + return firstDefinedNumber(...values); +} + +/** + * @description 숫자 후보 중 0이 아닌 값을 우선 선택합니다. + * @see lib/kis/dashboard.ts getDomesticDashboardBalance 손익/수익률 폴백 계산 + */ +export function pickNonZeroNumber(...values: Array) { + const nonZero = values.find( + (value): value is number => value !== undefined && value !== 0, + ); + if (nonZero !== undefined) return nonZero; + return firstDefinedNumber(...values); +} diff --git a/lib/kis/dashboard.ts b/lib/kis/dashboard.ts index d471c15..56ef931 100644 --- a/lib/kis/dashboard.ts +++ b/lib/kis/dashboard.ts @@ -8,6 +8,28 @@ import { kisGet } from "@/lib/kis/client"; import type { KisCredentialInput } from "@/lib/kis/config"; import { normalizeTradingEnv } from "@/lib/kis/config"; import type { KisAccountParts } from "@/lib/kis/account"; +import { + calcProfitRate, + calcProfitRateByPurchase, + createEmptyJournalSummary, + firstDefinedNumber, + formatDateLabel, + formatTimeLabel, + getLookbackRangeYmd, + normalizeSignedValue, + normalizeTimeDigits, + parseFirstRow, + parseIndexRow, + parseRows, + parseTradeSide, + pickNonZeroNumber, + pickPreferredAmount, + resolveCashBalance, + sumNumbers, + toDigits, + toNumber, + toOptionalNumber, +} from "@/lib/kis/dashboard-helpers"; interface KisBalanceOutput1Row { pdno?: string; @@ -478,7 +500,7 @@ export async function getDomesticDashboardIndices( credentials, ); - const row = parseIndexRow(response.output); + const row = parseIndexRow(response.output); const rawChange = toNumber(row.bstp_nmix_prdy_vrss); const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt); @@ -780,309 +802,3 @@ async function getDomesticTradeJournal( summary, }; } - -/** - * 기준일 기준 N일 범위의 YYYYMMDD 기간을 반환합니다. - * @param lookbackDays 과거 조회 일수 - * @returns 시작/종료 일자 - * @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal 조회 기간 계산 - */ -function getLookbackRangeYmd(lookbackDays: number) { - const end = new Date(); - const start = new Date(end); - start.setDate(end.getDate() - lookbackDays); - - return { - startDate: formatYmd(start), - endDate: formatYmd(end), - }; -} - -/** - * Date를 YYYYMMDD 문자열로 변환합니다. - * @param date 기준 일자 - * @returns YYYYMMDD - * @see lib/kis/dashboard.ts getLookbackRangeYmd - */ -function formatYmd(date: Date) { - const year = String(date.getFullYear()); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}${month}${day}`; -} - -/** - * 문자열에서 숫자만 추출합니다. - * @param value 원본 문자열 - * @returns 숫자 문자열 - * @see lib/kis/dashboard.ts 주문일자/매매일자/시각 정규화 - */ -function toDigits(value?: string) { - return (value ?? "").replace(/\D/g, ""); -} - -/** - * 주문 시각을 HHMMSS로 정규화합니다. - * @param value 시각 문자열 - * @returns 6자리 시각 문자열 - * @see lib/kis/dashboard.ts getDomesticOrderHistory 정렬/표시 시각 생성 - */ -function normalizeTimeDigits(value?: string) { - const digits = toDigits(value); - if (!digits) return "000000"; - return digits.padEnd(6, "0").slice(0, 6); -} - -/** - * YYYYMMDD를 YYYY-MM-DD로 변환합니다. - * @param value 날짜 문자열 - * @returns YYYY-MM-DD 또는 "-" - * @see lib/kis/dashboard.ts 주문내역/매매일지 날짜 컬럼 표시 - */ -function formatDateLabel(value: string) { - if (value.length !== 8) return "-"; - return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`; -} - -/** - * HHMMSS를 HH:MM:SS로 변환합니다. - * @param value 시각 문자열 - * @returns HH:MM:SS 또는 "-" - * @see lib/kis/dashboard.ts 주문내역 시각 컬럼 표시 - */ -function formatTimeLabel(value: string) { - if (value.length !== 6) return "-"; - return `${value.slice(0, 2)}:${value.slice(2, 4)}:${value.slice(4, 6)}`; -} - -/** - * KIS 매수/매도 코드를 공통 side 값으로 변환합니다. - * @param code 매수매도구분코드 - * @param name 매수매도구분명 또는 매매구분명 - * @returns buy/sell/unknown - * @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal - */ -function parseTradeSide(code?: string, name?: string): "buy" | "sell" | "unknown" { - const normalizedCode = (code ?? "").trim(); - const normalizedName = (name ?? "").trim(); - - if (normalizedCode === "01") return "sell"; - if (normalizedCode === "02") return "buy"; - if (normalizedName.includes("매도")) return "sell"; - if (normalizedName.includes("매수")) return "buy"; - return "unknown"; -} - -/** - * 매매일지 요약 기본값을 반환합니다. - * @returns 0으로 채운 요약 객체 - * @see lib/kis/dashboard.ts getDomesticDashboardActivity 매매일지 실패 폴백 - */ -function createEmptyJournalSummary(): DomesticTradeJournalSummary { - return { - totalRealizedProfit: 0, - totalRealizedRate: 0, - totalBuyAmount: 0, - totalSellAmount: 0, - totalFee: 0, - totalTax: 0, - }; -} - -/** - * 문자열 숫자를 number로 변환합니다. - * @param value KIS 숫자 문자열 - * @returns 파싱된 숫자(실패 시 0) - * @see lib/kis/dashboard.ts 잔고/지수 필드 공통 파싱 - */ -function toNumber(value?: string) { - if (!value) return 0; - const normalized = value.replaceAll(",", "").trim(); - if (!normalized) return 0; - const parsed = Number(normalized); - return Number.isFinite(parsed) ? parsed : 0; -} - -/** - * 문자열 숫자를 number로 변환하되 0도 유효값으로 유지합니다. - * @param value KIS 숫자 문자열 - * @returns 파싱된 숫자 또는 undefined - * @see lib/kis/dashboard.ts 요약값 폴백 순서 계산 - */ -function toOptionalNumber(value?: string) { - if (!value) return undefined; - const normalized = value.replaceAll(",", "").trim(); - if (!normalized) return undefined; - const parsed = Number(normalized); - return Number.isFinite(parsed) ? parsed : undefined; -} - -/** - * output 계열 데이터를 배열 형태로 변환합니다. - * @param value KIS output 값 - * @returns 레코드 배열 - * @see lib/kis/dashboard.ts 잔고 output1/output2 파싱 - */ -function parseRows(value: unknown): T[] { - if (Array.isArray(value)) return value as T[]; - if (value && typeof value === "object") return [value as T]; - return []; -} - -/** - * output 계열 데이터의 첫 행을 반환합니다. - * @param value KIS output 값 - * @returns 첫 번째 레코드 - * @see lib/kis/dashboard.ts 잔고 요약(output2) 파싱 - */ -function parseFirstRow(value: unknown) { - const rows = parseRows(value); - return rows[0]; -} - -/** - * 지수 output을 단일 레코드로 정규화합니다. - * @param output KIS output - * @returns 지수 레코드 - * @see lib/kis/dashboard.ts getDomesticDashboardIndices - */ -function parseIndexRow(output: unknown): KisIndexOutputRow { - if (Array.isArray(output) && output[0] && typeof output[0] === "object") { - return output[0] as KisIndexOutputRow; - } - if (output && typeof output === "object") { - return output as KisIndexOutputRow; - } - return {}; -} - -/** - * KIS 부호 코드(1/2 상승, 4/5 하락)를 실제 부호로 반영합니다. - * @param value 변동값 - * @param signCode 부호 코드 - * @returns 부호 적용 숫자 - * @see lib/kis/dashboard.ts getDomesticDashboardIndices - */ -function normalizeSignedValue(value: number, signCode?: string) { - const abs = Math.abs(value); - if (signCode === "4" || signCode === "5") return -abs; - if (signCode === "1" || signCode === "2") return abs; - return value; -} - -/** - * undefined가 아닌 첫 값을 반환합니다. - * @param values 후보 숫자 목록 - * @returns 첫 번째 유효값, 없으면 0 - * @see lib/kis/dashboard.ts 요약 수익률/손익 폴백 계산 - */ -function firstDefinedNumber(...values: Array) { - return values.find((value) => value !== undefined) ?? 0; -} - -/** - * 숫자 배열 합계를 계산합니다. - * @param values 숫자 배열 - * @returns 합계 - * @see lib/kis/dashboard.ts 보유종목 합계 계산 - */ -function sumNumbers(values: number[]) { - return values.reduce((total, value) => total + value, 0); -} - -/** - * 총자산 대비 손익률을 계산합니다. - * @param profit 손익 금액 - * @param totalAmount 총자산 금액 - * @returns 손익률(%) - * @see lib/kis/dashboard.ts 요약 수익률 폴백 계산 - */ -function calcProfitRate(profit: number, totalAmount: number) { - if (totalAmount <= 0) return 0; - const baseAmount = totalAmount - profit; - if (baseAmount <= 0) return 0; - return (profit / baseAmount) * 100; -} - -/** - * 매입금액 대비 손익률을 계산합니다. - * @param profit 손익 금액 - * @param purchaseAmount 매입금액 - * @returns 손익률(%) - * @see lib/kis/dashboard.ts getDomesticDashboardBalance 현재 손익률 산출 - */ -function calcProfitRateByPurchase(profit: number, purchaseAmount: number) { - if (purchaseAmount <= 0) return 0; - return (profit / purchaseAmount) * 100; -} - -/** - * 총자산과 평가금액 기준으로 일관된 현금성 자산(예수금)을 계산합니다. - * @param params 계산 파라미터 - * @returns 현금성 자산 금액 - * @remarks UI 흐름: 대시보드 API 응답 생성 -> 총자산/평가금/예수금 카드 반영 - * @see lib/kis/dashboard.ts getDomesticDashboardBalance - */ -function resolveCashBalance(params: { - apiReportedTotalAmount: number; - apiReportedNetAssetAmount: number; - evaluationAmount: number; - cashCandidates: Array; -}) { - const { - apiReportedTotalAmount, - apiReportedNetAssetAmount, - evaluationAmount, - cashCandidates, - } = params; - const referenceTotalAmount = pickPreferredAmount( - apiReportedNetAssetAmount, - apiReportedTotalAmount, - ); - const candidateCash = pickPreferredAmount(...cashCandidates); - const derivedCash = - referenceTotalAmount > 0 - ? Math.max(referenceTotalAmount - evaluationAmount, 0) - : undefined; - - if (derivedCash === undefined) return candidateCash; - - // 후보 예수금 + 평가금이 기준 총자산(순자산 우선)과 크게 다르면 역산값을 사용합니다. - const recomposedWithCandidate = candidateCash + evaluationAmount; - const mismatchWithApi = Math.abs( - recomposedWithCandidate - referenceTotalAmount, - ); - if (mismatchWithApi >= 1) { - return derivedCash; - } - - return candidateCash; -} - -/** - * 금액 후보 중 양수 값을 우선 선택합니다. - * @param values 금액 후보 - * @returns 양수 우선 금액 - * @see lib/kis/dashboard.ts getDomesticDashboardBalance 금액 필드 폴백 계산 - */ -function pickPreferredAmount(...values: Array) { - const positive = values.find( - (value): value is number => value !== undefined && value > 0, - ); - if (positive !== undefined) return positive; - return firstDefinedNumber(...values); -} - -/** - * 숫자 후보 중 0이 아닌 값을 우선 선택합니다. - * @param values 숫자 후보 - * @returns 0이 아닌 값 우선 결과 - * @see lib/kis/dashboard.ts getDomesticDashboardBalance 손익/수익률 폴백 계산 - */ -function pickNonZeroNumber(...values: Array) { - const nonZero = values.find( - (value): value is number => value !== undefined && value !== 0, - ); - if (nonZero !== undefined) return nonZero; - return firstDefinedNumber(...values); -} diff --git a/lib/kis/domestic-helpers.ts b/lib/kis/domestic-helpers.ts new file mode 100644 index 0000000..756ab76 --- /dev/null +++ b/lib/kis/domestic-helpers.ts @@ -0,0 +1,349 @@ +import type { + DashboardChartTimeframe, + StockCandlePoint, +} from "@/features/trade/types/trade.types"; + +type DomesticChartRow = Record; + +type OhlcvTuple = { + open: number; + high: number; + low: number; + close: number; + volume: number; +}; + +/** + * @description 문자열 숫자를 안전하게 number로 변환합니다. + * @see lib/kis/domestic.ts 국내 시세/차트 숫자 필드 파싱 + */ +export function toNumber(value?: string) { + if (!value) return 0; + const normalized = value.replace(/,/g, "").trim(); + if (!normalized) return 0; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * @description 숫자 문자열을 optional number로 변환합니다. + * @see lib/kis/domestic.ts 현재가 소스 선택 시 값 존재 여부 판단 + */ +export function toOptionalNumber(value?: string) { + if (!value) return undefined; + const normalized = value.replace(/,/g, "").trim(); + if (!normalized) return undefined; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** + * @description KIS 부호 코드를 실제 부호로 반영합니다. + * @see lib/kis/domestic.ts 지수/시세 변동값 정규화 + */ +export function normalizeSignedValue(value: number, signCode?: string) { + const abs = Math.abs(value); + + if (signCode === "4" || signCode === "5") return -abs; + if (signCode === "1" || signCode === "2") return abs; + return value; +} + +/** + * @description 시장명을 코스피/코스닥으로 정규화합니다. + * @see lib/kis/domestic.ts 종목 overview 응답 market 값 결정 + */ +export function resolveMarket(...values: Array) { + const merged = values.filter(Boolean).join(" "); + if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ")) { + return "KOSDAQ" as const; + } + return "KOSPI" as const; +} + +/** + * @description 일봉 output을 차트 공통 candle 포맷으로 변환합니다. + * @see lib/kis/domestic.ts getDomesticOverview candles 생성 + */ +export function toCandles( + rows: Array<{ + stck_bsop_date?: string; + stck_oprc?: string; + stck_hgpr?: string; + stck_lwpr?: string; + stck_clpr?: string; + acml_vol?: string; + }>, + currentPrice: number, +): StockCandlePoint[] { + const parsed = rows + .map((row) => ({ + date: row.stck_bsop_date ?? "", + open: toNumber(row.stck_oprc), + high: toNumber(row.stck_hgpr), + low: toNumber(row.stck_lwpr), + close: toNumber(row.stck_clpr), + volume: toNumber(row.acml_vol), + })) + .filter((item) => item.date.length === 8 && item.close > 0) + .sort((a, b) => a.date.localeCompare(b.date)) + .slice(-80) + .map((item) => ({ + time: formatDate(item.date), + price: item.close, + open: item.open > 0 ? item.open : item.close, + high: item.high > 0 ? item.high : item.close, + low: item.low > 0 ? item.low : item.close, + close: item.close, + volume: item.volume, + })); + + if (parsed.length > 0) return parsed; + + const now = new Date(); + const mm = `${now.getMonth() + 1}`.padStart(2, "0"); + const dd = `${now.getDate()}`.padStart(2, "0"); + const safePrice = Math.max(currentPrice, 0); + return [ + { + time: `${mm}/${dd}`, + timestamp: Math.floor(now.getTime() / 1000), + price: safePrice, + open: safePrice, + high: safePrice, + low: safePrice, + close: safePrice, + volume: 0, + }, + ]; +} + +export function formatDate(date: string) { + return `${date.slice(4, 6)}/${date.slice(6, 8)}`; +} + +export function firstDefinedNumber(...values: Array) { + return values.find((value) => value !== undefined); +} + +export function firstDefinedString(...values: Array) { + return values.find((value) => Boolean(value)); +} + +/** + * @description 장중/시간외에 따라 현재가 우선 소스를 결정합니다. + * @see lib/kis/domestic.ts getDomesticOverview priceSource 계산 + */ +export function resolveCurrentPriceSource( + marketPhase: "regular" | "afterHours", + overtime: { ovtm_untp_prpr?: string }, + ccnl: { stck_prpr?: string }, + quote: { stck_prpr?: string }, +): "inquire-price" | "inquire-ccnl" | "inquire-overtime-price" { + const hasOvertimePrice = + toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined; + const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined; + const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined; + + if (marketPhase === "afterHours") { + if (hasOvertimePrice) return "inquire-overtime-price"; + if (hasCcnlPrice) return "inquire-ccnl"; + return "inquire-price"; + } + + if (hasCcnlPrice) return "inquire-ccnl"; + if (hasQuotePrice) return "inquire-price"; + return "inquire-price"; +} + +export function firstPositive(...values: number[]) { + return values.find((value) => value > 0) ?? 0; +} + +/** + * @description KIS 차트 응답에서 output2 배열을 안전하게 추출합니다. + * @see lib/kis/domestic.ts getDomesticDailyTimeChart/getDomesticChart + */ +export function parseOutput2Rows(envelope: { + output2?: unknown; + output1?: unknown; + output?: unknown; +}) { + if (Array.isArray(envelope.output2)) return envelope.output2 as DomesticChartRow[]; + if (Array.isArray(envelope.output)) return envelope.output as DomesticChartRow[]; + for (const key of ["output2", "output", "output1"] as const) { + const value = envelope[key]; + if (value && typeof value === "object" && !Array.isArray(value)) { + return [value as DomesticChartRow]; + } + } + return [] as DomesticChartRow[]; +} + +export function readRowString(row: DomesticChartRow, ...keys: string[]) { + for (const key of keys) { + const value = row[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +export function readOhlcv(row: DomesticChartRow): OhlcvTuple | null { + const close = toNumber( + readRowString(row, "stck_clpr", "STCK_CLPR") || + readRowString(row, "stck_prpr", "STCK_PRPR"), + ); + if (close <= 0) return null; + + const open = toNumber(readRowString(row, "stck_oprc", "STCK_OPRC")) || close; + const high = + toNumber(readRowString(row, "stck_hgpr", "STCK_HGPR")) || + Math.max(open, close); + const low = + toNumber(readRowString(row, "stck_lwpr", "STCK_LWPR")) || + Math.min(open, close); + const volume = toNumber( + readRowString(row, "acml_vol", "ACML_VOL") || + readRowString(row, "cntg_vol", "CNTG_VOL"), + ); + return { open, high, low, close, volume }; +} + +export function parseDayCandleRow(row: DomesticChartRow): StockCandlePoint | null { + const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE"); + if (!/^\d{8}$/.test(date)) return null; + const ohlcv = readOhlcv(row); + if (!ohlcv) return null; + + return { + time: formatDate(date), + timestamp: toKstTimestamp(date, "090000"), + price: ohlcv.close, + ...ohlcv, + }; +} + +export function parseMinuteCandleRow( + row: DomesticChartRow, + minuteBucket: number, +): StockCandlePoint | null { + let date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE"); + const rawTime = readRowString(row, "stck_cntg_hour", "STCK_CNTG_HOUR"); + const time = /^\d{6}$/.test(rawTime) + ? rawTime + : /^\d{4}$/.test(rawTime) + ? `${rawTime}00` + : ""; + + if (!/^\d{8}$/.test(date)) date = nowYmdInKst(); + if (!/^\d{8}$/.test(date) || !/^\d{6}$/.test(time)) return null; + + const ohlcv = readOhlcv(row); + if (!ohlcv) return null; + + const bucketed = alignTimeToMinuteBucket(time, minuteBucket); + return { + time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`, + timestamp: toKstTimestamp(date, bucketed), + price: ohlcv.close, + ...ohlcv, + }; +} + +export function mergeCandlesByTimestamp(rows: StockCandlePoint[]) { + const map = new Map(); + for (const row of rows) { + if (!row.timestamp) continue; + const prev = map.get(row.timestamp); + if (!prev) { + map.set(row.timestamp, row); + continue; + } + map.set(row.timestamp, { + ...prev, + price: row.close ?? row.price, + close: row.close ?? row.price, + high: Math.max(prev.high ?? prev.price, row.high ?? row.price), + low: Math.min(prev.low ?? prev.price, row.low ?? row.price), + volume: (prev.volume ?? 0) + (row.volume ?? 0), + }); + } + return Array.from(map.values()).sort( + (a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0), + ); +} + +export function alignTimeToMinuteBucket(hhmmss: string, bucket: number) { + if (/^\d{4}$/.test(hhmmss)) hhmmss = `${hhmmss}00`; + if (bucket <= 1) return hhmmss; + const hh = Number(hhmmss.slice(0, 2)); + const mm = Number(hhmmss.slice(2, 4)); + const aligned = Math.floor(mm / bucket) * bucket; + return `${hh.toString().padStart(2, "0")}${aligned.toString().padStart(2, "0")}00`; +} + +export function toKstTimestamp(yyyymmdd: string, hhmmss: string) { + const y = Number(yyyymmdd.slice(0, 4)); + const mo = Number(yyyymmdd.slice(4, 6)); + const d = Number(yyyymmdd.slice(6, 8)); + const hh = Number(hhmmss.slice(0, 2)); + const mm = Number(hhmmss.slice(2, 4)); + const ss = Number(hhmmss.slice(4, 6)); + return Math.floor(Date.UTC(y, mo - 1, d, hh - 9, mm, ss) / 1000); +} + +export function shiftYmd(ymd: string, days: number) { + const utc = new Date( + Date.UTC( + Number(ymd.slice(0, 4)), + Number(ymd.slice(4, 6)) - 1, + Number(ymd.slice(6, 8)), + ), + ); + utc.setUTCDate(utc.getUTCDate() + days); + return toYmd(utc); +} + +export function nowYmdInKst() { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Seoul", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const map = new Map(parts.map((part) => [part.type, part.value])); + return `${map.get("year")}${map.get("month")}${map.get("day")}`; +} + +export function nowHmsInKst() { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Asia/Seoul", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(new Date()); + const map = new Map(parts.map((part) => [part.type, part.value])); + return `${map.get("hour")}${map.get("minute")}${map.get("second")}`; +} + +export function minutesForTimeframe(tf: DashboardChartTimeframe) { + if (tf === "30m") return 30; + if (tf === "1h") return 60; + return 1; +} + +export function subOneMinute(hhmmss: string) { + const hh = Number(hhmmss.slice(0, 2)); + const mm = Number(hhmmss.slice(2, 4)); + let totalMin = hh * 60 + mm - 1; + if (totalMin < 0) totalMin = 0; + + const hour = Math.floor(totalMin / 60); + const minute = totalMin % 60; + return `${String(hour).padStart(2, "0")}${String(minute).padStart(2, "0")}00`; +} + +function toYmd(date: Date) { + return `${date.getUTCFullYear()}${`${date.getUTCMonth() + 1}`.padStart(2, "0")}${`${date.getUTCDate()}`.padStart(2, "0")}`; +} diff --git a/lib/kis/domestic.ts b/lib/kis/domestic.ts index ef38d31..2b5d5a9 100644 --- a/lib/kis/domestic.ts +++ b/lib/kis/domestic.ts @@ -10,6 +10,27 @@ import { resolveDomesticKisSession, shouldUseOvertimeOrderBookApi, } from "@/lib/kis/domestic-market-session"; +import { + firstDefinedNumber, + firstDefinedString, + firstPositive, + mergeCandlesByTimestamp, + minutesForTimeframe, + normalizeSignedValue, + nowHmsInKst, + nowYmdInKst, + parseDayCandleRow, + parseMinuteCandleRow, + parseOutput2Rows, + readRowString, + resolveCurrentPriceSource, + resolveMarket, + shiftYmd, + subOneMinute, + toCandles, + toNumber, + toOptionalNumber, +} from "@/lib/kis/domestic-helpers"; /** * @file lib/kis/domestic.ts @@ -59,18 +80,6 @@ interface KisDomesticDailyPriceOutput { stck_clpr?: string; acml_vol?: string; } -interface KisDomesticItemChartRow { - stck_bsop_date?: string; - stck_cntg_hour?: string; - stck_oprc?: string; - stck_hgpr?: string; - stck_lwpr?: string; - stck_clpr?: string; - stck_prpr?: string; - cntg_vol?: string; - acml_vol?: string; -} - export interface KisDomesticOrderBookOutput { stck_prpr?: string; total_askp_rsqn?: string; @@ -394,87 +403,6 @@ export async function getDomesticOverview( }; } -function toNumber(value?: string) { - if (!value) return 0; - const normalized = value.replace(/,/g, "").trim(); - if (!normalized) return 0; - const parsed = Number(normalized); - return Number.isFinite(parsed) ? parsed : 0; -} - -function toOptionalNumber(value?: string) { - if (!value) return undefined; - const normalized = value.replace(/,/g, "").trim(); - if (!normalized) return undefined; - const parsed = Number(normalized); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function normalizeSignedValue(value: number, signCode?: string) { - const abs = Math.abs(value); - - if (signCode === "4" || signCode === "5") return -abs; - if (signCode === "1" || signCode === "2") return abs; - return value; -} - -function resolveMarket(...values: Array) { - const merged = values.filter(Boolean).join(" "); - if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ")) - return "KOSDAQ" as const; - return "KOSPI" as const; -} - -function toCandles( - rows: KisDomesticDailyPriceOutput[], - currentPrice: number, -): StockCandlePoint[] { - const parsed = rows - .map((row) => ({ - date: row.stck_bsop_date ?? "", - open: toNumber(row.stck_oprc), - high: toNumber(row.stck_hgpr), - low: toNumber(row.stck_lwpr), - close: toNumber(row.stck_clpr), - volume: toNumber(row.acml_vol), - })) - .filter((item) => item.date.length === 8 && item.close > 0) - .sort((a, b) => a.date.localeCompare(b.date)) - .slice(-80) - .map((item) => ({ - time: formatDate(item.date), - price: item.close, - open: item.open > 0 ? item.open : item.close, - high: item.high > 0 ? item.high : item.close, - low: item.low > 0 ? item.low : item.close, - close: item.close, - volume: item.volume, - })); - - if (parsed.length > 0) return parsed; - - const now = new Date(); - const mm = `${now.getMonth() + 1}`.padStart(2, "0"); - const dd = `${now.getDate()}`.padStart(2, "0"); - const safePrice = Math.max(currentPrice, 0); - return [ - { - time: `${mm}/${dd}`, - timestamp: Math.floor(now.getTime() / 1000), - price: safePrice, - open: safePrice, - high: safePrice, - low: safePrice, - close: safePrice, - volume: 0, - }, - ]; -} - -function formatDate(date: string) { - return `${date.slice(4, 6)}/${date.slice(6, 8)}`; -} - function getDomesticMarketPhaseInKst( now = new Date(), sessionOverride?: string | null, @@ -484,231 +412,16 @@ function getDomesticMarketPhaseInKst( ); } -function firstDefinedNumber(...values: Array) { - return values.find((value) => value !== undefined); -} - -function firstDefinedString(...values: Array) { - return values.find((value) => Boolean(value)); -} - -function resolveCurrentPriceSource( - marketPhase: DomesticMarketPhase, - overtime: KisDomesticOvertimePriceOutput, - ccnl: KisDomesticCcnlOutput, - quote: KisDomesticQuoteOutput, -): DomesticPriceSource { - const hasOvertimePrice = - toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined; - const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined; - const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined; - - if (marketPhase === "afterHours") { - if (hasOvertimePrice) return "inquire-overtime-price"; - if (hasCcnlPrice) return "inquire-ccnl"; - return "inquire-price"; - } - - if (hasCcnlPrice) return "inquire-ccnl"; - if (hasQuotePrice) return "inquire-price"; - return "inquire-price"; -} - function resolvePriceMarketDivCode() { return "J"; } -function firstPositive(...values: number[]) { - return values.find((value) => value > 0) ?? 0; -} - export interface DomesticChartResult { candles: StockCandlePoint[]; nextCursor: string | null; hasMore: boolean; } -// ─── KIS output2 배열 추출 ───────────────────────────────── -function parseOutput2Rows(envelope: { - output2?: unknown; - output1?: unknown; - output?: unknown; -}) { - if (Array.isArray(envelope.output2)) - return envelope.output2 as KisDomesticItemChartRow[]; - if (Array.isArray(envelope.output)) - return envelope.output as KisDomesticItemChartRow[]; - for (const key of ["output2", "output", "output1"] as const) { - const v = envelope[key]; - if (v && typeof v === "object" && !Array.isArray(v)) - return [v as KisDomesticItemChartRow]; - } - return []; -} - -// ─── Row → StockCandlePoint 변환 ─────────────────────────── -function readRowString(row: KisDomesticItemChartRow, ...keys: string[]) { - const record = row as Record; - for (const key of keys) { - const v = record[key]; - if (typeof v === "string" && v.trim()) return v.trim(); - } - return ""; -} - -function readOhlcv(row: KisDomesticItemChartRow) { - const close = toNumber( - readRowString(row, "stck_clpr", "STCK_CLPR") || - readRowString(row, "stck_prpr", "STCK_PRPR"), - ); - if (close <= 0) return null; - - const open = toNumber(readRowString(row, "stck_oprc", "STCK_OPRC")) || close; - const high = - toNumber(readRowString(row, "stck_hgpr", "STCK_HGPR")) || - Math.max(open, close); - const low = - toNumber(readRowString(row, "stck_lwpr", "STCK_LWPR")) || - Math.min(open, close); - const volume = toNumber( - readRowString(row, "acml_vol", "ACML_VOL") || - readRowString(row, "cntg_vol", "CNTG_VOL"), - ); - return { open, high, low, close, volume }; -} - -function parseDayCandleRow( - row: KisDomesticItemChartRow, -): StockCandlePoint | null { - const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE"); - if (!/^\d{8}$/.test(date)) return null; - const ohlcv = readOhlcv(row); - if (!ohlcv) return null; - - return { - time: formatDate(date), - timestamp: toKstTimestamp(date, "090000"), - price: ohlcv.close, - ...ohlcv, - }; -} - -function parseMinuteCandleRow( - row: KisDomesticItemChartRow, - minuteBucket: number, -): StockCandlePoint | null { - let date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE"); - const rawTime = readRowString(row, "stck_cntg_hour", "STCK_CNTG_HOUR"); - const time = /^\d{6}$/.test(rawTime) - ? rawTime - : /^\d{4}$/.test(rawTime) - ? `${rawTime}00` - : ""; - - if (!/^\d{8}$/.test(date)) date = nowYmdInKst(); // 당일 분봉은 날짜가 빠져있을 수 있음 - if (!/^\d{8}$/.test(date) || !/^\d{6}$/.test(time)) return null; - - const ohlcv = readOhlcv(row); - if (!ohlcv) return null; - - const bucketed = alignTimeToMinuteBucket(time, minuteBucket); - return { - time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`, - timestamp: toKstTimestamp(date, bucketed), - price: ohlcv.close, - ...ohlcv, - }; -} - -// ─── 같은 타임스탬프 봉 병합 ─────────────────────────────── -function mergeCandlesByTimestamp(rows: StockCandlePoint[]) { - const map = new Map(); - for (const row of rows) { - if (!row.timestamp) continue; - const prev = map.get(row.timestamp); - if (!prev) { - map.set(row.timestamp, row); - continue; - } - map.set(row.timestamp, { - ...prev, - price: row.close ?? row.price, - close: row.close ?? row.price, - high: Math.max(prev.high ?? prev.price, row.high ?? row.price), - low: Math.min(prev.low ?? prev.price, row.low ?? row.price), - volume: (prev.volume ?? 0) + (row.volume ?? 0), - }); - } - return Array.from(map.values()).sort( - (a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0), - ); -} - -// ─── 시간 유틸 ───────────────────────────────────────────── -function alignTimeToMinuteBucket(hhmmss: string, bucket: number) { - if (/^\d{4}$/.test(hhmmss)) hhmmss = `${hhmmss}00`; - if (bucket <= 1) return hhmmss; - const hh = Number(hhmmss.slice(0, 2)); - const mm = Number(hhmmss.slice(2, 4)); - const aligned = Math.floor(mm / bucket) * bucket; - return `${hh.toString().padStart(2, "0")}${aligned.toString().padStart(2, "0")}00`; -} - -function toKstTimestamp(yyyymmdd: string, hhmmss: string) { - const y = Number(yyyymmdd.slice(0, 4)); - const mo = Number(yyyymmdd.slice(4, 6)); - const d = Number(yyyymmdd.slice(6, 8)); - const hh = Number(hhmmss.slice(0, 2)); - const mm = Number(hhmmss.slice(2, 4)); - const ss = Number(hhmmss.slice(4, 6)); - return Math.floor(Date.UTC(y, mo - 1, d, hh - 9, mm, ss) / 1000); -} - -function toYmd(date: Date) { - return `${date.getUTCFullYear()}${`${date.getUTCMonth() + 1}`.padStart(2, "0")}${`${date.getUTCDate()}`.padStart(2, "0")}`; -} - -function shiftYmd(ymd: string, days: number) { - const utc = new Date( - Date.UTC( - Number(ymd.slice(0, 4)), - Number(ymd.slice(4, 6)) - 1, - Number(ymd.slice(6, 8)), - ), - ); - utc.setUTCDate(utc.getUTCDate() + days); - return toYmd(utc); -} - -function nowYmdInKst() { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: "Asia/Seoul", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date()); - const m = new Map(parts.map((p) => [p.type, p.value])); - return `${m.get("year")}${m.get("month")}${m.get("day")}`; -} - -function nowHmsInKst() { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: "Asia/Seoul", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }).formatToParts(new Date()); - const m = new Map(parts.map((p) => [p.type, p.value])); - return `${m.get("hour")}${m.get("minute")}${m.get("second")}`; -} - -function minutesForTimeframe(tf: DashboardChartTimeframe) { - if (tf === "30m") return 30; - if (tf === "1h") return 60; - return 1; -} - /** * 국내주식 주식일별분봉조회 (과거 분봉) * @param symbol 종목코드 @@ -794,7 +507,7 @@ export async function getDomesticChart( // ── 분봉 (1m / 30m / 1h) ── const minuteBucket = minutesForTimeframe(timeframe); - let rawRows: KisDomesticItemChartRow[] = []; + let rawRows: Array> = []; let nextCursor: string | null = null; // Case A: 과거 데이터 조회 (커서 존재) @@ -896,14 +609,3 @@ export async function getDomesticChart( return { candles, hasMore: Boolean(nextCursor), nextCursor }; } - -function subOneMinute(hhmmss: string) { - const hh = Number(hhmmss.slice(0, 2)); - const mm = Number(hhmmss.slice(2, 4)); - let totalMin = hh * 60 + mm - 1; - if (totalMin < 0) totalMin = 0; - - const h = Math.floor(totalMin / 60); - const m = totalMin % 60; - return `${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}00`; -} diff --git a/lib/kis/error-codes.ts b/lib/kis/error-codes.ts new file mode 100644 index 0000000..acfd3c9 --- /dev/null +++ b/lib/kis/error-codes.ts @@ -0,0 +1,188 @@ +/** + * @file lib/kis/error-codes.ts + * @description KIS FAQ 오류코드(msg_cd) 문구를 공통으로 해석하는 유틸입니다. + * @see https://apiportal.koreainvestment.com/faq-error-code 한국투자증권 공식 오류코드 기준 + */ + +export const KIS_ERROR_CODE_REFERENCE_URL = + "https://apiportal.koreainvestment.com/faq-error-code"; + +const KIS_ERROR_CODE_MESSAGE_MAP = { + EGW00001: "일시적 오류가 발생했습니다.", + EGW00002: "서버 에러가 발생했습니다.", + EGW00003: "접근이 거부되었습니다.", + EGW00004: "권한을 부여받지 않은 고객입니다.", + EGW00101: "유효하지 않은 요청입니다.", + EGW00102: "AppKey는 필수입니다.", + EGW00103: "유효하지 않은 AppKey입니다.", + EGW00104: "AppSecret은 필수입니다.", + EGW00105: "유효하지 않은 AppSecret입니다.", + EGW00106: "redirect_uri는 필수입니다.", + EGW00107: "유효하지 않은 redirect_uri입니다.", + EGW00108: "유효하지 않은 서비스구분(service)입니다.", + EGW00109: "scope는 필수입니다.", + EGW00110: "유효하지 않은 scope 입니다.", + EGW00111: "유효하지 않은 state 입니다.", + EGW00112: "유효하지 않은 grant 입니다.", + EGW00113: "응답구분(response_type)은 필수입니다.", + EGW00114: "지원하지 않는 응답구분(response_type)입니다.", + EGW00115: "권한부여 타입(grant_type)은 필수입니다.", + EGW00116: "지원하지 않는 권한부여 타입(grant_type)입니다.", + EGW00117: "지원하지 않는 토큰 타입(token_type)입니다.", + EGW00118: "유효하지 않은 code 입니다.", + EGW00119: "code를 찾을 수 없습니다.", + EGW00120: "기간이 만료된 code 입니다.", + EGW00121: "유효하지 않은 token 입니다.", + EGW00122: "token을 찾을 수 없습니다.", + EGW00123: "기간이 만료된 token 입니다.", + EGW00124: "유효하지 않은 session_key 입니다.", + EGW00125: "session_key를 찾을 수 없습니다.", + EGW00126: "기간이 만료된 session_key 입니다.", + EGW00127: "제휴사번호(corpno)는 필수입니다.", + EGW00128: "계좌번호(acctno)는 필수입니다.", + EGW00129: "HTS_ID는 필수입니다.", + EGW00130: "유효하지 않은 유저(user)입니다.", + EGW00131: "유효하지 않은 hashkey입니다.", + EGW00132: "Content-Type이 유효하지 않습니다.", + EGW00201: "초당 거래건수를 초과하였습니다.", + EGW00202: "GW라우팅 중 오류가 발생했습니다.", + EGW00203: "OPS라우팅 중 오류가 발생했습니다.", + EGW00204: "Internal Gateway 인스턴스를 잘못 입력했습니다.", + EGW00205: "credentials_type이 유효하지 않습니다.(Bearer)", + EGW00206: "API 사용 권한이 없습니다.", + EGW00207: "IP 주소가 없거나 유효하지 않습니다.", + EGW00208: "고객유형(custtype)이 유효하지 않습니다.", + EGW00209: "일련번호(seq_no)가 유효하지 않습니다.", + EGW00210: "법인고객의 경우 모의투자를 이용할 수 없습니다.", + EGW00211: "고객명(personalname)은 필수 입니다.", + EGW00212: "휴대전화번호(personalphone)는 필수 입니다.", + EGW00213: "제휴사명(corpname)은 필수 입니다. / 모의투자 tr이 아닙니다.", + EGW00300: "Gateway 라우팅 오류가 발생했습니다.", + EGW00301: "연결 시간이 초과되었습니다. 직전 거래를 반드시 확인하세요.", + EGW00302: "거래시간이 초과되었습니다. 직전 거래를 반드시 확인하세요.", + EGW00303: "법인고객에게 허용되지 않은 IP접근입니다.", + EGW00304: + "고객식별키(법인 personalSeckey, 개인 appSecret)가 유효하지 않습니다.", + OPSQ0001: "호출 전처리 오류 입니다.", + OPSQ0002: "없는 서비스 코드 입니다.", + OPSQ0003: "호출 오류 입니다.", + OPSQ0004: "호출 후처리 오류 입니다.", + OPSQ0005: "호출 후처리 오류 입니다.", + OPSQ0006: "호출 후처리 오류 입니다.", + OPSQ0007: "호출 후처리(헤더설정) 오류 입니다.", + OPSQ0008: "호출 후처리(MCI전송) 오류 입니다.", + OPSQ0009: "호출 후처리(MCI수신) 오류 입니다.", + OPSQ0010: "호출 결과처리(리소스 부족) 오류 입니다.", + OPSQ0011: "호출 결과처리(리소스 부족) 오류 입니다.", + OPSQ1002: "세션 연결 오류.", + OPSQ2000: "ERROR : INPUT INVALID_CHECK_ACNO", + OPSQ2001: "ERROR : INPUT INVALID_CHECK_MRKT_DIV_CODE", + OPSQ2002: "ERROR : INPUT INVALID_CHECK_FIELD_LENGTH", + OPSQ2003: "ERROR : SET_MCI_SEND_DATA", + OPSQ3001: "ERROR : RESPONSE_ADDITEMTOOBJECT", + OPSQ3002: "ERROR : GET_CALL_PARAM_MCI_SEND_DATA_LEN", + OPSQ3004: "ERROR : OUT_STRING_ARRAY ALLOC FAILED", + OPSQ9995: "JSON PARSING ERROR : body not found", + OPSQ9996: "JSON PARSING ERROR : header not found", + OPSQ9997: "JSON PARSING ERROR : invalid json format", + OPSQ9998: "JSON PARSING ERROR : seq_no not found", + OPSQ9999: "JSON PARSING ERROR : tr_id not found", + OPSP0000: "SUBSCRIBE SUCCESS", + OPSP0001: "UNSUBSCRIBE SUCCESS", + OPSP0002: "ALREADY IN SUBSCRIBE", + OPSP0003: "UNSUBSCRIBE ERROR(not found!)", + OPSP0007: "SUBSCRIBE INTERNAL ERROR", + OPSP0008: "MAX SUBSCRIBE OVER", + OPSP0009: "SUBSCRIBE ERROR : mci send failed", + OPSP0010: "SUBSCRIBE WARNNING : invalid appkey", + OPSP0011: "invalid approval(appkey) : NOT FOUND", + OPSP8991: "SUBSCRIBE ERROR : invalid tr_id", + OPSP8992: "SUBSCRIBE ERROR : invalid tr_key", + OPSP8993: "JSON PARSING ERROR : invalid tr_key", + OPSP8994: "JSON PARSING ERROR : personalseckey not found", + OPSP8995: "JSON PARSING ERROR : appsecret not found", + OPSP8996: "ALREADY IN USE appkey", + OPSP8997: "JSON PARSING ERROR : invalid tr_type", + OPSP8998: "JSON PARSING ERROR : invalid custtype", + OPSP8999: "resource not available (ALLOC_CALL_PARAM)", + OPSP9990: "JSON PARSING ERROR : tr_key not found", + OPSP9991: "JSON PARSING ERROR : input not found", + OPSP9992: "JSON PARSING ERROR : body not found", + OPSP9993: "JSON PARSING ERROR : internal error", + OPSP9994: "JSON PARSING ERROR : INVALID appkey", + OPSP9995: "JSON PARSING ERROR : resource not available", + OPSP9996: "JSON PARSING ERROR : appkey", + OPSP9997: "JSON PARSING ERROR : custtype not found", + OPSP9998: "JSON PARSING ERROR : header not found", + OPSP9999: "JSON PARSING ERROR : invalid json format", +} as const; + +export interface KisErrorGuide { + code: string; + message: string; + referenceUrl: string; +} + +interface BuildKisErrorDetailParams { + message?: string; + msgCode?: string; + extraMessages?: Array; +} + +function normalizeKisErrorCode(msgCode?: string) { + return msgCode?.trim().toUpperCase() ?? ""; +} + +/** + * @description KIS msg_cd를 공식 FAQ 문구와 매칭합니다. + * @param msgCode KIS 응답 msg_cd + * @returns 코드/문구/참고 URL 정보. 없으면 null + * @see lib/kis/client.ts kisGet/kisPost 비즈니스 오류 메시지 구성 + * @see features/kis-realtime/stores/kisWebSocketStore.ts 실시간 제어 오류 안내문 구성 + */ +export function getKisErrorGuide(msgCode?: string): KisErrorGuide | null { + const code = normalizeKisErrorCode(msgCode); + if (!code) return null; + + const message = + KIS_ERROR_CODE_MESSAGE_MAP[ + code as keyof typeof KIS_ERROR_CODE_MESSAGE_MAP + ]; + if (!message) return null; + + return { + code, + message, + referenceUrl: KIS_ERROR_CODE_REFERENCE_URL, + }; +} + +/** + * @description KIS 오류 조각(msg1/msg_cd/부가메시지)을 사람이 읽기 쉬운 한 줄로 합칩니다. + * @param params 오류 문자열 조합 입력값 + * @returns 중복 제거된 상세 메시지 + * @see lib/kis/token.ts buildTokenIssueDetail 토큰 발급/폐기 오류 상세 구성 + * @see lib/kis/approval.ts issueKisApprovalKey 승인키 발급 오류 상세 구성 + */ +export function buildKisErrorDetail({ + message, + msgCode, + extraMessages = [], +}: BuildKisErrorDetailParams) { + const tokens = new Set(); + + for (const raw of [...extraMessages, message]) { + const normalized = raw?.trim(); + if (normalized) tokens.add(normalized); + } + + const guide = getKisErrorGuide(msgCode); + const normalizedCode = normalizeKisErrorCode(msgCode); + if (guide) { + tokens.add(`${guide.code} (${guide.message})`); + } else if (normalizedCode) { + tokens.add(normalizedCode); + } + + return [...tokens].join(" / "); +} diff --git a/lib/kis/request.ts b/lib/kis/request.ts index 6348351..dc0469a 100644 --- a/lib/kis/request.ts +++ b/lib/kis/request.ts @@ -1,11 +1,12 @@ import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config"; import type { NextRequest } from "next/server"; +import { z } from "zod"; -interface KisCredentialRequestBody { - appKey?: string; - appSecret?: string; - tradingEnv?: string; -} +const kisCredentialRequestBodySchema = z.object({ + appKey: z.string().trim().optional(), + appSecret: z.string().trim().optional(), + tradingEnv: z.string().optional(), +}); /** * @description 요청 본문에서 KIS 인증 정보를 파싱합니다. @@ -14,14 +15,17 @@ interface KisCredentialRequestBody { export async function parseKisCredentialRequest( request: NextRequest, ): Promise { - let body: KisCredentialRequestBody = {}; + let rawBody: unknown = {}; try { - body = (await request.json()) as KisCredentialRequestBody; + rawBody = (await request.json()) as unknown; } catch { // 빈 본문 또는 JSON 파싱 실패는 아래 필수값 검증에서 처리합니다. } + const parsedBody = kisCredentialRequestBodySchema.safeParse(rawBody); + const body = parsedBody.success ? parsedBody.data : {}; + return { appKey: body.appKey?.trim(), appSecret: body.appSecret?.trim(), diff --git a/lib/kis/token.ts b/lib/kis/token.ts index 5e5174e..325d2a2 100644 --- a/lib/kis/token.ts +++ b/lib/kis/token.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { clearKisApprovalKeyCache } from "@/lib/kis/approval"; import type { KisConfig, KisCredentialInput } from "@/lib/kis/config"; import { getKisConfig } from "@/lib/kis/config"; +import { buildKisErrorDetail } from "@/lib/kis/error-codes"; /** * @file lib/kis/token.ts @@ -218,9 +219,11 @@ function buildTokenIssueBody(config: KisConfig) { * @see issueKisToken 토큰 발급 실패 에러 메시지 구성 */ function buildTokenIssueDetail(payload: KisTokenResponse) { - return [payload.msg1, payload.error_description, payload.error, payload.msg_cd] - .filter(Boolean) - .join(" / "); + return buildKisErrorDetail({ + message: payload.msg1, + msgCode: payload.msg_cd, + extraMessages: [payload.error_description, payload.error], + }); } /** @@ -321,7 +324,10 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) { const isSuccessCode = code === "" || code === "200"; if (!response.ok || !isSuccessCode) { - const detail = [payload.message, payload.msg1].filter(Boolean).join(" / "); + const detail = buildKisErrorDetail({ + message: payload.message, + extraMessages: [payload.msg1], + }); throw new Error( detail diff --git a/providers/query-provider.tsx b/providers/query-provider.tsx index ebdb123..d48c850 100644 --- a/providers/query-provider.tsx +++ b/providers/query-provider.tsx @@ -1,9 +1,17 @@ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import dynamic from "next/dynamic"; import { useState } from "react"; +const ReactQueryDevtools = dynamic( + () => + import("@tanstack/react-query-devtools").then( + (mod) => mod.ReactQueryDevtools, + ), + { ssr: false }, +); + /** * [React Query Provider] * @@ -41,7 +49,9 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { {children} {/* ========== DevTools (개발 환경에서만 표시) ========== */} - + {process.env.NODE_ENV === "development" ? ( + + ) : null} ); } diff --git a/middleware.ts b/proxy.ts similarity index 66% rename from middleware.ts rename to proxy.ts index 17c906f..59a9a99 100644 --- a/middleware.ts +++ b/proxy.ts @@ -2,31 +2,31 @@ import { type NextRequest } from "next/server"; import { updateSession } from "@/utils/supabase/middleware"; /** - * [Next.js 미들웨어 진입점] + * [Next.js Proxy 진입점] * * 웹사이트의 모든 요청은 이 함수를 가장 먼저 거쳐갑니다. * 여기서 로그인 여부를 체크하거나 세션을 갱신합니다. */ -export async function middleware(request: NextRequest) { +export async function proxy(request: NextRequest) { // 방금 만든 updateSession 함수를 호출하여 쿠키/세션을 관리합니다. return await updateSession(request); } /** - * [미들웨어 설정] + * [Proxy 설정] * - * 미들웨어가 '어떤 경로'에서 실행될지, '어떤 경로는 무시할지' 정하는 규칙입니다. + * Proxy가 '어떤 경로'에서 실행될지, '어떤 경로는 무시할지' 정하는 규칙입니다. */ export const config = { matcher: [ /* - * 아래 정규식(Regex)은 다음 파일들을 제외(exclude)하고 모든 요청을 미들웨어로 보냅니다: + * 아래 정규식(Regex)은 다음 파일들을 제외(exclude)하고 모든 요청을 Proxy로 보냅니다: * - _next/static (이미 빌드된 정적 파일들) * - _next/image (이미지 최적화 API) * - favicon.ico (파비콘 아이콘) * - .svg, .png, .jpg 등 이미지 파일들 * - * 즉, html 페이지 요청이나 데이터 요청에만 미들웨어가 작동하도록 하여 성능을 최적화합니다. + * 즉, html 페이지 요청이나 데이터 요청에만 Proxy가 작동하도록 하여 성능을 최적화합니다. */ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], diff --git a/stores/auth-store.ts b/stores/auth-store.ts deleted file mode 100644 index bd27f60..0000000 --- a/stores/auth-store.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -/** - * [사용자 정보 타입] - */ -export interface User { - id: string; - email: string; - name?: string; - avatar?: string; - createdAt?: string; -} - -/** - * [인증 상태 인터페이스] - */ -interface AuthState { - // ========== 상태 ========== - user: User | null; - isAuthenticated: boolean; - - // ========== 액션 ========== - setUser: (user: User | null) => void; - updateUser: (updates: Partial) => void; - logout: () => void; -} - -/** - * [인증 스토어] - * - * 전역 사용자 인증 상태를 관리합니다. - * - localStorage에 자동 저장 (persist 미들웨어) - * - 페이지 새로고침 시에도 상태 유지 - * - * @example - * ```tsx - * import { useAuthStore } from '@/stores/auth-store'; - * - * function Profile() { - * const { user, isAuthenticated, setUser } = useAuthStore(); - * - * if (!isAuthenticated) return ; - * return
Welcome, {user?.email}
; - * } - * ``` - */ -export const useAuthStore = create()( - persist( - (set) => ({ - // ========== 초기 상태 ========== - user: null, - isAuthenticated: false, - - // ========== 사용자 설정 ========== - setUser: (user) => - set({ - user, - isAuthenticated: !!user, - }), - - // ========== 사용자 정보 업데이트 ========== - updateUser: (updates) => - set((state) => ({ - user: state.user ? { ...state.user, ...updates } : null, - })), - - // ========== 로그아웃 ========== - logout: () => - set({ - user: null, - isAuthenticated: false, - }), - }), - { - name: "auth-storage", // localStorage 키 이름 - }, - ), -); diff --git a/stores/ui-store.ts b/stores/ui-store.ts deleted file mode 100644 index 8943a4e..0000000 --- a/stores/ui-store.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -/** - * [UI 상태 인터페이스] - */ -interface UIState { - // ========== 테마 ========== - theme: "light" | "dark" | "system"; - setTheme: (theme: "light" | "dark" | "system") => void; - - // ========== 사이드바 ========== - isSidebarOpen: boolean; - toggleSidebar: () => void; - setSidebarOpen: (isOpen: boolean) => void; - - // ========== 모달 ========== - isModalOpen: boolean; - modalContent: React.ReactNode | null; - openModal: (content: React.ReactNode) => void; - closeModal: () => void; - - // ========== 토스트/알림 ========== - toasts: Toast[]; - addToast: (toast: Omit) => void; - removeToast: (id: string) => void; -} - -/** - * [토스트 메시지 타입] - */ -export interface Toast { - id: string; - type: "success" | "error" | "warning" | "info"; - message: string; - duration?: number; -} - -/** - * [UI 스토어] - * - * 전역 UI 상태를 관리합니다. - * - 테마 설정 (다크/라이트 모드) - * - 사이드바 열림/닫힘 - * - 모달 상태 - * - 토스트 알림 - * - * @example - * ```tsx - * import { useUIStore } from '@/stores/ui-store'; - * - * function Header() { - * const { theme, setTheme, toggleSidebar } = useUIStore(); - * - * return ( - *
- * - * - *
- * ); - * } - * ``` - */ -export const useUIStore = create()( - persist( - (set) => ({ - // ========== 테마 ========== - theme: "system", - setTheme: (theme) => set({ theme }), - - // ========== 사이드바 ========== - isSidebarOpen: true, - toggleSidebar: () => - set((state) => ({ isSidebarOpen: !state.isSidebarOpen })), - setSidebarOpen: (isOpen) => set({ isSidebarOpen: isOpen }), - - // ========== 모달 ========== - isModalOpen: false, - modalContent: null, - openModal: (content) => set({ isModalOpen: true, modalContent: content }), - closeModal: () => set({ isModalOpen: false, modalContent: null }), - - // ========== 토스트 ========== - toasts: [], - addToast: (toast) => - set((state) => ({ - toasts: [ - ...state.toasts, - { - ...toast, - id: `toast-${Date.now()}-${Math.random()}`, - }, - ], - })), - removeToast: (id) => - set((state) => ({ - toasts: state.toasts.filter((toast) => toast.id !== id), - })), - }), - { - name: "ui-storage", // localStorage 키 이름 - // 일부 상태는 지속하지 않음 (모달, 토스트) - partialize: (state) => ({ - theme: state.theme, - isSidebarOpen: state.isSidebarOpen, - }), - }, - ), -);