스킬 정리 및 리팩토링

This commit is contained in:
2026-02-26 09:05:17 +09:00
parent 4c52d6d82f
commit 406af7408a
71 changed files with 3776 additions and 3934 deletions

View File

@@ -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 활용
- 변경 사항은 반드시 로컬에서 검증 후 완료 보고
- 에러 발생 시 근본 원인 파악 및 해결

View File

@@ -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/<domain>/` 를 따른다.
- 내부 구성 예시:
- `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/<domain>/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` 타입 사용 금지

View File

@@ -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 <PersonnelTableContainer />; // ← 실제 로직이 담긴 컴포넌트
}
```
---
### 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<number>(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
// 📌 제너릭 <T> 사용: 어떤 타입이든 data로 받을 수 있음
interface ApiResponse<T> {
data: T;
message: string;
}
export const personnelApi = {
getList: async (params): Promise<ApiResponse<PersonnelListResponse>> => {
const response = await axiosInstance.get('/api/v1/personnel', { params });
return response.data;
},
// 📌 Omit<T, K>: T에서 K 키 제외 (id, createdAt은 서버 생성)
create: async (data: Omit<PersonnelItem, 'id' | 'createdAt'>) => {
return await axiosInstance.post('/api/v1/personnel', data);
},
// 📌 Partial<T>: 모든 속성을 선택적으로 (부분 수정용)
update: async (id: string, data: Partial<PersonnelItem>) => {
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<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstNumber = getFirst<number>([1, 2, 3]); // number | undefined
```
**주요 유틸리티 타입**:
```tsx
interface Person { id: string; name: string; age: number; createdAt: Date; }
// Partial<T> - 모든 속성을 선택적으로 (부분 업데이트용)
type PartialPerson = Partial<Person>;
// Pick<T, K> - 특정 속성만 선택
type PersonName = Pick<Person, 'id' | 'name'>;
// Omit<T, K> - 특정 속성 제외 (생성 시 서버 자동 생성 필드 제외)
type PersonWithoutId = Omit<Person, 'id' | 'createdAt'>;
// Record<K, V> - 키-값 쌍의 객체 타입
type Filters = Record<string, string | number>;
```
**타입 가드 (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/ # 공통 스토어
```

View File

@@ -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<Date>(new Date());
// [State] 캘린더 팝오버 열림 상태
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
// [Ref] 파일 input 참조 (프로그래밍 방식 클릭용)
const fileInputRef = useRef<HTMLInputElement>(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 (
<Dialog>
{/* ========== 헤더 영역 ========== */}
<DialogHeader>
<DialogTitle>제목</DialogTitle>
</DialogHeader>
{/* ========== 본문: 날짜 선택 영역 ========== */}
<div className="space-y-4">
{/* 날짜 선택 Popover */}
<Popover>
{/* 트리거 버튼: 현재 선택된 날짜 표시 */}
<PopoverTrigger>...</PopoverTrigger>
{/* 캘린더 컨텐츠: 한국어 로케일 */}
<PopoverContent>...</PopoverContent>
</Popover>
</div>
{/* ========== 하단: 액션 버튼 영역 ========== */}
<div className="flex gap-2">
<Button>취소</Button>
<Button>확인</Button>
</div>
</Dialog>
);
```
**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는 파일명 + 함수명 + 역할**: 전체 경로 불필요
# 지금부터 작업
내가 주는 코드를 위 규칙에 맞게 "주석만" 보강하라.

View File

@@ -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`<br>`vercel-react-best-practices` | `sequential-thinking` (복잡한 로직)<br>`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` (의존성 분석)<br>`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개 룰

View File

@@ -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
<FEATURE_ROOT>/
├── apis/
│ ├── apiError.ts
│ ├── <feature>.api.ts # API 호출 로직
│ ├── <feature>Form.adapter.ts # Form <-> API 변환
│ └── <feature>List.adapter.ts # List <-> API 변환
├── hooks/
│ ├── queryKeys.ts # Query Key Factory
│ ├── use<Feature>List.ts # 목록 조회 Hooks
│ ├── use<Feature>Mutations.ts # CUD Hooks
│ └── use<Feature>Form.ts # Form Logic Hooks
├── types/
│ ├── api.types.ts # 공통 API 응답 규격
│ ├── <feature>.types.ts # 도메인 Entity
│ └── selectOption.types.ts # 공통 Select Option
├── stores/
│ └── <feature>Store.ts # Zustand Store
├── components/
│ ├── <Feature>Container.tsx # 메인 컨테이너
│ └── <Feature>Modal.tsx # 모달 컴포넌트
├── utils/ # (Optional)
│ └── <feature>Utils.ts # 순수 헬퍼 함수
└── constants/ # (Optional)
└── <feature>.constants.ts # 상수 (components 내부에 둬도 무방)
```
---
## ⚠️ 규칙 (Rules)
1. **로직 변경 금지**: 오직 파일 위치와 구조만 변경하며, 비즈니스 로직은 건드리지 않는다.
2. **Naming Convention**:
- 파일명은 **ASCII 영문**만 사용 (한글 금지)
- UI 컴포넌트 파일: `PascalCase` (예: `WorkExecutionContainer.tsx`)
- Hooks 및 일반 파일: `camelCase` (예: `useWorkExecutionList.ts`)
3. **Clean Import**: import 시 불필요한 별칭(alias)보다는 명확한 상대/절대 경로를 사용한다.

View File

@@ -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:** <https://skills.sh/>
## 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: <https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices>
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`

View File

@@ -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

View File

@@ -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. 계획 대비 완료체크]
- 완료/부분 완료/미완료
- 최종 판정: 배포 가능/보완 필요
```

View File

@@ -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

View File

@@ -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는 문서 근거 없이 단정하지 않는다.
- 구현 단계에서 성능에 큰 악영향이 보이면 즉시 메모(기록)하고 다음 단계에서 정리한다.

View File

@@ -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

View File

@@ -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. ...
[최종 판정]
- 배포 가능/보완 필요
```

View File

@@ -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

View File

@@ -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-..-..: ...
```
## 규칙
- 계획 승인 전에 실제 구현 코드를 대량 작성하지 않는다.
- 파일 삭제는 반드시 필요성/대체 경로를 확인한 뒤 진행한다.
- 동작 변경과 리팩토링을 섞지 않는다.

View File

@@ -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

View File

@@ -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
<FEATURE_ROOT>/
├── apis/
│ ├── apiError.ts
│ ├── <feature>.api.ts
│ ├── <feature>Form.adapter.ts
│ └── <feature>List.adapter.ts
├── hooks/
│ ├── queryKeys.ts
│ ├── use<Feature>List.ts
│ ├── use<Feature>Mutations.ts
│ └── use<Feature>Form.ts
├── types/
│ ├── api.types.ts
│ ├── <feature>.types.ts
│ └── selectOption.types.ts
├── stores/
│ └── <feature>Store.ts
├── components/
│ ├── <Feature>Container.tsx
│ └── <Feature>Modal.tsx
├── utils/ # Optional
│ └── <feature>Utils.ts
├── lib/ # Optional
│ └── <feature>Lib.ts
└── constants/ # Optional
└── <feature>.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 개선을 했으면 왜 개선했는지 한 줄 근거를 남긴다.

View File

@@ -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

View File

@@ -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 결과
- 생략/실패 사유 및 대체 검증

View File

@@ -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

View File

@@ -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

View File

@@ -1,67 +1,17 @@
# AGENTS.md (auto-trade) # AGENTS.md (auto-trade)
## 기본 원칙 ## 운영 원칙
- 모든 응답과 설명은 한국어로 작성. - 중복 규칙은 `AGENTS.md`에 두지 않고 각 스킬(`.agents/skills/*/SKILL.md`)에서 관리한다.
- 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임. - 개발 작업은 스킬 기반으로 수행한다.
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
- 소스 수정시 사이드이팩트가 발생하지 않게 사용자에게 묻거나 최대한 영향이 가지 않게 수정한다. 진짜 필요없는건 삭제하고 영향이 갈 내용이면 이전 소스가 영향이 없는지 검증하고 수정한다.
## 프로젝트 요약 ## 스킬 호출 규칙
- Next.js 16 App Router, React 19, TypeScript - 개발 요청 키워드(`개발해줘`, `구현해줘`, `기능 추가`, `버그 수정`, `리팩토링`, `개선해줘`, `고쳐줘`, `최적화해줘`, `만들어줘`)가 들어오면 `dev-auto-pipeline` 스킬을 우선 사용한다.
- 상태 관리: zustand - 파이프라인 단계 스킬은 아래 순서로 사용한다.
- 데이터: Supabase 1. `dev-plan-writer`
- 폼 및 검증: react-hook-form, zod 2. `dev-mcp-implementation`
- UI: Tailwind CSS v4, Radix UI (`components.json` 사용) 3. `dev-refactor-polish`
4. `dev-test-gate`
## 명령어 5. `dev-plan-completion-checker`
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
- 개발 서버(포트 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` 샘플 코드
- 공식 문서와 엑셀/실코드가 다르면 엑셀과 실코드를 우선 기준으로 판단하고, 차이가 크면 사용자에게 최신 파일 재확인 요청
## 소개문구
- 불안감을 해소하고 확신을 주는 문구
- 친근하고 확신에 찬 문구를 사용하여 심리적 장벽을 낮추는 전략

56
app/api/kis/_response.ts Normal file
View File

@@ -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<string, unknown>;
}
/**
* @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;
}

View File

@@ -3,6 +3,11 @@ import type { DashboardActivityResponse } from "@/features/dashboard/types/dashb
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard"; import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { import {
readKisAccountParts, readKisAccountParts,
readKisCredentialsFromHeaders, readKisCredentialsFromHeaders,
@@ -23,29 +28,31 @@ import {
export async function GET(request: Request) { export async function GET(request: Request) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { 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); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: "KIS API 키 설정이 필요합니다.", code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
}, message: "KIS API 키 설정이 필요합니다.",
{ status: 400 }, });
);
} }
const account = readKisAccountParts(request.headers); const account = readKisAccountParts(request.headers);
if (!account) { if (!account) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.", "계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
}, });
{ status: 400 },
);
} }
try { try {
@@ -66,10 +73,13 @@ export async function GET(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "주문내역/매매일지 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(
return NextResponse.json({ error: message }, { status: 500 }); error,
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
),
});
} }
} }

View File

@@ -3,6 +3,11 @@ import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashbo
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard"; import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { import {
readKisAccountParts, readKisAccountParts,
readKisCredentialsFromHeaders, readKisCredentialsFromHeaders,
@@ -21,29 +26,31 @@ import {
export async function GET(request: Request) { export async function GET(request: Request) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { 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); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: "KIS API 키 설정이 필요합니다.", code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
}, message: "KIS API 키 설정이 필요합니다.",
{ status: 400 }, });
);
} }
const account = readKisAccountParts(request.headers); const account = readKisAccountParts(request.headers);
if (!account) { if (!account) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.", "계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
}, });
{ status: 400 },
);
} }
try { try {
@@ -62,10 +69,10 @@ export async function GET(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "잔고 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "잔고 조회 중 오류가 발생했습니다."),
return NextResponse.json({ error: message }, { status: 500 }); });
} }
} }

View File

@@ -2,11 +2,16 @@ import type {
DashboardChartTimeframe, DashboardChartTimeframe,
DashboardStockChartResponse, DashboardStockChartResponse,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config"; import { hasKisConfig } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic"; import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { hasKisApiSession } from "@/app/api/kis/_session"; 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[] = [ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m", "1m",
@@ -23,7 +28,11 @@ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { 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 { searchParams } = new URL(request.url);
@@ -34,28 +43,29 @@ export async function GET(request: NextRequest) {
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined; const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
if (!/^\d{6}$/.test(symbol)) { if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json( return createKisApiErrorResponse({
{ error: "symbol은 6자리 숫자여야 합니다." }, status: 400,
{ status: 400 }, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
); message: "symbol은 6자리 숫자여야 합니다.",
});
} }
if (!VALID_TIMEFRAMES.includes(timeframe)) { if (!VALID_TIMEFRAMES.includes(timeframe)) {
return NextResponse.json( return createKisApiErrorResponse({
{ error: "지원하지 않는 timeframe입니다." }, status: 400,
{ status: 400 }, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
); message: "지원하지 않는 timeframe입니다.",
});
} }
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.", "대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
}, });
{ status: 400 },
);
} }
try { try {
@@ -81,24 +91,10 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "KIS 차트 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "KIS 차트 조회 중 오류가 발생했습니다."),
return NextResponse.json({ error: message }, { status: 500 }); });
} }
} }
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,
};
}

View File

@@ -3,6 +3,11 @@ import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashbo
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard"; import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session"; 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"; 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) { export async function GET(request: Request) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { 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); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: "KIS API 키 설정이 필요합니다.", code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
}, message: "KIS API 키 설정이 필요합니다.",
{ status: 400 }, });
);
} }
try { try {
@@ -47,10 +55,10 @@ export async function GET(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "지수 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "지수 조회 중 오류가 발생했습니다."),
return NextResponse.json({ error: message }, { status: 500 }); });
} }
} }

View File

@@ -1,67 +1,137 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { executeOrderCash } from "@/lib/kis/trade"; import { executeOrderCash } from "@/lib/kis/trade";
import { import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse, DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { parseKisAccountParts } from "@/lib/kis/account";
import { import {
KisCredentialInput, createKisApiErrorResponse,
hasKisConfig, KIS_API_ERROR_CODE,
normalizeTradingEnv, toKisApiErrorMessage,
} from "@/lib/kis/config"; } from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/** /**
* @file app/api/kis/domestic/order-cash/route.ts * @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API * @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) { export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv,
message: "로그인이 필요합니다.", message: "로그인이 필요합니다.",
}, tradingEnv,
{ status: 401 }, });
);
} }
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
tradingEnv,
message: "KIS API 키 설정이 필요합니다.", message: "KIS API 키 설정이 필요합니다.",
}, tradingEnv,
{ status: 400 }, });
);
} }
try { try {
const body = (await request.json()) as DashboardStockCashOrderRequest; let rawBody: unknown = {};
try {
// TODO: Validate body fields (symbol, quantity, price, etc.) rawBody = (await request.json()) as unknown;
if ( } catch {
!body.symbol || return createKisApiErrorResponse({
!body.accountNo || status: 400,
!body.accountProductCode || code: KIS_API_ERROR_CODE.INVALID_REQUEST,
body.quantity <= 0 message: "요청 본문(JSON)을 읽을 수 없습니다.",
) {
return NextResponse.json(
{
ok: false,
tradingEnv, 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( const output = await executeOrderCash(
@@ -71,8 +141,8 @@ export async function POST(request: NextRequest) {
orderType: body.orderType, orderType: body.orderType,
quantity: body.quantity, quantity: body.quantity,
price: body.price, price: body.price,
accountNo: body.accountNo, accountNo: accountParts.accountNo,
accountProductCode: body.accountProductCode, accountProductCode: accountParts.accountProductCode,
}, },
credentials, credentials,
); );
@@ -88,31 +158,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json(response); return NextResponse.json(response);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "주문 전송 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."),
return NextResponse.json(
{
ok: false,
tradingEnv, tradingEnv,
message, });
},
{ status: 500 },
);
} }
} }
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,
};
}

View File

@@ -5,15 +5,17 @@ import {
} from "@/lib/kis/domestic"; } from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import { import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
import { import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession, parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session"; } 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 * @file app/api/kis/domestic/orderbook/route.ts
@@ -23,28 +25,32 @@ import {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { 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 { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim(); const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) { if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json( return createKisApiErrorResponse({
{ error: "symbol은 6자리 숫자여야 합니다." }, status: 400,
{ status: 400 }, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
); message: "symbol은 6자리 숫자여야 합니다.",
});
} }
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: "KIS API 키 설정이 필요합니다.", code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
}, message: "KIS API 키 설정이 필요합니다.",
{ status: 400 }, });
);
} }
try { try {
@@ -95,28 +101,14 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "호가 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "호가 조회 중 오류가 발생했습니다."),
return NextResponse.json({ error: message }, { status: 500 }); });
} }
} }
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) { function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null; if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER); const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);

View File

@@ -1,14 +1,19 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks"; import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types"; import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import { getDomesticOverview } from "@/lib/kis/domestic"; import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession, parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session"; } from "@/lib/kis/domestic-market-session";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/** /**
* @file app/api/kis/domestic/overview/route.ts * @file app/api/kis/domestic/overview/route.ts
@@ -23,26 +28,33 @@ import {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { 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 { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim(); const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) { 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); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.", "대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
}, });
{ status: 400 },
);
} }
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol); const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
@@ -71,28 +83,14 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다."; return createKisApiErrorResponse({
return NextResponse.json({ error: message }, { status: 500 }); 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) { function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null; if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER); const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);

View File

@@ -5,6 +5,10 @@ import type {
KoreanStockIndexItem, KoreanStockIndexItem,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
const SEARCH_LIMIT = 10; const SEARCH_LIMIT = 10;
@@ -29,7 +33,11 @@ const SEARCH_LIMIT = 10;
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { 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)를 읽고 공백을 제거합니다. // [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.

View File

@@ -6,6 +6,11 @@ import {
} from "@/lib/kis/request"; } from "@/lib/kis/request";
import { revokeKisAccessToken } from "@/lib/kis/token"; import { revokeKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session"; 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"; import { NextRequest, NextResponse } from "next/server";
/** /**
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv,
message: "로그인이 필요합니다.", message: "로그인이 필요합니다.",
} satisfies DashboardKisRevokeResponse, tradingEnv,
{ status: 401 }, });
);
} }
const invalidMessage = validateKisCredentialInput(credentials); const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) { if (invalidMessage) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv,
message: invalidMessage, message: invalidMessage,
} satisfies DashboardKisRevokeResponse, tradingEnv,
{ status: 400 }, });
);
} }
try { try {
@@ -54,18 +55,11 @@ export async function POST(request: NextRequest) {
message, message,
} satisfies DashboardKisRevokeResponse); } satisfies DashboardKisRevokeResponse);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 401,
? error.message code: KIS_API_ERROR_CODE.UNAUTHORIZED,
: "API 토큰 폐기 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "API 토큰 폐기 중 오류가 발생했습니다."),
return NextResponse.json(
{
ok: false,
tradingEnv, tradingEnv,
message, });
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
} }
} }

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types"; import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import { parseKisAccountParts } from "@/lib/kis/account"; 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 { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import { validateKisCredentialInput } from "@/lib/kis/request"; import { validateKisCredentialInput } from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token"; import { getKisAccessToken } from "@/lib/kis/token";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
interface KisProfileValidateRequestBody { const kisProfileValidateBodySchema = z.object({
appKey?: string; appKey: z.string().trim().min(1, "앱 키를 입력해 주세요."),
appSecret?: string; appSecret: z.string().trim().min(1, "앱 시크릿을 입력해 주세요."),
tradingEnv?: string; tradingEnv: z.string().optional(),
accountNo?: string; accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
} });
interface BalanceValidationPreset { interface BalanceValidationPreset {
inqrDvsn: "01" | "02"; inqrDvsn: "01" | "02";
@@ -50,34 +56,44 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv: fallbackTradingEnv,
message: "로그인이 필요합니다.", message: "로그인이 필요합니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">, tradingEnv: fallbackTradingEnv,
{ status: 401 }, });
);
} }
let body: KisProfileValidateRequestBody = {}; let rawBody: unknown = {};
try { try {
body = (await request.json()) as KisProfileValidateRequestBody; rawBody = (await request.json()) as unknown;
} catch { } catch {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv: fallbackTradingEnv,
message: "요청 본문(JSON)을 읽을 수 없습니다.", message: "요청 본문(JSON)을 읽을 수 없습니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">, tradingEnv: fallbackTradingEnv,
{ status: 400 }, });
);
} }
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 = { const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(), appKey: body.appKey.trim(),
appSecret: body.appSecret?.trim(), appSecret: body.appSecret.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv), tradingEnv: normalizeTradingEnv(body.tradingEnv),
}; };
@@ -85,39 +101,25 @@ export async function POST(request: NextRequest) {
const invalidCredentialMessage = validateKisCredentialInput(credentials); const invalidCredentialMessage = validateKisCredentialInput(credentials);
if (invalidCredentialMessage) { if (invalidCredentialMessage) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv,
message: invalidCredentialMessage, message: invalidCredentialMessage,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
const accountNoInput = (body.accountNo ?? "").trim();
if (!accountNoInput) {
return NextResponse.json(
{
ok: false,
tradingEnv, tradingEnv,
message: "계좌번호를 입력해 주세요.", });
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
} }
const accountNoInput = body.accountNo.trim();
const accountParts = parseKisAccountParts(accountNoInput); const accountParts = parseKisAccountParts(accountNoInput);
if (!accountParts) { if (!accountParts) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
tradingEnv, tradingEnv,
message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", });
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
} }
try { try {
@@ -150,19 +152,12 @@ export async function POST(request: NextRequest) {
}, },
} satisfies DashboardKisProfileValidateResponse); } satisfies DashboardKisProfileValidateResponse);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 400,
? error.message code: KIS_API_ERROR_CODE.UNAUTHORIZED,
: "계좌 검증 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."),
return NextResponse.json(
{
ok: false,
tradingEnv, tradingEnv,
message, });
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
} }
} }

View File

@@ -6,6 +6,11 @@ import {
} from "@/lib/kis/request"; } from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token"; import { getKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session"; 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"; import { NextRequest, NextResponse } from "next/server";
/** /**
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv,
message: "로그인이 필요합니다.", message: "로그인이 필요합니다.",
} satisfies DashboardKisValidateResponse, tradingEnv,
{ status: 401 }, });
);
} }
const invalidMessage = validateKisCredentialInput(credentials); const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) { if (invalidMessage) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv,
message: invalidMessage, message: invalidMessage,
} satisfies DashboardKisValidateResponse, tradingEnv,
{ status: 400 }, });
);
} }
try { try {
@@ -54,18 +55,11 @@ export async function POST(request: NextRequest) {
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)", message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse); } satisfies DashboardKisValidateResponse);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 401,
? error.message code: KIS_API_ERROR_CODE.UNAUTHORIZED,
: "API 키 검증 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "API 키 검증 중 오류가 발생했습니다."),
return NextResponse.json(
{
ok: false,
tradingEnv, tradingEnv,
message, });
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
} }
} }

View File

@@ -6,6 +6,11 @@ import {
parseKisCredentialRequest, parseKisCredentialRequest,
validateKisCredentialInput, validateKisCredentialInput,
} from "@/lib/kis/request"; } from "@/lib/kis/request";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
/** /**
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv,
message: "로그인이 필요합니다.", message: "로그인이 필요합니다.",
} satisfies DashboardKisWsApprovalResponse, tradingEnv,
{ status: 401 }, });
);
} }
const invalidMessage = validateKisCredentialInput(credentials); const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) { if (invalidMessage) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv,
message: invalidMessage, message: invalidMessage,
} satisfies DashboardKisWsApprovalResponse, tradingEnv,
{ status: 400 }, });
);
} }
try { try {
@@ -57,18 +58,14 @@ export async function POST(request: NextRequest) {
message: "웹소켓 승인키 발급이 완료되었습니다.", message: "웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse); } satisfies DashboardKisWsApprovalResponse);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 401,
? error.message code: KIS_API_ERROR_CODE.UNAUTHORIZED,
: "웹소켓 승인키 발급 중 오류가 발생했습니다."; message: toKisApiErrorMessage(
error,
return NextResponse.json( "웹소켓 승인키 발급 중 오류가 발생했습니다.",
{ ),
ok: false,
tradingEnv, tradingEnv,
message, });
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
} }
} }

View File

@@ -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 기준으로 맵에 추가합니다.

View File

@@ -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`

View File

@@ -66,6 +66,7 @@ export function SessionManager() {
for (const key of SESSION_RELATED_STORAGE_KEYS) { for (const key of SESSION_RELATED_STORAGE_KEYS) {
window.localStorage.removeItem(key); window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
} }
}, []); }, []);

View File

@@ -1,4 +1,9 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; 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 { import type {
DashboardActivityResponse, DashboardActivityResponse,
DashboardBalanceResponse, DashboardBalanceResponse,
@@ -21,18 +26,16 @@ export async function fetchDashboardBalance(
): Promise<DashboardBalanceResponse> { ): Promise<DashboardBalanceResponse> {
const response = await fetch("/api/kis/domestic/balance", { const response = await fetch("/api/kis/domestic/balance", {
method: "GET", method: "GET",
headers: buildKisRequestHeaders(credentials), headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
cache: "no-store", cache: "no-store",
}); });
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardBalanceResponse | DashboardBalanceResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "잔고 조회 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.",
);
} }
return payload as DashboardBalanceResponse; return payload as DashboardBalanceResponse;
@@ -55,12 +58,10 @@ export async function fetchDashboardIndices(
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardIndicesResponse | DashboardIndicesResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "지수 조회 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.",
);
} }
return payload as DashboardIndicesResponse; return payload as DashboardIndicesResponse;
@@ -77,39 +78,17 @@ export async function fetchDashboardActivity(
): Promise<DashboardActivityResponse> { ): Promise<DashboardActivityResponse> {
const response = await fetch("/api/kis/domestic/activity", { const response = await fetch("/api/kis/domestic/activity", {
method: "GET", method: "GET",
headers: buildKisRequestHeaders(credentials), headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
cache: "no-store", cache: "no-store",
}); });
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardActivityResponse | DashboardActivityResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "활동 데이터 조회 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "활동 데이터 조회 중 오류가 발생했습니다.",
);
} }
return payload as DashboardActivityResponse; 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<string, string> = {
"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;
}

View File

@@ -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 (
<div className={cn("relative h-full w-full", className)}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-100 dark:bg-zinc-900">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-zinc-200 border-t-brand-500 dark:border-zinc-800" />
</div>
)}
<Spline
scene={sceneUrl}
onLoad={() => setIsLoading(false)}
className="h-full w-full"
/>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils"; 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_MAX_DELAY_MS = 30_000;
const RECONNECT_JITTER_MS = 300; 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<KisWebSocketState>((set, get) => ({ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
isConnected: false, isConnected: false,
error: null, error: null,
@@ -105,7 +121,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
// 소켓 생성 // 소켓 생성
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지 // socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
const ws = new WebSocket(wsConnection.wsUrl); const ws = new WebSocket(wsConnection.wsUrl);
console.log("[KisWebSocket] Connecting to:", wsConnection.wsUrl); wsDebugLog("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
socket = ws; socket = ws;
ws.onopen = () => { ws.onopen = () => {
@@ -116,7 +132,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
set({ isConnected: true, error: null }); set({ isConnected: true, error: null });
reconnectAttempt = 0; reconnectAttempt = 0;
console.log("[KisWebSocket] Connected"); wsDebugLog("[KisWebSocket] Connected");
// 재연결 시 기존 구독 복구 // 재연결 시 기존 구독 복구
const approvalKey = wsConnection.approvalKey; const approvalKey = wsConnection.approvalKey;
@@ -147,7 +163,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
if (canAutoReconnect) { if (canAutoReconnect) {
reconnectAttempt += 1; reconnectAttempt += 1;
const delayMs = getReconnectDelayMs(reconnectAttempt); 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})`, `[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<KisWebSocketState>((set, get) => ({
} }
reconnectAttempt = 0; reconnectAttempt = 0;
console.log( wsDebugLog(
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`, `[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`,
); );
} }
@@ -224,7 +240,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
const now = Date.now(); const now = Date.now();
if (now - lastAppKeyConflictAt > 5_000) { if (now - lastAppKeyConflictAt > 5_000) {
lastAppKeyConflictAt = now; lastAppKeyConflictAt = now;
console.warn( wsDebugWarn(
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.", "[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
); );
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도 // 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
@@ -374,11 +390,11 @@ function sendSubscription(
try { try {
const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType); const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType);
ws.send(JSON.stringify(msg)); ws.send(JSON.stringify(msg));
console.debug( wsDebugLog(
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`, `[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
); );
} catch (e) { } 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") { if (message.msgCd === "OPSP8996") {
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다."; return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
} }
const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / "); const detail = buildKisErrorDetail({
message: message.msg1,
msgCode: message.msgCd,
});
return detail return detail
? `실시간 제어 메시지 오류: ${detail}` ? `실시간 제어 메시지 오류: ${detail}`
: "실시간 제어 메시지 오류"; : "실시간 제어 메시지 오류";

View File

@@ -6,8 +6,6 @@ import {
ChevronLeft, ChevronLeft,
Home, Home,
Settings, Settings,
User,
Wallet,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -31,20 +29,6 @@ const MENU_ITEMS: MenuItem[] = [
badge: "LIVE", badge: "LIVE",
showInBottomNav: true, showInBottomNav: true,
}, },
{
title: "자산현황",
href: "/assets",
icon: Wallet,
variant: "ghost",
showInBottomNav: true,
},
{
title: "프로필",
href: "/profile",
icon: User,
variant: "ghost",
showInBottomNav: false,
},
{ {
title: "설정", title: "설정",
href: "/settings", href: "/settings",

View File

@@ -6,7 +6,7 @@
"use client"; "use client";
import { User } from "@supabase/supabase-js"; 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 { useRouter } from "next/navigation";
import { signout } from "@/features/auth/actions"; import { signout } from "@/features/auth/actions";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 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) { for (const key of SESSION_RELATED_STORAGE_KEYS) {
window.localStorage.removeItem(key); window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
} }
}; };
@@ -97,11 +98,6 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}>
<UserIcon className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}> <DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
<span></span> <span></span>

View File

@@ -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<string, string> = {
"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;
}
}

View File

@@ -1,4 +1,8 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import {
resolveKisApiErrorMessage,
type KisApiErrorPayload,
} from "@/features/settings/apis/kis-api-utils";
import type { import type {
DashboardKisProfileValidateResponse, DashboardKisProfileValidateResponse,
DashboardKisRevokeResponse, DashboardKisRevokeResponse,
@@ -25,13 +29,13 @@ async function postKisAuthApi<T extends KisApiBaseResponse>(
cache: "no-store", cache: "no-store",
}); });
const payload = (await response.json()) as T; const payload = (await response.json()) as T | KisApiErrorPayload;
if (!response.ok || !payload.ok) { if (!response.ok || !payload.ok) {
throw new Error(payload.message || fallbackErrorMessage); throw new Error(resolveKisApiErrorMessage(payload, fallbackErrorMessage));
} }
return payload; return payload as T;
} }
/** /**

View File

@@ -240,11 +240,14 @@ export const useKisRuntimeStore = create<
}), }),
{ {
name: "autotrade-kis-runtime-store", name: "autotrade-kis-runtime-store",
storage: createJSONStorage(() => localStorage), // 민감정보(appKey/appSecret/accountNo)는 브라우저 세션 범위로만 유지합니다.
storage: createJSONStorage(() => sessionStorage),
onRehydrateStorage: () => (state) => { onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true); state?.setHasHydrated(true);
}, },
partialize: (state) => ({ partialize: (state) => ({
// 새로고침 시 인증이 풀리지 않도록, "세션 범위"에서만 인증/입력 상태를 유지합니다.
// 브라우저 종료 시 sessionStorage가 비워지므로 장기 영속(localStorage)은 하지 않습니다.
kisTradingEnvInput: state.kisTradingEnvInput, kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput, kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput, kisAppSecretInput: state.kisAppSecretInput,
@@ -254,7 +257,6 @@ export const useKisRuntimeStore = create<
isKisProfileVerified: state.isKisProfileVerified, isKisProfileVerified: state.isKisProfileVerified,
verifiedAccountNo: state.verifiedAccountNo, verifiedAccountNo: state.verifiedAccountNo,
tradingEnv: state.tradingEnv, tradingEnv: state.tradingEnv,
// wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive).
}), }),
}, },
), ),

View File

@@ -1,4 +1,9 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; 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 { import type {
DashboardChartTimeframe, DashboardChartTimeframe,
DashboardStockCashOrderRequest, DashboardStockCashOrderRequest,
@@ -8,11 +13,6 @@ import type {
DashboardStockOverviewResponse, DashboardStockOverviewResponse,
DashboardStockSearchResponse, DashboardStockSearchResponse,
} from "@/features/trade/types/trade.types"; } 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 호출 * 종목 검색 API 호출
@@ -32,12 +32,10 @@ export async function fetchStockSearch(
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardStockSearchResponse | DashboardStockSearchResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "종목 검색 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.",
);
} }
return payload as DashboardStockSearchResponse; return payload as DashboardStockSearchResponse;
@@ -56,19 +54,19 @@ export async function fetchStockOverview(
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`, `/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
{ {
method: "GET", method: "GET",
headers: buildKisRequestHeaders(credentials), headers: buildKisRequestHeaders(credentials, {
includeSessionOverride: true,
}),
cache: "no-store", cache: "no-store",
}, },
); );
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardStockOverviewResponse | DashboardStockOverviewResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "종목 조회 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.",
);
} }
return payload as DashboardStockOverviewResponse; return payload as DashboardStockOverviewResponse;
@@ -88,7 +86,9 @@ export async function fetchStockOrderBook(
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`, `/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
{ {
method: "GET", method: "GET",
headers: buildKisRequestHeaders(credentials), headers: buildKisRequestHeaders(credentials, {
includeSessionOverride: true,
}),
cache: "no-store", cache: "no-store",
signal, signal,
}, },
@@ -96,12 +96,10 @@ export async function fetchStockOrderBook(
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardStockOrderBookResponse | DashboardStockOrderBookResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "호가 조회 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "호가 조회 중 오류가 발생했습니다.",
);
} }
return payload as DashboardStockOrderBookResponse; return payload as DashboardStockOrderBookResponse;
@@ -124,18 +122,18 @@ export async function fetchStockChart(
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, { const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
method: "GET", method: "GET",
headers: buildKisRequestHeaders(credentials), headers: buildKisRequestHeaders(credentials, {
includeSessionOverride: true,
}),
cache: "no-store", cache: "no-store",
}); });
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardStockChartResponse | DashboardStockChartResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "차트 조회 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "차트 조회 중 오류가 발생했습니다.",
);
} }
return payload as DashboardStockChartResponse; return payload as DashboardStockChartResponse;
@@ -152,51 +150,21 @@ export async function fetchOrderCash(
): Promise<DashboardStockCashOrderResponse> { ): Promise<DashboardStockCashOrderResponse> {
const response = await fetch("/api/kis/domestic/order-cash", { const response = await fetch("/api/kis/domestic/order-cash", {
method: "POST", method: "POST",
headers: buildKisRequestHeaders(credentials, { jsonContentType: true }), headers: buildKisRequestHeaders(credentials, {
jsonContentType: true,
includeSessionOverride: true,
}),
body: JSON.stringify(request), body: JSON.stringify(request),
cache: "no-store", cache: "no-store",
}); });
const payload = (await response.json()) as DashboardStockCashOrderResponse; const payload = (await response.json()) as
| DashboardStockCashOrderResponse
| KisApiErrorPayload;
if (!response.ok) { 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<string, string> = {
"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;
}
} }

View File

@@ -33,98 +33,18 @@ import {
toRealtimeTickBar, toRealtimeTickBar,
upsertRealtimeBar, upsertRealtimeBar,
} from "./chart-utils"; } from "./chart-utils";
import {
const UP_COLOR = "#ef4444"; areBarsEqual,
const MINUTE_SYNC_INTERVAL_MS = 30000; type ChartPalette,
const REALTIME_STALE_THRESHOLD_MS = 12000; CHART_MIN_HEIGHT,
const CHART_MIN_HEIGHT = 220; DEFAULT_CHART_PALETTE,
getChartPaletteFromCssVars,
interface ChartPalette { MINUTE_SYNC_INTERVAL_MS,
backgroundColor: string; MINUTE_TIMEFRAMES,
downColor: string; PERIOD_TIMEFRAMES,
volumeDownColor: string; REALTIME_STALE_THRESHOLD_MS,
textColor: string; UP_COLOR,
borderColor: string; } from "./stock-line-chart-meta";
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: "주" },
];
interface StockLineChartProps { interface StockLineChartProps {
symbol?: string; symbol?: string;
@@ -161,6 +81,7 @@ export function StockLineChart({
const lastRealtimeAppliedAtRef = useRef(0); const lastRealtimeAppliedAtRef = useRef(0);
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE); const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
const renderableBarsRef = useRef<ChartBar[]>([]); const renderableBarsRef = useRef<ChartBar[]>([]);
const initialThemeModeRef = useRef<"light" | "dark">("light");
const activeThemeMode: "light" | "dark" = const activeThemeMode: "light" | "dark" =
resolvedTheme === "dark" resolvedTheme === "dark"
@@ -172,6 +93,10 @@ export function StockLineChart({
? "dark" ? "dark"
: "light"; : "light";
useEffect(() => {
initialThemeModeRef.current = activeThemeMode;
}, [activeThemeMode]);
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태 // 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
const loadingMoreRef = useRef(false); const loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {}); const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
@@ -244,8 +169,10 @@ export function StockLineChart({
})), })),
); );
} catch (error) { } catch (error) {
if (process.env.NODE_ENV !== "production") {
console.error("Failed to render chart series data:", error); console.error("Failed to render chart series data:", error);
} }
}
}, []); }, []);
/** /**
@@ -296,7 +223,7 @@ export function StockLineChart({
if (!container || chartRef.current) return; if (!container || chartRef.current) return;
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다. // 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
const palette = getChartPaletteFromCssVars(activeThemeMode); const palette = getChartPaletteFromCssVars(initialThemeModeRef.current);
chartPaletteRef.current = palette; chartPaletteRef.current = palette;
const chart = createChart(container, { const chart = createChart(container, {
@@ -411,7 +338,7 @@ export function StockLineChart({
volumeSeriesRef.current = null; volumeSeriesRef.current = null;
setIsChartReady(false); setIsChartReady(false);
}; };
}, [activeThemeMode]); }, []);
useEffect(() => { useEffect(() => {
const chart = chartRef.current; const chart = chartRef.current;
@@ -460,6 +387,7 @@ export function StockLineChart({
initialLoadCompleteRef.current = false; initialLoadCompleteRef.current = false;
let disposed = false; let disposed = false;
let initialLoadTimer: number | null = null;
const load = async () => { const load = async () => {
setIsLoading(true); setIsLoading(true);
@@ -508,7 +436,7 @@ export function StockLineChart({
setBars(mergedBars); setBars(mergedBars);
setNextCursor(resolvedNextCursor); setNextCursor(resolvedNextCursor);
window.setTimeout(() => { initialLoadTimer = window.setTimeout(() => {
if (!disposed) initialLoadCompleteRef.current = true; if (!disposed) initialLoadCompleteRef.current = true;
}, 350); }, 350);
} catch (error) { } catch (error) {
@@ -531,6 +459,9 @@ export function StockLineChart({
return () => { return () => {
disposed = true; disposed = true;
if (initialLoadTimer !== null) {
window.clearTimeout(initialLoadTimer);
}
}; };
}, [credentials, symbol, timeframe]); }, [credentials, symbol, timeframe]);
@@ -550,7 +481,7 @@ export function StockLineChart({
*/ */
useEffect(() => { useEffect(() => {
if (!latestTick) return; if (!latestTick) return;
if (bars.length === 0) return; if (renderableBarsRef.current.length === 0) return;
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`; const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
if (lastRealtimeKeyRef.current === dedupeKey) return; if (lastRealtimeKeyRef.current === dedupeKey) return;
@@ -561,7 +492,7 @@ export function StockLineChart({
lastRealtimeKeyRef.current = dedupeKey; lastRealtimeKeyRef.current = dedupeKey;
lastRealtimeAppliedAtRef.current = Date.now(); lastRealtimeAppliedAtRef.current = Date.now();
setBars((prev) => upsertRealtimeBar(prev, realtimeBar)); setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
}, [bars.length, latestTick, timeframe]); }, [latestTick, timeframe]);
/** /**
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다. * @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
@@ -715,25 +646,3 @@ export function StockLineChart({
</div> </div>
); );
} }
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;
}

View File

@@ -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;
}

View File

@@ -12,6 +12,7 @@ import type {
DashboardOrderSide, DashboardOrderSide,
DashboardStockItem, DashboardStockItem,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import { parseKisAccountParts } from "@/lib/kis/account";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface OrderFormProps { interface OrderFormProps {
@@ -60,6 +61,14 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
return; return;
} }
const accountParts = parseKisAccountParts(verifiedCredentials.accountNo);
if (!accountParts) {
alert(
"계좌번호 형식이 올바르지 않습니다. 설정에서 8-2 형식(예: 12345678-01)으로 다시 확인해 주세요.",
);
return;
}
const response = await placeOrder( const response = await placeOrder(
{ {
symbol: stock.symbol, symbol: stock.symbol,
@@ -67,8 +76,8 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
orderType: "limit", orderType: "limit",
price: priceNum, price: priceNum,
quantity: qtyNum, quantity: qtyNum,
accountNo: verifiedCredentials.accountNo, accountNo: `${accountParts.accountNo}-${accountParts.accountProductCode}`,
accountProductCode: "01", accountProductCode: accountParts.accountProductCode,
}, },
verifiedCredentials, verifiedCredentials,
); );
@@ -84,8 +93,17 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
parseInt(quantity.replace(/,/g, "") || "0", 10); parseInt(quantity.replace(/,/g, "") || "0", 10);
const setPercent = (pct: string) => { const setPercent = (pct: string) => {
// TODO: 계좌 잔고 연동 시 퍼센트 자동 계산으로 교체 const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100;
console.log("Percent clicked:", pct); 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); const isMarketDataAvailable = Boolean(stock);

View File

@@ -1,15 +1,26 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import type { import type {
DashboardRealtimeTradeTick, DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse, DashboardStockOrderBookResponse,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils"; import type { BookRow } from "./orderbook-utils";
import { AnimatedQuantity } from "./AnimatedQuantity"; import {
buildBookRows,
// ─── 타입 ─────────────────────────────────────────────── buildFallbackLevelsFromTick,
hasOrderBookLevelData,
resolveReferencePrice,
} from "./orderbook-utils";
import {
BookHeader,
BookSideRows,
CumulativeRows,
CurrentPriceBar,
OrderBookSkeleton,
SummaryPanel,
TradeTape,
} from "./orderbook-sections";
interface OrderBookProps { interface OrderBookProps {
symbol?: string; symbol?: string;
@@ -20,228 +31,10 @@ interface OrderBookProps {
isLoading?: boolean; isLoading?: boolean;
} }
interface BookRow {
price: number;
size: number;
changeValue: number | null;
isHighlighted: boolean;
}
/** /**
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다. * @description 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다. * @see features/trade/components/orderbook/orderbook-utils.ts 호가 계산/포맷 유틸
*/ * @see features/trade/components/orderbook/orderbook-sections.tsx 호가/체결/요약 UI 섹션
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호가와 체결 목록을 표시합니다.
*/ */
export function OrderBook({ export function OrderBook({
symbol, symbol,
@@ -256,21 +49,23 @@ export function OrderBook({
() => buildFallbackLevelsFromTick(latestTick), () => buildFallbackLevelsFromTick(latestTick),
[latestTick], [latestTick],
); );
const hasRealtimeLevelData = useMemo(
() => hasOrderBookLevelData(realtimeLevels),
[realtimeLevels],
);
const levels = useMemo(() => { const levels = useMemo(() => {
if (hasOrderBookLevelData(realtimeLevels)) return realtimeLevels; if (hasRealtimeLevelData) return realtimeLevels;
return fallbackLevelsFromTick; return fallbackLevelsFromTick;
}, [fallbackLevelsFromTick, realtimeLevels]); }, [fallbackLevelsFromTick, hasRealtimeLevelData, realtimeLevels]);
const isTickFallbackActive =
!hasOrderBookLevelData(realtimeLevels) && fallbackLevelsFromTick.length > 0; const isTickFallbackActive =
!hasRealtimeLevelData && fallbackLevelsFromTick.length > 0;
// 체결가: tick에서 우선, 없으면 0
const latestPrice = const latestPrice =
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0; latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
// 등락률 기준가
const basePrice = resolveReferencePrice({ referencePrice, latestTick }); const basePrice = resolveReferencePrice({ referencePrice, latestTick });
// 매도호가 (역순: 10호가 → 1호가)
const askRows: BookRow[] = useMemo( const askRows: BookRow[] = useMemo(
() => () =>
buildBookRows({ buildBookRows({
@@ -282,7 +77,6 @@ export function OrderBook({
[levels, basePrice, latestPrice], [levels, basePrice, latestPrice],
); );
// 매수호가 (1호가 → 10호가)
const bidRows: BookRow[] = useMemo( const bidRows: BookRow[] = useMemo(
() => () =>
buildBookRows({ buildBookRows({
@@ -294,31 +88,42 @@ export function OrderBook({
[levels, basePrice, latestPrice], [levels, basePrice, latestPrice],
); );
const askMax = Math.max(1, ...askRows.map((r) => r.size)); const askMax = useMemo(() => Math.max(1, ...askRows.map((r) => r.size)), [askRows]);
const bidMax = Math.max(1, ...bidRows.map((r) => r.size)); const bidMax = useMemo(() => Math.max(1, ...bidRows.map((r) => r.size)), [bidRows]);
const mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]); const mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]);
const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]); const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]);
// 스프레드·수급 불균형 const { bestAsk, spread, totalAsk, totalBid, imbalance } = useMemo(() => {
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0; const resolvedBestAsk = levels.find((level) => level.askPrice > 0)?.askPrice ?? 0;
const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0; const resolvedBestBid = levels.find((level) => level.bidPrice > 0)?.bidPrice ?? 0;
const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0; const resolvedSpread =
const totalAsk = resolvedBestAsk > 0 && resolvedBestBid > 0
? resolvedBestAsk - resolvedBestBid
: 0;
const resolvedTotalAsk =
orderBook?.totalAskSize && orderBook.totalAskSize > 0 orderBook?.totalAskSize && orderBook.totalAskSize > 0
? orderBook.totalAskSize ? orderBook.totalAskSize
: (latestTick?.totalAskSize ?? 0); : (latestTick?.totalAskSize ?? 0);
const totalBid = const resolvedTotalBid =
orderBook?.totalBidSize && orderBook.totalBidSize > 0 orderBook?.totalBidSize && orderBook.totalBidSize > 0
? orderBook.totalBidSize ? orderBook.totalBidSize
: (latestTick?.totalBidSize ?? 0); : (latestTick?.totalBidSize ?? 0);
const imbalance = const resolvedImbalance =
totalAsk + totalBid > 0 resolvedTotalAsk + resolvedTotalBid > 0
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100 ? ((resolvedTotalBid - resolvedTotalAsk) /
(resolvedTotalAsk + resolvedTotalBid)) *
100
: 0; : 0;
// 체결가 행 중앙 스크롤 return {
bestAsk: resolvedBestAsk,
spread: resolvedSpread,
totalAsk: resolvedTotalAsk,
totalBid: resolvedTotalBid,
imbalance: resolvedImbalance,
};
}, [latestTick?.totalAskSize, latestTick?.totalBidSize, levels, orderBook]);
// ─── 빈/로딩 상태 ───
if (!symbol) { if (!symbol) {
return ( return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -340,7 +145,7 @@ export function OrderBook({
return ( return (
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-[linear-gradient(180deg,rgba(13,13,24,0.95),rgba(8,8,18,0.98))]"> <div className="flex h-full min-h-0 flex-col bg-white dark:bg-[linear-gradient(180deg,rgba(13,13,24,0.95),rgba(8,8,18,0.98))]">
<Tabs defaultValue="normal" className="h-full min-h-0"> <Tabs defaultValue="normal" className="h-full min-h-0">
{/* 탭 헤더 */} {/* ========== ORDERBOOK TAB HEADER ========== */}
<div className="border-b border-border/60 bg-muted/15 px-2 pt-2 dark:border-brand-800/50 dark:bg-brand-950/60"> <div className="border-b border-border/60 bg-muted/15 px-2 pt-2 dark:border-brand-800/50 dark:bg-brand-950/60">
<TabsList variant="line" className="w-full justify-start"> <TabsList variant="line" className="w-full justify-start">
<TabsTrigger value="normal" className="px-3"> <TabsTrigger value="normal" className="px-3">
@@ -355,10 +160,9 @@ export function OrderBook({
</TabsList> </TabsList>
</div> </div>
{/* ── 일반호가 탭 ── */} {/* ========== ORDERBOOK NORMAL TAB ========== */}
<TabsContent value="normal" className="min-h-0 flex-1"> <TabsContent value="normal" className="min-h-0 flex-1">
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_220px_220px] 2xl:grid-cols-[minmax(0,1fr)_250px_240px] xl:overflow-hidden"> <div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_220px_220px] 2xl:grid-cols-[minmax(0,1fr)_250px_240px] xl:overflow-hidden">
{/* 호가 테이블 */}
<div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45"> <div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45">
{isTickFallbackActive && ( {isTickFallbackActive && (
<div className="border-b border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200"> <div className="border-b border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
@@ -368,7 +172,6 @@ export function OrderBook({
)} )}
<BookHeader /> <BookHeader />
<div className="xl:hidden"> <div className="xl:hidden">
{/* 모바일: 양방향 호가가 항상 보이도록 6호가씩 고정 노출 */}
<BookSideRows rows={mobileAskRows} side="ask" maxSize={askMax} /> <BookSideRows rows={mobileAskRows} side="ask" maxSize={askMax} />
<CurrentPriceBar <CurrentPriceBar
latestPrice={latestPrice} latestPrice={latestPrice}
@@ -380,7 +183,6 @@ export function OrderBook({
<BookSideRows rows={mobileBidRows} side="bid" maxSize={bidMax} /> <BookSideRows rows={mobileBidRows} side="bid" maxSize={bidMax} />
</div> </div>
<ScrollArea className="hidden min-h-0 flex-1 xl:block"> <ScrollArea className="hidden min-h-0 flex-1 xl:block">
{/* 데스크톱: 전체 호가 스크롤 */}
<BookSideRows rows={askRows} side="ask" maxSize={askMax} /> <BookSideRows rows={askRows} side="ask" maxSize={askMax} />
<CurrentPriceBar <CurrentPriceBar
latestPrice={latestPrice} latestPrice={latestPrice}
@@ -393,14 +195,12 @@ export function OrderBook({
</ScrollArea> </ScrollArea>
</div> </div>
{/* 체결량 영역 */}
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r"> <div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
<div className="h-full min-h-0"> <div className="h-full min-h-0">
<TradeTape ticks={recentTicks} maxRows={10} /> <TradeTape ticks={recentTicks} maxRows={10} />
</div> </div>
</div> </div>
{/* 실시간 정보 영역 */}
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0"> <div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0">
<div className="h-full min-h-0"> <div className="h-full min-h-0">
<SummaryPanel <SummaryPanel
@@ -416,7 +216,7 @@ export function OrderBook({
</div> </div>
</TabsContent> </TabsContent>
{/* ── 누적호가 탭 ── */} {/* ========== ORDERBOOK CUMULATIVE TAB ========== */}
<TabsContent value="cumulative" className="min-h-0 flex-1"> <TabsContent value="cumulative" className="min-h-0 flex-1">
<ScrollArea className="h-full border-t dark:border-brand-800/45"> <ScrollArea className="h-full border-t dark:border-brand-800/45">
<div className="p-3"> <div className="p-3">
@@ -430,7 +230,7 @@ export function OrderBook({
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
{/* ── 호가주문 탭 ── */} {/* ========== ORDERBOOK ORDER TAB ========== */}
<TabsContent value="order" className="min-h-0 flex-1"> <TabsContent value="order" className="min-h-0 flex-1">
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/75"> <div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/75">
. .
@@ -440,454 +240,3 @@ export function OrderBook({
</div> </div>
); );
} }
// ─── 하위 컴포넌트 ──────────────────────────────────────
/**
* @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 (
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-blue-50/60 via-amber-50/90 to-red-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-blue-950/30 dark:via-amber-900/30 dark:to-red-950/30 xl:h-10">
<div className="px-2 text-right text-[10px] font-semibold text-blue-600 dark:text-blue-400">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex flex-col items-center justify-center">
<span
className={cn(
"text-base leading-none font-bold tabular-nums",
latestPrice > 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) : "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[11px] font-semibold leading-none",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-600 dark:text-blue-400",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-semibold text-red-600 dark:text-red-400">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
);
}
/** 호가 표 헤더 */
function BookHeader() {
return (
<div className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-blue-50/40 via-muted/20 to-red-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-blue-950/30 dark:via-brand-900/40 dark:to-red-950/30 dark:text-brand-100/80">
<div className="flex items-center justify-end px-2 text-blue-600/80 dark:text-blue-400/80">
</div>
<div className="flex items-center justify-center border-x border-border/50 dark:border-brand-800/40">
</div>
<div className="flex items-center justify-start px-2 text-red-600/80 dark:text-red-400/80">
</div>
</div>
);
}
/** 매도 또는 매수 호가 행 목록 */
function BookSideRows({
rows,
side,
maxSize,
}: {
rows: BookRow[];
side: "ask" | "bid";
maxSize: number;
}) {
const isAsk = side === "ask";
return (
<div
className={cn(
isAsk
? "bg-linear-to-r from-blue-50/40 via-blue-50/10 to-transparent dark:from-blue-950/35 dark:via-blue-950/10 dark:to-transparent"
: "bg-linear-to-r from-transparent via-red-50/10 to-red-50/45 dark:from-transparent dark:via-red-950/10 dark:to-red-950/35",
)}
>
{rows.map((row, i) => {
const ratio =
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
return (
<div
key={`${side}-${row.price}-${i}`}
className={cn(
"grid h-[29px] grid-cols-3 border-b border-border/30 text-[11px] transition-colors hover:bg-muted/15 xl:h-[29px] xl:text-xs dark:border-brand-800/25 dark:hover:bg-brand-900/20",
row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/60 dark:bg-amber-800/35",
)}
>
{/* 매도잔량 (좌측) */}
<div className="relative flex items-center justify-end overflow-hidden px-2">
{isAsk && (
<>
<DepthBar ratio={ratio} side="ask" />
{row.size > 0 ? (
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="ask"
className="relative z-10"
/>
) : (
<span className="relative z-10 text-transparent">0</span>
)}
</>
)}
</div>
{/* 호가 (중앙) */}
<div
className={cn(
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
row.isHighlighted &&
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
)}
>
<span
className={cn(
"text-[12px] xl:text-[13px]",
isAsk ? "text-blue-600 dark:text-blue-400" : "text-red-600",
)}
>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
className={cn(
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
getChangeToneClass(row.changeValue),
)}
>
{row.changeValue === null
? "-"
: fmtSignedChange(row.changeValue)}
</span>
</div>
{/* 매수잔량 (우측) */}
<div className="relative flex items-center justify-start overflow-hidden px-1">
{!isAsk && (
<>
<DepthBar ratio={ratio} side="bid" />
{row.size > 0 ? (
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="bid"
className="relative z-10"
/>
) : (
<span className="relative z-10 text-transparent">0</span>
)}
</>
)}
</div>
</div>
);
})}
</div>
);
}
/** 우측 요약 패널 */
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 (
<div className="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
{summaryItems.map((item) => (
<SummaryMetricCell
key={item.label}
label={item.label}
value={item.value}
tone={item.tone}
/>
))}
</div>
</div>
);
}
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 (
<div className="flex h-full min-w-0 items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
<span className="truncate text-[11px] text-muted-foreground dark:text-brand-100/70">
{label}
</span>
<span
className={cn(
"shrink-0 text-xs font-semibold tabular-nums",
tone === "ask" && "text-blue-600 dark:text-blue-400",
tone === "bid" && "text-red-600",
)}
>
{value}
</span>
</div>
);
}
/** 잔량 깊이 바 */
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
if (ratio <= 0) return null;
return (
<div
className={cn(
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
side === "ask"
? "right-0.5 bg-blue-300/55 dark:bg-blue-700/50"
: "left-0.5 bg-red-300/60 dark:bg-red-600/45",
)}
style={{ width: `${ratio}%` }}
/>
);
}
/** 체결 목록 (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 = (
<div>
{visibleTicks.length === 0 && (
<div className="flex min-h-[96px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70 xl:min-h-[140px]">
.
</div>
)}
{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 (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-7 grid-cols-3 border-b border-border/40 px-2 text-[13px] dark:border-brand-800/35"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
getChangeToneClass(
t.change,
"text-foreground dark:text-brand-50",
),
)}
>
{fmt(t.price)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
volumeToneClass,
)}
>
{fmt(t.tradeVolume)}
</div>
</div>
);
})}
</div>
);
return (
<div className="flex min-h-0 flex-col bg-background dark:bg-brand-900/20">
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 px-2 text-xs font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex items-center"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
</div>
{shouldUseScrollableList ? (
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
) : (
tapeRows
)}
</div>
);
}
/** 누적호가 행 */
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 (
<div className="space-y-1">
{rows.map((r, i) => (
<div
key={i}
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs dark:border-brand-800/45 dark:bg-black/20"
>
<span className="tabular-nums text-blue-600 dark:text-blue-400">{fmt(r.askAcc)}</span>
<span className="text-center font-medium tabular-nums">
{fmt(r.price)}
</span>
<span className="text-right tabular-nums text-red-600 dark:text-red-400">
{fmt(r.bidAcc)}
</span>
</div>
))}
</div>
);
}
/** 로딩 스켈레톤 */
function OrderBookSkeleton() {
return (
<div className="flex h-full flex-col p-3">
<div className="mb-3 grid grid-cols-3 gap-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2">
{Array.from({ length: 16 }).map((_, i) => (
<Skeleton key={i} className="h-7 w-full" />
))}
</div>
</div>
);
}

View File

@@ -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 (
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-blue-50/60 via-amber-50/90 to-red-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-blue-950/30 dark:via-amber-900/30 dark:to-red-950/30 xl:h-10">
<div className="px-2 text-right text-[10px] font-semibold text-blue-600 dark:text-blue-400">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex flex-col items-center justify-center">
<span
className={cn(
"text-base leading-none font-bold tabular-nums",
latestPrice > 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) : "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[11px] font-semibold leading-none",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-600 dark:text-blue-400",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-semibold text-red-600 dark:text-red-400">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
);
}
/** 호가 표 헤더 */
export function BookHeader() {
return (
<div className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-blue-50/40 via-muted/20 to-red-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-blue-950/30 dark:via-brand-900/40 dark:to-red-950/30 dark:text-brand-100/80">
<div className="flex items-center justify-end px-2 text-blue-600/80 dark:text-blue-400/80">
</div>
<div className="flex items-center justify-center border-x border-border/50 dark:border-brand-800/40">
</div>
<div className="flex items-center justify-start px-2 text-red-600/80 dark:text-red-400/80">
</div>
</div>
);
}
/** 매도 또는 매수 호가 행 목록 */
export function BookSideRows({
rows,
side,
maxSize,
}: {
rows: BookRow[];
side: "ask" | "bid";
maxSize: number;
}) {
const isAsk = side === "ask";
return (
<div
className={cn(
isAsk
? "bg-linear-to-r from-blue-50/40 via-blue-50/10 to-transparent dark:from-blue-950/35 dark:via-blue-950/10 dark:to-transparent"
: "bg-linear-to-r from-transparent via-red-50/10 to-red-50/45 dark:from-transparent dark:via-red-950/10 dark:to-red-950/35",
)}
>
{rows.map((row, i) => {
const ratio =
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
return (
<div
key={`${side}-${row.price}-${i}`}
className={cn(
"grid h-[29px] grid-cols-3 border-b border-border/30 text-[11px] transition-colors hover:bg-muted/15 xl:h-[29px] xl:text-xs dark:border-brand-800/25 dark:hover:bg-brand-900/20",
row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/60 dark:bg-amber-800/35",
)}
>
<div className="relative flex items-center justify-end overflow-hidden px-2">
{isAsk && (
<>
<DepthBar ratio={ratio} side="ask" />
{row.size > 0 ? (
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="ask"
className="relative z-10"
/>
) : (
<span className="relative z-10 text-transparent">0</span>
)}
</>
)}
</div>
<div
className={cn(
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
row.isHighlighted &&
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
)}
>
<span
className={cn(
"text-[12px] xl:text-[13px]",
isAsk ? "text-blue-600 dark:text-blue-400" : "text-red-600",
)}
>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
className={cn(
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
getChangeToneClass(row.changeValue),
)}
>
{row.changeValue === null
? "-"
: fmtSignedChange(row.changeValue)}
</span>
</div>
<div className="relative flex items-center justify-start overflow-hidden px-1">
{!isAsk && (
<>
<DepthBar ratio={ratio} side="bid" />
{row.size > 0 ? (
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="bid"
className="relative z-10"
/>
) : (
<span className="relative z-10 text-transparent">0</span>
)}
</>
)}
</div>
</div>
);
})}
</div>
);
}
/** 우측 요약 패널 */
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 (
<div className="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
{summaryItems.map((item) => (
<SummaryMetricCell
key={item.label}
label={item.label}
value={item.value}
tone={item.tone}
/>
))}
</div>
</div>
);
}
/** 체결 목록 (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 = (
<div>
{visibleTicks.length === 0 && (
<div className="flex min-h-[96px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70 xl:min-h-[140px]">
.
</div>
)}
{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 (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-7 grid-cols-3 border-b border-border/40 px-2 text-[13px] dark:border-brand-800/35"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
getChangeToneClass(
t.change,
"text-foreground dark:text-brand-50",
),
)}
>
{fmt(t.price)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
volumeToneClass,
)}
>
{fmt(t.tradeVolume)}
</div>
</div>
);
})}
</div>
);
return (
<div className="flex min-h-0 flex-col bg-background dark:bg-brand-900/20">
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 px-2 text-xs font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex items-center"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
</div>
{shouldUseScrollableList ? (
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
) : (
tapeRows
)}
</div>
);
}
/** 누적호가 행 */
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 (
<div className="space-y-1">
{rows.map((r, i) => (
<div
key={i}
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs dark:border-brand-800/45 dark:bg-black/20"
>
<span className="tabular-nums text-blue-600 dark:text-blue-400">
{fmt(r.askAcc)}
</span>
<span className="text-center font-medium tabular-nums">
{fmt(r.price)}
</span>
<span className="text-right tabular-nums text-red-600 dark:text-red-400">
{fmt(r.bidAcc)}
</span>
</div>
))}
</div>
);
}
/** 로딩 스켈레톤 */
export function OrderBookSkeleton() {
return (
<div className="flex h-full flex-col p-3">
<div className="mb-3 grid grid-cols-3 gap-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2">
{Array.from({ length: 16 }).map((_, i) => (
<Skeleton key={i} className="h-7 w-full" />
))}
</div>
</div>
);
}
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 (
<div className="flex h-full min-w-0 items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
<span className="truncate text-[11px] text-muted-foreground dark:text-brand-100/70">
{label}
</span>
<span
className={cn(
"shrink-0 text-xs font-semibold tabular-nums",
tone === "ask" && "text-blue-600 dark:text-blue-400",
tone === "bid" && "text-red-600",
)}
>
{value}
</span>
</div>
);
}
/** 잔량 깊이 바 */
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
if (ratio <= 0) return null;
return (
<div
className={cn(
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
side === "ask"
? "right-0.5 bg-blue-300/55 dark:bg-blue-700/50"
: "left-0.5 bg-red-300/60 dark:bg-red-600/45",
)}
style={{ width: `${ratio}%` }}
/>
);
}

View File

@@ -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;
}

View File

@@ -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 },
],
},
];

View File

@@ -1,10 +1,48 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { z } from "zod";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type { import type {
DashboardStockCashOrderRequest, DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse, DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import { fetchOrderCash } from "@/features/trade/apis/kis-stock.api"; 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() { export function useOrder() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -28,6 +66,15 @@ export function useOrder() {
setResult(null); setResult(null);
try { try {
const validationResult = placeOrderRequestSchema.safeParse(request);
if (!validationResult.success) {
setError(
validationResult.error.issues[0]?.message ??
"주문 요청 값이 올바르지 않습니다.",
);
return null;
}
const data = await fetchOrderCash(request, credentials); const data = await fetchOrderCash(request, credentials);
setResult(data); setResult(data);
return data; return data;

View File

@@ -177,8 +177,17 @@ export interface DashboardStockCashOrderRequest {
orderType: DashboardOrderType; orderType: DashboardOrderType;
quantity: number; quantity: number;
price: number; price: number;
/**
* KIS 계좌번호(권장: 8-2, 예: 12345678-01)
* @see lib/kis/account.ts parseKisAccountParts 서버 주문 라우트에서 8-2 파싱에 사용합니다.
*/
accountNo: string; accountNo: string;
accountProductCode: string; /**
* 계좌상품코드(2자리, 선택)
* @description accountNo가 8-2 형식이면 서버에서 자동 파싱합니다.
* @see app/api/kis/domestic/order-cash/route.ts 주문 요청 검증/계좌 파싱
*/
accountProductCode?: string;
} }
/** /**

View File

@@ -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 <div>Loading...</div>;
* if (error) return <div>Error: {error.message}</div>;
* if (!user) return <div>Not logged in</div>;
*
* return <div>Welcome, {user.email}</div>;
* }
* ```
*/
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,
});
}

View File

@@ -1,4 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
import type { KisCredentialInput } from "@/lib/kis/config"; import type { KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config"; import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
@@ -60,9 +61,11 @@ async function issueKisApprovalKey(
const payload = tryParseApprovalResponse(rawText); const payload = tryParseApprovalResponse(rawText);
if (!response.ok || !payload.approval_key) { if (!response.ok || !payload.approval_key) {
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd] const detail = buildKisErrorDetail({
.filter(Boolean) message: payload.msg1,
.join(" / "); msgCode: payload.msg_cd,
extraMessages: [payload.error_description, payload.error],
});
throw new Error( throw new Error(
detail detail

View File

@@ -1,5 +1,6 @@
import type { KisCredentialInput } from "@/lib/kis/config"; import type { KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config"; import { getKisConfig } from "@/lib/kis/config";
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
import { getKisAccessToken } from "@/lib/kis/token"; import { getKisAccessToken } from "@/lib/kis/token";
/** /**
@@ -57,7 +58,11 @@ export async function kisGet<TOutput>(
const payload = tryParseKisEnvelope<TOutput>(rawText); const payload = tryParseKisEnvelope<TOutput>(rawText);
if (!response.ok) { 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( throw new Error(
detail detail
? `KIS API 요청 실패 (${response.status}): ${detail}` ? `KIS API 요청 실패 (${response.status}): ${detail}`
@@ -66,7 +71,10 @@ export async function kisGet<TOutput>(
} }
if (payload.rt_cd && payload.rt_cd !== "0") { 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 비즈니스 오류가 발생했습니다."); throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
} }
@@ -112,7 +120,11 @@ export async function kisPost<TOutput>(
const payload = tryParseKisEnvelope<TOutput>(rawText); const payload = tryParseKisEnvelope<TOutput>(rawText);
if (!response.ok) { 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( throw new Error(
detail detail
? `KIS API 요청 실패 (${response.status}): ${detail}` ? `KIS API 요청 실패 (${response.status}): ${detail}`
@@ -121,7 +133,10 @@ export async function kisPost<TOutput>(
} }
if (payload.rt_cd && payload.rt_cd !== "0") { 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 비즈니스 오류가 발생했습니다."); throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
} }

View File

@@ -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<T>(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<T>(value: unknown) {
const rows = parseRows<T>(value);
return rows[0];
}
/**
* @description 지수 output을 단일 레코드로 정규화합니다.
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
*/
export function parseIndexRow<T extends object>(
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<number | undefined>) {
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<number | undefined>;
}) {
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<number | undefined>) {
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<number | undefined>) {
const nonZero = values.find(
(value): value is number => value !== undefined && value !== 0,
);
if (nonZero !== undefined) return nonZero;
return firstDefinedNumber(...values);
}

View File

@@ -8,6 +8,28 @@ import { kisGet } from "@/lib/kis/client";
import type { KisCredentialInput } from "@/lib/kis/config"; import type { KisCredentialInput } from "@/lib/kis/config";
import { normalizeTradingEnv } from "@/lib/kis/config"; import { normalizeTradingEnv } from "@/lib/kis/config";
import type { KisAccountParts } from "@/lib/kis/account"; 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 { interface KisBalanceOutput1Row {
pdno?: string; pdno?: string;
@@ -478,7 +500,7 @@ export async function getDomesticDashboardIndices(
credentials, credentials,
); );
const row = parseIndexRow(response.output); const row = parseIndexRow<KisIndexOutputRow>(response.output);
const rawChange = toNumber(row.bstp_nmix_prdy_vrss); const rawChange = toNumber(row.bstp_nmix_prdy_vrss);
const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt); const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt);
@@ -780,309 +802,3 @@ async function getDomesticTradeJournal(
summary, 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<T>(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<T>(value: unknown) {
const rows = parseRows<T>(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<number | undefined>) {
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<number | undefined>;
}) {
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<number | undefined>) {
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<number | undefined>) {
const nonZero = values.find(
(value): value is number => value !== undefined && value !== 0,
);
if (nonZero !== undefined) return nonZero;
return firstDefinedNumber(...values);
}

349
lib/kis/domestic-helpers.ts Normal file
View File

@@ -0,0 +1,349 @@
import type {
DashboardChartTimeframe,
StockCandlePoint,
} from "@/features/trade/types/trade.types";
type DomesticChartRow = Record<string, unknown>;
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<string | undefined>) {
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<number | undefined>) {
return values.find((value) => value !== undefined);
}
export function firstDefinedString(...values: Array<string | undefined>) {
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<number, StockCandlePoint>();
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")}`;
}

View File

@@ -10,6 +10,27 @@ import {
resolveDomesticKisSession, resolveDomesticKisSession,
shouldUseOvertimeOrderBookApi, shouldUseOvertimeOrderBookApi,
} from "@/lib/kis/domestic-market-session"; } 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 * @file lib/kis/domestic.ts
@@ -59,18 +80,6 @@ interface KisDomesticDailyPriceOutput {
stck_clpr?: string; stck_clpr?: string;
acml_vol?: 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 { export interface KisDomesticOrderBookOutput {
stck_prpr?: string; stck_prpr?: string;
total_askp_rsqn?: 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<string | undefined>) {
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( function getDomesticMarketPhaseInKst(
now = new Date(), now = new Date(),
sessionOverride?: string | null, sessionOverride?: string | null,
@@ -484,231 +412,16 @@ function getDomesticMarketPhaseInKst(
); );
} }
function firstDefinedNumber(...values: Array<number | undefined>) {
return values.find((value) => value !== undefined);
}
function firstDefinedString(...values: Array<string | undefined>) {
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() { function resolvePriceMarketDivCode() {
return "J"; return "J";
} }
function firstPositive(...values: number[]) {
return values.find((value) => value > 0) ?? 0;
}
export interface DomesticChartResult { export interface DomesticChartResult {
candles: StockCandlePoint[]; candles: StockCandlePoint[];
nextCursor: string | null; nextCursor: string | null;
hasMore: boolean; 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<string, unknown>;
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<number, StockCandlePoint>();
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 종목코드 * @param symbol 종목코드
@@ -794,7 +507,7 @@ export async function getDomesticChart(
// ── 분봉 (1m / 30m / 1h) ── // ── 분봉 (1m / 30m / 1h) ──
const minuteBucket = minutesForTimeframe(timeframe); const minuteBucket = minutesForTimeframe(timeframe);
let rawRows: KisDomesticItemChartRow[] = []; let rawRows: Array<Record<string, unknown>> = [];
let nextCursor: string | null = null; let nextCursor: string | null = null;
// Case A: 과거 데이터 조회 (커서 존재) // Case A: 과거 데이터 조회 (커서 존재)
@@ -896,14 +609,3 @@ export async function getDomesticChart(
return { candles, hasMore: Boolean(nextCursor), nextCursor }; 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`;
}

188
lib/kis/error-codes.ts Normal file
View File

@@ -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<string | undefined>;
}
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<string>();
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(" / ");
}

View File

@@ -1,11 +1,12 @@
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config"; import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { z } from "zod";
interface KisCredentialRequestBody { const kisCredentialRequestBodySchema = z.object({
appKey?: string; appKey: z.string().trim().optional(),
appSecret?: string; appSecret: z.string().trim().optional(),
tradingEnv?: string; tradingEnv: z.string().optional(),
} });
/** /**
* @description 요청 본문에서 KIS 인증 정보를 파싱합니다. * @description 요청 본문에서 KIS 인증 정보를 파싱합니다.
@@ -14,14 +15,17 @@ interface KisCredentialRequestBody {
export async function parseKisCredentialRequest( export async function parseKisCredentialRequest(
request: NextRequest, request: NextRequest,
): Promise<KisCredentialInput> { ): Promise<KisCredentialInput> {
let body: KisCredentialRequestBody = {}; let rawBody: unknown = {};
try { try {
body = (await request.json()) as KisCredentialRequestBody; rawBody = (await request.json()) as unknown;
} catch { } catch {
// 빈 본문 또는 JSON 파싱 실패는 아래 필수값 검증에서 처리합니다. // 빈 본문 또는 JSON 파싱 실패는 아래 필수값 검증에서 처리합니다.
} }
const parsedBody = kisCredentialRequestBodySchema.safeParse(rawBody);
const body = parsedBody.success ? parsedBody.data : {};
return { return {
appKey: body.appKey?.trim(), appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(), appSecret: body.appSecret?.trim(),

View File

@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { clearKisApprovalKeyCache } from "@/lib/kis/approval"; import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
import type { KisConfig, KisCredentialInput } from "@/lib/kis/config"; import type { KisConfig, KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config"; import { getKisConfig } from "@/lib/kis/config";
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
/** /**
* @file lib/kis/token.ts * @file lib/kis/token.ts
@@ -218,9 +219,11 @@ function buildTokenIssueBody(config: KisConfig) {
* @see issueKisToken 토큰 발급 실패 에러 메시지 구성 * @see issueKisToken 토큰 발급 실패 에러 메시지 구성
*/ */
function buildTokenIssueDetail(payload: KisTokenResponse) { function buildTokenIssueDetail(payload: KisTokenResponse) {
return [payload.msg1, payload.error_description, payload.error, payload.msg_cd] return buildKisErrorDetail({
.filter(Boolean) message: payload.msg1,
.join(" / "); 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"; const isSuccessCode = code === "" || code === "200";
if (!response.ok || !isSuccessCode) { 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( throw new Error(
detail detail

View File

@@ -1,9 +1,17 @@
"use client"; "use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import dynamic from "next/dynamic";
import { useState } from "react"; import { useState } from "react";
const ReactQueryDevtools = dynamic(
() =>
import("@tanstack/react-query-devtools").then(
(mod) => mod.ReactQueryDevtools,
),
{ ssr: false },
);
/** /**
* [React Query Provider] * [React Query Provider]
* *
@@ -41,7 +49,9 @@ export function QueryProvider({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
{/* ========== DevTools (개발 환경에서만 표시) ========== */} {/* ========== DevTools (개발 환경에서만 표시) ========== */}
{process.env.NODE_ENV === "development" ? (
<ReactQueryDevtools initialIsOpen={false} position="bottom" /> <ReactQueryDevtools initialIsOpen={false} position="bottom" />
) : null}
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@@ -2,31 +2,31 @@ import { type NextRequest } from "next/server";
import { updateSession } from "@/utils/supabase/middleware"; import { updateSession } from "@/utils/supabase/middleware";
/** /**
* [Next.js ] * [Next.js Proxy ]
* *
* . * .
* . * .
*/ */
export async function middleware(request: NextRequest) { export async function proxy(request: NextRequest) {
// 방금 만든 updateSession 함수를 호출하여 쿠키/세션을 관리합니다. // 방금 만든 updateSession 함수를 호출하여 쿠키/세션을 관리합니다.
return await updateSession(request); return await updateSession(request);
} }
/** /**
* [ ] * [Proxy ]
* *
* '어떤 경로' , '어떤 경로는 무시할지' . * Proxy '어떤 경로' , '어떤 경로는 무시할지' .
*/ */
export const config = { export const config = {
matcher: [ matcher: [
/* /*
* (Regex) (exclude) : * (Regex) (exclude) Proxy :
* - _next/static ( ) * - _next/static ( )
* - _next/image ( API) * - _next/image ( API)
* - favicon.ico ( ) * - favicon.ico ( )
* - .svg, .png, .jpg * - .svg, .png, .jpg
* *
* , html . * , html Proxy .
*/ */
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
], ],

View File

@@ -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<User>) => void;
logout: () => void;
}
/**
* [인증 스토어]
*
* 전역 사용자 인증 상태를 관리합니다.
* - localStorage에 자동 저장 (persist 미들웨어)
* - 페이지 새로고침 시에도 상태 유지
*
* @example
* ```tsx
* import { useAuthStore } from '@/stores/auth-store';
*
* function Profile() {
* const { user, isAuthenticated, setUser } = useAuthStore();
*
* if (!isAuthenticated) return <Login />;
* return <div>Welcome, {user?.email}</div>;
* }
* ```
*/
export const useAuthStore = create<AuthState>()(
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 키 이름
},
),
);

View File

@@ -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<Toast, "id">) => 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 (
* <header>
* <button onClick={toggleSidebar}>Menu</button>
* <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
* Toggle Theme
* </button>
* </header>
* );
* }
* ```
*/
export const useUIStore = create<UIState>()(
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,
}),
},
),
);