47 Commits

Author SHA1 Message Date
406af7408a 스킬 정리 및 리팩토링 2026-02-26 09:05:17 +09:00
4c52d6d82f 한국투자증권 전체 api 문서 2026-02-25 10:02:06 +09:00
076f27a12a 호가창 매수,매도 색상 변경 2026-02-25 10:01:58 +09:00
f875e338eb codex rule 업데이트 2026-02-25 10:01:49 +09:00
a16af8ad7d 트레이딩창 UI 배치 및 UX 수정 및 기획서 추가 2026-02-24 15:48:02 +09:00
19ebb1c6ea 실시간 웹소켓 리팩토링 2026-02-23 17:09:16 +09:00
276ef09d89 차트 수정 2026-02-13 16:41:10 +09:00
b73867c65d [공통컴포넌트] alert 제작 2026-02-13 16:12:08 +09:00
7c194d7452 보안 점검 및 대시보드 문구 수정 2026-02-13 15:44:41 +09:00
1ac907cd27 대시보드 실시간 기능 추가 2026-02-13 12:56:56 +09:00
12feeb2775 대시보드 추가기능 + 계좌인증 2026-02-12 17:16:41 +09:00
434a814246 대시보드 구현 2026-02-12 14:20:07 +09:00
8f1d75b4d5 정리 2026-02-12 10:24:03 +09:00
3cea3e66d0 임시커밋 2026-02-11 16:31:28 +09:00
f650d51f68 디자인 변경 2026-02-11 15:27:03 +09:00
95291e6922 테마 적용 2026-02-11 14:06:06 +09:00
def87bd47a git ignore 추가 2026-02-11 11:19:44 +09:00
89bad1d141 차트 수정 2026-02-11 11:18:15 +09:00
e5a518b211 레이아웃 변경 대시보드 2026-02-10 17:29:57 +09:00
ca01f33d71 대시보드 중간 커밋 2026-02-10 17:16:49 +09:00
851a2acd69 대시보드 2026-02-06 17:50:35 +09:00
35916430b7 Merge branch 'features/version1' into dev 2026-02-06 14:48:35 +09:00
ac7effc939 feat: 브랜드 컬러 토큰 적용 및 색상 규칙 추가 2026-02-06 14:44:14 +09:00
d2c66a639d Feat: 세션 유지 컴포넌트 추가 및 주석 디자인 다크테마 적용 2026-02-06 10:43:16 +09:00
d31e3f9bc9 Feat: auth 관련페이지 header 적용 2026-02-06 09:14:49 +09:00
f1e340d9f1 Feat: 로그인 여부에 따른 메인페이지 이동 및 dashboard 처리 2026-02-05 16:36:42 +09:00
ded49b5e2a Feat: 대시보드 추가
app/(auth)/forgot-password/page.tsx
- 비밀번호 초기화 페이지 레이아웃 정리 및 카드 스타일 개선

app/(auth)/login/page.tsx
- 로그인 페이지 레이아웃 경량화 및 메시지 표시 유지

app/(auth)/reset-password/page.tsx
- 리셋 비밀번호 페이지 레이아웃 정리

app/(auth)/signup/page.tsx
- 회원가입 페이지 레이아웃 정리 및 링크 배치 개선

app/(auth)/layout.tsx
- 인증 관련 공통 배경 레이아웃 추가 (그라디언트/블러 효과 분리)

app/(main)/layout.tsx
- 메인 레이아웃 추가 (헤더, 사이드바 포함)

app/(main)/page.tsx
- 대시보드 기본 페이지 추가 (위젯/플레이스홀더)

app/page.tsx
- 기존 메인 페이지 제거 (대시보드로 대체)

components/ui/avatar.tsx
- 아바타 UI 컴포넌트 추가

components/ui/dropdown-menu.tsx
- 드롭다운 메뉴 UI 컴포넌트 추가 (Radix 기반)

features/layout/components/header.tsx
- 헤더 컴포넌트 추가 (사용자 상태 표시 및 메뉴 연결)

features/layout/components/sidebar.tsx
- 사이드바 네비게이션 컴포넌트 추가

features/layout/components/user-menu.tsx
- 사용자 드롭다운 메뉴 추가 (로그아웃 등)

features/layout/types/index.ts
- 레이아웃 관련 타입 정의 추가

package.json
- Radix 드롭다운, Framer Motion 등 UI 관련 의존성 추가

package-lock.json
- 패키지 잠금파일 갱신 및 교체

- 인증 및 메인 영역 구조를 분리하고 공통 레이아웃과 재사용 가능한 UI 컴포넌트를 도입하여 향후 페이지 확장 및 유지보수성을 개선합니다
2026-02-05 15:56:41 +09:00
2d34d70948 Fix: 회원가입 인증 리다이렉트 처리와 UI 그라디언트 클래스 수정
app/auth/callback/route.ts
- 이메일 인증 완료 판별을 세션 생성 시간 기준에서 쿼리 파라미터(auth_type=signup) 기반으로 변경
- 회원가입 인증인 경우 자동 로그인 세션 종료 후 로그인 페이지로 리다이렉트 처리

features/auth/actions.ts
- 회원가입 시 이메일 리다이렉트 URL에 auth_type=signup 쿼리 파라미터 추가

app/forgot-password/page.tsx
- 배경 및 카드 아이콘의 Tailwind 클래스명 일부 수정(bg-gradient-to→bg-linear-to, radial-gradient var() 구문 정리)

app/login/page.tsx
- 배경 및 카드 아이콘의 Tailwind 클래스명 일부 정리 및 일관화

app/reset-password/page.tsx
- 배경 및 카드 아이콘의 Tailwind 클래스명 일부 정리 및 일관화

app/signup/page.tsx
- 배경 및 카드 아이콘의 Tailwind 클래스명 일부 정리 및 일관화

features/auth/components/reset-password-form.tsx
- 버튼 그라디언트 클래스명(bg-gradient-to-r→bg-linear-to-r) 수정
2026-02-05 15:39:44 +09:00
9c967af9c1 Feat: 회원가입 직후 이메일 인증 완료 처리 추가
app/auth/callback/route.ts
- OAuth 콜백 처리 시 신규 사용자(생성 시간 1분 이내)를 감지하면 자동 로그인 세션을 종료하고 로그인 페이지로 리다이렉트하며 이메일 인증 완료 메시지를 전달하도록 로직 추가
- 일반 로그인은 기존처럼 코드 파라미터 제거 후 깔끔한 URL로 리다이렉트하도록 유지

features/auth/constants.ts
- 이메일 인증 성공 메시지 상수 추가
- 일부 문자열 포맷팅 정리
2026-02-05 13:19:53 +09:00
aae7000807 Fix: 인증 콜백 처리 개선 및 프로젝트 문서 추가
app/auth/callback/route.ts
- NextRequest 타입 사용으로 요청/URL 파라미터 처리 개선
- 에러 파라미터 초기 처리 추가 및 사용자 메시지 매핑
- Supabase 코드 교환 흐름 정리(성공/실패 처리 분리), 로컬/프록시 환경에 따른 리다이렉트 로직 보강
- 잘못된 접근(인증 링크 오류) 처리 추가 및 로깅 개선

AGENTS.md
- 개발 규칙, 명령어, 설명 방식 등 에이전트용 가이드 문서 추가 (한국어 규칙 포함)

PROJECT_CONTEXT.md
- 프로젝트 기술 스택, 폴더 구조, 주요 규칙 및 작업 흐름을 정리한 기준 문서 추가
2026-02-05 12:13:06 +09:00
22ced3a6ae Refactor: 인증 흐름 개선 및 에러 메시지 통합
.vscode/settings.json
- chatgpt 확장 자동 실행 비활성화 설정 추가

app/auth/callback/route.ts
- OAuth 콜백 처리 개선: 에러 메시지 매핑 함수 사용 및 리다이렉트 로직 정리

app/auth/confirm/route.ts
- 이메일 인증(토큰 검증) 라우트 신규 구현: recovery 쿠키 설정 및 안전한 리다이렉트 처리

app/forgot-password/page.tsx
- UI 텍스트/플레이스홀더 정리, 메시지 렌더링 조건부 처리

app/reset-password/page.tsx
- 리셋 페이지 접근/세션 검증 로직 정리 및 UI 문구/아이콘 변경

features/auth/actions.ts
- 에러 처리 통합(getAuthErrorMessage 사용), 서버 액션 주석 정리
- 비밀번호 재설정 플로우 반환값을 객체로 변경하고 recovery 쿠키 삭제/로그아웃 처리

features/auth/components/reset-password-form.tsx
- 클라이언트 폼: updatePassword 결과 처리 개선, 라우터 리다이렉션 및 에러 메시지 표시 개선

features/auth/constants.ts
- 인증 관련 상수(에러 메시지, 코드/상태 매핑, 라우트, 검증 규칙, 쿠키 이름) 신규 추가

features/auth/errors.ts
- Supabase Auth 에러를 한글 메시지로 변환하는 유틸 추가

features/auth/schemas/auth-schema.ts
- zod 스키마 메시지 문구 정리 및 포맷 통일

utils/supabase/middleware.ts
- 세션/쿠키 갱신 및 라우트 보호 로직 개선, recovery 쿠키 기반 리다이렉트 처리 추가

utils/supabase/server.ts
- 서버용 Supabase 클라이언트 초기화 함수 추가 (쿠키 읽기/쓰기 처리)
2026-02-05 09:38:42 +09:00
edcfa2a837 Refactor: 인증 콜백 에러 메시지 변수에 타입 명시
app/auth/callback/route.ts
- AUTH_ERROR_MESSAGES.DEFAULT를 할당하는 message 변수에 string 타입 명시
2026-02-04 12:28:15 +09:00
4b41267ea5 Fix: 로그인 폼의 클라이언트 초기화 및 하이드레이션 문제 해결
features/auth/components/login-form.tsx
- 서버 렌더링 시 하이드레이션 불일치 방지를 위해 상태 초기값에서 서버(윈도우 미존재) 분기 처리로 고정값 반환
- localStorage 접근을 안전하게 처리하도록 lazy initializer에 window 검사 추가
- 클라이언트에서 localStorage 동기화를 권장하는 주석(useEffect 사용 권장) 추가
- 버튼 스타일 클래스명 수정(bg-gradient-to-r → bg-linear-to-r)으로 스타일 정정
2026-02-04 12:28:04 +09:00
0436ddf41c docs: 프로젝트 개발 규칙 문서 추가
- auto-trade.md: 개발 기본 원칙 및 도구 활용 가이드
2026-02-04 09:35:50 +09:00
63a09034a9 refactor: 인증 페이지를 React Hook Form 컴포넌트로 마이그레이션
- signup/page.tsx: SignupForm 컴포넌트 사용
- login/page.tsx: LoginForm 컴포넌트 사용
- reset-password/page.tsx: ResetPasswordForm 컴포넌트 사용
- auth/callback/route.ts: 불필요한 주석 제거
2026-02-04 09:35:42 +09:00
462d3c1923 feat: React Hook Form 기반 인증 폼 컴포넌트 추가
- SignupForm: 회원가입 폼 (비밀번호 확인 필드 포함)
- ResetPasswordForm: 비밀번호 재설정 폼
- LoginForm: 로그인 폼 (로딩 상태 추가)
- Zod 스키마 기반 자동 검증 및 타입 안전성
2026-02-04 09:35:29 +09:00
7500b963c0 refactor: 비밀번호 검증 규칙 통일
- 비밀번호 규칙을 8자 + 대소문자/숫자/특수문자로 통일
- constants.ts와 actions.ts의 검증 로직 일치
2026-02-04 09:35:15 +09:00
a7bcbeda72 feat: 로딩 스피너 컴포넌트 추가
- LoadingSpinner: 전체 화면 로딩 스피너
- InlineSpinner: 인라인 로딩 스피너 (버튼 내부용)
2026-02-04 09:35:07 +09:00
09277205e7 feat: React Query 사용자 정보 조회 훅 추가
- use-user-query.ts 생성
- Supabase 인증 사용자 정보 자동 캐싱 및 재검증
2026-02-04 09:35:00 +09:00
ac292bcf2a feat: React Query 설정 및 루트 레이아웃 통합
- QueryProvider 컴포넌트 생성
- React Query DevTools 추가
- 루트 레이아웃에 QueryProvider 래핑
2026-02-04 09:34:54 +09:00
c0ecec6586 feat: Zustand 전역 상태 관리 스토어 추가
- auth-store.ts: 사용자 인증 상태 관리 (localStorage 지속성)
- ui-store.ts: UI 상태 관리 (테마, 사이드바, 모달, 토스트)
2026-02-04 09:34:49 +09:00
06a90b4fd6 feat: Zod 스키마 기반 인증 폼 검증 추가
- auth-schema.ts 생성
- signupSchema, loginSchema, resetPasswordSchema, forgotPasswordSchema 정의
- 타입 안전한 폼 데이터 타입 자동 추론
2026-02-04 09:34:41 +09:00
40757e393a chore: React Hook Form, Zustand, React Query 패키지 설치
- react-hook-form, @hookform/resolvers, zod 추가
- zustand 추가
- @tanstack/react-query, @tanstack/react-query-devtools 추가
2026-02-04 09:34:27 +09:00
151626b181 Feat: 소셜 로그인 추가 및 인증 페이지 UI 테마 정리
app/login/page.tsx
- Google/Kakao 소셜 로그인 버튼을 폼으로 연동하고 액션 호출 추가
- 로그인 페이지의 배경/버튼/텍스트 색상 등 UI 테마를 파스텔에서 그레이/다크톤으로 통일
- 소셜 버튼 마크업 및 스타일 정리

app/signup/page.tsx
- 회원가입 페이지 배경 및 버튼 색상을 그레이/다크톤으로 변경
- 아이콘 배경 그라디언트 및 링크 색상 업데이트

app/forgot-password/page.tsx
- 비밀번호 재설정 요청 페이지의 배경, 블러 효과 및 버튼 스타일을 그레이/다크톤으로 변경
- 아이콘 배경 및 링크 색상 업데이트

app/reset-password/page.tsx
- 비밀번호 재설정 페이지의 배경, 블러 효과 및 버튼 스타일을 그레이/다크톤으로 변경
- 아이콘 배경 업데이트

features/auth/actions.ts
- Google 및 Kakao OAuth 시작을 위한 공통 헬퍼(signInWithProvider) 추가
- signInWithGoogle 및 signInWithKakao 액션을 구현하여 OAuth 흐름을 시작하도록 추가
2026-02-03 15:44:55 +09:00
43119caf80 Feat: 예제 환경 파일 추가 및 gitignore 예외 등록
.env.example
- Supabase 환경 변수 예제 파일 추가 (NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY)

.gitignore
- .env.example을 예외 처리하여 저장소에 포함되도록 수정
- 주석 및 섹션 정리, 커스텀 항목 정리
2026-02-03 11:05:06 +09:00
12182823b0 회원가입 2026-02-03 10:51:46 +09:00
3058b93c66 기본 .gitignore 파일 추가
.gitignore
- 의존성, 빌드 출력물, 테스트 결과, 환경변수 파일, 에디터/IDE 설정, OS 생성 파일, 로그 등 프로젝트에 불필요한 파일 및 디렉터리를 포괄적으로 무시하도록 설정 추가
- TypeScript, Turbopack, Vercel, PWA, Sentry, Storybook 관련 항목과 잠재적 로컬 파일들을 포함
2026-02-03 10:48:01 +09:00
191 changed files with 55672 additions and 0 deletions

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,65 @@
---
name: nextjs-app-router-patterns
description: Best practices and patterns for building applications with Next.js App Router (v13+).
---
# Next.js App Router Patterns
## Core Principles
### Server-First by Default
- **Use Server Components** for everything possible (data fetching, layout, static content).
- **Use Client Components** (`"use client"`) only when interactivity (hooks, event listeners) is needed.
- **Pass Data Down**: Fetch data in Server Components and pass it as props to Client Components.
- **Composition**: Wrap Client Components around Server Components to avoid "rendering undefined" issues or waterfall de-opts.
### Routing & Layouts
- **File Structure**:
- `page.tsx`: Route UI.
- `layout.tsx`: Shared UI (wraps pages).
- `loading.tsx`: Loading state (Suspense).
- `error.tsx`: Error boundary.
- `not-found.tsx`: 404 UI.
- `template.tsx`: Layout that re-mounts on navigation.
- **Parallel Routes**: Use `@folder` for parallel UI (e.g. dashboards).
- **Intercepting Routes**: Use `(..)` to intercept navigation (e.g. modals).
- **Route Groups**: Use `(group)` to organize routes without affecting the URL path.
## Data Fetching Patterns
### Server Side
- **Direct Async/Await**: `const data = await fetch(...)` inside the component.
- **Request Memoization**: `fetch` is automatically memoized. For DB calls, use `React.cache`.
- **Data Caching**:
- `fetch(url, { next: { revalidate: 3600 } })` for ISR.
- `fetch(url, { cache: 'no-store' })` for SSR.
- Use `unstable_cache` for caching DB results.
### Client Side
- Use **SWR** or **TanStack Query** for client-side fetching.
- Avoid `useEffect` for data fetching to prevent waterfalls.
- Prefetch data using `queryClient.prefetchQuery` in Server Components and hydrate on client.
## Server Actions
- Use **Server Actions** (`"use server"`) for mutations (form submissions, button clicks).
- Define actions in separate files (e.g. `actions.ts`) for better organization and security.
- Use `useFormState` (or `useActionState` in React 19) to handle loading/error states.
## Optimization
- **Images**: Use `next/image` for automatic resizing and format conversion.
- **Fonts**: Use `next/font` to eliminate layout shift (CLS).
- **Scripts**: Use `next/script` with `strategy="afterInteractive"`.
- **Streaming**: Use `<Suspense>` to stream parts of the UI (e.g. slow data fetches).
## Common Anti-Patterns to Avoid
1. **Fetching in Client Components without cache lib**: Leads to waterfalls.
2. **"use client" at top level layout**: Forces the entire tree to be client-side.
3. **Prop Drilling**: specialized `Context` should be used sparingly; prefer Composition.
4. **Large Barrel Files**: Avoid `index.ts` exporting everything; import directly to aid tree-shaking.

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

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Supabase 환경 설정 예제 파일
# 이 파일을 .env.local로 복사한 뒤 실제 값을 채워 주세요.
# 값 확인: https://supabase.com/dashboard/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 세션 타임아웃(분 단위)
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30

6
.gemini/settings.json Normal file
View File

@@ -0,0 +1,6 @@
{
"tools": {
"approvalMode": "auto_edit",
"allowed": ["run_shell_command"]
}
}

132
.gitignore vendored Normal file
View File

@@ -0,0 +1,132 @@
# ========================================
# Dependencies (의존성)
# ========================================
node_modules/
.pnp/
.pnp.js
# ========================================
# Build outputs (빌드 출력물)
# ========================================
.next/
out/
build/
dist/
# ========================================
# Testing (테스트)
# ========================================
coverage/
.nyc_output/
# ========================================
# Environment files (환경변수 파일)
# ========================================
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local
# ★ 예제 파일은 공유해야 하므로 예외 처리 (깃에 올라감)
!.env.example
# ========================================
# IDE & Editor (에디터 설정)
# ========================================
.idea/
.vscode/
*.swp
*.swo
*~
# ========================================
# OS generated files (OS 생성 파일)
# ========================================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# ========================================
# Debug logs (디버그 로그)
# ========================================
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
lerna-debug.log*
# ========================================
# TypeScript (타입스크립트)
# ========================================
*.tsbuildinfo
next-env.d.ts
# ========================================
# Turbopack (터보팩)
# ========================================
.turbo/
# ========================================
# Vercel (배포 관련)
# ========================================
.vercel/
# ========================================
# PWA files (PWA 관련)
# ========================================
public/sw.js
public/workbox-*.js
public/worker-*.js
public/sw.js.map
public/workbox-*.js.map
# ========================================
# Misc (기타)
# ========================================
*.pem
*.log
*.pid
*.seed
*.pid.lock
# ========================================
# Lock files (선택 - 협업 시 주석 해제)
# ========================================
# package-lock.json
# yarn.lock
# pnpm-lock.yaml
# ========================================
# Sentry (에러 모니터링)
# ========================================
.sentryclirc
# ========================================
# Storybook (스토리북)
# ========================================
storybook-static/
# ========================================
# Local files (로컬 전용)
# ========================================
*.local
.cache/
node_modules
.tmp/
# ========================================
# Custom
# ========================================
.playwright-mcp/
# ========================================
# Documentation (문서)
# ========================================
docs/

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"chatgpt.openOnStartup": false
}

17
AGENTS.md Normal file
View File

@@ -0,0 +1,17 @@
# AGENTS.md (auto-trade)
## 운영 원칙
- 중복 규칙은 `AGENTS.md`에 두지 않고 각 스킬(`.agents/skills/*/SKILL.md`)에서 관리한다.
- 개발 작업은 스킬 기반으로 수행한다.
## 스킬 호출 규칙
- 개발 요청 키워드(`개발해줘`, `구현해줘`, `기능 추가`, `버그 수정`, `리팩토링`, `개선해줘`, `고쳐줘`, `최적화해줘`, `만들어줘`)가 들어오면 `dev-auto-pipeline` 스킬을 우선 사용한다.
- 파이프라인 단계 스킬은 아래 순서로 사용한다.
1. `dev-plan-writer`
2. `dev-mcp-implementation`
3. `dev-refactor-polish`
4. `dev-test-gate`
5. `dev-plan-completion-checker`
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.

159
README.md
View File

@@ -1 +1,160 @@
# auto-trade
한국투자증권(KIS) Open API와 Supabase 인증을 연결한 국내주식 트레이딩 대시보드입니다.
사용자는 로그인 후 API 키를 검증하고, 종목 검색/상세/차트/호가/체결/주문 화면을 한 곳에서 사용할 수 있습니다.
## 1) 핵심 기능
- 인증: Supabase 이메일/소셜 로그인, 비밀번호 재설정, 세션 타임아웃 자동 로그아웃
- KIS 연결: 실전/모의 모드 선택, API 키 검증, 웹소켓 승인키 발급
- 트레이드 화면: 종목 검색, 종목 개요, 캔들 차트, 실시간 호가/체결, 현금 주문
- 종목 검색 인덱스: `korean-stocks.json` 기반 고속 검색 + 자동 갱신 스크립트
## 2) 기술 스택
- 프레임워크: Next.js 16 (App Router), React 19, TypeScript
- 상태관리: Zustand
- 서버 상태: TanStack Query (React Query)
- 인증/백엔드: Supabase (`@supabase/ssr`, `@supabase/supabase-js`)
- UI: Tailwind CSS v4, Radix UI, Sonner
- 차트: `lightweight-charts`
## 3) 화면/라우트
- `/`: 서비스 랜딩 페이지
- `/login`, `/signup`, `/forgot-password`, `/reset-password`: 인증 페이지
- `/dashboard`: 로그인 전용(현재 플레이스홀더)
- `/settings`: KIS API 키 연결/해제
- `/trade`: 실제 트레이딩 대시보드
## 4) UI 흐름 (중요)
- 인증 흐름: 로그인 UI -> `features/auth/actions.ts` -> Supabase Auth -> 세션 쿠키 반영 -> 보호 라우트 접근 허용
- KIS 연결 흐름: 설정 UI -> `/api/kis/validate` -> 토큰 발급 성공 확인 -> `use-kis-runtime-store`에 검증 상태 저장
- 실시간 흐름: 트레이드 UI -> `/api/kis/ws/approval` -> `useKisTradeWebSocket` 구독 -> 체결/호가 파싱 -> 차트/호가창 반영
- 검색 흐름: 검색 UI -> `/api/kis/domestic/search` -> `korean-stocks.json` 메모리 검색 -> 결과 목록 반영
## 5) 빠른 시작
### 5-1. 요구 사항
- Node.js 20 이상
- npm 10 이상 권장
### 5-2. 설치
```bash
npm install
```
### 5-3. 환경변수 설정
`.env.example`을 복사해서 `.env.local`을 만듭니다.
```bash
cp .env.example .env.local
```
Windows PowerShell:
```powershell
Copy-Item .env.example .env.local
```
필수 값은 아래를 먼저 채우면 됩니다.
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
KIS는 `.env`에 저장하지 않고 `/settings` 입력 폼에서 직접 입력합니다.
- 앱키/시크릿/계좌번호는 사용자 브라우저에서만 관리
- 서버 API는 로그인 세션 + 요청 헤더로 전달된 키가 있어야 동작
- `NEXT_PUBLIC_BASE_URL` (선택, 배포 도메인 사용 시 권장)
### 5-4. 로컬 실행
```bash
npm run dev
```
- 개발 서버: `http://localhost:3001`
- Turbopack 적용: `package.json``dev` 스크립트에 `--turbopack` 포함
### 5-5. 점검 명령
```bash
npm run lint
npm run build
npm run start
```
## 6) 종목 인덱스 동기화
`features/trade/data/korean-stocks.json`은 수동 편집용 파일이 아닙니다.
KIS 마스터 파일(KOSPI/KOSDAQ)에서 자동 생성합니다.
```bash
npm run sync:stocks
```
검증만 하고 싶으면:
```bash
npm run sync:stocks:check
```
상세 문서: `docs/trade-stock-sync.md`
## 7) API 엔드포인트 요약
- 인증/연결
- `POST /api/kis/validate`: API 키 검증
- `POST /api/kis/revoke`: 토큰 폐기
- `POST /api/kis/ws/approval`: 웹소켓 승인키 발급
- 국내주식
- `GET /api/kis/domestic/search?q=...`: 종목 검색(로컬 인덱스)
- `GET /api/kis/domestic/overview?symbol=...`: 종목 개요
- `GET /api/kis/domestic/orderbook?symbol=...`: 호가
- `GET /api/kis/domestic/chart?symbol=...&timeframe=...`: 차트
- `POST /api/kis/domestic/order-cash`: 현금 주문
## 8) 프로젝트 구조
```text
app/
(home)/ 랜딩
(auth)/ 로그인/회원가입/비밀번호 재설정
(main)/ 로그인 후 화면(dashboard/trade/settings)
api/kis/ KIS 연동 API 라우트
features/
auth/ 인증 UI/액션/상수
settings/ KIS 키 설정 UI + 런타임 스토어
trade/ 검색/차트/호가/주문/웹소켓
lib/kis/ KIS REST/WS 공통 로직
scripts/
sync-korean-stocks.mjs
utils/supabase/ 서버/클라이언트/미들웨어 클라이언트
```
## 9) 트러블슈팅
- KIS 검증 실패
- 입력한 앱키/시크릿, 실전/모의 모드가 맞는지 확인
- KIS Open API 앱 권한과 IP 허용 설정 확인
- 실시간 체결/호가가 안 들어옴
- `/settings`에서 검증 상태가 유지되는지 확인
- 장 구간(장중/동시호가/시간외)에 따라 데이터가 달라질 수 있음
- 브라우저 콘솔 디버그가 필요하면 `localStorage.KIS_WS_DEBUG = "1"` 설정
- 검색 결과가 기대와 다름
- 검색은 KIS 실시간 검색 API가 아니라 로컬 인덱스(`korean-stocks.json`) 기반
- 최신 종목 반영이 필요하면 `npm run sync:stocks` 실행
## 10) 운영 주의사항
- 현재 KIS 입력값과 검증 상태 일부는 브라우저 로컬 스토리지(localStorage, 브라우저 저장소)에 저장됩니다.
- 실전 운영 시에는 민감정보 보관 정책(암호화, 만료, 서버 보관 방식)을 반드시 따로 설계하세요.
- 주문은 계좌번호가 필요합니다. 계좌번호 입력/검증 UX는 운영 환경에 맞게 보완이 필요합니다.

View File

@@ -0,0 +1,87 @@
import FormMessage from "@/components/form-message";
import { requestPasswordReset } from "@/features/auth/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Link from "next/link";
import { AUTH_ROUTES } from "@/features/auth/constants";
import { Mail } from "lucide-react";
/**
* [비밀번호 찾기 페이지]
*
* 사용자가 비밀번호를 잊어버렸을 때 재설정 링크를 요청하는 페이지입니다.
* - 이메일 입력 폼 제공
* - 서버 액션(requestPasswordReset)과 연동
*/
export default async function ForgotPasswordPage({
searchParams,
}: {
searchParams: Promise<{ message?: string }>;
}) {
const { message } = await searchParams;
return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{message && <FormMessage message={message} />}
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<Mail className="h-7 w-7 text-white" />
</div>
<CardTitle className="text-3xl font-bold tracking-tight">
</CardTitle>
<CardDescription className="text-base">
.
<br />
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="name@example.com"
autoComplete="email"
required
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/>
</div>
<Button
formAction={requestPasswordReset}
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
>
</Button>
</form>
<div className="text-center">
<Link
href={AUTH_ROUTES.LOGIN}
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

34
app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { Header } from "@/features/layout/components/header";
import { createClient } from "@/utils/supabase/server";
export default async function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-brand-50 via-white to-brand-100/60 dark:from-brand-950 dark:via-gray-950 dark:to-brand-900/40">
{/* ========== 헤더 (홈 이동용) ========== */}
<Header user={user} />
{/* ========== 배경 그라디언트 레이어 ========== */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-brand-200/40 via-brand-100/20 to-transparent dark:from-brand-800/25 dark:via-brand-900/15" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-brand-300/30 via-brand-200/15 to-transparent dark:from-brand-700/20 dark:via-brand-800/10" />
{/* ========== 애니메이션 블러 효과 ========== */}
<div className="absolute left-1/4 top-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-300/25 blur-3xl dark:bg-brand-700/15" />
<div className="absolute bottom-1/4 right-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-400/20 blur-3xl delay-700 dark:bg-brand-600/15" />
<div className="absolute right-1/3 top-1/3 h-48 w-48 animate-pulse rounded-full bg-brand-200/30 blur-2xl delay-1000 dark:bg-brand-800/20" />
{/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */}
<main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12">
{children}
</main>
</div>
);
}

51
app/(auth)/login/page.tsx Normal file
View File

@@ -0,0 +1,51 @@
import FormMessage from "@/components/form-message";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import LoginForm from "@/features/auth/components/login-form";
import { LogIn } from "lucide-react";
/**
* [로그인 페이지 컴포넌트]
*
* 브랜드 컬러 기반 글래스모피즘 카드 디자인
* - 보라색 그라디언트 아이콘 배지
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
*
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
*/
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ message: string }>;
}) {
const { message } = await searchParams;
return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
<FormMessage message={message} />
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<LogIn className="h-7 w-7 text-white" />
</div>
<CardTitle className="text-3xl font-bold tracking-tight">
!
</CardTitle>
<CardDescription className="text-base">
.
</CardDescription>
</CardHeader>
<CardContent>
<LoginForm />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import FormMessage from "@/components/form-message";
import ResetPasswordForm from "@/features/auth/components/reset-password-form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import { KeyRound } from "lucide-react";
/**
* [비밀번호 재설정 페이지]
*
* 이메일 링크를 타고 들어온 사용자가 새 비밀번호를 설정하는 페이지입니다.
* - URL에 포함된 토큰 검증은 Middleware 및 Auth Confirm Route에서 선행됩니다.
* - 유효한 세션(Recovery Mode)이 없으면 로그인 페이지로 리다이렉트됩니다.
*/
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: Promise<{ message?: string }>;
}) {
const params = await searchParams;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect(`/login`);
}
const { message } = params;
return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{message && <FormMessage message={message} />}
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<KeyRound className="h-7 w-7 text-white" />
</div>
<CardTitle className="text-3xl font-bold tracking-tight">
</CardTitle>
<CardDescription className="text-base">
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ResetPasswordForm />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { AUTH_ROUTES } from "@/features/auth/constants";
import FormMessage from "@/components/form-message";
import SignupForm from "@/features/auth/components/signup-form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { UserPlus } from "lucide-react";
export default async function SignupPage({
searchParams,
}: {
searchParams: Promise<{ message: string }>;
}) {
const { message } = await searchParams;
return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
<FormMessage message={message} />
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<UserPlus className="h-7 w-7 text-white" />
</div>
<CardTitle className="text-3xl font-bold tracking-tight">
</CardTitle>
<CardDescription className="text-base">
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<SignupForm />
<p className="text-center text-sm text-muted-foreground">
?{" "}
<Link
href={AUTH_ROUTES.LOGIN}
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

215
app/(home)/page.tsx Normal file
View File

@@ -0,0 +1,215 @@
/**
* @file app/(home)/page.tsx
* @description 서비스 메인 랜딩 페이지(Server Component)
*/
import Link from "next/link";
import { ArrowRight, Sparkles } from "lucide-react";
import { Header } from "@/features/layout/components/header";
import { AUTH_ROUTES } from "@/features/auth/constants";
import { Button } from "@/components/ui/button";
import ShaderBackground from "@/components/ui/shader-background";
import { createClient } from "@/utils/supabase/server";
import { AnimatedBrandTone } from "@/components/ui/animated-brand-tone";
interface StartStep {
step: string;
title: string;
description: string;
}
const START_STEPS: StartStep[] = [
{
step: "01",
title: "1분이면 충분해요",
description:
"복잡한 서류나 방문 없이, 쓰던 계좌 그대로 안전하게 연결할 수 있어요.",
},
{
step: "02",
title: "내 스타일대로 골라보세요",
description:
"공격적인 투자부터 안정적인 관리까지, 나에게 딱 맞는 전략이 준비되어 있어요.",
},
{
step: "03",
title: "이제 일상을 즐기세요",
description:
"차트는 JOORIN-E가 하루 종일 보고 있을게요. 마음 편히 본업에 집중하세요.",
},
];
/**
* 홈 메인 랜딩 페이지
* @returns 랜딩 UI
*/
export default async function HomePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
const primaryCtaLabel = user ? "시작하기" : "지금 무료로 시작하기";
return (
<div className="flex min-h-screen flex-col overflow-x-hidden bg-black text-white selection:bg-brand-500/30">
<Header user={user} showDashboardLink={true} blendWithBackground={true} />
<main className="relative isolate flex-1">
{/* ========== BACKGROUND ========== */}
<ShaderBackground opacity={0.6} className="-z-20" />
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-black/60"
/>
{/* ========== HERO SECTION ========== */}
<section className="container mx-auto max-w-5xl px-4 pt-32 md:pt-48">
<div className="flex flex-col items-center text-center">
<span className="inline-flex animate-in fade-in-0 slide-in-from-top-2 items-center gap-2 rounded-full border border-brand-400/30 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-wide text-brand-200 backdrop-blur-md duration-1000">
<Sparkles className="h-3.5 w-3.5" />
, JOORIN-E
</span>
<h1 className="mt-8 animate-in slide-in-from-bottom-4 text-5xl font-black tracking-tight text-white duration-1000 md:text-8xl">
,
<br />
<span className="bg-linear-to-b from-brand-300 via-brand-200 to-brand-500 bg-clip-text text-transparent">
.
</span>
</h1>
<p className="mt-8 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/60 duration-1000 md:text-xl">
, .
<br className="hidden md:block" />
24 .
</p>
<div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
<Button
asChild
size="lg"
className="group h-14 min-w-[200px] rounded-full bg-brand-500 px-10 text-lg font-bold text-white transition-all hover:scale-105 hover:bg-brand-400 active:scale-95"
>
<Link href={primaryCtaHref}>
{primaryCtaLabel}
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
</div>
</div>
</section>
{/* ========== BRAND TONE SECTION (움직이는 글자) ========== */}
<section className="container mx-auto max-w-5xl px-4 py-24 md:py-40">
<AnimatedBrandTone />
</section>
{/* ========== SIMPLE STEPS SECTION ========== */}
<section className="container mx-auto max-w-5xl px-4 py-24">
<div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
<div className="flex-1 text-center md:text-left">
<h2 className="text-3xl font-black md:text-5xl">
<br />
<span className="text-brand-300"> 3 .</span>
</h2>
<p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
JOORIN-E가 .
<br />
&apos;&apos; .
</p>
</div>
<div className="flex-2 grid w-full gap-4 md:grid-cols-1">
{START_STEPS.map((item) => (
<div
key={item.step}
className="group flex items-center gap-6 rounded-2xl border border-white/5 bg-white/5 p-6 transition-all hover:bg-white/10"
>
<span className="text-3xl font-black text-brand-500/50 group-hover:text-brand-500">
{item.step}
</span>
<div>
<h3 className="text-lg font-bold text-white">
{item.title}
</h3>
<p className="mt-1 text-sm text-white/50">
{item.description}
</p>
</div>
</div>
))}
</div>
</div>
{/* 보안 안심 문구 (사용자 요청 반영) */}
<div className="mt-16 flex flex-col items-center justify-center text-center animate-in slide-in-from-bottom-6 duration-1000 delay-300">
<div className="flex max-w-2xl flex-col items-center gap-4 rounded-2xl border border-brand-500/20 bg-brand-500/5 p-8 backdrop-blur-sm md:flex-row md:gap-8 md:text-left">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-brand-500/10 text-brand-400">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-shield-check"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-brand-100">
, ?
</h3>
<p className="mt-2 text-sm leading-relaxed text-brand-200/70">
<strong className="text-brand-200">
, .
</strong>
<br />
JOORIN-E는 API
.
<br className="hidden md:block" />
()
,
<br className="hidden md:block" />
.
</p>
</div>
</div>
</div>
</section>
{/* ========== FINAL CTA SECTION ========== */}
<section className="container mx-auto max-w-5xl px-4 py-32">
<div className="relative overflow-hidden rounded-[2.5rem] border border-brand-500/20 bg-linear-to-b from-brand-500/10 to-transparent p-12 text-center md:p-24">
<h2 className="text-3xl font-black md:text-6xl">
.
<br />
.
</h2>
<div className="mt-12 flex justify-center">
<Button
asChild
size="lg"
className="h-16 rounded-full bg-white px-12 text-xl font-black text-black transition-all hover:scale-110 active:scale-95"
>
<Link href={primaryCtaHref}>{primaryCtaLabel}</Link>
</Button>
</div>
<p className="mt-8 text-sm text-white/30">
© 2026 POPUP STUDIO. All rights reserved.
</p>
</div>
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,25 @@
/**
* @file app/(main)/dashboard/page.tsx
* @description 로그인 사용자 전용 대시보드 페이지(Server Component)
*/
import { redirect } from "next/navigation";
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
import { createClient } from "@/utils/supabase/server";
/**
* 대시보드 페이지
* @returns DashboardContainer UI
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 상태 헤더/지수/보유종목 UI를 제공합니다.
*/
export default async function DashboardPage() {
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
return <DashboardContainer />;
}

25
app/(main)/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { Header } from "@/features/layout/components/header";
import { MobileBottomNav, Sidebar } from "@/features/layout/components/sidebar";
import { createClient } from "@/utils/supabase/server";
export default async function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
return (
<div className="flex min-h-screen flex-col bg-background">
<Header user={user} />
<div className="flex flex-1 pt-16">
<Sidebar />
<main className="min-w-0 flex-1 pb-20 md:pb-0">{children}</main>
</div>
<MobileBottomNav />
</div>
);
}

View File

@@ -0,0 +1,26 @@
/**
* @file app/(main)/settings/page.tsx
* @description 로그인 사용자 전용 설정 페이지(Server Component)
*/
import { redirect } from "next/navigation";
import { SettingsContainer } from "@/features/settings/components/SettingsContainer";
import { createClient } from "@/utils/supabase/server";
/**
* 설정 페이지
* @returns SettingsContainer UI
* @see features/settings/components/SettingsContainer.tsx KIS 인증 설정 UI를 제공합니다.
*/
export default async function SettingsPage() {
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
return <SettingsContainer />;
}

26
app/(main)/trade/page.tsx Normal file
View File

@@ -0,0 +1,26 @@
/**
* @file app/(main)/trade/page.tsx
* @description 로그인 사용자 전용 트레이딩 페이지(Server Component)
*/
import { redirect } from "next/navigation";
import { TradeContainer } from "@/features/trade/components/TradeContainer";
import { createClient } from "@/utils/supabase/server";
/**
* 트레이딩 페이지
* @returns TradeContainer UI
* @see features/trade/components/TradeContainer.tsx 종목 검색/차트/호가/주문 기능을 제공합니다.
*/
export default async function TradePage() {
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
return <TradeContainer />;
}

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

18
app/api/kis/_session.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createClient } from "@/utils/supabase/server";
/**
* @description KIS API 라우트 접근 전에 Supabase 로그인 세션을 검증합니다.
* @returns 로그인 세션 존재 여부
* @remarks UI 흐름: 클라이언트 요청 -> KIS API route -> hasKisApiSession -> (실패 시 401, 성공 시 KIS 호출)
* @see app/api/kis/domestic/balance/route.ts 잔고 API 세션 가드
* @see app/api/kis/validate/route.ts 인증 검증 API 세션 가드
*/
export async function hasKisApiSession() {
const supabase = await createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
return Boolean(!error && user);
}

View File

@@ -0,0 +1,39 @@
import { parseKisAccountParts } from "@/lib/kis/account";
import {
normalizeTradingEnv,
type KisCredentialInput,
} from "@/lib/kis/config";
/**
* @description 요청 헤더에서 KIS 키를 읽어옵니다.
* @param headers 요청 헤더
* @returns KIS 인증 입력값
* @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 인증키 파싱
* @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 인증키 파싱
*/
export 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,
};
}
/**
* @description 요청 헤더에서 계좌번호(8-2)를 읽어옵니다.
* @param headers 요청 헤더
* @returns 계좌번호 파트(8 + 2) 또는 null
* @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱
*/
export function readKisAccountParts(headers: Headers) {
const headerAccountNo = headers.get("x-kis-account-no");
const headerAccountProductCode = headers.get("x-kis-account-product-code");
return parseKisAccountParts(headerAccountNo, headerAccountProductCode);
}

View File

@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
} from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/activity/route.ts
* @description 국내주식 주문내역/매매일지 조회 API
*/
/**
* 대시보드 하단(주문내역/매매일지) 조회 API
* @returns 주문내역 목록 + 매매일지 목록/요약
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/activity -> ActivitySection 렌더링
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 섹션 렌더링
*/
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
const account = readKisAccountParts(request.headers);
if (!account) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
});
}
try {
const result = await getDomesticDashboardActivity(account, credentials);
const response: DashboardActivityResponse = {
source: "kis",
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
orders: result.orders,
tradeJournal: result.tradeJournal,
journalSummary: result.journalSummary,
warnings: result.warnings,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(
error,
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
),
});
}
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
} from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/balance/route.ts
* @description 국내주식 계좌 잔고/보유종목 조회 API
*/
/**
* 대시보드 잔고 조회 API
* @returns 총자산/손익/보유종목 목록
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
*/
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
const account = readKisAccountParts(request.headers);
if (!account) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
});
}
try {
const result = await getDomesticDashboardBalance(account, credentials);
const response: DashboardBalanceResponse = {
source: "kis",
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
summary: result.summary,
holdings: result.holdings,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "잔고 조회 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,100 @@
import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/trade/types/trade.types";
import { hasKisConfig } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
"30m",
"1h",
"1d",
"1w",
];
/**
* @file app/api/kis/domestic/chart/route.ts
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
*/
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
const timeframe = (
searchParams.get("timeframe") ?? "1d"
).trim() as DashboardChartTimeframe;
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
if (!/^\d{6}$/.test(symbol)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
}
if (!VALID_TIMEFRAMES.includes(timeframe)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "지원하지 않는 timeframe입니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
});
}
try {
const chart = await getDomesticChart(
symbol,
timeframe,
credentials,
cursor,
);
const response: DashboardStockChartResponse = {
symbol,
timeframe,
candles: chart.candles,
nextCursor: chart.nextCursor,
hasMore: chart.hasMore,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "KIS 차트 조회 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/indices/route.ts
* @description 국내 주요 지수(KOSPI/KOSDAQ) 조회 API
*/
/**
* 대시보드 지수 조회 API
* @returns 코스피/코스닥 지수 목록
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다.
*/
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
try {
const items = await getDomesticDashboardIndices(credentials);
const response: DashboardIndicesResponse = {
source: "kis",
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
items,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "지수 조회 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { parseKisAccountParts } from "@/lib/kis/account";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API
*/
const orderCashBodySchema = z
.object({
symbol: z
.string()
.trim()
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
side: z.enum(["buy", "sell"], {
message: "주문 구분(side)은 buy/sell만 허용됩니다.",
}),
orderType: z.enum(["limit", "market"], {
message: "주문 유형(orderType)은 limit/market만 허용됩니다.",
}),
quantity: z.coerce
.number()
.int("주문수량은 정수여야 합니다.")
.positive("주문수량은 1주 이상이어야 합니다."),
price: z.coerce.number(),
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
accountProductCode: z.string().trim().optional(),
})
.superRefine((body, ctx) => {
if (body.orderType === "limit" && body.price <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["price"],
message: "지정가 주문은 주문가격이 0보다 커야 합니다.",
});
}
if (body.orderType === "market" && body.price < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["price"],
message: "시장가 주문은 주문가격이 0 이상이어야 합니다.",
});
}
const accountParts = parseKisAccountParts(
body.accountNo,
body.accountProductCode,
);
if (!accountParts) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accountNo"],
message:
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
});
}
});
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
tradingEnv,
});
}
try {
let rawBody: unknown = {};
try {
rawBody = (await request.json()) as unknown;
} catch {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
tradingEnv,
});
}
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(
{
symbol: body.symbol,
side: body.side,
orderType: body.orderType,
quantity: body.quantity,
price: body.price,
accountNo: accountParts.accountNo,
accountProductCode: accountParts.accountProductCode,
},
credentials,
);
const response: DashboardStockCashOrderResponse = {
ok: true,
tradingEnv,
message: "주문이 전송되었습니다.",
orderNo: output.ODNO,
orderTime: output.ORD_TMD,
orderOrgNo: output.KRX_FWDG_ORD_ORGNO,
};
return NextResponse.json(response);
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -0,0 +1,163 @@
import { NextRequest, NextResponse } from "next/server";
import {
getDomesticOrderBook,
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/orderbook/route.ts
* @description 국내주식 호가 조회 API
*/
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
try {
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
const raw = await getDomesticOrderBook(symbol, credentials, {
sessionOverride,
});
const levels = Array.from({ length: 10 }, (_, i) => {
const idx = i + 1;
return {
askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`),
bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`),
askSize: readOrderBookNumber(
raw,
`askp_rsqn${idx}`,
`ovtm_untp_askp_rsqn${idx}`,
),
bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)),
};
});
const response: DashboardStockOrderBookResponse = {
symbol,
source: "kis",
levels,
totalAskSize: readOrderBookNumber(
raw,
"total_askp_rsqn",
"ovtm_untp_total_askp_rsqn",
"ovtm_total_askp_rsqn",
),
totalBidSize: readOrderBookNumber(
raw,
"total_bidp_rsqn",
"ovtm_untp_total_bidp_rsqn",
"ovtm_total_bidp_rsqn",
),
businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"),
hourClassCode: readOrderBookString(raw, "hour_cls_code"),
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "호가 조회 중 오류가 발생했습니다."),
});
}
}
function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
return parseDomesticKisSession(raw);
}
/**
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
*/
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
const record = raw as Record<string, unknown>;
const value = resolveOrderBookValue(record, keys) ?? "0";
const normalized =
typeof value === "string"
? value.replaceAll(",", "").trim()
: String(value ?? "0");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* @description 호가 응답 필드를 문자열로 읽습니다.
* @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출
*/
function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
const record = raw as Record<string, unknown>;
const value = resolveOrderBookValue(record, keys);
if (value === undefined || value === null) return undefined;
const text = String(value).trim();
return text.length > 0 ? text : undefined;
}
function resolveOrderBookValue(record: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const direct = record[key];
if (direct !== undefined && direct !== null) return direct;
const upper = record[key.toUpperCase()];
if (upper !== undefined && upper !== null) return upper;
}
return undefined;
}
function resolveBidSizeKeys(index: number) {
if (index === 2) {
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"];
}
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`];
}

View File

@@ -0,0 +1,98 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/overview/route.ts
* @description 국내주식 종목 상세(현재가 + 차트) API
*/
/**
* 국내주식 종목 상세 API
* @param request query string의 symbol(6자리 종목코드) 사용
* @returns 대시보드 상세 모델
*/
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
});
}
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
try {
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
const overview = await getDomesticOverview(
symbol,
fallbackMeta,
credentials,
{ sessionOverride },
);
const response: DashboardStockOverviewResponse = {
stock: overview.stock,
source: "kis",
priceSource: overview.priceSource,
marketPhase: overview.marketPhase,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "KIS 조회 중 오류가 발생했습니다."),
});
}
}
function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
return parseDomesticKisSession(raw);
}

View File

@@ -0,0 +1,122 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type {
DashboardStockSearchItem,
DashboardStockSearchResponse,
KoreanStockIndexItem,
} from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
const SEARCH_LIMIT = 10;
/**
* @file app/api/kis/domestic/search/route.ts
* @description 국내주식 종목명/종목코드 검색 API
* @remarks
* - [레이어] API Route
* - [사용자 행동] 대시보드 검색창 엔터/검색 버튼 클릭 시 호출
* - [데이터 흐름] dashboard-main.tsx -> /api/kis/domestic/search -> KOREAN_STOCK_INDEX 필터/정렬 -> JSON 응답
* - [연관 파일] features/trade/data/korean-stocks.ts, features/trade/components/dashboard-main.tsx
* @author jihoon87.lee
*/
/**
* 국내주식 검색 API
* @param request query string의 q(검색어) 사용
* @returns 종목 검색 결과 목록
* @see features/trade/components/dashboard-main.tsx 검색 폼에서 호출합니다.
*/
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
const { searchParams } = new URL(request.url);
const query = (searchParams.get("q") ?? "").trim();
// [Step 2] 검색어가 없으면 빈 목록을 즉시 반환해 불필요한 계산을 줄입니다.
if (!query) {
const response: DashboardStockSearchResponse = {
query,
items: [],
total: 0,
};
return NextResponse.json(response);
}
const normalized = normalizeKeyword(query);
// [Step 3] 인덱스에서 코드/이름 포함 여부로 1차 필터링 후 점수를 붙입니다.
const ranked = KOREAN_STOCK_INDEX.filter((item) => {
const symbol = item.symbol;
const name = normalizeKeyword(item.name);
return symbol.includes(normalized) || name.includes(normalized);
})
.map((item) => ({
item,
score: getSearchScore(item, normalized),
}))
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.item.market !== b.item.market) return a.item.market.localeCompare(b.item.market);
return a.item.name.localeCompare(b.item.name, "ko");
});
// [Step 4] UI에서 필요한 최소 필드만 남겨 SEARCH_LIMIT 만큼 반환합니다.
const items: DashboardStockSearchItem[] = ranked.slice(0, SEARCH_LIMIT).map(({ item }) => ({
symbol: item.symbol,
name: item.name,
market: item.market,
}));
const response: DashboardStockSearchResponse = {
query,
items,
total: ranked.length,
};
// [Step 5] DashboardStockSearchResponse 형태로 응답합니다.
return NextResponse.json(response);
}
/**
* 검색어 정규화(공백 제거 + 소문자)
* @param value 원본 문자열
* @returns 정규화 문자열
* @see app/api/kis/domestic/search/route.ts 한글/영문 검색 비교 정확도를 높입니다.
*/
function normalizeKeyword(value: string) {
return value.replaceAll(/\s+/g, "").toLowerCase();
}
/**
* 검색 결과 점수 계산
* @param item 종목 인덱스 항목
* @param normalizedQuery 정규화된 검색어
* @returns 높은 값일수록 우선순위 상위
* @see app/api/kis/domestic/search/route.ts 검색 결과 정렬 기준으로 사용합니다.
*/
function getSearchScore(item: KoreanStockIndexItem, normalizedQuery: string) {
const normalizedName = normalizeKeyword(item.name);
const normalizedSymbol = item.symbol.toLowerCase();
if (normalizedSymbol === normalizedQuery) return 120;
if (normalizedName === normalizedQuery) return 110;
if (normalizedSymbol.startsWith(normalizedQuery)) return 100;
if (normalizedName.startsWith(normalizedQuery)) return 90;
if (normalizedName.includes(normalizedQuery)) return 70;
if (normalizedSymbol.includes(normalizedQuery)) return 60;
return 0;
}

View File

@@ -0,0 +1,65 @@
import type { DashboardKisRevokeResponse } from "@/features/trade/types/trade.types";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import { revokeKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/revoke/route.ts
* @description 사용자 입력 KIS API 키로 액세스 토큰을 폐기합니다.
*/
/**
* @description KIS 액세스 토큰 폐기
* @see features/settings/components/KisAuthForm.tsx
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidMessage,
tradingEnv,
});
}
try {
const message = await revokeKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse);
} catch (error) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(error, "API 토큰 폐기 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -0,0 +1,247 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { parseKisAccountParts } from "@/lib/kis/account";
import { kisGet } from "@/lib/kis/client";
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import { validateKisCredentialInput } from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
const kisProfileValidateBodySchema = z.object({
appKey: z.string().trim().min(1, "앱 키를 입력해 주세요."),
appSecret: z.string().trim().min(1, "앱 시크릿을 입력해 주세요."),
tradingEnv: z.string().optional(),
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
});
interface BalanceValidationPreset {
inqrDvsn: "01" | "02";
prcsDvsn: "00" | "01";
}
const BALANCE_VALIDATION_PRESETS: BalanceValidationPreset[] = [
{
// 명세 기본 요청값
inqrDvsn: "01",
prcsDvsn: "01",
},
{
// 일부 계좌/환경 호환값
inqrDvsn: "02",
prcsDvsn: "00",
},
];
/**
* @file app/api/kis/validate-profile/route.ts
* @description 한국투자증권 계좌번호를 검증합니다.
*/
/**
* @description 앱키/앱시크릿키 + 계좌번호 유효성을 검증합니다.
* @remarks UI 흐름: /settings -> KisProfileForm 확인 버튼 -> /api/kis/validate-profile -> store 반영 -> 대시보드 상태 확장
* @see features/settings/components/KisProfileForm.tsx 계좌 확인 버튼에서 호출합니다.
* @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수
*/
export async function POST(request: NextRequest) {
const fallbackTradingEnv = normalizeTradingEnv(
request.headers.get("x-kis-trading-env") ?? undefined,
);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv: fallbackTradingEnv,
});
}
let rawBody: unknown = {};
try {
rawBody = (await request.json()) as unknown;
} catch {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
tradingEnv: fallbackTradingEnv,
});
}
const parsedBody = kisProfileValidateBodySchema.safeParse(rawBody);
if (!parsedBody.success) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message:
parsedBody.error.issues[0]?.message ??
"요청 본문 값이 올바르지 않습니다.",
tradingEnv: fallbackTradingEnv,
});
}
const body = parsedBody.data;
const credentials: KisCredentialInput = {
appKey: body.appKey.trim(),
appSecret: body.appSecret.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const invalidCredentialMessage = validateKisCredentialInput(credentials);
if (invalidCredentialMessage) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidCredentialMessage,
tradingEnv,
});
}
const accountNoInput = body.accountNo.trim();
const accountParts = parseKisAccountParts(accountNoInput);
if (!accountParts) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
tradingEnv,
});
}
try {
// 1) 토큰 발급으로 앱키/시크릿 사전 검증
try {
await getKisAccessToken(credentials);
} catch (error) {
throw new Error(`앱키 검증 실패: ${toErrorMessage(error)}`);
}
// 2) 계좌 유효성 검증 (실제 계좌 조회 API)
try {
await validateAccountByBalanceApi(
accountParts.accountNo,
accountParts.accountProductCode,
credentials,
);
} catch (error) {
throw new Error(`계좌 검증 실패: ${toErrorMessage(error)}`);
}
const normalizedAccountNo = `${accountParts.accountNo}-${accountParts.accountProductCode}`;
return NextResponse.json({
ok: true,
tradingEnv,
message: "계좌번호 검증이 완료되었습니다.",
account: {
normalizedAccountNo,
},
} satisfies DashboardKisProfileValidateResponse);
} catch (error) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."),
tradingEnv,
});
}
}
/**
* @description 계좌번호로 잔고 조회 API를 호출해 유효성을 확인합니다.
* @param accountNo 계좌번호 앞 8자리
* @param accountProductCode 계좌번호 뒤 2자리
* @param credentials KIS 인증 정보
* @see app/api/kis/validate-profile/route.ts POST
*/
async function validateAccountByBalanceApi(
accountNo: string,
accountProductCode: string,
credentials: KisCredentialInput,
) {
const trId = normalizeTradingEnv(credentials.tradingEnv) === "real" ? "TTTC8434R" : "VTTC8434R";
const attemptErrors: string[] = [];
for (const preset of BALANCE_VALIDATION_PRESETS) {
try {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/trading/inquire-balance",
trId,
{
CANO: accountNo,
ACNT_PRDT_CD: accountProductCode,
AFHR_FLPR_YN: "N",
OFL_YN: "",
INQR_DVSN: preset.inqrDvsn,
UNPR_DVSN: "01",
FUND_STTL_ICLD_YN: "N",
FNCG_AMT_AUTO_RDPT_YN: "N",
PRCS_DVSN: preset.prcsDvsn,
CTX_AREA_FK100: "",
CTX_AREA_NK100: "",
},
credentials,
);
validateInquireBalanceResponse(response);
return;
} catch (error) {
attemptErrors.push(
`INQR_DVSN=${preset.inqrDvsn}, PRCS_DVSN=${preset.prcsDvsn}: ${toErrorMessage(error)}`,
);
}
}
throw new Error(
`계좌 확인 요청이 모두 실패했습니다. ${attemptErrors.join(" | ")}`,
);
}
/**
* @description 주식잔고조회 응답 구조를 최소 검증합니다.
* @param response KIS 원본 응답
* @see app/api/kis/validate-profile/route.ts validateAccountByBalanceApi
*/
function validateInquireBalanceResponse(
response: {
output1?: unknown;
output2?: unknown;
},
) {
const output1Ok =
Array.isArray(response.output1) ||
(response.output1 !== null && typeof response.output1 === "object");
const output2Ok =
Array.isArray(response.output2) ||
(response.output2 !== null && typeof response.output2 === "object");
if (!output1Ok && !output2Ok) {
throw new Error("응답에 output1/output2가 없습니다. 요청 파라미터를 확인해 주세요.");
}
}
/**
* @description Error 객체를 사용자 표시용 문자열로 변환합니다.
* @param error unknown 에러
* @returns 메시지 문자열
* @see app/api/kis/validate-profile/route.ts POST
*/
function toErrorMessage(error: unknown) {
return error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.";
}

View File

@@ -0,0 +1,65 @@
import type { DashboardKisValidateResponse } from "@/features/trade/types/trade.types";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/validate/route.ts
* @description 사용자 입력 KIS API 키를 검증합니다.
*/
/**
* @description 액세스 토큰 발급 성공 여부로 API 키를 검증합니다.
* @see features/settings/components/KisAuthForm.tsx
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidMessage,
tradingEnv,
});
}
try {
await getKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse);
} catch (error) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(error, "API 키 검증 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -0,0 +1,71 @@
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/ws/approval/route.ts
* @description KIS 웹소켓 승인키와 WS URL을 발급합니다.
*/
/**
* @description 실시간 웹소켓 연결 정보를 발급합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidMessage,
tradingEnv,
});
}
try {
const approvalKey = await getKisApprovalKey(credentials);
const wsUrl = resolveKisWebSocketUrl(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
approvalKey,
wsUrl,
message: "웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse);
} catch (error) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(
error,
"웹소켓 승인키 발급 중 오류가 발생했습니다.",
),
tradingEnv,
});
}
}

121
app/auth/callback/route.ts Normal file
View File

@@ -0,0 +1,121 @@
import { createClient } from "@/utils/supabase/server";
import { type NextRequest, NextResponse } from "next/server"; // NextRequest 추가
import { AUTH_ERROR_MESSAGES, AUTH_ROUTES } from "@/features/auth/constants";
import { getAuthErrorMessage } from "@/features/auth/errors";
/**
* OAuth/이메일 인증 콜백 처리
*
* Supabase 인증 후 리다이렉트되는 라우트입니다.
* - 인증 코드를 세션으로 교환합니다.
* - 인증 에러를 처리합니다.
* - 최종 목적지(Next URL)로 리다이렉트합니다.
*/
export async function GET(request: NextRequest) {
// --------------------------------------------------------------------------
// 1. 요청 파라미터 및 URL 준비
// --------------------------------------------------------------------------
const requestUrl = request.nextUrl.clone(); // URL 조작을 위해 복제
const code = requestUrl.searchParams.get("code");
const next = requestUrl.searchParams.get("next") ?? AUTH_ROUTES.HOME;
// 에러 파라미터 확인
const error = requestUrl.searchParams.get("error");
const error_code = requestUrl.searchParams.get("error_code");
const error_description = requestUrl.searchParams.get("error_description");
const origin = requestUrl.origin;
// --------------------------------------------------------------------------
// 2. 초기 에러 처리 (Provider 레벨 에러)
// --------------------------------------------------------------------------
if (error) {
console.error("Auth callback error parameter:", {
error,
error_code,
error_description,
});
let message: string = AUTH_ERROR_MESSAGES.DEFAULT;
if (error === "access_denied") {
message = AUTH_ERROR_MESSAGES.OAUTH_ACCESS_DENIED;
} else if (error === "server_error") {
message = AUTH_ERROR_MESSAGES.OAUTH_SERVER_ERROR;
}
// 로그인 페이지로 에러와 함께 이동
return NextResponse.redirect(
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(message)}`,
);
}
// --------------------------------------------------------------------------
// 3. 인증 코드 교환 (Supabase 공식 패턴 적용)
// --------------------------------------------------------------------------
if (code) {
const supabase = await createClient();
// 코드 교환 실행
const { error: exchangeError } =
await supabase.auth.exchangeCodeForSession(code);
if (!exchangeError) {
// ----------------------------------------------------------------------
// 3-1. 교환 성공: 리다이렉트 처리
// code 교환으로 세션이 생성된 상태입니다.
// ----------------------------------------------------------------------
// 회원가입 인증 여부 확인 (쿼리 파라미터 기반)
// actions.ts의 signup 함수에서 emailRedirectTo에 auth_type=signup을 추가해서 보냅니다.
const authType = requestUrl.searchParams.get("auth_type");
const isSignupVerification = authType === "signup";
// 회원가입 인증인 경우:
// 이메일 인증만 완료하고, 자동 로그인된 세션은 종료시킨 뒤 로그인 페이지로 보냅니다.
if (isSignupVerification) {
await supabase.auth.signOut();
return NextResponse.redirect(
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(
AUTH_ERROR_MESSAGES.EMAIL_VERIFIED_SUCCESS,
)}`,
);
}
// 그 외 일반적인 로그인/인증인 경우:
// 코드 파라미터 등을 제거하고 깨끗한 URL로 이동합니다.
const forwardedHost = request.headers.get("x-forwarded-host"); // 로드밸런서 지원
const isLocalEnv = process.env.NODE_ENV === "development";
// 리다이렉트할 최종 URL 설정
if (isLocalEnv) {
// 로컬 개발 환경
return NextResponse.redirect(`${origin}${next}`);
} else if (forwardedHost) {
// 프로덕션 환경 (Vercel 등 프록시 뒤)
return NextResponse.redirect(`https://${forwardedHost}${next}`);
} else {
// 기본
return NextResponse.redirect(`${origin}${next}`);
}
}
// ------------------------------------------------------------------------
// 3-2. 교환 실패: 에러 처리
// ------------------------------------------------------------------------
console.error("Auth exchange error:", exchangeError.message);
const message = getAuthErrorMessage(exchangeError);
return NextResponse.redirect(
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(message)}`,
);
}
// --------------------------------------------------------------------------
// 4. 잘못된 접근 처리
// --------------------------------------------------------------------------
const errorMessage = encodeURIComponent(
AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK,
);
return NextResponse.redirect(
`${origin}${AUTH_ROUTES.LOGIN}?message=${errorMessage}`,
);
}

84
app/auth/confirm/route.ts Normal file
View File

@@ -0,0 +1,84 @@
import { createClient } from "@/utils/supabase/server";
import {
AUTH_ERROR_MESSAGES,
AUTH_ROUTES,
RECOVERY_COOKIE_MAX_AGE_SECONDS,
RECOVERY_COOKIE_NAME,
} from "@/features/auth/constants";
import { getAuthErrorMessage } from "@/features/auth/errors";
import { type EmailOtpType } from "@supabase/supabase-js";
import { NextResponse, type NextRequest } from "next/server";
const RESET_PASSWORD_PATH = AUTH_ROUTES.RESET_PASSWORD;
const LOGIN_PATH = AUTH_ROUTES.LOGIN;
/**
* 이메일 인증(/auth/confirm) 처리
* - token_hash + type 검증
* - recovery 타입일 경우 세션 쿠키 설정 후 비밀번호 재설정 페이지로 리다이렉트
*/
export async function GET(request: NextRequest) {
// 1) 이메일 링크에 들어있는 값 읽기
const { searchParams } = new URL(request.url);
// token_hash: 인증에 필요한 값
// type: 어떤 인증인지 구분 (예: 가입, 비밀번호 재설정)
const tokenHash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
// redirect_to/next: 인증 후에 이동할 주소
const rawRedirect =
searchParams.get("redirect_to") ?? searchParams.get("next");
// 보안상 우리 사이트 안 경로(`/...`)만 허용
const safeRedirect =
rawRedirect && rawRedirect.startsWith("/") ? rawRedirect : null;
// 일반 인증이 끝난 뒤 이동할 경로
const nextPath = safeRedirect ?? AUTH_ROUTES.HOME;
// 비밀번호 재설정일 때 이동할 경로
const recoveryPath = safeRedirect ?? RESET_PASSWORD_PATH;
// 필수 값이 없으면 로그인으로 보내고 에러를 보여줌
if (!tokenHash || !type) {
return redirectWithError(request, AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
}
// 2) Supabase에게 "이 링크가 맞는지" 확인 요청
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash: tokenHash,
});
// 확인 실패 시 이유를 알기 쉬운 메시지로 보여줌
if (error) {
console.error("[Auth Confirm] verifyOtp error:", error.message);
const message = getAuthErrorMessage(error);
return redirectWithError(request, message);
}
// 3) 비밀번호 재설정이면 재설정 페이지로 보내고 쿠키를 저장
if (type === "recovery") {
const response = NextResponse.redirect(new URL(recoveryPath, request.url));
response.cookies.set(RECOVERY_COOKIE_NAME, "1", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS,
path: "/",
});
return response;
}
// 4) 그 외 인증은 기본 경로로 이동
return NextResponse.redirect(new URL(nextPath, request.url));
}
// 로그인 페이지로 보내면서 에러 메시지를 함께 전달
function redirectWithError(request: NextRequest, message: string) {
const encodedMessage = encodeURIComponent(message);
return NextResponse.redirect(
new URL(`${LOGIN_PATH}?message=${encodedMessage}`, request.url),
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

194
app/globals.css Normal file
View File

@@ -0,0 +1,194 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-brand-50: var(--brand-50);
--color-brand-100: var(--brand-100);
--color-brand-200: var(--brand-200);
--color-brand-300: var(--brand-300);
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-800: var(--brand-800);
--color-brand-900: var(--brand-900);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--animate-gradient-x: gradient-x 15s ease infinite;
@keyframes gradient-x {
0%,
100% {
background-size: 200% 200%;
background-position: left center;
}
50% {
background-size: 200% 200%;
background-position: right center;
}
}
}
:root {
/* BRAND PALETTE CONTROL
* 이 블록만 수정하면 랜딩/대시보드의 보라 톤이 함께 바뀝니다.
*/
/* 초기 브랜드 보라값(원본 기준) */
--brand-50: oklch(0.97 0.02 294);
--brand-100: oklch(0.93 0.05 294);
--brand-200: oklch(0.87 0.1 294);
--brand-300: oklch(0.79 0.15 294);
--brand-400: oklch(0.7 0.2 294);
--brand-500: oklch(0.62 0.24 294);
--brand-600: oklch(0.56 0.26 294);
--brand-700: oklch(0.49 0.24 295);
--brand-800: oklch(0.4 0.2 296);
--brand-900: oklch(0.33 0.14 297);
/* 차트(canvas): 봉 하락색은 요청대로 파란색 유지 */
--brand-chart-background-light: #ffffff;
--brand-chart-background-dark: #17131e;
--brand-chart-text-light: #6b21a8;
--brand-chart-text-dark: #e9d5ff;
--brand-chart-border-light: #e9d5ff;
--brand-chart-border-dark: rgba(216, 180, 254, 0.3);
--brand-chart-grid-light: #f3e8ff;
--brand-chart-grid-dark: rgba(216, 180, 254, 0.14);
--brand-chart-crosshair-light: #c084fc;
--brand-chart-crosshair-dark: rgba(233, 213, 255, 0.75);
--brand-chart-background: #ffffff;
--brand-chart-down: #2563eb;
--brand-chart-volume-down: rgba(37, 99, 235, 0.45);
--brand-chart-text: #6b21a8;
--brand-chart-border: var(--brand-chart-border-light);
--brand-chart-grid: var(--brand-chart-grid-light);
--brand-chart-crosshair: var(--brand-chart-crosshair-light);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: var(--brand-600);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: var(--brand-500);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: var(--brand-600);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
/* 다크 모드 시인성 개선: 배경 대비는 유지하고, 카드/보더/보조 텍스트를 더 읽기 쉽게 조정 */
--background: oklch(0.17 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.235 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.235 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: var(--brand-600);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.285 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.285 0 0);
--muted-foreground: oklch(0.83 0 0);
--accent: oklch(0.285 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 18%);
--input: oklch(1 0 0 / 22%);
--ring: var(--brand-500);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.235 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: var(--brand-600);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.285 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 18%);
--sidebar-ring: oklch(0.78 0 0);
/* 다크 테마용 차트 배경/격자 대비 */
--brand-chart-background: var(--brand-chart-background-dark);
--brand-chart-text: var(--brand-chart-text-dark);
--brand-chart-border: var(--brand-chart-border-dark);
--brand-chart-grid: var(--brand-chart-grid-dark);
--brand-chart-crosshair: var(--brand-chart-crosshair-dark);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

79
app/layout.tsx Normal file
View File

@@ -0,0 +1,79 @@
/**
* @file app/layout.tsx
* @description 애플리케이션의 최상위 루트 레이아웃 (RootLayout)
* @remarks
* - [레이어] Infrastructure/Layout
* - [역할] 전역 스타일(Font/CSS), 테마(Provider), 세션 관리(Manager) 초기화
* - [데이터 흐름] Providers -> Children
* - [연관 파일] globals.css, theme-provider.tsx
*/
import type { Metadata } from "next";
import { Geist, Geist_Mono, Outfit } from "next/font/google";
import { QueryProvider } from "@/providers/query-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { SessionManager } from "@/features/auth/components/session-manager";
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
import { Toaster } from "sonner";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const outfit = Outfit({
variable: "--font-heading",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
description:
"주린이를 위한 자동매매 파트너 JOORIN-E. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
};
/**
* RootLayout 컴포넌트
* @param children 렌더링할 자식 컴포넌트
* @returns HTML 구조 및 전역 Provider 래퍼
* @see theme-provider.tsx - 다크모드 지원
* @see session-manager.tsx - 세션 타임아웃 감지
*/
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="scroll-smooth" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} ${outfit.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionManager />
<GlobalAlertModal />
<QueryProvider>{children}</QueryProvider>
<Toaster
richColors
position="top-right"
toastOptions={{
duration: 4000,
}}
/>
</ThemeProvider>
</body>
</html>
);
}

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`

Binary file not shown.

View File

@@ -0,0 +1,266 @@
# 브라우저 상주 자동매매 통합 계획서 v3.1 (AI/무저장 정책 반영)
## 요약
1. 자동매매는 브라우저가 켜져 있을 때만 동작합니다.
2. 백그라운드 탭(가려진 탭)에서는 동작을 허용합니다.
3. 탭 종료, 브라우저 종료, 앱 종료, 외부 페이지 이탈 시 자동주문은 즉시 중지됩니다.
4. 종료 직전 강한 경고를 보여주고 중지 이벤트를 서버에 기록합니다.
5. 투자금/손실한도는 퍼센트와 금액을 동시에 받고 더 보수적인 값(더 작은 값)을 실적용합니다.
6. 전략 선택은 프롬프트 입력, 검수 카탈로그, 온라인 실시간 수집을 모두 지원하며 복수선택 가능합니다.
7. 실거래 우선, 장중 기본, 보수적 위험관리 기본값을 유지합니다.
8. AI(인공지능)로 매수/매도 신호 후보를 만들고, 최종 주문은 규칙 엔진(고정 검증 로직)이 결정합니다.
9. 한국투자증권 API 키/시크릿/계좌번호는 서버 DB에 저장하지 않습니다.
10. KIS 민감정보는 브라우저 실행 세션 기준으로만 유지하고, 서버는 요청 처리 시에만 일시 사용합니다.
## 1) 기술 아키텍처
1. 프론트엔드: Next.js 16 App Router + React 19 + TypeScript.
2. 상태관리: Zustand 기반 `autotrade-engine-store` 신규.
3. 실시간: 기존 KIS WebSocket 스토어 재사용, 자동매매 엔진 훅으로 연결.
4. 서버 API: Next.js Route Handler(Node 런타임)로 전략/세션/로그/중지 API 제공.
5. 데이터 저장: Supabase Postgres + RLS(행 단위 권한).
6. 인증: Supabase Auth 세션 필수.
7. 보안: KIS 민감정보는 서버 저장 금지, 요청 단위(한 번 호출)로만 처리.
## 2) 배포 구조
1. 앱 배포: Vercel(기존 유지).
2. DB/인증: Supabase(기존 유지).
3. 자동매매 엔진: 브라우저 내부 실행(별도 워커 서버 없음).
4. 서버 역할: 주문 위임, 상태 기록, 위험한도 검증, 감사로그 저장(민감정보 저장 제외).
5. 만료 정리: Vercel Cron(1분 주기) 또는 DB 함수로 heartbeat 만료 세션 `stopped` 전환.
6. 장애 로그: Vercel Logs + Supabase Logs + Sentry(권장) 연동.
## 3) 필수 환경변수
1. `NEXT_PUBLIC_SUPABASE_URL`
2. `NEXT_PUBLIC_SUPABASE_ANON_KEY`
3. `SUPABASE_SERVICE_ROLE_KEY`
4. `AUTOTRADE_HEARTBEAT_TTL_SEC` (기본 90)
5. `AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT` (기본 20)
6. `AUTOTRADE_ONLINE_STRATEGY_ENABLED` (기본 true)
7. `ONLINE_STRATEGY_PROVIDER_KEY` (온라인 수집용 키)
8. `KIS_SERVER_STORAGE_DISABLED` (고정값 `true`, 서버 저장 차단 가드)
## 3-1) KIS 키/계좌 무저장 정책(추가)
1. 저장 금지 대상: `appKey`, `appSecret`, `accountNo`, `accountProductCode`.
2. 서버 DB(Supabase 포함)에는 위 값을 절대 저장하지 않습니다.
3. 서버 로그에도 원문을 남기지 않고 마스킹(일부 가리기) 처리합니다.
4. 자동매매 요청 시 민감정보는 헤더로 전달하고, 요청 처리 후 즉시 폐기합니다.
5. 브라우저 보관은 `sessionStorage` 우선, `localStorage` 영구 저장은 자동매매 모드에서 금지합니다.
6. UI 흐름: 설정 UI 입력 -> 메모리/세션 저장 -> API 호출 헤더 전달 -> 서버 즉시 사용 후 폐기.
## 4) 데이터 모델(Supabase)
1. `auto_trade_strategies`
2. 주요 컬럼: `user_id`, `name`, `strategy_source_type(prompt|catalog|online)`, `symbols[]`, `allocation_percent`, `allocation_amount`, `effective_allocation_amount`, `daily_loss_percent`, `daily_loss_amount`, `effective_daily_loss_limit`, `resolved_params(jsonb)`, `status`.
3. `auto_trade_sessions`
4. 주요 컬럼: `strategy_id`, `desired_state`, `runtime_state`, `leader_tab_id`, `last_heartbeat_at`, `started_at`, `ended_at`, `stop_reason`.
5. `auto_trade_order_attempts`
6. 주요 컬럼: `session_id`, `symbol`, `idempotency_key(unique)`, `request_payload`, `response_payload`, `status`, `blocked_reason`.
7. `auto_trade_signal_logs`
8. 주요 컬럼: `session_id`, `signal_payload`, `decision(execute|skip|block)`, `decision_reason`, `source_type`, `risk_grade`.
9. `auto_trade_online_strategies`
10. 주요 컬럼: `title`, `source_url`, `strategy_text`, `fetched_at`, `parser_score`, `risk_grade`, `is_approved`.
11. `auto_trade_audit_logs`
12. 주요 컬럼: `user_id`, `action`, `payload`, `created_at`.
13. `kis_credentials*` 계열 테이블은 만들지 않습니다(무저장 정책).
## 5) API 설계
1. `POST /api/autotrade/strategies/compile`
2. 입력: 프롬프트/온라인 텍스트.
3. 출력: 표준 규칙(JSON) + 검증결과.
4. `POST /api/autotrade/strategies/validate`
5. 출력: 실행 가능 여부, 차단 사유.
6. `GET /api/autotrade/templates`
7. 검수 카탈로그 전략 목록 제공.
8. `POST /api/autotrade/strategies/discover`
9. 온라인 실시간 수집 전략 목록 제공.
10. `POST /api/autotrade/strategies`
11. 전략 저장(배분/손실한도 실적용값 계산 포함).
12. `POST /api/autotrade/sessions/start`
13. 세션 시작 + 리스크 스냅샷 생성.
14. `POST /api/autotrade/sessions/heartbeat`
15. 리더 탭 생존신호 갱신.
16. `POST /api/autotrade/sessions/stop`
17. `reason`: `browser_exit|external_leave|manual|emergency|heartbeat_timeout`.
18. `GET /api/autotrade/sessions/active`
19. 현재 실행 세션/리더 정보 조회.
20. `GET /api/autotrade/sessions/{id}/logs`
21. 신호/주문/오류 로그 조회.
22. 자동매매 관련 API(주문/세션/리스크)는 요청 헤더에 KIS 정보 포함이 필수입니다.
23. 서버는 헤더 값 유효성만 검사하고 DB에는 저장하지 않습니다.
24. 실패 응답/에러 로그에서도 민감정보는 마스킹합니다.
## 5-1) AI 자동매매 설계(추가)
1. 핵심 원칙: AI는 "신호 후보 생성기", 최종 주문 판단은 "규칙 엔진"이 담당.
2. 이유: AI 단독 주문은 일관성(항상 같은 판단)과 추적성이 약해 리스크가 큽니다.
3. AI 입력 데이터:
4. 실시간 체결/호가, 최근 변동성, 거래량, 전략 파라미터, 장 상태(정규장/시간외).
5. AI 출력 데이터:
6. `signal`(buy/sell/hold), `confidence`(신뢰도), `reason`(한 줄 근거), `ttlSec`(신호 유효시간).
7. 실행 흐름:
8. 사용자 전략 선택/프롬프트 입력 -> AI 해석 -> 규칙 JSON 변환 -> 리스크 검증 -> 주문 실행/차단.
9. 온라인 유명 단타 기법 처리:
10. 실시간 수집 -> 정규화(형식 맞추기) -> 위험등급 부여 -> 사용자 선택 -> 검증 통과 시 활성화.
11. AI 장애 대응:
12. AI 응답 지연/실패 시 신규 주문 중지 또는 보수 모드(`hold`) 강제.
13. AI 드리프트(성능 저하) 대응:
14. 최근 N건 성능 추적 후 기준 미달 전략 자동 일시정지.
15. UI 흐름:
16. 전략 화면 -> "AI 제안 받기" 클릭 -> 제안 전략 목록 표시 -> 사용자 선택/수정 -> 저장/시뮬레이션 -> 시작.
17. 운영 기본값:
18. `confidence`가 임계치(예: 0.65) 미만이면 주문 차단.
19. `reason`이 비어 있으면 주문 차단(설명 없는 주문 금지).
20. 동일 종목 반대 신호가 짧은 시간에 반복되면 쿨다운 연장.
## 5-2) 자동매매 설정 팝업 UX(사용자 요청 반영)
1. 진입 흐름:
2. 자동매매 버튼 클릭 -> 자동매매 설정 팝업 오픈 -> 설정 입력 -> "자동매매 시작" 클릭.
3. 팝업 필수 입력:
4. 전략 프롬프트(자유 입력)
5. 유명 기법 선택(복수 선택): ORB(시가 범위 돌파), VWAP 되돌림, 거래량 돌파, 이동평균 교차, 갭 돌파.
6. 투자금 설정: 퍼센트(%) + 금액(원) 동시 입력.
7. 전략별 일일 손실한도: 퍼센트(%) + 금액(원) 동시 입력.
8. 거래 대상: 종목 다중 선택(또는 관심종목 가져오기).
9. 실행 전 검증:
10. AI 해석 결과 미리보기(어떤 근거로 매수/매도할지 요약)
11. 리스크 요약(실적용 투자금, 실적용 손실한도, 예상 최대 주문 수)
12. 동의 체크(브라우저 종료/외부 이탈 시 즉시 중지)
13. 버튼 정책:
14. 필수값 누락 또는 검증 실패 시 시작 버튼 비활성화.
15. 시작 성공 시 상단 고정 배너와 세션 상태 카드 즉시 표시.
## 5-3) AI API 선택 권장안(실행 가능한 추천)
1. 결론:
2. 1차는 OpenAI API를 기본으로 시작하고, 2차에서 Gemini/Claude를 붙일 수 있게 다중 제공자 어댑터(연결 레이어) 구조로 개발합니다.
3. 추천 이유(요약):
4. Structured Outputs(스키마 고정 출력) + Function Calling(함수 호출) 문서/생태계가 성숙해서 자동매매 검증 파이프라인 구성에 유리합니다.
5. 비용/속도 최적화 모델 선택지가 넓어 PoC(개념검증) -> 운영 전환이 쉽습니다.
6. 제공자별 특징:
7. OpenAI: 엄격 모드(`strict`) 기반 함수 스키마 강제가 명확하고, `parallel_tool_calls=false`로 1회 1액션 제어가 쉽습니다.
8. Gemini: 함수 호출 모드(`AUTO`/`ANY`/`NONE`/`VALIDATED`)가 명확하고 JSON 스키마 출력 지원이 좋아 대체 제공자로 적합합니다.
9. Claude: `strict: true` 도구 호출과 구조화 출력이 강점이며, 보조/백업 제공자로 적합합니다.
10. 운영 권장:
11. 1차: OpenAI 단일 운영
12. 2차: OpenAI 실패/지연 시 Gemini 폴백(대체 경로)
13. 3차: Claude까지 확장하는 3중화(고가용성)
## 5-4) AI 판단 -> 주문 실행 파이프라인(실전형)
1. Step 1. 입력 수집:
2. 사용자 프롬프트 + 선택한 유명 기법 + 실시간 시세/호가 + 보유/가용자산 + 리스크 한도.
3. Step 2. AI 해석:
4. AI가 `signal`, `confidence`, `reason`, `ttlSec`, `proposed_order`를 JSON으로 반환.
5. Step 3. 규칙 엔진 검증:
6. 스키마 검증(형식), 정책 검증(리스크), 시장상태 검증(장중 여부), 중복주문 검증(idempotency).
7. Step 4. 주문 결정:
8. 검증 통과 -> KIS 주문 API 호출.
9. 검증 실패 -> 주문 차단 + 사유 로그 기록.
10. Step 5. 사후 평가:
11. 체결/미체결 결과를 AI 평가 입력으로 재사용해 프롬프트/기법 가중치 조정.
## 5-5) AI 호출 프롬프트/출력 표준(권장 JSON)
1. 시스템 프롬프트 핵심:
2. "너는 주문 실행기가 아니라 신호 생성기다. 스키마에 맞는 JSON만 반환하고 설명문은 금지한다."
3. 출력 스키마:
4. `signal`: `buy|sell|hold`
5. `confidence`: `0~1`
6. `reason`: 짧은 한국어 근거
7. `proposed_order`: `{symbol, side, orderType, price, quantity}`
8. `risk_flags`: `string[]`
9. `ttlSec`: 신호 만료 시간
10. 차단 규칙:
11. `confidence < threshold` 또는 `reason` 누락 또는 `risk_flags`에 차단 사유 포함 시 주문 금지.
## 5-6) 서버 무저장 정책과 AI 호출 결합 방식
1. KIS 민감정보(`appKey`, `appSecret`, `accountNo`)는 AI API 호출 입력에 넣지 않습니다.
2. AI에는 가격/지표/포지션 요약 같은 비식별 데이터(개인 식별이 어려운 데이터)만 전달합니다.
3. 실제 주문 직전 단계에서만 브라우저 세션의 KIS 정보로 주문 API를 호출합니다.
4. 서버는 주문 처리 중 헤더를 일시 사용 후 폐기하며 DB/로그 저장을 금지합니다.
5. 에러 로그/감사로그에는 주문 사유와 결과만 남기고 민감값은 마스킹 처리합니다.
## 6) 브라우저 엔진 동작
1. 엔진 상태: `IDLE`, `ARMED`, `RUNNING`, `STOPPING`, `STOPPED`, `ERROR`.
2. 멀티탭 제어: `localStorage` lock + `BroadcastChannel` 동기화.
3. 리더 탭만 주문 실행, 팔로워 탭은 조회 전용.
4. 주문은 틱 이벤트(WebSocket 수신) 기반으로 처리해 백그라운드 타이머 지연 영향을 줄입니다.
5. heartbeat 10초 주기 전송, TTL 90초 초과 시 서버 강제 종료.
6. 새로고침 시 로컬 snapshot으로 이어서 실행.
7. 브라우저 완전 종료 후 재진입 시 자동 재개 금지, `중지 상태`로 복구 후 사용자 재시작 필요.
8. 백그라운드 탭에서도 WebSocket 이벤트 기반으로 신호 계산/주문은 유지합니다.
## 7) 강한 경고/즉시 중지 UX
1. 실행 중 상단 빨간 경고 바 고정: "브라우저/탭 종료 또는 외부 이동 시 자동주문이 즉시 중지됩니다."
2. 외부 링크 클릭 시 사전 모달 강제: "이동하면 자동매매가 중지됩니다. 계속할까요?"
3. 탭 닫기/브라우저 종료는 `beforeunload` 기본 경고 사용.
4. 종료 시퀀스: `STOPPING` 전환 -> 신규 주문 차단 -> `sendBeacon(stop)` -> lock 해제 -> `STOPPED`.
5. 브라우저 보안 제한으로 `beforeunload` 커스텀 문구는 사용하지 않습니다(표준 경고만 가능).
## 8) 자산 배분/손실한도 입력 규칙
1. 투자금 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
2. 실적용 투자금: `min(가용자산*퍼센트, 금액)`.
3. 일일 손실한도 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
4. 실적용 손실한도: `min(전략투자금*퍼센트, 금액)`.
5. UI에 실적용 값 실시간 계산 표시.
6. 유효성 검증: 0보다 큰 값, 최대 퍼센트 상한, 가용자산 초과 금액 차단.
7. UI에 "현재 가용자산 기준 실제 주문 가능 금액"을 즉시 표시합니다.
## 9) 전략 선택 체계(복수선택)
1. 소스 탭 3개: `프롬프트`, `검수 카탈로그`, `온라인 실시간 수집`.
2. 사용자는 소스별 전략을 여러 개 선택해 하나의 실행세트로 저장 가능.
3. 프롬프트 전략: 자연어 입력 -> 컴파일 -> 검증 통과 시 활성화.
4. 카탈로그 전략: 운영 검수 완료 버전만 제공.
5. 온라인 전략: 실시간 수집 결과를 보여주되 검증 통과 전에는 실행 금지.
6. 온라인/프롬프트 전략은 위험등급(`low|mid|high`) 자동 부여 후 실행 제한에 반영.
## 10) 보수적 위험관리 기본값
1. 전략별 일일 손실한도 기본 2%.
2. 전략별 일일 최대 주문 20건.
3. 종목별 주문 쿨다운 60초.
4. 단일 주문 상한: 전략 투자금의 25%.
5. 데이터 지연 5초 초과 시 신규 주문 차단.
6. 연속 실패 3회 시 자동 중지.
7. lock 충돌 2회 이상 시 자동 중지.
8. 비상정지 버튼은 언제나 최상단 고정 노출.
## 11) 구현 파일 범위
1. `features/autotrade/components/*` (전략 선택, 배분 입력, 경고 배너, 실행 상태 패널)
2. `features/autotrade/hooks/useAutotradeEngine.ts`
3. `features/autotrade/stores/use-autotrade-engine-store.ts`
4. `features/autotrade/types/autotrade.types.ts`
5. `app/api/autotrade/**/route.ts`
6. `lib/autotrade/*` (컴파일, 검증, 리스크 게이트, lock 유틸)
7. 기존 `TradeContainer`/`OrderForm`에 자동매매 섹션 통합
8. `features/settings/store/use-kis-runtime-store.ts` 자동매매 모드에서 민감정보 `persist` 제외
9. `app/api/kis/*``app/api/autotrade/*` 민감정보 마스킹 유틸 공통 적용
## 12) 테스트 시나리오
1. 멀티탭 3개에서 리더 1개만 주문하는지 확인.
2. 백그라운드 탭에서 실시간 신호 기반 주문이 유지되는지 확인.
3. 외부 링크 이탈 시 강한 경고 후 즉시 중지되는지 확인.
4. 탭 종료/브라우저 종료에서 `sendBeacon` + TTL 강제종료가 동작하는지 확인.
5. 퍼센트+금액 입력 시 실적용 값이 작은 값으로 계산되는지 확인.
6. 전략별 일일 손실한도 초과 시 즉시 차단되는지 확인.
7. 온라인 전략 검증 실패 시 실행이 막히는지 확인.
8. 새로고침 후 동일 세션이 중복주문 없이 이어지는지 확인.
9. 서버 DB/로그에 KIS 키/계좌 원문이 저장되지 않는지 확인.
10. AI 응답 누락/지연 시 주문이 차단되는지 확인.
11. AI `confidence` 임계치 미만에서 주문 차단되는지 확인.
## 13) 단계별 배포 계획
1. 1주차: DB 마이그레이션 + API 골격 + 타입 정의.
2. 2주차: 브라우저 엔진(lock/heartbeat/stop flow) + 기본 UI.
3. 3주차: 전략 소스 3종(프롬프트/카탈로그/온라인) + 컴파일/검증.
4. 4주차: 리스크 정책 완성 + 통합/E2E + 운영 모니터링.
5. 롤아웃: 기능 플래그로 5% 사용자 -> 30% -> 전체 오픈.
## 14) 수용 기준
1. 실행 중 종료 트리거 발생 시 신규 주문이 즉시 0건이어야 합니다.
2. 멀티탭에서 중복 주문이 발생하지 않아야 합니다.
3. 사용자는 전략별 투자금/손실한도를 퍼센트+금액으로 모두 설정할 수 있어야 합니다.
4. 프롬프트/카탈로그/온라인 전략 복수선택 저장과 실행이 가능해야 합니다.
5. 로그 화면에서 신호-판단-주문-중지 이유가 연결되어 추적 가능해야 합니다.
## 15) 명시적 가정/기본값
1. "다른 페이지 이동"은 외부 도메인 이탈 기준입니다.
2. 앱 내부 라우트 이동은 중지 트리거가 아닙니다.
3. 브라우저가 완전히 종료되면 자동매매는 반드시 중지 상태로 종료됩니다.
4. 브라우저 재진입 시 자동 재개는 하지 않고 사용자 재시작으로만 실행합니다.
5. 온라인 전략은 "실시간 수집 가능"이지만 "검증 통과 후 실행"을 강제합니다.
6. KIS API 키/계좌 정보는 서버 저장을 금지하고 요청 단위 처리만 허용합니다.

View File

@@ -0,0 +1,42 @@
# Korean Stocks 동기화
`korean-stocks.json`은 수동 편집 파일이 아니라 자동 생성 파일입니다.
직접 수정하지 말고 동기화 스크립트로 갱신하세요.
## 실행 명령
```bash
npm run sync:stocks
```
- KIS 최신 KOSPI/KOSDAQ 마스터 파일을 내려받아
`features/trade/data/korean-stocks.json`을 다시 생성합니다.
```bash
npm run sync:stocks:check
```
- 현재 파일이 최신인지 검사합니다.
- 갱신이 필요하면 종료 코드 `1`로 끝납니다.
```bash
npm run sync:stocks -- --dry-run
```
- 원격 파일 파싱/검증만 하고 저장은 하지 않습니다.
## 권장 운영 방법
1. 하루 1회(또는 배포 전) `npm run sync:stocks` 실행
2. `npm run lint`, `npm run build`로 기본 검증
3. 갱신된 `features/trade/data/korean-stocks.json` 커밋
## 참고
- 데이터 출처:
- `https://new.real.download.dws.co.kr/common/master/kospi_code.mst.zip`
- `https://new.real.download.dws.co.kr/common/master/kosdaq_code.mst.zip`
- 비정상 데이터 저장을 막기 위해 최소 건수 검증(안전장치)을 넣었습니다.
- 임시 파일 저장 후 교체(원자적 저장) 방식이라 중간 손상 위험을 줄입니다.
- 공식 문서:
- `https://apiportal.koreainvestment.com/apiservice-category`

View File

@@ -0,0 +1,146 @@
# Global Alert System 사용 가이드
이 문서는 애플리케이션 전역에서 사용 가능한 `Global Alert System`의 설계 목적, 설치 방법, 그리고 사용법을 설명합니다.
## 1. 개요 (Overview)
Global Alert System은 Zustand 상태 관리 라이브러리와 Shadcn/ui의 `AlertDialog` 컴포넌트를 결합하여 만든 전역 알림 시스템입니다.
복잡한 모달 로직을 매번 구현할 필요 없이, `useGlobalAlert` 훅 하나로 어디서든 일관된 UI의 알림 및 확인 창을 호출할 수 있습니다.
### 주요 특징
- **전역 상태 관리**: `useGlobalAlertStore`를 통해 모달의 상태를 중앙에서 관리합니다.
- **간편한 Hook**: `useGlobalAlert` 훅을 통해 직관적인 API (`alert.success`, `alert.confirm` 등)를 제공합니다.
- **다양한 타입 지원**: Success, Error, Warning, Info 등 상황에 맞는 스타일과 아이콘을 자동으로 적용합니다.
- **비동기 지원**: 확인/취소 버튼 클릭 시 콜백 함수를 실행할 수 있습니다.
---
## 2. 설치 및 설정 (Setup)
이미 `app/layout.tsx`에 설정되어 있으므로, 개발자는 별도의 설정 없이 바로 사용할 수 있습니다.
### 파일 구조
```
features/layout/
├── components/
│ └── GlobalAlertModal.tsx # 실제 렌더링되는 모달 컴포넌트
├── hooks/
│ └── use-global-alert.ts # 개발자가 사용하는 Custom Hook
└── stores/
└── use-global-alert-store.ts # Zustand Store
```
### Layout 통합
`app/layout.tsx``GlobalAlertModal`이 이미 등록되어 있습니다.
```tsx
// app/layout.tsx
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<GlobalAlertModal /> {/* 전역 모달 등록 */}
{children}
</body>
</html>
);
}
```
---
## 3. 사용법 (Usage)
### Hook 가져오기
```tsx
import { useGlobalAlert } from "@/features/layout/hooks/use-global-alert";
const { alert } = useGlobalAlert();
```
### 기본 알림 (Alert)
사용자에게 단순히 정보를 전달하고 확인 버튼만 있는 알림입니다.
```tsx
// 1. 성공 알림
alert.success("저장이 완료되었습니다.");
// 2. 에러 알림
alert.error("데이터 불러오기에 실패했습니다.");
// 3. 경고 알림
alert.warning("입력 값이 올바르지 않습니다.");
// 4. 정보 알림
alert.info("새로운 버전이 업데이트되었습니다.");
```
옵션을 추가하여 제목이나 버튼 텍스트를 변경할 수 있습니다.
```tsx
alert.success("저장 완료", {
title: "성공", // 기본값: 타입에 따른 제목 (예: "성공", "오류")
confirmLabel: "닫기", // 기본값: "확인"
});
```
### 확인 대화상자 (Confirm)
사용자의 선택(확인/취소)을 요구하는 대화상자입니다.
```tsx
alert.confirm("정말로 삭제하시겠습니까?", {
type: "warning", // 기본값: warning (아이콘과 색상 변경됨)
confirmLabel: "삭제",
cancelLabel: "취소",
onConfirm: () => {
console.log("삭제 버튼 클릭됨");
// 여기에 삭제 로직 추가
},
onCancel: () => {
console.log("취소 버튼 클릭됨");
},
});
```
---
## 4. API Reference
### `useGlobalAlert()`
Hook은 `alert` 객체와 `close` 함수를 반환합니다.
#### `alert` Methods
| 메서드 | 설명 | 파라미터 |
| --------- | ----------------------- | ---------------------------------------------- |
| `success` | 성공 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `error` | 오류 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `warning` | 경고 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `info` | 정보 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `confirm` | 확인/취소 대화상자 표시 | `(message: ReactNode, options?: AlertOptions)` |
#### `AlertOptions` Interface
```typescript
interface AlertOptions {
title?: ReactNode; // 모달 제목 (생략 시 타입에 맞는 기본 제목)
confirmLabel?: string; // 확인 버튼 텍스트 (기본: "확인")
cancelLabel?: string; // 취소 버튼 텍스트 (Confirm 모드에서 기본: "취소")
onConfirm?: () => void; // 확인 버튼 클릭 시 실행될 콜백
onCancel?: () => void; // 취소 버튼 클릭 시 실행될 콜백
type?: AlertType; // 알림 타입 ("success" | "error" | "warning" | "info")
}
```

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,58 @@
/**
* @file components/form-message.tsx
* @description 폼 제출 결과(성공/에러) 메시지를 표시하는 컴포넌트
* @remarks
* - [레이어] Components/UI/Feedback
* - [기능] URL 쿼리 파라미터(`message`)를 감지하여 표시 후 URL 정리
* - [UX] 메시지 확인 후 새로고침 시 메시지가 남지 않도록 히스토리 정리 (History API)
*/
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/**
* 폼 메시지 컴포넌트
* @param message 표시할 메시지 텍스트
* @returns 메시지 박스 또는 null
*/
export default function FormMessage({ message }: { message: string }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
// 메시지가 있고, URL에 message 파라미터가 있다면
if (message && searchParams.has("message")) {
// 1. 현재 URL 파라미터 복사
const params = new URLSearchParams(searchParams.toString());
// 2. message 파라미터 삭제
params.delete("message");
// 3. URL 업데이트 (페이지 새로고침 없이 주소만 변경)
// replaceState를 사용하여 히스토리에 남기지 않고 주소창만 깔끔하게 바꿉니다.
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
window.history.replaceState(null, "", newUrl);
}
}, [message, pathname, searchParams]);
if (!message) return null;
// 에러 메시지인지 성공 메시지인지 대략적으로 판단 (성공 메시지는 보통 '확인', '완료' 등이 포함됨)
// 여기서는 간단하게 모든 메시지를 동일한 스타일로 보여주되, 필요하면 분기 가능합니다.
const isError = !message.includes("완료") && !message.includes("확인");
return (
<div
className={`rounded-md p-4 text-sm ${
isError
? "bg-red-50 text-red-700 dark:bg-red-900/50 dark:text-red-200"
: "bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200"
}`}
>
{message}
</div>
);
}

View File

@@ -0,0 +1,25 @@
/**
* @file components/theme-provider.tsx
* @description next-themes 라이브러리를 사용한 테마 제공자 (Wrapper)
* @remarks
* - [레이어] Infrastructure/Provider
* - [역할] 앱 전역에 테마 컨텍스트 주입 (Light/Dark 모드 지원)
* - [연관 파일] layout.tsx (사용처)
*/
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
/**
* ThemeProvider 컴포넌트
* @param props next-themes Provider props
* @returns NextThemesProvider 래퍼
*/
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,64 @@
/**
* @file components/theme-toggle.tsx
* @description 라이트/다크 테마 즉시 전환 토글 버튼
* @remarks
* - [레이어] Components/UI
* - [사용자 행동] 버튼 클릭 -> 라이트/다크 즉시 전환
* - [연관 파일] theme-provider.tsx (next-themes)
*/
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface ThemeToggleProps {
className?: string;
iconClassName?: string;
}
/**
* 테마 토글 컴포넌트
* @remarks next-themes의 useTheme 훅 사용
* @returns 단일 클릭으로 라이트/다크를 전환하는 버튼
* @see features/layout/components/header.tsx Header 액션 영역 - 사용자 테마 전환 버튼
*/
export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
const { resolvedTheme, setTheme } = useTheme();
const handleToggleTheme = React.useCallback(() => {
// 시스템 테마 사용 중에도 현재 화면 기준으로 명확히 라이트/다크를 토글합니다.
setTheme(resolvedTheme === "dark" ? "light" : "dark");
}, [resolvedTheme, setTheme]);
return (
<Button
type="button"
variant="ghost"
size="icon"
className={className}
onClick={handleToggleTheme}
aria-label="테마 전환"
>
{/* ========== LIGHT ICON ========== */}
<Sun
className={cn(
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
iconClassName,
)}
/>
{/* ========== DARK ICON ========== */}
<Moon
className={cn(
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
iconClassName,
)}
/>
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@@ -0,0 +1,150 @@
/**
* @file components/ui/alert-dialog.tsx
* @description 알림 대화상자 (Alert Dialog) 컴포넌트 (Shadcn/ui)
* @remarks
* - [레이어] Components/UI/Primitive
* - [기능] 중요한 작업 확인 컨텍스트 제공 (로그아웃 경고 등)
* @see session-manager.tsx - 로그아웃 경고에 사용
*/
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,115 @@
"use client";
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
const TONE_PHRASES = [
{ q: "주식이 너무 어려워요...", a: "걱정하지 마. JOORIN-E가 다 해줄게." },
{
q: "내 돈, 정말 안전할까?",
a: "안심해도 돼. 금융권 수준 보안키로 지키니까.",
},
{
q: "손실 날까 봐 불안해요...",
a: "걱정하지 마. 안전 장치가 24시간 작동해.",
},
{
q: "복잡한 건 딱 질색인데..",
a: "몰라도 돼. 클릭 몇 번이면 바로 시작이야.",
},
];
/**
* @description 프리미엄한 텍스트 리빌 효과를 제공하는 브랜드 톤 컴포넌트
*/
export function AnimatedBrandTone() {
const [index, setIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setIndex((prev) => (prev + 1) % TONE_PHRASES.length);
}, 5000);
return () => clearInterval(timer);
}, []);
return (
<div className="flex min-h-[300px] flex-col items-center justify-center py-10 text-center md:min-h-[400px]">
<AnimatePresence mode="wait">
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="flex flex-col items-center w-full"
>
{/* 질문 (Q) */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-sm font-medium text-brand-300/60 md:text-lg"
>
&ldquo;{TONE_PHRASES[index].q}&rdquo;
</motion.p>
{/* 답변 (A) - 타이핑 효과 */}
<div className="mt-8 flex flex-col items-center gap-2">
<h2 className="text-4xl font-extrabold tracking-wide text-white drop-shadow-lg md:text-6xl lg:text-7xl">
<div className="inline-block break-keep whitespace-pre-wrap leading-tight">
{TONE_PHRASES[index].a.split("").map((char, i) => (
<motion.span
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0,
delay: 0.5 + i * 0.1, // 글자당 0.1초 딜레이로 타이핑 효과
}}
className={cn(
"inline-block",
// 앞부분 강조 색상 로직은 단순화하거나 유지 (여기서는 전체 텍스트 톤 유지)
i < 5 ? "text-brand-300" : "text-white",
)}
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
{/* 깜빡이는 커서 */}
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0] }}
transition={{
duration: 0.8,
repeat: Infinity,
ease: "linear",
}}
className="ml-1 inline-block h-[0.8em] w-1.5 bg-brand-400 align-middle shadow-[0_0_10px_rgba(45,212,191,0.5)]"
/>
</div>
</h2>
</div>
</motion.div>
</AnimatePresence>
{/* 인디케이터 - 유휴 상태 표시 및 선택 기능 */}
<div className="mt-16 flex gap-3">
{TONE_PHRASES.map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
className={cn(
"h-1.5 transition-all duration-500 rounded-full",
i === index
? "w-10 bg-brand-500 shadow-[0_0_20px_rgba(20,184,166,0.3)]"
: "w-2 bg-white/10 hover:bg-white/20",
)}
aria-label={`Go to slide ${i + 1}`}
/>
))}
</div>
</div>
);
}

109
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}

48
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

64
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,109 @@
"use client";
import { cn } from "@/lib/utils";
/**
* [로딩 스피너 컴포넌트]
*
* 전역적으로 사용 가능한 로딩 스피너입니다.
* - 크기 조절 가능 (sm, md, lg)
* - 색상 커스터마이징 가능
* - 텍스트와 함께 사용 가능
*
* @example
* // 기본 사용
* <LoadingSpinner />
*
* @example
* // 크기 및 텍스트 지정
* <LoadingSpinner size="lg" text="로딩 중..." />
*
* @example
* // 버튼 내부에서 사용
* <Button disabled={isLoading}>
* {isLoading ? <LoadingSpinner size="sm" /> : "제출"}
* </Button>
*/
interface LoadingSpinnerProps {
/** 스피너 크기 */
size?: "sm" | "md" | "lg";
/** 스피너와 함께 표시할 텍스트 */
text?: string;
/** 추가 CSS 클래스 */
className?: string;
/** 스피너 색상 (Tailwind 클래스) */
color?: string;
}
export function LoadingSpinner({
size = "md",
text,
className,
color = "border-gray-900 dark:border-white",
}: LoadingSpinnerProps) {
// 크기별 스타일 매핑
const sizeClasses = {
sm: "h-4 w-4 border-2",
md: "h-8 w-8 border-3",
lg: "h-12 w-12 border-4",
};
return (
<div className={cn("flex items-center justify-center gap-2", className)}>
{/* ========== 회전 스피너 ========== */}
<div
className={cn(
"animate-spin rounded-full border-solid border-t-transparent",
sizeClasses[size],
color,
)}
role="status"
aria-label="로딩 중"
/>
{/* ========== 로딩 텍스트 (선택적) ========== */}
{text && (
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{text}
</span>
)}
</div>
);
}
/**
* [인라인 스피너 컴포넌트]
*
* 버튼 내부나 작은 공간에서 사용하기 적합한 미니 스피너입니다.
*
* @example
* <Button disabled={isLoading}>
* {isLoading && <InlineSpinner />}
* 로그인
* </Button>
*/
export function InlineSpinner({ className }: { className?: string }) {
return (
<svg
className={cn("h-4 w-4 animate-spin", className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-label="로딩 중"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,244 @@
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ShaderBackgroundProps {
className?: string;
opacity?: number;
}
const VS_SOURCE = `
attribute vec4 aVertexPosition;
void main() {
gl_Position = aVertexPosition;
}
`;
const FS_SOURCE = `
precision highp float;
uniform vec2 iResolution;
uniform float iTime;
const float overallSpeed = 0.2;
const float gridSmoothWidth = 0.015;
const float axisWidth = 0.05;
const float majorLineWidth = 0.025;
const float minorLineWidth = 0.0125;
const float majorLineFrequency = 5.0;
const float minorLineFrequency = 1.0;
const vec4 gridColor = vec4(0.5);
const float scale = 5.0;
const vec4 lineColor = vec4(0.4, 0.2, 0.8, 1.0);
const float minLineWidth = 0.01;
const float maxLineWidth = 0.2;
const float lineSpeed = 1.0 * overallSpeed;
const float lineAmplitude = 1.0;
const float lineFrequency = 0.2;
const float warpSpeed = 0.2 * overallSpeed;
const float warpFrequency = 0.5;
const float warpAmplitude = 1.0;
const float offsetFrequency = 0.5;
const float offsetSpeed = 1.33 * overallSpeed;
const float minOffsetSpread = 0.6;
const float maxOffsetSpread = 2.0;
const int linesPerGroup = 16;
#define drawCircle(pos, radius, coord) smoothstep(radius + gridSmoothWidth, radius, length(coord - (pos)))
#define drawSmoothLine(pos, halfWidth, t) smoothstep(halfWidth, 0.0, abs(pos - (t)))
#define drawCrispLine(pos, halfWidth, t) smoothstep(halfWidth + gridSmoothWidth, halfWidth, abs(pos - (t)))
#define drawPeriodicLine(freq, width, t) drawCrispLine(freq / 2.0, width, abs(mod(t, freq) - (freq) / 2.0))
float drawGridLines(float axis) {
return drawCrispLine(0.0, axisWidth, axis)
+ drawPeriodicLine(majorLineFrequency, majorLineWidth, axis)
+ drawPeriodicLine(minorLineFrequency, minorLineWidth, axis);
}
float drawGrid(vec2 space) {
return min(1.0, drawGridLines(space.x) + drawGridLines(space.y));
}
float random(float t) {
return (cos(t) + cos(t * 1.3 + 1.3) + cos(t * 1.4 + 1.4)) / 3.0;
}
float getPlasmaY(float x, float horizontalFade, float offset) {
return random(x * lineFrequency + iTime * lineSpeed) * horizontalFade * lineAmplitude + offset;
}
void main() {
vec2 fragCoord = gl_FragCoord.xy;
vec4 fragColor;
vec2 uv = fragCoord.xy / iResolution.xy;
vec2 space = (fragCoord - iResolution.xy / 2.0) / iResolution.x * 2.0 * scale;
float horizontalFade = 1.0 - (cos(uv.x * 6.28) * 0.5 + 0.5);
float verticalFade = 1.0 - (cos(uv.y * 6.28) * 0.5 + 0.5);
space.y += random(space.x * warpFrequency + iTime * warpSpeed) * warpAmplitude * (0.5 + horizontalFade);
space.x += random(space.y * warpFrequency + iTime * warpSpeed + 2.0) * warpAmplitude * horizontalFade;
vec4 lines = vec4(0.0);
vec4 bgColor1 = vec4(0.1, 0.1, 0.3, 1.0);
vec4 bgColor2 = vec4(0.3, 0.1, 0.5, 1.0);
for(int l = 0; l < linesPerGroup; l++) {
float normalizedLineIndex = float(l) / float(linesPerGroup);
float offsetTime = iTime * offsetSpeed;
float offsetPosition = float(l) + space.x * offsetFrequency;
float rand = random(offsetPosition + offsetTime) * 0.5 + 0.5;
float halfWidth = mix(minLineWidth, maxLineWidth, rand * horizontalFade) / 2.0;
float offset = random(offsetPosition + offsetTime * (1.0 + normalizedLineIndex)) * mix(minOffsetSpread, maxOffsetSpread, horizontalFade);
float linePosition = getPlasmaY(space.x, horizontalFade, offset);
float line = drawSmoothLine(linePosition, halfWidth, space.y) / 2.0 + drawCrispLine(linePosition, halfWidth * 0.15, space.y);
float circleX = mod(float(l) + iTime * lineSpeed, 25.0) - 12.0;
vec2 circlePosition = vec2(circleX, getPlasmaY(circleX, horizontalFade, offset));
float circle = drawCircle(circlePosition, 0.01, space) * 4.0;
line = line + circle;
lines += line * lineColor * rand;
}
fragColor = mix(bgColor1, bgColor2, uv.x);
fragColor *= verticalFade;
fragColor.a = 1.0;
fragColor += lines;
gl_FragColor = fragColor;
}
`;
/**
* @description Compile one shader source.
* @see components/ui/shader-background.tsx ShaderBackground useEffect - WebGL init flow
*/
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("Shader compile error:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
/**
* @description Create and link WebGL shader program.
* @see components/ui/shader-background.tsx ShaderBackground useEffect - shader program setup
*/
function initShaderProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertexShader || !fragmentShader) return null;
const shaderProgram = gl.createProgram();
if (!shaderProgram) return null;
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.error("Shader program link error:", gl.getProgramInfoLog(shaderProgram));
gl.deleteProgram(shaderProgram);
return null;
}
return shaderProgram;
}
/**
* @description Animated shader background canvas.
* @param className Tailwind class for canvas.
* @param opacity Canvas opacity.
* @see https://21st.dev/community/components/thanh/shader-background/default
*/
const ShaderBackground = ({ className, opacity = 0.9 }: ShaderBackgroundProps) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl");
if (!gl) {
console.warn("WebGL not supported.");
return;
}
const shaderProgram = initShaderProgram(gl, VS_SOURCE, FS_SOURCE);
if (!shaderProgram) return;
const positionBuffer = gl.createBuffer();
if (!positionBuffer) return;
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const vertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition");
const resolution = gl.getUniformLocation(shaderProgram, "iResolution");
const time = gl.getUniformLocation(shaderProgram, "iTime");
const resizeCanvas = () => {
const dpr = window.devicePixelRatio || 1;
const nextWidth = Math.floor(window.innerWidth * dpr);
const nextHeight = Math.floor(window.innerHeight * dpr);
canvas.width = nextWidth;
canvas.height = nextHeight;
gl.viewport(0, 0, nextWidth, nextHeight);
};
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
const startTime = Date.now();
let frameId = 0;
const render = () => {
const currentTime = (Date.now() - startTime) / 1000;
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(shaderProgram);
if (resolution) gl.uniform2f(resolution, canvas.width, canvas.height);
if (time) gl.uniform1f(time, currentTime);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(vertexPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vertexPosition);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
frameId = requestAnimationFrame(render);
};
frameId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener("resize", resizeCanvas);
gl.deleteBuffer(positionBuffer);
gl.deleteProgram(shaderProgram);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn("fixed inset-0 -z-10 h-full w-full", className)}
style={{ opacity }}
/>
);
};
export default ShaderBackground;

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

91
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

428
features/auth/actions.ts Normal file
View File

@@ -0,0 +1,428 @@
/**
* @file features/auth/actions.ts
* @description 인증 관련 서버 액션 (Server Actions) 모음
* @remarks
* - [레이어] Service/API (Server Actions)
* - [역할] 로그인, 회원가입, 로그아웃, 비밀번호 재설정 등 인증 로직 처리
* - [데이터 흐름] Client Form -> Server Action -> Supabase Auth -> Client Redirect
* - [연관 파일] login-form.tsx, signup-form.tsx, utils/supabase/server.ts
*/
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import {
AUTH_ERROR_MESSAGES,
type AuthFormData,
type AuthError,
RECOVERY_COOKIE_NAME,
} from "./constants";
import { getAuthErrorMessage } from "./errors";
// ========================================
// 헬퍼 함수
// ========================================
/**
* FormData 추출 헬퍼 (이메일/비밀번호)
* @param formData HTML form 데이터
* @returns 이메일(trim 적용), 비밀번호
*/
function extractAuthData(formData: FormData): AuthFormData {
const email = (formData.get("email") as string)?.trim() || "";
const password = (formData.get("password") as string) || "";
return { email, password };
}
/**
* 비밀번호 강도 검증 함수
* @param password 검증할 비밀번호
* @returns 에러 객체 또는 null
* @remarks 최소 8자, 대/소문자, 숫자, 특수문자 포함 필수
*/
function validatePassword(password: string): AuthError | null {
// [Step 1] 최소 길이 체크 (8자 이상)
if (password.length < 8) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_SHORT,
type: "validation",
};
}
// [Step 2] 대문자 포함 여부
if (!/[A-Z]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// [Step 3] 소문자 포함 여부
if (!/[a-z]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// [Step 4] 숫자 포함 여부
if (!/[0-9]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// [Step 5] 특수문자 포함 여부
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// [Step 6] 모든 검증 통과
return null;
}
/**
* 입력값 유효성 검증 함수
* @param email 사용자 이메일
* @param password 사용자 비밀번호
* @returns 에러 객체 또는 null
* @see login - 로그인 액션에서 호출
* @see signup - 회원가입 액션에서 호출
*/
function validateAuthInput(email: string, password: string): AuthError | null {
// [Step 1] 빈 값 체크
if (!email || !password) {
return {
message: AUTH_ERROR_MESSAGES.EMPTY_FIELDS,
type: "validation",
};
}
// [Step 2] 이메일 형식 체크 (간단한 @ 포함 여부 확인)
if (!email.includes("@")) {
return {
message: AUTH_ERROR_MESSAGES.INVALID_EMAIL,
type: "validation",
};
}
// [Step 3] 비밀번호 강도 체크
const passwordValidation = validatePassword(password);
if (passwordValidation) {
return passwordValidation;
}
// [Step 4] 검증 통과
return null;
}
// ========================================
// Server Actions (서버 액션)
// ========================================
/**
* [로그인 액션]
*
* 사용자가 입력한 이메일/비밀번호로 로그인을 시도합니다.
*
* 처리 과정:
* 1. FormData에서 이메일/비밀번호 추출
* 2. 입력값 유효성 검증 (빈 값, 이메일 형식, 비밀번호 길이)
* 3. Supabase Auth를 통한 로그인 시도
* 4. 로그인 실패 시 에러 메시지와 함께 로그인 페이지로 리다이렉트
* 5. 로그인 성공 시 캐시 무효화 및 메인 페이지로 리다이렉트
*
* @param formData 이메일, 비밀번호가 포함된 FormData
* @see login-form.tsx - 로그인 폼 제출 시 호출
*/
export async function login(formData: FormData) {
// [Step 1] FormData에서 이메일/비밀번호 추출
const { email, password } = extractAuthData(formData);
// [Step 2] 입력값 유효성 검증
const validationError = validateAuthInput(email, password);
if (validationError) {
return redirect(
`/login?message=${encodeURIComponent(validationError.message)}`,
);
}
// [Step 3] Supabase 클라이언트 생성 및 로그인 시도
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
// [Step 4] 로그인 실패 시 에러 처리
if (error) {
const message = getAuthErrorMessage(error);
return redirect(`/login?message=${encodeURIComponent(message)}`);
}
// [Step 5] 로그인 성공 - 캐시 무효화 및 메인 페이지로 리다이렉트
revalidatePath("/", "layout");
redirect("/");
}
/**
* [회원가입 액션]
*
* 새로운 사용자를 등록합니다.
*
* 처리 과정:
* 1. FormData에서 이메일/비밀번호 추출
* 2. 입력값 유효성 검증
* 3. Supabase Auth를 통한 회원가입 시도
* 4. 이메일 인증 리다이렉트 URL 설정 (확인 링크 클릭 시 돌아올 주소)
* 5-1. 즉시 세션 생성 시: 메인 페이지로 리다이렉트 (바로 로그인됨)
* 5-2. 이메일 인증 필요 시: 로그인 페이지로 리다이렉트 (안내 메시지 포함)
*
* @param formData 이메일, 비밀번호가 포함된 FormData
* @see signup-form.tsx - 회원가입 폼 제출 시 호출
*/
export async function signup(formData: FormData) {
// [Step 1] FormData에서 이메일/비밀번호 추출
const { email, password } = extractAuthData(formData);
// [Step 2] 입력값 유효성 검증
const validationError = validateAuthInput(email, password);
if (validationError) {
return redirect(
`/signup?message=${encodeURIComponent(validationError.message)}`,
);
}
// [Step 3] Supabase 클라이언트 생성 및 회원가입 시도
const supabase = await createClient();
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
// 이메일 인증 완료 후 리다이렉트될 URL
emailRedirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/auth/callback?auth_type=signup`,
},
});
// [Step 4] 회원가입 실패 시 에러 처리
if (error) {
const message = getAuthErrorMessage(error);
return redirect(`/signup?message=${encodeURIComponent(message)}`);
}
// [Step 5] 회원가입 성공 처리
if (data.session) {
// [Case 1] 즉시 세션 생성됨 (이메일 인증 불필요)
revalidatePath("/", "layout");
redirect("/");
}
// [Case 2] 이메일 인증 필요 (로그인 페이지로 이동)
revalidatePath("/", "layout");
redirect(
`/login?message=${encodeURIComponent("회원가입이 완료되었습니다. 이메일을 확인하여 인증을 완료해 주세요.")}`,
);
}
/**
* [로그아웃 액션]
*
* 현재 세션을 종료하고 로그인 페이지로 이동합니다.
*
* 처리 과정:
* 1. Supabase Auth 세션 종료 (서버 + 클라이언트 쿠키 삭제)
* 2. Next.js 캐시 무효화하여 인증 상태 갱신
* 3. 로그인 페이지로 리다이렉트
*
* @see user-menu.tsx - 로그아웃 메뉴 클릭 시 호출
* @see session-manager.tsx - 세션 타임아웃 시 호출
*/
export async function signout() {
const supabase = await createClient();
// [Step 1] Supabase 세션 종료 (서버 + 클라이언트 쿠키 삭제)
await supabase.auth.signOut();
// [Step 2] Next.js 캐시 무효화
revalidatePath("/", "layout");
// [Step 3] 로그인 페이지로 리다이렉트
redirect("/");
}
/**
* [비밀번호 재설정 요청 액션]
*
* 사용자 이메일로 비밀번호 재설정 링크를 발송합니다.
* 보안을 위해 이메일 존재 여부와 관계없이 동일한 메시지를 표시합니다.
*
* 처리 과정:
* 1. FormData에서 이메일 추출
* 2. 이메일 형식 검증
* 3. Supabase를 통한 재설정 링크 발송
* 4. 성공 메시지와 함께 로그인 페이지로 리다이렉트
*
* @param formData 이메일 포함
* @see forgot-password/page.tsx - 비밀번호 찾기 폼 제출 시 호출
*/
export async function requestPasswordReset(formData: FormData) {
// [Step 1] FormData에서 이메일 추출
const email = (formData.get("email") as string)?.trim() || "";
// [Step 2] 이메일 유효성 검증
if (!email) {
return redirect(
`/forgot-password?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.EMPTY_EMAIL)}`,
);
}
if (!email.includes("@")) {
return redirect(
`/forgot-password?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.INVALID_EMAIL)}`,
);
}
// [Step 3] Supabase를 통한 재설정 링크 발송
const supabase = await createClient();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/reset-password`,
});
// [Step 4] 에러 처리
if (error) {
console.error("Password reset error:", error.message);
const message = getAuthErrorMessage(error);
return redirect(`/forgot-password?message=${encodeURIComponent(message)}`);
}
// [Step 5] 성공 메시지 표시 (보안상 항상 성공 메시지 리턴 권장)
redirect(
`/login?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.PASSWORD_RESET_SENT)}`,
);
}
/**
* [비밀번호 업데이트 액션]
*
* 비밀번호 재설정 링크를 통해 접근한 사용자의 비밀번호를 업데이트합니다.
*
* 처리 과정:
* 1. FormData에서 새 비밀번호 추출
* 2. 비밀번호 길이 및 강도 검증
* 3. Supabase를 통한 비밀번호 업데이트
* 4. 실패 시 에러 메시지 반환
* 5. 성공 시 세션/쿠키 정리 후 로그아웃 및 캐시 무효화
* 6. 성공 결과 반환
*
* @param formData 새 비밀번호 포함
* @see reset-password-form.tsx - 비밀번호 재설정 폼 제출 시 호출
*/
export async function updatePassword(formData: FormData) {
// [Step 1] 새 비밀번호 추출
const password = (formData.get("password") as string) || "";
// [Step 2] 비밀번호 강도 검증
const passwordValidation = validatePassword(password);
if (passwordValidation) {
return { ok: false, message: passwordValidation.message };
}
// [Step 3] Supabase를 통한 비밀번호 업데이트
const supabase = await createClient();
const { error } = await supabase.auth.updateUser({
password: password,
});
// [Step 4] 에러 처리
if (error) {
const message = getAuthErrorMessage(error);
return { ok: false, message };
}
// [Step 5] 세션 및 쿠키 정리 후 로그아웃
const cookieStore = await cookies();
cookieStore.delete(RECOVERY_COOKIE_NAME);
await supabase.auth.signOut();
revalidatePath("/", "layout");
// [Step 6] 성공 응답 반환
return {
ok: true,
message: AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS,
};
}
// ========================================
// 소셜 로그인 (OAuth)
// ========================================
/**
* [OAuth 로그인 공통 헬퍼]
*
* 처리 과정:
* 1. Supabase OAuth 로그인 URL 생성 (PKCE)
* 2. 생성 중 에러 발생 시 로그인 페이지로 리다이렉트 (에러 메시지 포함)
* 3. 성공 시 해당 OAuth 제공자 페이지(data.url)로 리다이렉트
*
* @param provider 'google' | 'kakao'
* @param extraOptions 추가 옵션 (예: prompt)
* @see signInWithGoogle
* @see signInWithKakao
*/
async function signInWithProvider(
provider: "google" | "kakao",
extraOptions: { queryParams?: { [key: string]: string } } = {},
) {
const supabase = await createClient();
// [Step 1] OAuth 인증 시작 (URL 생성)
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
// PKCE 플로우를 위한 콜백 URL
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/auth/callback`,
...extraOptions,
},
});
// [Step 2] 에러 처리
if (error) {
console.error(`[${provider} OAuth] 로그인 실패:`, error.message);
const message = getAuthErrorMessage(error);
return redirect(`/login?message=${encodeURIComponent(message)}`);
}
// [Step 3] OAuth 제공자 로그인 페이지로 리다이렉트
if (data.url) {
redirect(data.url);
}
// [Step 4] URL 생성 실패 시 에러 처리
redirect(
`/login?message=${encodeURIComponent("로그인 처리 중 오류가 발생했습니다.")}`,
);
}
/**
* [Google 로그인 액션]
* @see login-form.tsx - 구글 로그인 버튼 클릭 시 호출
*/
export async function signInWithGoogle() {
return signInWithProvider("google");
}
/**
* [Kakao 로그인 액션]
* @see login-form.tsx - 카카오 로그인 버튼 클릭 시 호출
*/
export async function signInWithKakao() {
return signInWithProvider("kakao", { queryParams: { prompt: "login" } });
}

View File

@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { AUTH_ROUTES } from "@/features/auth/constants";
import {
login,
signInWithGoogle,
signInWithKakao,
} from "@/features/auth/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { InlineSpinner } from "@/components/ui/loading-spinner";
/**
* [로그인 폼 클라이언트 컴포넌트]
*
* 이메일 기억하기 기능을 제공하는 로그인 폼입니다.
* - localStorage를 사용하여 이메일 저장/불러오기
* - 체크박스 선택 시 이메일 자동 저장
* - 서버 액션(login)과 연동
* - 하이드레이션 이슈 해결을 위해 useEffect 사용
*/
export default function LoginForm() {
// ========== 상태 관리 ==========
const [email, setEmail] = useState(() => {
if (typeof window === "undefined") return "";
return localStorage.getItem("auto-trade-saved-email") || "";
});
const [rememberMe, setRememberMe] = useState(() => {
if (typeof window === "undefined") return false;
return !!localStorage.getItem("auto-trade-saved-email");
});
const [isLoading, setIsLoading] = useState(false);
// ========== 폼 제출 핸들러 ==========
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
const formData = new FormData(e.currentTarget);
// localStorage 처리 (동기)
if (rememberMe) {
localStorage.setItem("auto-trade-saved-email", email);
} else {
localStorage.removeItem("auto-trade-saved-email");
}
// 서버 액션 호출 (리다이렉트 발생)
try {
await login(formData);
} catch (error) {
console.error("Login error:", error);
setIsLoading(false);
}
};
return (
<div className="space-y-6">
{/* ========== 로그인 폼 ========== */}
<form className="space-y-5" onSubmit={handleSubmit}>
{/* ========== 이메일 입력 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="your@email.com"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/>
</div>
{/* ========== 비밀번호 입력 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
</Label>
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
autoComplete="current-password"
required
minLength={8}
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/>
</div>
{/* ========== 이메일 기억하기 & 비밀번호 찾기 ========== */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked === true)}
/>
<Label
htmlFor="remember"
className="cursor-pointer text-sm font-normal leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
</Label>
</div>
<Link
href={AUTH_ROUTES.FORGOT_PASSWORD}
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
</Link>
</div>
{/* ========== 로그인 버튼 ========== */}
<Button
type="submit"
disabled={isLoading}
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
size="lg"
>
{isLoading ? (
<span className="flex items-center gap-2">
<InlineSpinner />
...
</span>
) : (
"로그인"
)}
</Button>
{/* ========== 회원가입 링크 ========== */}
<p className="text-center text-sm text-muted-foreground">
?{" "}
<Link
href={AUTH_ROUTES.SIGNUP}
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
</Link>
</p>
</form>
{/* ========== 소셜 로그인 구분선 ========== */}
<div className="relative">
<Separator className="my-6" />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-muted-foreground dark:bg-brand-950">
</span>
</div>
{/* ========== 소셜 로그인 버튼들 ========== */}
<div className="grid grid-cols-2 gap-3">
{/* ========== Google 로그인 버튼 ========== */}
<form action={signInWithGoogle}>
<Button
type="submit"
variant="outline"
className="h-11 w-full border-brand-200/50 bg-white shadow-sm transition-all duration-200 hover:bg-brand-50 hover:shadow-md dark:border-brand-800/50 dark:bg-brand-950/50 dark:hover:bg-brand-900/50"
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</Button>
</form>
{/* ========== Kakao 로그인 버튼 ========== */}
<form action={signInWithKakao}>
<Button
type="submit"
variant="outline"
className="h-11 w-full border-none bg-[#FEE500] font-semibold text-[#3C1E1E] shadow-sm transition-all duration-200 hover:bg-[#FDD835] hover:shadow-md"
>
<svg
className="mr-2 h-5 w-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm-1.5 14.5h-3v-9h3v9zm3 0h-3v-5h3v5zm0-6h-3v-3h3v3z" />
</svg>
Kakao
</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { updatePassword } from "@/features/auth/actions";
import {
resetPasswordSchema,
type ResetPasswordFormData,
} from "@/features/auth/schemas/auth-schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InlineSpinner } from "@/components/ui/loading-spinner";
import { useState } from "react";
const DEFAULT_ERROR_MESSAGE =
"알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
export default function ResetPasswordForm() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [serverError, setServerError] = useState("");
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<ResetPasswordFormData>({
resolver: zodResolver(resetPasswordSchema),
mode: "onBlur",
});
const password = watch("password");
const confirmPassword = watch("confirmPassword");
const onSubmit = async (data: ResetPasswordFormData) => {
setServerError("");
setIsLoading(true);
try {
const formData = new FormData();
formData.append("password", data.password);
const result = await updatePassword(formData);
if (result?.ok) {
const message = encodeURIComponent(result.message);
router.replace(`/login?message=${message}`);
return;
}
setServerError(result?.message || DEFAULT_ERROR_MESSAGE);
} catch (error) {
console.error("Password reset error:", error);
setServerError(DEFAULT_ERROR_MESSAGE);
} finally {
setIsLoading(false);
}
};
return (
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
{serverError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200">
{serverError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
</Label>
<Input
id="password"
type="password"
placeholder="새 비밀번호를 입력해주세요"
autoComplete="new-password"
disabled={isLoading}
{...register("password")}
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/>
<p className="text-xs text-muted-foreground">
8 , /// 1 .
</p>
{errors.password && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.password.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium">
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="새 비밀번호를 다시 입력해주세요"
autoComplete="new-password"
disabled={isLoading}
{...register("confirmPassword")}
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/>
{confirmPassword &&
password !== confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
.
</p>
)}
{confirmPassword &&
password === confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-brand-600 dark:text-brand-400">
.
</p>
)}
{errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.confirmPassword.message}
</p>
)}
</div>
<Button
type="submit"
disabled={isLoading}
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
>
{isLoading ? (
<span className="flex items-center gap-2">
<InlineSpinner />
...
</span>
) : (
"비밀번호 변경"
)}
</Button>
</form>
);
}

View File

@@ -0,0 +1,172 @@
/**
* @file features/auth/components/session-manager.tsx
* @description 사용자 세션 타임아웃 및 자동 로그아웃 관리 컴포넌트
* @remarks
* - [레이어] Components/Infrastructure
* - [사용자 행동] 로그인 -> 활동 감지 -> 비활동 -> (경고) -> 로그아웃
* - [데이터 흐름] Event -> Zustand Store -> Timer -> Logout
* - [연관 파일] stores/session-store.ts, features/auth/constants.ts
*/
"use client";
import { useEffect, useState, useCallback } from "react";
import { createClient } from "@/utils/supabase/client";
import { useRouter, usePathname } from "next/navigation";
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useSessionStore } from "@/stores/session-store";
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
// import { toast } from "sonner"; // Unused for now
// 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃)
// const WARNING_MS = 60 * 1000;
const SESSION_RELATED_STORAGE_KEYS = [
"session-storage",
"auth-storage",
"autotrade-kis-runtime-store",
] as const;
/**
* 세션 관리자 컴포넌트
* 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리
* @returns 숨겨진 기능성 컴포넌트 (Global Layout에 포함)
* @remarks RootLayout에 포함되어 전역적으로 동작
* @see layout.tsx - RootLayout에서 렌더링
* @see session-store.ts - 마지막 활동 시간 관리
*/
export function SessionManager() {
const router = useRouter();
const pathname = usePathname();
// [State] 타임아웃 경고 모달 표시 여부 (현재 미사용)
const [showWarning, setShowWarning] = useState(false);
// 인증 페이지에서는 동작하지 않음
const isAuthPage = ["/login", "/signup", "/forgot-password"].includes(
pathname,
);
const { setLastActive } = useSessionStore();
/**
* @description 세션 만료 로그아웃 시 세션 관련 로컬 스토리지를 정리합니다.
* @see features/layout/components/user-menu.tsx 수동 로그아웃 경로에서도 동일한 키를 제거합니다.
*/
const clearSessionRelatedStorage = useCallback(() => {
if (typeof window === "undefined") return;
for (const key of SESSION_RELATED_STORAGE_KEYS) {
window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
}
}, []);
/**
* 로그아웃 처리 핸들러
* @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음
*/
const handleLogout = useCallback(async () => {
// [Step 1] Supabase 클라이언트 생성
const supabase = createClient();
// [Step 2] 서버 사이드 로그아웃 요청
await supabase.auth.signOut();
// [Step 3] 로컬 스토어 및 세션 정보 초기화
useSessionStore.persist.clearStorage();
clearSessionRelatedStorage();
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
router.refresh();
}, [clearSessionRelatedStorage, router]);
useEffect(() => {
if (isAuthPage) return;
// 마지막 활동 시간 업데이트 함수
const updateLastActive = () => {
setLastActive(Date.now());
if (showWarning) setShowWarning(false);
};
// [Step 0] 인증 페이지에서 메인 페이지로 진입한 직후를 "활동"으로 간주해
// 이전 세션 잔여 시간(예: 00:00)으로 즉시 로그아웃되는 현상을 방지합니다.
updateLastActive();
// [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치)
const events = ["mousedown", "keydown", "scroll", "touchstart"];
const handleActivity = () => updateLastActive();
events.forEach((event) => window.addEventListener(event, handleActivity));
// [Step 2] 주기적(1초)으로 세션 만료 여부 확인
const intervalId = setInterval(async () => {
const currentLastActive = useSessionStore.getState().lastActive;
const now = Date.now();
const timeSinceLastActive = now - currentLastActive;
// 타임아웃 초과 시 로그아웃
if (timeSinceLastActive >= SESSION_TIMEOUT_MS) {
await handleLogout();
}
// 경고 로직 (현재 비활성)
// else if (timeSinceLastActive >= TIMEOUT_MS - WARNING_MS) {
// setShowWarning(true);
// }
}, 1000);
// [Step 3] 탭 활성화/컴퓨터 깨어남 감지 (절전 모드 대응)
const handleVisibilityChange = async () => {
if (!document.hidden) {
const currentLastActive = useSessionStore.getState().lastActive;
const now = Date.now();
// 절전 모드 복귀 시 즉시 만료 체크
if (now - currentLastActive >= SESSION_TIMEOUT_MS) {
await handleLogout();
}
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
events.forEach((event) =>
window.removeEventListener(event, handleActivity),
);
clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [pathname, isAuthPage, showWarning, handleLogout, setLastActive]);
return (
<AlertDialog open={showWarning} onOpenChange={setShowWarning}>
<AlertDialogContent>
{/* ========== 헤더: 제목 및 설명 ========== */}
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
1 .
.
</AlertDialogDescription>
</AlertDialogHeader>
{/* ========== 하단: 액션 버튼 ========== */}
<AlertDialogFooter>
<AlertDialogAction onClick={() => setShowWarning(false)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,79 @@
/**
* @file features/auth/components/session-timer.tsx
* @description 헤더에 표시되는 세션 만료 카운트다운 컴포넌트
* @remarks
* - [레이어] Components/UI
* - [사용자 행동] 남은 시간 확인 -> 만료 임박 시 붉은색 경고
* - [데이터 흐름] Zustand Store -> Calculation -> UI
* - [연관 파일] stores/session-store.ts, features/layout/header.tsx
*/
"use client";
import { useEffect, useState } from "react";
import { useSessionStore } from "@/stores/session-store";
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
import { cn } from "@/lib/utils";
/**
* 세션 만료 타이머 컴포넌트
* 남은 시간을 mm:ss 형태로 표시 (10분 미만 시 경고 스타일)
* @returns 시간 표시 배지 (모바일 숨김)
* @remarks 1초마다 리렌더링 발생
* @see header.tsx - 로그인 상태일 때 헤더에 표시
*/
interface SessionTimerProps {
/** 셰이더 배경 위에서 가독성을 높이는 투명 모드 */
blendWithBackground?: boolean;
}
export function SessionTimer({ blendWithBackground = false }: SessionTimerProps) {
const lastActive = useSessionStore((state) => state.lastActive);
// [State] 남은 시간 (밀리초)
const [timeLeft, setTimeLeft] = useState<number>(SESSION_TIMEOUT_MS);
useEffect(() => {
const calculateTimeLeft = () => {
const now = Date.now();
const passed = now - lastActive;
// [Step 1] 남은 시간 계산 (음수 방지)
const remaining = Math.max(0, SESSION_TIMEOUT_MS - passed);
setTimeLeft(remaining);
};
calculateTimeLeft(); // 초기 실행
// [Step 2] 1초마다 남은 시간 갱신
const interval = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(interval);
}, [lastActive]);
// [Step 3] 시간 포맷팅 (mm:ss)
const minutes = Math.floor(timeLeft / 60000);
const seconds = Math.floor((timeLeft % 60000) / 1000);
// [Step 4] 10분 미만일 때 긴급 스타일 적용
const isUrgent = timeLeft < 10 * 60 * 1000;
return (
<div
className={cn(
"hidden rounded-full border px-3 py-1.5 text-sm font-medium tabular-nums backdrop-blur-md transition-colors md:block",
isUrgent
? "border-red-200 bg-red-50/50 text-red-500 dark:border-red-800 dark:bg-red-900/20"
: blendWithBackground
? "border-white/30 bg-black/45 text-white shadow-sm shadow-black/40"
: "border-border/40 bg-background/50 text-muted-foreground",
)}
>
{/* ========== 라벨 ========== */}
<span className="mr-2"> </span>
{/* ========== 시간 표시 ========== */}
{minutes.toString().padStart(2, "0")}:
{seconds.toString().padStart(2, "0")}
</div>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signup } from "@/features/auth/actions";
import {
signupSchema,
type SignupFormData,
} from "@/features/auth/schemas/auth-schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InlineSpinner } from "@/components/ui/loading-spinner";
import { useState } from "react";
/**
* [회원가입 폼 클라이언트 컴포넌트 - React Hook Form 버전]
*
* React Hook Form과 Zod를 사용한 회원가입 폼입니다.
* - 타입 안전한 폼 검증
* - 자동 에러 메시지 관리
* - 비밀번호/비밀번호 확인 일치 검증
* - 로딩 상태 표시
*
* @see app/signup/page.tsx - 이 컴포넌트를 사용하는 페이지
*/
export default function SignupForm() {
// ========== 로딩 상태 ==========
const [isLoading, setIsLoading] = useState(false);
const [serverError, setServerError] = useState("");
// ========== React Hook Form 설정 ==========
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: "onBlur", // 포커스 아웃 시 검증
});
// 비밀번호 실시간 감시 (일치 여부 표시용)
const password = watch("password");
const confirmPassword = watch("confirmPassword");
// ========== 폼 제출 핸들러 ==========
const onSubmit = async (data: SignupFormData) => {
setServerError("");
setIsLoading(true);
try {
const formData = new FormData();
formData.append("email", data.email);
formData.append("password", data.password);
await signup(formData);
} catch (error) {
console.error("Signup error:", error);
setServerError("회원가입 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
{/* ========== 서버 에러 메시지 표시 ========== */}
{serverError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200">
{serverError}
</div>
)}
{/* ========== 이메일 입력 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
autoComplete="email"
disabled={isLoading}
{...register("email")}
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/>
{errors.email && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.email.message}
</p>
)}
</div>
{/* ========== 비밀번호 입력 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
autoComplete="new-password"
disabled={isLoading}
{...register("password")}
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/>
<p className="text-xs text-muted-foreground">
8 , , , ,
</p>
{errors.password && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.password.message}
</p>
)}
</div>
{/* ========== 비밀번호 확인 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium">
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
autoComplete="new-password"
disabled={isLoading}
{...register("confirmPassword")}
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/>
{/* 비밀번호 불일치 시 실시간 피드백 */}
{confirmPassword &&
password !== confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
</p>
)}
{/* 비밀번호 일치 시 확인 메시지 */}
{confirmPassword &&
password === confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-brand-600 dark:text-brand-400">
</p>
)}
{/* Zod 검증 에러 */}
{errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.confirmPassword.message}
</p>
)}
</div>
{/* ========== 회원가입 버튼 ========== */}
<Button
type="submit"
disabled={isLoading}
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
>
{isLoading ? (
<span className="flex items-center gap-2">
<InlineSpinner />
...
</span>
) : (
"회원가입 완료"
)}
</Button>
</form>
);
}

257
features/auth/constants.ts Normal file
View File

@@ -0,0 +1,257 @@
/**
* @file features/auth/constants.ts
* @description 인증 모듈 전반에서 사용되는 상수, 에러 메시지, 설정값 정의
* @remarks
* - [레이어] Core/Constants
* - [사용자 행동] 로그인/회원가입/비밀번호 찾기 등 인증 전반
* - [데이터 흐름] UI/Service -> Constants -> UI (메시지 표시)
* - [주의사항] 환경 변수(SESSION_TIMEOUT_MINUTES)에 의존하는 상수 포함
*/
// ========================================
// 에러 메시지 상수
// ========================================
/**
* 인증 에러 메시지 매핑
* Supabase의 영문 에러를 한글로 변환하기 위한 매핑 테이블
*/
export const AUTH_ERROR_MESSAGES = {
// === 로그인/회원가입 관련 ===
INVALID_CREDENTIALS: "이메일 또는 비밀번호가 일치하지 않습니다.",
USER_EXISTS: "이미 가입된 이메일 주소입니다.",
EMAIL_NOT_CONFIRMED: "이메일 인증이 완료되지 않았습니다.",
// === 입력값 검증 ===
EMPTY_FIELDS: "이메일과 비밀번호를 모두 입력해 주세요.",
EMPTY_EMAIL: "이메일을 입력해 주세요.",
INVALID_EMAIL: "올바른 이메일 형식이 아닙니다.",
// === 비밀번호 관련 ===
PASSWORD_TOO_SHORT: "비밀번호는 최소 8자 이상이어야 합니다.",
PASSWORD_TOO_WEAK:
"비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.",
PASSWORD_MISMATCH: "비밀번호가 일치하지 않습니다.",
PASSWORD_SAME_AS_OLD: "새 비밀번호는 기존 비밀번호와 달라야 합니다.",
// === 비밀번호 재설정 ===
PASSWORD_RESET_SENT: "비밀번호 재설정 링크를 이메일로 발송했습니다.",
PASSWORD_RESET_SUCCESS: "비밀번호가 성공적으로 변경되었습니다.",
PASSWORD_RESET_FAILED: "비밀번호 변경에 실패했습니다.",
// === 인증 링크 ===
INVALID_AUTH_LINK: "인증 링크가 만료되었거나 유효하지 않습니다.",
EMAIL_VERIFIED_SUCCESS: "이메일 인증이 완료되었습니다. 로그인해 주세요.",
// === 소셜 로그인 (OAuth) 관련 ===
OAUTH_ACCESS_DENIED: "로그인이 취소되었습니다.",
OAUTH_SERVER_ERROR:
"인증 서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
OAUTH_INVALID_SCOPE:
"필요한 권한이 설정되지 않았습니다. 개발자 설정 확인이 필요합니다.",
OAUTH_UNAUTHORIZED_CLIENT:
"인증 앱 설정(Client ID/Secret)에 문제가 있습니다.",
OAUTH_UNKNOWN_ERROR: "소셜 로그인 중 알 수 없는 오류가 발생했습니다.",
// === Rate Limit ===
EMAIL_RATE_LIMIT:
"이메일 발송 제한을 초과했습니다. 잠시 후 다시 시도해 주세요.",
EMAIL_RATE_LIMIT_DETAILED:
"이메일 발송 제한을 초과했습니다. Supabase 무료 플랜은 시간당 이메일 발송 횟수가 제한됩니다. 약 1시간 후에 다시 시도해 주세요.",
// === 기타 ===
DEFAULT: "요청을 처리하는 중 오류가 발생했습니다.",
} as const;
// ========================================
// Supabase Auth 에러 코드 매핑
// ========================================
export const AUTH_ERROR_CODE_MESSAGES = {
anonymous_provider_disabled: "익명 로그인은 비활성화되어 있습니다.",
bad_code_verifier: "PKCE code verifier가 일치하지 않습니다.",
bad_json: "요청 본문이 올바른 JSON이 아닙니다.",
bad_jwt: "Authorization 헤더의 JWT가 유효하지 않습니다.",
bad_oauth_callback: "OAuth 콜백에 필요한 값(state)이 없습니다.",
bad_oauth_state: "OAuth state 형식이 올바르지 않습니다.",
captcha_failed: "CAPTCHA 검증에 실패했습니다.",
conflict: "요청 충돌이 발생했습니다. 잠시 후 다시 시도해주세요.",
email_address_invalid: "예시/테스트 도메인은 사용할 수 없습니다.",
email_address_not_authorized:
"기본 SMTP 사용 시 허용되지 않은 이메일 주소입니다.",
email_conflict_identity_not_deletable:
"이메일 충돌로 이 아이덴티티를 삭제할 수 없습니다.",
email_exists: "이미 가입된 이메일 주소입니다.",
email_not_confirmed: "이메일 인증이 완료되지 않았습니다.",
email_provider_disabled: "이메일/비밀번호 가입이 비활성화되어 있습니다.",
flow_state_expired: "로그인 흐름이 만료되었습니다. 다시 시도해주세요.",
flow_state_not_found: "로그인 흐름을 찾을 수 없습니다. 다시 시도해주세요.",
hook_payload_invalid_content_type:
"훅 페이로드의 Content-Type이 올바르지 않습니다.",
hook_payload_over_size_limit: "훅 페이로드가 최대 크기를 초과했습니다.",
hook_timeout: "훅 요청 시간이 초과되었습니다.",
hook_timeout_after_retry: "훅 요청 재시도 후에도 시간이 초과되었습니다.",
identity_already_exists: "이미 연결된 아이덴티티입니다.",
identity_not_found: "아이덴티티를 찾을 수 없습니다.",
insufficient_aal: "추가 인증이 필요합니다.",
invalid_credentials: "이메일 또는 비밀번호가 일치하지 않습니다.",
invite_not_found: "초대 링크가 만료되었거나 이미 사용되었습니다.",
manual_linking_disabled: "계정 연결 기능이 비활성화되어 있습니다.",
mfa_challenge_expired: "MFA 인증 시간이 초과되었습니다.",
mfa_factor_name_conflict: "MFA 요인 이름이 중복되었습니다.",
mfa_factor_not_found: "MFA 요인을 찾을 수 없습니다.",
mfa_ip_address_mismatch: "MFA 등록 시작/종료 IP가 일치하지 않습니다.",
mfa_phone_enroll_not_enabled: "전화 MFA 등록이 비활성화되어 있습니다.",
mfa_phone_verify_not_enabled: "전화 MFA 검증이 비활성화되어 있습니다.",
mfa_totp_enroll_not_enabled: "TOTP MFA 등록이 비활성화되어 있습니다.",
mfa_totp_verify_not_enabled: "TOTP MFA 검증이 비활성화되어 있습니다.",
mfa_verification_failed: "MFA 검증에 실패했습니다.",
mfa_verification_rejected: "MFA 검증이 거부되었습니다.",
mfa_verified_factor_exists: "이미 검증된 전화 MFA가 존재합니다.",
mfa_web_authn_enroll_not_enabled:
"WebAuthn MFA 등록이 비활성화되어 있습니다.",
mfa_web_authn_verify_not_enabled:
"WebAuthn MFA 검증이 비활성화되어 있습니다.",
no_authorization: "Authorization 헤더가 필요합니다.",
not_admin: "관리자 권한이 없습니다.",
oauth_provider_not_supported: "OAuth 제공자가 비활성화되어 있습니다.",
otp_disabled: "OTP 로그인이 비활성화되어 있습니다.",
otp_expired: "OTP가 만료되었습니다.",
over_email_send_rate_limit: "이메일 발송 제한을 초과했습니다.",
over_request_rate_limit: "요청 제한을 초과했습니다.",
over_sms_send_rate_limit: "SMS 발송 제한을 초과했습니다.",
phone_exists: "이미 가입된 전화번호입니다.",
phone_not_confirmed: "전화번호 인증이 완료되지 않았습니다.",
phone_provider_disabled: "전화번호 가입이 비활성화되어 있습니다.",
provider_disabled: "OAuth 제공자가 비활성화되어 있습니다.",
provider_email_needs_verification: "OAuth 이메일 인증이 필요합니다.",
reauthentication_needed: "비밀번호 변경을 위해 재인증이 필요합니다.",
reauthentication_not_valid: "재인증 코드가 유효하지 않습니다.",
refresh_token_already_used: "세션이 만료되었습니다. 다시 로그인해주세요.",
refresh_token_not_found: "세션을 찾을 수 없습니다.",
request_timeout: "요청 시간이 초과되었습니다.",
same_password: "새 비밀번호는 기존 비밀번호와 달라야 합니다.",
saml_assertion_no_email: "SAML 응답에 이메일이 없습니다.",
saml_assertion_no_user_id: "SAML 응답에 사용자 ID가 없습니다.",
saml_entity_id_mismatch: "SAML 엔티티 ID가 일치하지 않습니다.",
saml_idp_already_exists: "SAML IdP가 이미 등록되어 있습니다.",
saml_idp_not_found: "SAML IdP를 찾을 수 없습니다.",
saml_metadata_fetch_failed: "SAML 메타데이터를 불러오지 못했습니다.",
saml_provider_disabled: "SAML SSO가 비활성화되어 있습니다.",
saml_relay_state_expired: "SAML relay state가 만료되었습니다.",
saml_relay_state_not_found: "SAML relay state를 찾을 수 없습니다.",
session_expired: "세션이 만료되었습니다.",
session_not_found: "세션을 찾을 수 없습니다.",
signup_disabled: "회원가입이 비활성화되어 있습니다.",
single_identity_not_deletable: "유일한 아이덴티티는 삭제할 수 없습니다.",
sms_send_failed: "SMS 발송에 실패했습니다.",
sso_domain_already_exists: "SSO 도메인이 이미 등록되어 있습니다.",
sso_provider_not_found: "SSO 제공자를 찾을 수 없습니다.",
too_many_enrolled_mfa_factors: "등록 가능한 MFA 요인 수를 초과했습니다.",
unexpected_audience: "토큰 audience가 일치하지 않습니다.",
unexpected_failure: "인증 서버 오류가 발생했습니다.",
user_already_exists: "이미 존재하는 사용자입니다.",
user_banned: "계정이 일시적으로 차단되었습니다.",
user_not_found: "사용자를 찾을 수 없습니다.",
user_sso_managed: "SSO 사용자 정보는 수정할 수 없습니다.",
validation_failed: "요청 값 형식이 올바르지 않습니다.",
weak_password: "비밀번호가 정책을 만족하지 않습니다.",
} as const;
export const AUTH_ERROR_STATUS_MESSAGES = {
403: "요청한 기능을 사용할 수 없습니다.",
422: "요청을 처리할 수 없는 상태입니다.",
429: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.",
500: "인증 서버 오류가 발생했습니다.",
501: "요청한 기능이 활성화되어 있지 않습니다.",
} as const;
// ========================================
// 라우트 경로 상수
// ========================================
/**
* 인증 관련 라우트 경로
*/
export const AUTH_ROUTES = {
LOGIN: "/login",
SIGNUP: "/signup",
FORGOT_PASSWORD: "/forgot-password",
RESET_PASSWORD: "/reset-password",
AUTH_CONFIRM: "/auth/confirm",
AUTH_CALLBACK: "/auth/callback",
HOME: "/",
DASHBOARD: "/dashboard",
} as const;
/**
* 로그인 없이 접근 가능한 페이지 목록
* 미들웨어에서 라우트 보호에 사용
*/
export const PUBLIC_AUTH_PAGES = [
AUTH_ROUTES.LOGIN,
AUTH_ROUTES.SIGNUP,
AUTH_ROUTES.FORGOT_PASSWORD,
AUTH_ROUTES.RESET_PASSWORD,
AUTH_ROUTES.AUTH_CONFIRM,
AUTH_ROUTES.AUTH_CALLBACK,
] as const;
// 복구 플로우 전용 쿠키 (비밀번호 재설정 화면 외 접근 차단에 사용)
export const RECOVERY_COOKIE_NAME = "sb-recovery";
export const RECOVERY_COOKIE_MAX_AGE_SECONDS = 10 * 60;
// ========================================
// 검증 규칙 상수
// ========================================
/**
* 비밀번호 검증 규칙
* - 최소 8자 이상
* - 대문자 1개 이상
* - 소문자 1개 이상
* - 숫자 1개 이상
* - 특수문자 1개 이상
*/
export const PASSWORD_RULES = {
MIN_LENGTH: 8,
REQUIRE_UPPERCASE: true,
REQUIRE_LOWERCASE: true,
REQUIRE_NUMBER: true,
REQUIRE_SPECIAL_CHAR: true,
} as const;
// ========================================
// 세션 관련 상수
// ========================================
/**
* 세션 타임아웃 시간 (밀리초)
* 환경 변수에서 분 단위를 가져와 밀리초로 변환합니다.
* 기본값: 30분
*/
export const SESSION_TIMEOUT_MS =
(Number(process.env.NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES) || 30) * 60 * 1000;
// 경고 표시 시간 (타임아웃 1분 전)
export const SESSION_WARNING_MS = 60 * 1000;
// ========================================
// 타입 정의
// ========================================
/**
* 인증 폼 데이터 타입
*/
export type AuthFormData = {
email: string;
password: string;
};
/**
* 인증 에러 타입
*/
export type AuthError = {
message: string;
type: "validation" | "auth" | "unknown";
};

30
features/auth/errors.ts Normal file
View File

@@ -0,0 +1,30 @@
import {
AUTH_ERROR_CODE_MESSAGES,
AUTH_ERROR_MESSAGES,
AUTH_ERROR_STATUS_MESSAGES,
} from "./constants";
export type AuthApiErrorLike = {
message?: string | null;
code?: string | null;
status?: number | null;
};
/**
* Supabase Auth 에러 데이터를 인간이 읽을 수 있는 한글 메시지로 변환합니다.
*/
export function getAuthErrorMessage(error: AuthApiErrorLike): string {
if (error.code && error.code in AUTH_ERROR_CODE_MESSAGES) {
return AUTH_ERROR_CODE_MESSAGES[
error.code as keyof typeof AUTH_ERROR_CODE_MESSAGES
];
}
if (error.status && error.status in AUTH_ERROR_STATUS_MESSAGES) {
return AUTH_ERROR_STATUS_MESSAGES[
error.status as keyof typeof AUTH_ERROR_STATUS_MESSAGES
];
}
return AUTH_ERROR_MESSAGES.DEFAULT;
}

View File

@@ -0,0 +1,69 @@
import { z } from "zod";
import { PASSWORD_RULES } from "@/features/auth/constants";
const passwordSchema = z
.string()
.min(PASSWORD_RULES.MIN_LENGTH, {
message: `비밀번호는 최소 ${PASSWORD_RULES.MIN_LENGTH}자 이상이어야 합니다.`,
})
.regex(/[A-Z]/, {
message: "대문자를 최소 1개 이상 포함해야 합니다.",
})
.regex(/[a-z]/, {
message: "소문자를 최소 1개 이상 포함해야 합니다.",
})
.regex(/[0-9]/, {
message: "숫자를 최소 1개 이상 포함해야 합니다.",
})
.regex(/[!@#$%^&*(),.?":{}|<>]/, {
message: "특수문자를 최소 1개 이상 포함해야 합니다.",
});
export const signupSchema = z
.object({
email: z
.string()
.min(1, { message: "이메일을 입력해 주세요." })
.email({ message: "유효한 이메일 형식이 아닙니다." }),
password: passwordSchema,
confirmPassword: z
.string()
.min(1, { message: "비밀번호 확인을 입력해 주세요." }),
})
.refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다.",
path: ["confirmPassword"],
});
export const resetPasswordSchema = z
.object({
password: passwordSchema,
confirmPassword: z
.string()
.min(1, { message: "비밀번호 확인을 입력해 주세요." }),
})
.refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다.",
path: ["confirmPassword"],
});
export const loginSchema = z.object({
email: z
.string()
.min(1, { message: "이메일을 입력해 주세요." })
.email({ message: "유효한 이메일 형식이 아닙니다." }),
password: z.string().min(1, { message: "비밀번호를 입력해 주세요." }),
rememberMe: z.boolean().optional(),
});
export const forgotPasswordSchema = z.object({
email: z
.string()
.min(1, { message: "이메일을 입력해 주세요." })
.email({ message: "유효한 이메일 형식이 아닙니다." }),
});
export type SignupFormData = z.infer<typeof signupSchema>;
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
export type LoginFormData = z.infer<typeof loginSchema>;
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;

View File

@@ -0,0 +1,94 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import {
buildKisRequestHeaders,
resolveKisApiErrorMessage,
type KisApiErrorPayload,
} from "@/features/settings/apis/kis-api-utils";
import type {
DashboardActivityResponse,
DashboardBalanceResponse,
DashboardIndicesResponse,
} from "@/features/dashboard/types/dashboard.types";
/**
* @file features/dashboard/apis/dashboard.api.ts
* @description 대시보드 잔고/지수 API 클라이언트
*/
/**
* 계좌 잔고/보유종목을 조회합니다.
* @param credentials KIS 인증 정보
* @returns 잔고 응답
* @see app/api/kis/domestic/balance/route.ts 서버 라우트
*/
export async function fetchDashboardBalance(
credentials: KisRuntimeCredentials,
): Promise<DashboardBalanceResponse> {
const response = await fetch("/api/kis/domestic/balance", {
method: "GET",
headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardBalanceResponse
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(resolveKisApiErrorMessage(payload, "잔고 조회 중 오류가 발생했습니다."));
}
return payload as DashboardBalanceResponse;
}
/**
* 시장 지수(KOSPI/KOSDAQ)를 조회합니다.
* @param credentials KIS 인증 정보
* @returns 지수 응답
* @see app/api/kis/domestic/indices/route.ts 서버 라우트
*/
export async function fetchDashboardIndices(
credentials: KisRuntimeCredentials,
): Promise<DashboardIndicesResponse> {
const response = await fetch("/api/kis/domestic/indices", {
method: "GET",
headers: buildKisRequestHeaders(credentials),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardIndicesResponse
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(resolveKisApiErrorMessage(payload, "지수 조회 중 오류가 발생했습니다."));
}
return payload as DashboardIndicesResponse;
}
/**
* 주문내역/매매일지(활동 데이터)를 조회합니다.
* @param credentials KIS 인증 정보
* @returns 활동 데이터 응답
* @see app/api/kis/domestic/activity/route.ts 서버 라우트
*/
export async function fetchDashboardActivity(
credentials: KisRuntimeCredentials,
): Promise<DashboardActivityResponse> {
const response = await fetch("/api/kis/domestic/activity", {
method: "GET",
headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardActivityResponse
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(resolveKisApiErrorMessage(payload, "활동 데이터 조회 중 오류가 발생했습니다."));
}
return payload as DashboardActivityResponse;
}

View File

@@ -0,0 +1,331 @@
import { AlertCircle, ClipboardList, FileText, RefreshCcw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
DashboardActivityResponse,
DashboardTradeSide,
} from "@/features/dashboard/types/dashboard.types";
import {
formatCurrency,
formatPercent,
getChangeToneClass,
} from "@/features/dashboard/utils/dashboard-format";
import { cn } from "@/lib/utils";
interface ActivitySectionProps {
activity: DashboardActivityResponse | null;
isLoading: boolean;
error: string | null;
onRetry?: () => void;
}
/**
* @description 대시보드 하단 주문내역/매매일지 섹션입니다.
* @remarks UI 흐름: DashboardContainer -> ActivitySection -> tabs(주문내역/매매일지) -> 리스트 렌더링
* @see features/dashboard/components/DashboardContainer.tsx 하단 영역에서 호출합니다.
* @see app/api/kis/domestic/activity/route.ts 주문내역/매매일지 데이터 소스
*/
export function ActivitySection({
activity,
isLoading,
error,
onRetry,
}: ActivitySectionProps) {
const orders = activity?.orders ?? [];
const journalRows = activity?.tradeJournal ?? [];
const summary = activity?.journalSummary;
const warnings = activity?.warnings ?? [];
return (
<Card>
<CardHeader className="pb-3">
{/* ========== TITLE ========== */}
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardList className="h-4 w-4 text-brand-600 dark:text-brand-400" />
·
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{isLoading && !activity && (
<p className="text-sm text-muted-foreground">
/ .
</p>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
<p className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</p>
<p className="mt-1 text-xs text-red-700/80 dark:text-red-300/80">
/ API는 .
</p>
{onRetry ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
>
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
/
</Button>
) : null}
</div>
)}
{warnings.length > 0 && (
<div className="flex flex-wrap gap-2">
{warnings.map((warning) => (
<Badge
key={warning}
variant="outline"
className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
>
<AlertCircle className="h-3 w-3" />
{warning}
</Badge>
))}
</div>
)}
{/* ========== TABS ========== */}
<Tabs defaultValue="orders" className="gap-3">
<TabsList className="w-full justify-start">
<TabsTrigger value="orders"> {orders.length}</TabsTrigger>
<TabsTrigger value="journal"> {journalRows.length}</TabsTrigger>
</TabsList>
<TabsContent value="orders">
<div className="overflow-hidden rounded-xl border border-border/70">
<div className="grid grid-cols-[100px_1fr_140px_120px_120px_90px] gap-2 bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<ScrollArea className="h-[280px]">
{orders.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">
.
</p>
) : (
<div className="divide-y divide-border/60">
{orders.map((order) => (
<div
key={`${order.orderNo}-${order.orderDate}-${order.orderTime}`}
className="grid grid-cols-[100px_1fr_140px_120px_120px_90px] items-center gap-2 px-3 py-2 text-sm"
>
{/* ========== ORDER DATETIME ========== */}
<div className="text-xs text-muted-foreground">
<p>{order.orderDate}</p>
<p>{order.orderTime}</p>
</div>
{/* ========== STOCK INFO ========== */}
<div>
<p className="font-medium text-foreground">{order.name}</p>
<p className="text-xs text-muted-foreground">
{order.symbol} · {getSideLabel(order.side)}
</p>
</div>
{/* ========== ORDER INFO ========== */}
<div className="text-xs">
<p> {order.orderQuantity.toLocaleString("ko-KR")}</p>
<p className="text-muted-foreground">
{order.orderTypeName} · {formatCurrency(order.orderPrice)}
</p>
</div>
{/* ========== FILLED INFO ========== */}
<div className="text-xs">
<p> {order.filledQuantity.toLocaleString("ko-KR")}</p>
<p className="text-muted-foreground">
{formatCurrency(order.filledAmount)}
</p>
</div>
{/* ========== AVG PRICE ========== */}
<div className="text-xs font-medium text-foreground">
{formatCurrency(order.averageFilledPrice)}
</div>
{/* ========== STATUS ========== */}
<div>
<Badge
variant="outline"
className={cn(
"text-[11px]",
order.isCanceled
? "border-slate-300 text-slate-600 dark:border-slate-700 dark:text-slate-300"
: order.remainingQuantity > 0
? "border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-300"
: "border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-300",
)}
>
{order.isCanceled
? "취소"
: order.remainingQuantity > 0
? "미체결"
: "체결완료"}
</Badge>
</div>
</div>
))}
</div>
)}
</ScrollArea>
</div>
</TabsContent>
<TabsContent value="journal" className="space-y-3">
{/* ========== JOURNAL SUMMARY ========== */}
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<SummaryMetric
label="총 실현손익"
value={summary ? `${formatCurrency(summary.totalRealizedProfit)}` : "-"}
toneClass={summary ? getChangeToneClass(summary.totalRealizedProfit) : undefined}
/>
<SummaryMetric
label="총 수익률"
value={summary ? formatPercent(summary.totalRealizedRate) : "-"}
toneClass={summary ? getChangeToneClass(summary.totalRealizedProfit) : undefined}
/>
<SummaryMetric
label="총 매수금액"
value={summary ? `${formatCurrency(summary.totalBuyAmount)}` : "-"}
/>
<SummaryMetric
label="총 매도금액"
value={summary ? `${formatCurrency(summary.totalSellAmount)}` : "-"}
/>
</div>
<div className="overflow-hidden rounded-xl border border-border/70">
<div className="grid grid-cols-[100px_1fr_120px_130px_120px_110px] gap-2 bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<span></span>
<span></span>
<span></span>
<span>/</span>
<span>()</span>
<span></span>
</div>
<ScrollArea className="h-[280px]">
{journalRows.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">
.
</p>
) : (
<div className="divide-y divide-border/60">
{journalRows.map((row) => {
const toneClass = getChangeToneClass(row.realizedProfit);
return (
<div
key={`${row.tradeDate}-${row.symbol}-${row.realizedProfit}-${row.buyAmount}-${row.sellAmount}`}
className="grid grid-cols-[100px_1fr_120px_130px_120px_110px] items-center gap-2 px-3 py-2 text-sm"
>
<p className="text-xs text-muted-foreground">{row.tradeDate}</p>
<div>
<p className="font-medium text-foreground">{row.name}</p>
<p className="text-xs text-muted-foreground">{row.symbol}</p>
</div>
<p className={cn("text-xs font-medium", getSideToneClass(row.side))}>
{getSideLabel(row.side)}
</p>
<p className="text-xs">
{formatCurrency(row.buyAmount)} / {formatCurrency(row.sellAmount)}
</p>
<p className={cn("text-xs font-medium", toneClass)}>
{formatCurrency(row.realizedProfit)} ({formatPercent(row.realizedRate)})
</p>
<p className="text-xs text-muted-foreground">
{formatCurrency(row.fee)}
<br />
{formatCurrency(row.tax)}
</p>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
</TabsContent>
</Tabs>
{!isLoading && !error && !activity && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
.
</p>
)}
</CardContent>
</Card>
);
}
interface SummaryMetricProps {
label: string;
value: string;
toneClass?: string;
}
/**
* @description 매매일지 요약 지표 카드입니다.
* @param label 지표명
* @param value 지표값
* @param toneClass 값 색상 클래스
* @see features/dashboard/components/ActivitySection.tsx 매매일지 상단 요약 표시
*/
function SummaryMetric({ label, value, toneClass }: SummaryMetricProps) {
return (
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("mt-1 text-sm font-semibold text-foreground", toneClass)}>{value}</p>
</div>
);
}
/**
* @description 매수/매도 라벨 텍스트를 반환합니다.
* @param side 매수/매도 구분값
* @returns 라벨 문자열
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 표시
*/
function getSideLabel(side: DashboardTradeSide) {
if (side === "buy") return "매수";
if (side === "sell") return "매도";
return "기타";
}
/**
* @description 매수/매도 라벨 색상 클래스를 반환합니다.
* @param side 매수/매도 구분값
* @returns Tailwind 텍스트 클래스
* @see features/dashboard/components/ActivitySection.tsx 매매구분 표시
*/
function getSideToneClass(side: DashboardTradeSide) {
if (side === "buy") return "text-red-600 dark:text-red-400";
if (side === "sell") return "text-blue-600 dark:text-blue-400";
return "text-muted-foreground";
}

View File

@@ -0,0 +1,36 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface DashboardAccessGateProps {
canAccess: boolean;
}
/**
* @description KIS 인증 여부에 따라 대시보드 접근 가이드를 렌더링합니다.
* @param canAccess 대시보드 접근 가능 여부
* @see features/dashboard/components/DashboardContainer.tsx 인증되지 않은 경우 이 컴포넌트를 렌더링합니다.
*/
export function DashboardAccessGate({ canAccess }: DashboardAccessGateProps) {
if (canAccess) return null;
return (
<div className="flex h-full items-center justify-center p-6">
<section className="w-full max-w-xl rounded-2xl border border-brand-200 bg-background p-6 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/18">
{/* ========== UNVERIFIED NOTICE ========== */}
<h2 className="text-lg font-semibold text-foreground">
.
</h2>
<p className="mt-2 text-sm text-muted-foreground">
, 릿, .
</p>
{/* ========== ACTION ========== */}
<div className="mt-4">
<Button asChild className="bg-brand-600 hover:bg-brand-700">
<Link href="/settings"> </Link>
</Button>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,362 @@
"use client";
import { useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ActivitySection } from "@/features/dashboard/components/ActivitySection";
import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate";
import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton";
import { HoldingsList } from "@/features/dashboard/components/HoldingsList";
import { MarketSummary } from "@/features/dashboard/components/MarketSummary";
import { StatusHeader } from "@/features/dashboard/components/StatusHeader";
import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview";
import { useDashboardData } from "@/features/dashboard/hooks/use-dashboard-data";
import { useMarketRealtime } from "@/features/dashboard/hooks/use-market-realtime";
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { useHoldingsRealtime } from "@/features/dashboard/hooks/use-holdings-realtime";
import type {
DashboardBalanceSummary,
DashboardHoldingItem,
DashboardMarketIndexItem,
} from "@/features/dashboard/types/dashboard.types";
import type { KisRealtimeStockTick } from "@/features/dashboard/utils/kis-stock-realtime.utils";
/**
* @file DashboardContainer.tsx
* @description 대시보드 메인 레이아웃 및 데이터 통합 관리 컴포넌트
* @remarks
* - [레이어] Components / Container
* - [사용자 행동] 대시보드 진입 -> 전체 자산/시장 지수/보유 종목 확인 -> 특정 종목 선택 상세 확인
* - [데이터 흐름] API(REST/WS) -> Hooks(useDashboardData, useMarketRealtime, useHoldingsRealtime) -> UI 병합 -> 하위 컴포넌트 전파
* - [연관 파일] use-dashboard-data.ts, use-holdings-realtime.ts, StatusHeader.tsx, HoldingsList.tsx
* @author jihoon87.lee
*/
export function DashboardContainer() {
// [Store] KIS 런타임 설정 상태 (인증 여부, 접속 계좌, 웹소켓 정보 등)
const {
verifiedCredentials,
isKisVerified,
isKisProfileVerified,
verifiedAccountNo,
_hasHydrated,
wsApprovalKey,
wsUrl,
} = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
isKisProfileVerified: state.isKisProfileVerified,
verifiedAccountNo: state.verifiedAccountNo,
_hasHydrated: state._hasHydrated,
wsApprovalKey: state.wsApprovalKey,
wsUrl: state.wsUrl,
})),
);
// KIS 접근 가능 여부 판단
const canAccess = isKisVerified && Boolean(verifiedCredentials);
// [Hooks] 기본적인 대시보드 데이터(잔고, 지수, 활동내역) 조회 및 선택 상태 관리
// @see use-dashboard-data.ts - 초기 데이터 로딩 및 폴링 처리
const {
activity,
balance,
indices: initialIndices,
selectedSymbol,
setSelectedSymbol,
isLoading,
isRefreshing,
activityError,
balanceError,
indicesError,
lastUpdatedAt,
refresh,
} = useDashboardData(canAccess ? verifiedCredentials : null);
// [Hooks] 시장 지수(코스피/코스닥) 실시간 웹소켓 데이터 구독
// @see use-market-realtime.ts - 웹소켓 연결 및 지수 파싱
const { realtimeIndices, isConnected: isWsConnected } = useMarketRealtime(
verifiedCredentials,
isKisVerified,
);
// [Hooks] 보유 종목 실시간 시세 웹소켓 데이터 구독
// @see use-holdings-realtime.ts - 보유 종목 리스트 기반 시세 업데이트
const { realtimeData: realtimeHoldings } = useHoldingsRealtime(
balance?.holdings ?? [],
);
const reconnectWebSocket = useKisWebSocketStore((state) => state.reconnect);
// [Step 1] REST API로 가져온 기본 지수 정보와 실시간 웹소켓 시세 병합
const indices = useMemo(() => {
if (initialIndices.length === 0) {
return buildRealtimeOnlyIndices(realtimeIndices);
}
return initialIndices.map((item) => {
const realtime = realtimeIndices[item.code];
if (!realtime) return item;
return {
...item,
price: realtime.price,
change: realtime.change,
changeRate: realtime.changeRate,
};
});
}, [initialIndices, realtimeIndices]);
// [Step 2] 초기 잔고 데이터와 실시간 보유 종목 시세를 병합하여 손익 재계산
const mergedHoldings = useMemo(
() => mergeHoldingsWithRealtime(balance?.holdings ?? [], realtimeHoldings),
[balance?.holdings, realtimeHoldings],
);
const isKisRestConnected = Boolean(
(balance && !balanceError) ||
(initialIndices.length > 0 && !indicesError) ||
(activity && !activityError),
);
const hasRealtimeStreaming =
Object.keys(realtimeIndices).length > 0 ||
Object.keys(realtimeHoldings).length > 0;
const isRealtimePending = Boolean(
wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming,
);
const effectiveIndicesError = indices.length === 0 ? indicesError : null;
const indicesWarning =
indices.length > 0 && indicesError
? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다."
: null;
/**
* 대시보드 수동 새로고침 시 REST 조회 + 웹소켓 재연결을 함께 수행합니다.
* @remarks UI 흐름: StatusHeader/각 카드 다시 불러오기 버튼 -> handleRefreshAll -> REST 재조회 + WS 완전 종료 후 재연결
* @see features/dashboard/components/StatusHeader.tsx 상단 다시 불러오기 버튼
* @see features/kis-realtime/stores/kisWebSocketStore.ts reconnect
*/
const handleRefreshAll = async () => {
await Promise.allSettled([
refresh(),
reconnectWebSocket({ refreshApproval: false }),
]);
};
/**
* 실시간 보유종목 데이터를 기반으로 전체 자산 요약을 계산합니다.
* @returns 실시간 요약 데이터 (총자산, 손익, 평가금액 등)
*/
const mergedSummary = useMemo(
() => buildRealtimeSummary(balance?.summary ?? null, mergedHoldings),
[balance?.summary, mergedHoldings],
);
// [Step 3] 실시간 병합 데이터에서 현재 선택된 종목 정보를 추출
// @see StockDetailPreview.tsx - 선택된 종목의 상세 정보 표시
const realtimeSelectedHolding = useMemo(() => {
if (!selectedSymbol || mergedHoldings.length === 0) return null;
return (
mergedHoldings.find((item) => item.symbol === selectedSymbol) ?? null
);
}, [mergedHoldings, selectedSymbol]);
// 하이드레이션 이전에는 로딩 스피너 표시
if (!_hasHydrated) {
return (
<div className="flex h-full items-center justify-center p-6">
<LoadingSpinner />
</div>
);
}
// KIS 인증이 되지 않은 경우 접근 제한 게이트 표시
if (!canAccess) {
return <DashboardAccessGate canAccess={canAccess} />;
}
// 데이터 로딩 중이며 아직 데이터가 없는 경우 스켈레톤 표시
if (isLoading && !balance && indices.length === 0) {
return <DashboardSkeleton />;
}
return (
<section className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
{/* ========== 상단 상태 영역: 계좌 연결 정보 및 새로고침 ========== */}
<StatusHeader
summary={mergedSummary}
isKisRestConnected={isKisRestConnected}
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
isRealtimePending={isRealtimePending}
isProfileVerified={isKisProfileVerified}
verifiedAccountNo={verifiedAccountNo}
isRefreshing={isRefreshing}
lastUpdatedAt={lastUpdatedAt}
onRefresh={() => {
void handleRefreshAll();
}}
/>
{/* ========== 메인 그리드 구성 ========== */}
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
{/* 왼쪽 섹션: 보유 종목 목록 리스트 */}
<HoldingsList
holdings={mergedHoldings}
selectedSymbol={selectedSymbol}
isLoading={isLoading}
error={balanceError}
onRetry={() => {
void handleRefreshAll();
}}
onSelect={setSelectedSymbol}
/>
{/* 오른쪽 섹션: 시장 지수 요약 및 선택 종목 상세 정보 */}
<div className="grid gap-4">
{/* 시장 지수 현황 (코스피/코스닥) */}
<MarketSummary
items={indices}
isLoading={isLoading}
error={effectiveIndicesError}
warning={indicesWarning}
isRealtimePending={isRealtimePending}
onRetry={() => {
void handleRefreshAll();
}}
/>
{/* 선택된 종목의 실시간 상세 요약 정보 */}
<StockDetailPreview
holding={realtimeSelectedHolding}
totalAmount={mergedSummary?.totalAmount ?? 0}
/>
</div>
</div>
{/* ========== 하단 섹션: 최근 매매/충전 활동 내역 ========== */}
<ActivitySection
activity={activity}
isLoading={isLoading}
error={activityError}
onRetry={() => {
void handleRefreshAll();
}}
/>
</section>
);
}
/**
* @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다.
* @param realtimeIndices 실시간 지수 맵
* @returns 화면 렌더링용 지수 배열
* @remarks UI 흐름: DashboardContainer -> buildRealtimeOnlyIndices -> MarketSummary 렌더링
* @see features/dashboard/hooks/use-market-realtime.ts 실시간 지수 수신 훅
*/
function buildRealtimeOnlyIndices(
realtimeIndices: Record<string, { price: number; change: number; changeRate: number }>,
) {
const baseItems: DashboardMarketIndexItem[] = [
{ market: "KOSPI", code: "0001", name: "코스피", price: 0, change: 0, changeRate: 0 },
{ market: "KOSDAQ", code: "1001", name: "코스닥", price: 0, change: 0, changeRate: 0 },
];
return baseItems
.map((item) => {
const realtime = realtimeIndices[item.code];
if (!realtime) return null;
return {
...item,
price: realtime.price,
change: realtime.change,
changeRate: realtime.changeRate,
} satisfies DashboardMarketIndexItem;
})
.filter((item): item is DashboardMarketIndexItem => Boolean(item));
}
/**
* @description 보유종목 리스트에 실시간 체결가를 병합해 현재가/평가금액/손익을 재계산합니다.
* @param holdings REST 기준 보유종목
* @param realtimeHoldings 종목별 실시간 체결 데이터
* @returns 병합된 보유종목 리스트
* @remarks UI 흐름: DashboardContainer -> mergeHoldingsWithRealtime -> HoldingsList/StockDetailPreview 반영
* @see features/dashboard/hooks/use-holdings-realtime.ts 보유종목 실시간 체결 구독
*/
function mergeHoldingsWithRealtime(
holdings: DashboardHoldingItem[],
realtimeHoldings: Record<string, KisRealtimeStockTick>,
) {
if (holdings.length === 0 || Object.keys(realtimeHoldings).length === 0) {
return holdings;
}
return holdings.map((item) => {
const tick = realtimeHoldings[item.symbol];
if (!tick) return item;
const currentPrice = tick.currentPrice;
const purchaseAmount = item.averagePrice * item.quantity;
const evaluationAmount = currentPrice * item.quantity;
const profitLoss = evaluationAmount - purchaseAmount;
const profitRate = purchaseAmount > 0 ? (profitLoss / purchaseAmount) * 100 : 0;
return {
...item,
currentPrice,
evaluationAmount,
profitLoss,
profitRate,
};
});
}
/**
* @description 실시간 보유종목 기준으로 대시보드 요약(총자산/손익)을 일관되게 재계산합니다.
* @param summary REST API 요약 값
* @param holdings 실시간 병합된 보유종목
* @returns 재계산된 요약 값
* @remarks UI 흐름: DashboardContainer -> buildRealtimeSummary -> StatusHeader 카드 반영
* @see features/dashboard/components/StatusHeader.tsx 상단 요약 렌더링
*/
function buildRealtimeSummary(
summary: DashboardBalanceSummary | null,
holdings: DashboardHoldingItem[],
) {
if (!summary) return null;
if (holdings.length === 0) return summary;
const evaluationAmount = holdings.reduce(
(total, item) => total + item.evaluationAmount,
0,
);
const purchaseAmount = holdings.reduce(
(total, item) => total + item.averagePrice * item.quantity,
0,
);
const totalProfitLoss = evaluationAmount - purchaseAmount;
const totalProfitRate =
purchaseAmount > 0 ? (totalProfitLoss / purchaseAmount) * 100 : 0;
const evaluationDelta = evaluationAmount - summary.evaluationAmount;
const baseTotalAmount =
summary.apiReportedNetAssetAmount > 0
? summary.apiReportedNetAssetAmount
: summary.totalAmount;
// 실시간은 "기준 순자산 + 평가금 증감분"으로만 반영합니다.
const totalAmount = Math.max(baseTotalAmount + evaluationDelta, 0);
const netAssetAmount = totalAmount;
const cashBalance = Math.max(totalAmount - evaluationAmount, 0);
return {
...summary,
totalAmount,
netAssetAmount,
cashBalance,
evaluationAmount,
purchaseAmount,
totalProfitLoss,
totalProfitRate,
} satisfies DashboardBalanceSummary;
}

View File

@@ -0,0 +1,59 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* @description 대시보드 초기 로딩 스켈레톤 UI입니다.
* @see features/dashboard/components/DashboardContainer.tsx isLoading 상태에서 렌더링합니다.
*/
export function DashboardSkeleton() {
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
{/* ========== HEADER SKELETON ========== */}
<Card className="border-brand-200 dark:border-brand-800/50">
<CardContent className="grid gap-3 p-4 md:grid-cols-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
{/* ========== BODY SKELETON ========== */}
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-14 w-full" />
))}
</CardContent>
</Card>
<div className="grid gap-4">
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
/**
* @file HoldingsList.tsx
* @description 대시보드 좌측 영역의 보유 종목 리스트 컴포넌트
* @remarks
* - [레이어] Components / UI
* - [사용자 행동] 종목 리스트 스크롤 -> 특정 종목 클릭(선택) -> 우측 상세 프레뷰 갱신
* - [데이터 흐름] DashboardContainer(mergedHoldings) -> HoldingsList -> HoldingItemRow -> onSelect(Callback)
* - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
* @author jihoon87.lee
*/
import { AlertCircle, Wallet2 } from "lucide-react";
import { RefreshCcw } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
import {
formatCurrency,
formatPercent,
getChangeToneClass,
} from "@/features/dashboard/utils/dashboard-format";
import { cn } from "@/lib/utils";
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
interface HoldingsListProps {
/** 보유 종목 데이터 리스트 (실시간 시세 병합됨) */
holdings: DashboardHoldingItem[];
/** 현재 선택된 종목의 심볼 (없으면 null) */
selectedSymbol: string | null;
/** 데이터 로딩 상태 */
isLoading: boolean;
/** 에러 메시지 (없으면 null) */
error: string | null;
/** 섹션 재조회 핸들러 */
onRetry?: () => void;
/** 종목 선택 시 호출되는 핸들러 */
onSelect: (symbol: string) => void;
}
/**
* [컴포넌트] 보유 종목 리스트
* 사용자의 잔고 정보를 바탕으로 실시간 시세가 반영된 종목 카드 목록을 렌더링합니다.
*
* @param props HoldingsListProps
* @see DashboardContainer.tsx - 좌측 메인 영역에서 실시간 병합 데이터를 전달받아 호출
* @see DashboardContainer.tsx - setSelectedSymbol 핸들러를 onSelect로 전달
*/
export function HoldingsList({
holdings,
selectedSymbol,
isLoading,
error,
onRetry,
onSelect,
}: HoldingsListProps) {
return (
<Card className="h-full">
{/* ========== 카드 헤더: 타이틀 및 설명 ========== */}
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Wallet2 className="h-4 w-4 text-brand-600 dark:text-brand-400" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
{/* ========== 카드 본문: 상태별 메시지 및 리스트 ========== */}
<CardContent>
{/* 로딩 중 상태 (데이터가 아직 없는 경우) */}
{isLoading && holdings.length === 0 && (
<p className="text-sm text-muted-foreground">
.
</p>
)}
{/* 에러 발생 상태 */}
{error && (
<div className="mb-2 rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
<p className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</p>
<p className="mt-1 text-xs text-red-700/80 dark:text-red-300/80">
API가 . .
</p>
{onRetry ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
>
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
</Button>
) : null}
</div>
)}
{/* 데이터 없음 상태 */}
{!isLoading && holdings.length === 0 && !error && (
<p className="text-sm text-muted-foreground"> .</p>
)}
{/* 종목 리스트 렌더링 영역 */}
{holdings.length > 0 && (
<ScrollArea className="h-[420px] pr-3">
<div className="space-y-2">
{holdings.map((holding) => (
<HoldingItemRow
key={holding.symbol}
holding={holding}
isSelected={selectedSymbol === holding.symbol}
onSelect={onSelect}
/>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
);
}
interface HoldingItemRowProps {
/** 개별 종목 정보 */
holding: DashboardHoldingItem;
/** 선택 여부 */
isSelected: boolean;
/** 클릭 핸들러 */
onSelect: (symbol: string) => void;
}
/**
* [컴포넌트] 보유 종목 개별 행 (아이템)
* 종목의 기본 정보와 실시간 시세, 현재 손익 상태를 표시합니다.
*
* @param props HoldingItemRowProps
* @see HoldingsList.tsx - holdings.map 내에서 호출
* @see use-price-flash.ts - 현재가 변경 감지 및 애니메이션 효과 트리거
*/
function HoldingItemRow({
holding,
isSelected,
onSelect,
}: HoldingItemRowProps) {
// [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리
// @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정
const flash = usePriceFlash(holding.currentPrice, holding.symbol);
// [UI] 손익 상태에 따른 텍스트 색상 클래스 결정 (상승: red, 하락: blue)
const toneClass = getChangeToneClass(holding.profitLoss);
return (
<button
type="button"
// [Step 1] 종목 클릭 시 부모의 선택 핸들러 호출
onClick={() => onSelect(holding.symbol)}
className={cn(
"w-full rounded-xl border px-3 py-3 text-left transition-all relative overflow-hidden",
isSelected
? "border-brand-400 bg-brand-50/60 ring-1 ring-brand-300 dark:border-brand-600 dark:bg-brand-900/20 dark:ring-brand-700"
: "border-border/70 bg-background hover:border-brand-200 hover:bg-brand-50/30 dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
)}
>
{/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */}
<div className="flex items-center justify-between gap-2">
<div>
{/* 종목명 및 기본 정보 */}
<p className="text-sm font-semibold text-foreground">
{holding.name}
</p>
<p className="text-xs text-muted-foreground">
{holding.symbol} · {holding.market} · {holding.quantity}
</p>
</div>
<div className="text-right">
<div className="relative inline-flex items-center justify-end gap-1">
{/* 시세 변동 애니메이션 (Flash) 표시 영역 */}
{flash && (
<span
key={flash.id}
className={cn(
"pointer-events-none absolute -left-12 top-0 whitespace-nowrap text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
flash.type === "up" ? "text-red-500" : "text-blue-500",
)}
>
{flash.type === "up" ? "+" : ""}
{flash.val.toLocaleString()}
</span>
)}
{/* 실시간 현재가 */}
<p className="text-sm font-semibold text-foreground transition-colors duration-300">
{formatCurrency(holding.currentPrice)}
</p>
</div>
{/* 실시간 수익률 */}
<p className={cn("text-xs font-medium", toneClass)}>
{formatPercent(holding.profitRate)}
</p>
</div>
</div>
{/* ========== 행 하단: 평단가, 평가액 및 실시간 손익 ========== */}
<div className="mt-2 grid grid-cols-3 gap-1 text-xs">
<span className="text-muted-foreground">
{formatCurrency(holding.averagePrice)}
</span>
<span className="text-muted-foreground">
{formatCurrency(holding.evaluationAmount)}
</span>
<span className={cn("text-right font-medium", toneClass)}>
{formatCurrency(holding.profitLoss)}
</span>
</div>
</button>
);
}

View File

@@ -0,0 +1,192 @@
import { BarChart3, TrendingDown, TrendingUp } from "lucide-react";
import { RefreshCcw } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types";
import { Button } from "@/components/ui/button";
import {
formatCurrency,
formatSignedCurrency,
formatSignedPercent,
} from "@/features/dashboard/utils/dashboard-format";
import { cn } from "@/lib/utils";
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
interface MarketSummaryProps {
items: DashboardMarketIndexItem[];
isLoading: boolean;
error: string | null;
warning?: string | null;
isRealtimePending?: boolean;
onRetry?: () => void;
}
/**
* @description 코스피/코스닥 지수 요약 카드입니다.
* @see features/dashboard/components/DashboardContainer.tsx 우측 상단 영역에서 호출합니다.
*/
export function MarketSummary({
items,
isLoading,
error,
warning = null,
isRealtimePending = false,
onRetry,
}: MarketSummaryProps) {
return (
<Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/50 to-background dark:border-brand-800/45 dark:from-brand-950/20 dark:to-background">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
<BarChart3 className="h-4 w-4" />
</CardTitle>
</div>
<CardDescription> / .</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
{/* ========== LOADING STATE ========== */}
{isLoading && items.length === 0 && (
<div className="col-span-full py-4 text-center text-sm text-muted-foreground animate-pulse">
...
</div>
)}
{/* ========== REALTIME PENDING STATE ========== */}
{isRealtimePending && items.length === 0 && !isLoading && !error && (
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
.
</div>
)}
{/* ========== ERROR/WARNING STATE ========== */}
{error && (
<div className="col-span-full rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/30 dark:text-red-400">
<p> .</p>
<p className="mt-1 text-xs opacity-80">
{toCompactErrorMessage(error)}
</p>
<p className="mt-1 text-xs opacity-80">
API / .
</p>
{onRetry ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
>
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
</Button>
) : null}
</div>
)}
{!error && warning && (
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
{warning}
</div>
)}
{/* ========== INDEX CARDS ========== */}
{items.map((item) => (
<IndexItem key={item.code} item={item} />
))}
{!isLoading && items.length === 0 && !error && (
<div className="col-span-full py-4 text-center text-sm text-muted-foreground">
.
</div>
)}
</CardContent>
</Card>
);
}
/**
* @description 길고 복잡한 서버 오류를 대시보드 카드에 맞는 짧은 문구로 축약합니다.
* @param error 원본 오류 문자열
* @returns 화면 노출용 오류 메시지
* @see features/dashboard/components/MarketSummary.tsx 지수 오류 배너 상세 문구
*/
function toCompactErrorMessage(error: string) {
const normalized = error.replaceAll(/\s+/g, " ").trim();
if (!normalized) return "잠시 후 다시 시도해 주세요.";
if (normalized.length <= 120) return normalized;
return `${normalized.slice(0, 120)}...`;
}
function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
const isUp = item.change > 0;
const isDown = item.change < 0;
const toneClass = isUp
? "text-red-600 dark:text-red-400"
: isDown
? "text-blue-600 dark:text-blue-400"
: "text-muted-foreground";
const bgClass = isUp
? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30"
: isDown
? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30"
: "bg-muted/50 border-border/50";
const flash = usePriceFlash(item.price, item.code);
return (
<div
className={cn(
"relative flex flex-col justify-between rounded-xl border p-4 transition-all hover:bg-background/80",
bgClass,
)}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
{item.market}
</span>
{isUp ? (
<TrendingUp className="h-4 w-4 text-red-500/70" />
) : isDown ? (
<TrendingDown className="h-4 w-4 text-blue-500/70" />
) : null}
</div>
<div className="mt-2 text-2xl font-bold tracking-tight relative w-fit">
{formatCurrency(item.price)}
{/* Flash Indicator */}
{flash && (
<div
key={flash.id} // Force re-render for animation restart using state ID
className={cn(
"absolute left-full top-1 ml-2 text-sm font-bold animate-out fade-out slide-out-to-top-2 duration-1000 fill-mode-forwards pointer-events-none whitespace-nowrap",
flash.type === "up" ? "text-red-500" : "text-blue-500",
)}
>
{flash.type === "up" ? "+" : ""}
{flash.val.toFixed(2)}
</div>
)}
</div>
<div
className={cn(
"mt-1 flex items-center gap-2 text-sm font-medium",
toneClass,
)}
>
<span>{formatSignedCurrency(item.change)}</span>
<span className="rounded-md bg-background/50 px-1.5 py-0.5 text-xs shadow-sm">
{formatSignedPercent(item.changeRate)}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import Link from "next/link";
import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import type { DashboardBalanceSummary } from "@/features/dashboard/types/dashboard.types";
import {
formatCurrency,
formatSignedCurrency,
formatSignedPercent,
getChangeToneClass,
} from "@/features/dashboard/utils/dashboard-format";
import { cn } from "@/lib/utils";
interface StatusHeaderProps {
summary: DashboardBalanceSummary | null;
isKisRestConnected: boolean;
isWebSocketReady: boolean;
isRealtimePending: boolean;
isProfileVerified: boolean;
verifiedAccountNo: string | null;
isRefreshing: boolean;
lastUpdatedAt: string | null;
onRefresh: () => void;
}
/**
* @description 대시보드 상단 상태 헤더(총자산/손익/연결상태/빠른액션) 컴포넌트입니다.
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트에서 상태 값을 전달받아 렌더링합니다.
*/
export function StatusHeader({
summary,
isKisRestConnected,
isWebSocketReady,
isRealtimePending,
isProfileVerified,
verifiedAccountNo,
isRefreshing,
lastUpdatedAt,
onRefresh,
}: StatusHeaderProps) {
const toneClass = getChangeToneClass(summary?.totalProfitLoss ?? 0);
const updatedLabel = lastUpdatedAt
? new Date(lastUpdatedAt).toLocaleTimeString("ko-KR", {
hour12: false,
})
: "--:--:--";
const hasApiTotalAmount =
Boolean(summary) && (summary?.apiReportedTotalAmount ?? 0) > 0;
const hasApiNetAssetAmount =
Boolean(summary) && (summary?.apiReportedNetAssetAmount ?? 0) > 0;
const isApiTotalAmountDifferent =
Boolean(summary) &&
Math.abs(
(summary?.apiReportedTotalAmount ?? 0) - (summary?.totalAmount ?? 0),
) >= 1;
const realtimeStatusLabel = isWebSocketReady
? isRealtimePending
? "수신 대기중"
: "연결됨"
: "미연결";
return (
<Card className="relative overflow-hidden border-brand-200 shadow-sm dark:border-brand-800/50">
{/* ========== BACKGROUND DECORATION ========== */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-r from-brand-100/70 via-brand-50/50 to-transparent dark:from-brand-900/35 dark:via-brand-900/20" />
<CardContent className="relative grid gap-3 p-4 md:grid-cols-[1fr_1fr_1fr_auto]">
{/* ========== TOTAL ASSET ========== */}
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
<p className="text-xs font-medium text-muted-foreground"> ( )</p>
<p className="mt-1 text-xl font-semibold tracking-tight">
{summary ? `${formatCurrency(summary.totalAmount)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
() {summary ? `${formatCurrency(summary.cashBalance)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{" "}
{summary ? `${formatCurrency(summary.evaluationAmount)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
(KIS){" "}
{summary ? `${formatCurrency(summary.totalDepositAmount)}` : "-"}
</p>
<p className="mt-1 text-[11px] text-muted-foreground/80">
.
</p>
<p className="mt-1 text-xs text-muted-foreground">
( ){" "}
{summary ? `${formatCurrency(summary.netAssetAmount)}` : "-"}
</p>
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
<p className="mt-1 text-xs text-muted-foreground">
KIS {formatCurrency(summary?.apiReportedTotalAmount ?? 0)}
</p>
) : null}
{hasApiNetAssetAmount ? (
<p className="mt-1 text-xs text-muted-foreground">
KIS {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}
</p>
) : null}
</div>
{/* ========== PROFIT/LOSS ========== */}
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
<p className="text-xs font-medium text-muted-foreground"> </p>
<p
className={cn(
"mt-1 text-xl font-semibold tracking-tight",
toneClass,
)}
>
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}` : "-"}
</p>
<p className={cn("mt-1 text-xs font-medium", toneClass)}>
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{" "}
{summary ? `${formatCurrency(summary.evaluationAmount)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{" "}
{summary ? `${formatCurrency(summary.purchaseAmount)}` : "-"}
</p>
</div>
{/* ========== CONNECTION STATUS ========== */}
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-medium">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1",
isKisRestConnected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/10 text-red-600 dark:text-red-400",
)}
>
<Wifi className="h-3.5 w-3.5" />
{isKisRestConnected ? "연결됨" : "연결 끊김"}
</span>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1",
isWebSocketReady
? isRealtimePending
? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground",
)}
>
<Activity className="h-3.5 w-3.5" />
{realtimeStatusLabel}
</span>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1",
isProfileVerified
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
)}
>
<Activity className="h-3.5 w-3.5" />
{isProfileVerified ? "완료" : "미완료"}
</span>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{updatedLabel}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{maskAccountNo(verifiedAccountNo)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{summary ? `${formatCurrency(summary.loanAmount)}` : "-"}
</p>
</div>
{/* ========== QUICK ACTIONS ========== */}
<div className="flex flex-col gap-2 sm:flex-row md:flex-col md:items-stretch md:justify-center">
<Button
type="button"
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
className="w-full border-brand-200 text-brand-700 hover:bg-brand-50 dark:border-brand-700 dark:text-brand-300 dark:hover:bg-brand-900/30"
>
<RefreshCcw
className={cn("h-4 w-4 mr-2", isRefreshing ? "animate-spin" : "")}
/>
</Button>
<Button
asChild
className="w-full bg-brand-600 text-white hover:bg-brand-700 dark:bg-brand-600 dark:text-white dark:hover:bg-brand-500"
>
<Link href="/settings">
<Settings2 className="h-4 w-4 mr-2" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
/**
* @description 계좌번호를 마스킹해 표시합니다.
* @param value 계좌번호(8-2)
* @returns 마스킹 문자열
* @see features/dashboard/components/StatusHeader.tsx 시스템 상태 영역 계좌 표시
*/
function maskAccountNo(value: string | null) {
if (!value) return "-";
const digits = value.replace(/\D/g, "");
if (digits.length !== 10) return "********";
return "********-**";
}

View File

@@ -0,0 +1,235 @@
/**
* @file StockDetailPreview.tsx
* @description 대시보드 우측 영역의 선택 종목 상세 정보 및 실시간 시세 반영 컴포넌트
* @remarks
* - [레이어] Components / UI
* - [사용자 행동] 종목 리스트에서 항목 선택 -> 상세 정보 조회 -> 실시간 시세 변동 확인
* - [데이터 흐름] DashboardContainer(realtimeSelectedHolding) -> StockDetailPreview -> Metric(UI)
* - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
* @author jihoon87.lee
*/
import { BarChartBig, ExternalLink, MousePointerClick } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useRouter } from "next/navigation";
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
import {
formatCurrency,
formatPercent,
getChangeToneClass,
} from "@/features/dashboard/utils/dashboard-format";
import { cn } from "@/lib/utils";
interface StockDetailPreviewProps {
/** 선택된 종목 정보 (없으면 null) */
holding: DashboardHoldingItem | null;
/** 현재 총 자산 (비중 계산용) */
totalAmount: number;
}
/**
* [컴포넌트] 선택 종목 상세 요약 카드
* 대시보드에서 선택된 특정 종목의 매입가, 현재가, 수익률 등 상세 지표를 실시간으로 보여줍니다.
*
* @param props StockDetailPreviewProps
* @see DashboardContainer.tsx - HoldingsList 선택 결과를 실시간 데이터로 전달받아 렌더링
*/
export function StockDetailPreview({
holding,
totalAmount,
}: StockDetailPreviewProps) {
const router = useRouter();
const setPendingTarget = useTradeNavigationStore(
(state) => state.setPendingTarget,
);
// [State/Hook] 실시간 가격 변동 애니메이션 상태 관리
// @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달
const currentPrice = holding?.currentPrice ?? 0;
const priceFlash = usePriceFlash(currentPrice, holding?.symbol);
// [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링
if (!holding) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
.
</p>
</CardContent>
</Card>
);
}
// [Step 2] 수익/손실 여부에 따른 UI 톤(색상) 결정
const profitToneClass = getChangeToneClass(holding.profitLoss);
// [Step 3] 총 자산 대비 비중 계산
const allocationRate =
totalAmount > 0
? Math.min((holding.evaluationAmount / totalAmount) * 100, 100)
: 0;
return (
<Card>
{/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */}
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
</CardTitle>
<CardDescription className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() => {
setPendingTarget({
symbol: holding.symbol,
name: holding.name,
market: holding.market,
});
router.push("/trade");
}}
className={cn(
"group flex items-center gap-1.5 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5",
"text-sm font-bold text-brand-700 transition-all cursor-pointer",
"hover:border-brand-400 hover:bg-brand-100 hover:shadow-sm",
"dark:border-brand-800/60 dark:bg-brand-900/40 dark:text-brand-400 dark:hover:border-brand-600 dark:hover:bg-brand-900/60",
)}
title={`${holding.name} 종목 상세 거래로 이동`}
>
<span className="truncate">{holding.name}</span>
<span className="text-[10px] font-medium opacity-70">
({holding.symbol})
</span>
<ExternalLink className="h-3 w-3 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</button>
<span className="text-xs text-muted-foreground">
· {holding.market}
</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* ========== 실시간 주요 지표 영역 (Grid) ========== */}
<div className="grid grid-cols-2 gap-2 text-sm">
<Metric
label="보유 수량"
value={`${holding.quantity.toLocaleString("ko-KR")}`}
/>
<Metric
label="매입 평균가"
value={`${formatCurrency(holding.averagePrice)}`}
/>
<Metric
label="현재가"
value={`${formatCurrency(holding.currentPrice)}`}
flash={priceFlash}
/>
<Metric
label="수익률"
value={formatPercent(holding.profitRate)}
valueClassName={profitToneClass}
/>
<Metric
label="현재 손익"
value={`${formatCurrency(holding.profitLoss)}`}
valueClassName={profitToneClass}
/>
<Metric
label="평가금액"
value={`${formatCurrency(holding.evaluationAmount)}`}
/>
</div>
{/* ========== 자산 비중 그래프 영역 ========== */}
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span> </span>
<span>{formatPercent(allocationRate)}</span>
</div>
<div className="mt-2 h-2 rounded-full bg-muted">
<div
className="h-2 rounded-full bg-linear-to-r from-brand-500 to-brand-700"
style={{ width: `${allocationRate}%` }}
/>
</div>
</div>
{/* ========== 추가 기능 예고 영역 (Placeholder) ========== */}
<div className="rounded-xl border border-dashed border-border/80 bg-muted/30 p-3">
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground/80">
<MousePointerClick className="h-4 w-4 text-brand-500" />
( )
</p>
<p className="mt-1 text-xs text-muted-foreground">
/ .
</p>
</div>
</CardContent>
</Card>
);
}
interface MetricProps {
/** 지표 레이블 */
label: string;
/** 표시될 값 */
value: string;
/** 값 텍스트 추가 스타일 */
valueClassName?: string;
/** 가격 변동 애니메이션 상태 */
flash?: { type: "up" | "down"; val: number; id: number } | null;
}
/**
* [컴포넌트] 상세 카드용 개별 지표 아이템
* 레이블과 값을 박스 형태로 렌더링하며, 필요한 경우 시세 변동 Flash 애니메이션을 처리합니다.
*
* @param props MetricProps
* @see StockDetailPreview.tsx - 내부 그리드 영역에서 여러 개 호출
*/
function Metric({ label, value, valueClassName, flash }: MetricProps) {
return (
<div className="relative overflow-hidden rounded-xl border border-border/70 bg-background/70 p-3 transition-colors">
{/* 시세 변동 시 나타나는 일시적인 수치 표시 (Flash) */}
{flash && (
<span
key={flash.id}
className={cn(
"pointer-events-none absolute right-2 top-2 text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
flash.type === "up" ? "text-red-500" : "text-blue-500",
)}
>
{flash.type === "up" ? "+" : ""}
{flash.val.toLocaleString()}
</span>
)}
{/* 지표 레이블 및 본체 값 */}
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={cn(
"mt-1 text-sm font-semibold text-foreground transition-colors",
valueClassName,
)}
>
{value}
</p>
</div>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import {
fetchDashboardActivity,
fetchDashboardBalance,
fetchDashboardIndices,
} from "@/features/dashboard/apis/dashboard.api";
import type {
DashboardActivityResponse,
DashboardBalanceResponse,
DashboardIndicesResponse,
} from "@/features/dashboard/types/dashboard.types";
interface UseDashboardDataResult {
activity: DashboardActivityResponse | null;
balance: DashboardBalanceResponse | null;
indices: DashboardIndicesResponse["items"];
selectedSymbol: string | null;
setSelectedSymbol: (symbol: string) => void;
isLoading: boolean;
isRefreshing: boolean;
activityError: string | null;
balanceError: string | null;
indicesError: string | null;
lastUpdatedAt: string | null;
refresh: () => Promise<void>;
}
const POLLING_INTERVAL_MS = 60_000;
/**
* @description 대시보드 잔고/지수 상태를 관리하는 훅입니다.
* @param credentials KIS 인증 정보
* @returns 대시보드 데이터/로딩/오류 상태
* @remarks UI 흐름: 대시보드 진입 -> refresh("initial") -> balance/indices API 병렬 호출 -> 카드별 상태 반영
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트 컨테이너
* @see features/dashboard/apis/dashboard.api.ts 실제 API 호출 함수
*/
export function useDashboardData(
credentials: KisRuntimeCredentials | null,
): UseDashboardDataResult {
const [activity, setActivity] = useState<DashboardActivityResponse | null>(null);
const [balance, setBalance] = useState<DashboardBalanceResponse | null>(null);
const [indices, setIndices] = useState<DashboardIndicesResponse["items"]>([]);
const [selectedSymbol, setSelectedSymbolState] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [activityError, setActivityError] = useState<string | null>(null);
const [balanceError, setBalanceError] = useState<string | null>(null);
const [indicesError, setIndicesError] = useState<string | null>(null);
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
const requestSeqRef = useRef(0);
const hasAccountNo = Boolean(credentials?.accountNo?.trim());
/**
* @description 잔고/지수 데이터를 병렬로 갱신합니다.
* @param mode 초기 로드/수동 새로고침/주기 갱신 구분
* @see features/dashboard/hooks/use-dashboard-data.ts useEffect 초기 호출/폴링/수동 새로고침
*/
const refreshInternal = useCallback(
async (mode: "initial" | "manual" | "polling") => {
if (!credentials) return;
const requestSeq = ++requestSeqRef.current;
const isInitial = mode === "initial";
if (isInitial) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
const tasks: [
Promise<DashboardBalanceResponse | null>,
Promise<DashboardIndicesResponse>,
Promise<DashboardActivityResponse | null>,
] = [
hasAccountNo
? fetchDashboardBalance(credentials)
: Promise.resolve(null),
fetchDashboardIndices(credentials),
hasAccountNo
? fetchDashboardActivity(credentials)
: Promise.resolve(null),
];
const [balanceResult, indicesResult, activityResult] = await Promise.allSettled(tasks);
if (requestSeq !== requestSeqRef.current) return;
let hasAnySuccess = false;
if (!hasAccountNo) {
setBalance(null);
setBalanceError(
"계좌번호가 없어 잔고를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.",
);
setActivity(null);
setActivityError(
"계좌번호가 없어 주문내역/매매일지를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.",
);
setSelectedSymbolState(null);
} else if (balanceResult.status === "fulfilled") {
hasAnySuccess = true;
setBalance(balanceResult.value);
setBalanceError(null);
setSelectedSymbolState((prev) => {
const nextHoldings = balanceResult.value?.holdings ?? [];
if (nextHoldings.length === 0) return null;
if (prev && nextHoldings.some((item) => item.symbol === prev)) {
return prev;
}
return nextHoldings[0]?.symbol ?? null;
});
} else {
setBalanceError(balanceResult.reason instanceof Error ? balanceResult.reason.message : "잔고 조회에 실패했습니다.");
}
if (hasAccountNo && activityResult.status === "fulfilled") {
hasAnySuccess = true;
setActivity(activityResult.value);
setActivityError(null);
} else if (hasAccountNo && activityResult.status === "rejected") {
setActivityError(activityResult.reason instanceof Error ? activityResult.reason.message : "주문내역/매매일지 조회에 실패했습니다.");
}
if (indicesResult.status === "fulfilled") {
hasAnySuccess = true;
setIndices(indicesResult.value.items);
setIndicesError(null);
} else {
setIndicesError(indicesResult.reason instanceof Error ? indicesResult.reason.message : "시장 지수 조회에 실패했습니다.");
}
if (hasAnySuccess) {
setLastUpdatedAt(new Date().toISOString());
}
if (isInitial) {
setIsLoading(false);
} else {
setIsRefreshing(false);
}
},
[credentials, hasAccountNo],
);
/**
* @description 대시보드 수동 새로고침 핸들러입니다.
* @see features/dashboard/components/StatusHeader.tsx 새로고침 버튼 onClick
*/
const refresh = useCallback(async () => {
await refreshInternal("manual");
}, [refreshInternal]);
useEffect(() => {
if (!credentials) return;
const timeout = window.setTimeout(() => {
void refreshInternal("initial");
}, 0);
return () => window.clearTimeout(timeout);
}, [credentials, refreshInternal]);
useEffect(() => {
if (!credentials) return;
const interval = window.setInterval(() => {
void refreshInternal("polling");
}, POLLING_INTERVAL_MS);
return () => window.clearInterval(interval);
}, [credentials, refreshInternal]);
const setSelectedSymbol = useCallback((symbol: string) => {
setSelectedSymbolState(symbol);
}, []);
return {
activity,
balance,
indices,
selectedSymbol,
setSelectedSymbol,
isLoading,
isRefreshing,
activityError,
balanceError,
indicesError,
lastUpdatedAt,
refresh,
};
}

View File

@@ -0,0 +1,81 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
import {
type KisRealtimeStockTick,
parseKisRealtimeStockTick,
} from "@/features/dashboard/utils/kis-stock-realtime.utils";
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
const STOCK_REALTIME_TR_ID = "H0STCNT0";
/**
* @description 보유 종목 목록에 대한 실시간 체결 데이터를 구독합니다.
* @param holdings 보유 종목 목록
* @returns 종목별 실시간 체결 데이터/연결 상태
* @remarks UI 흐름: DashboardContainer -> useHoldingsRealtime -> HoldingsList/summary 실시간 반영
* @see features/dashboard/components/DashboardContainer.tsx 보유종목 실시간 병합
*/
export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) {
const [realtimeData, setRealtimeData] = useState<
Record<string, KisRealtimeStockTick>
>({});
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
const connectRef = useRef(useKisWebSocketStore.getState().connect);
const { isConnected } = useKisWebSocketStore();
const uniqueSymbols = useMemo(
() =>
Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(),
[holdings],
);
const symbolKey = useMemo(() => uniqueSymbols.join(","), [uniqueSymbols]);
useEffect(() => {
if (uniqueSymbols.length === 0) {
const resetTimerId = window.setTimeout(() => {
setRealtimeData({});
}, 0);
return () => window.clearTimeout(resetTimerId);
}
connectRef.current();
const unsubs: (() => void)[] = [];
uniqueSymbols.forEach((symbol) => {
const unsub = subscribeRef.current(
STOCK_REALTIME_TR_ID,
symbol,
(data: string) => {
const tick = parseKisRealtimeStockTick(data);
if (tick) {
setRealtimeData((prev) => {
const prevTick = prev[tick.symbol];
if (
prevTick?.currentPrice === tick.currentPrice &&
prevTick?.change === tick.change &&
prevTick?.changeRate === tick.changeRate
) {
return prev;
}
return {
...prev,
[tick.symbol]: tick,
};
});
}
},
);
unsubs.push(unsub);
});
return () => {
unsubs.forEach((unsub) => unsub());
};
}, [symbolKey, uniqueSymbols]);
return { realtimeData, isConnected };
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useState, useCallback } from "react";
import {
parseKisRealtimeIndexTick,
type KisRealtimeIndexTick,
} from "@/features/dashboard/utils/kis-index-realtime.utils";
import { useKisWebSocket } from "@/features/kis-realtime/hooks/useKisWebSocket";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
const INDEX_TR_ID = "H0UPCNT0";
const KOSPI_SYMBOL = "0001";
const KOSDAQ_SYMBOL = "1001";
interface UseMarketRealtimeResult {
realtimeIndices: Record<string, KisRealtimeIndexTick>;
isConnected: boolean;
hasReceivedTick: boolean;
isPending: boolean;
lastTickAt: string | null;
}
/**
* @description 코스피/코스닥 실시간 지수 웹소켓 구독 상태를 관리합니다.
* @param credentials KIS 인증 정보(하위 호환 파라미터)
* @param isVerified KIS 연결 인증 여부
* @returns 실시간 지수 맵/연결 상태/수신 대기 상태
* @remarks UI 흐름: DashboardContainer -> useMarketRealtime -> MarketSummary/StatusHeader 렌더링 반영
* @see features/dashboard/components/DashboardContainer.tsx 지수 데이터 통합 및 상태 전달
*/
export function useMarketRealtime(
_credentials: KisRuntimeCredentials | null, // 하위 호환성을 위해 남겨둠 (실제로는 스토어 사용)
isVerified: boolean, // 하위 호환성을 위해 남겨둠
): UseMarketRealtimeResult {
const [realtimeIndices, setRealtimeIndices] = useState<
Record<string, KisRealtimeIndexTick>
>({});
const [lastTickAt, setLastTickAt] = useState<string | null>(null);
const handleMessage = useCallback((data: string) => {
const tick = parseKisRealtimeIndexTick(data);
if (tick) {
setLastTickAt(new Date().toISOString());
setRealtimeIndices((prev) => ({
...prev,
[tick.symbol]: tick,
}));
}
}, []);
// KOSPI 구독
const { isConnected: isKospiConnected } = useKisWebSocket({
symbol: KOSPI_SYMBOL,
trId: INDEX_TR_ID,
onMessage: handleMessage,
enabled: isVerified,
});
// KOSDAQ 구독
const { isConnected: isKosdaqConnected } = useKisWebSocket({
symbol: KOSDAQ_SYMBOL,
trId: INDEX_TR_ID,
onMessage: handleMessage,
enabled: isVerified,
});
const hasReceivedTick = Object.keys(realtimeIndices).length > 0;
const isPending = isVerified && (isKospiConnected || isKosdaqConnected) && !hasReceivedTick;
return {
realtimeIndices,
isConnected: isKospiConnected || isKosdaqConnected,
hasReceivedTick,
isPending,
lastTickAt,
};
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef, useState } from "react";
const FLASH_DURATION_MS = 2_000;
/**
* @description 가격 변동 시 일시 플래시(+/-) 값을 생성합니다.
* @param currentPrice 현재가
* @param key 종목 식별 키(종목 변경 시 상태 초기화)
* @returns 플래시 값(up/down, 변화량) 또는 null
* @remarks UI 흐름: 시세 변경 -> usePriceFlash -> 플래시 값 노출 -> 2초 후 자동 제거
* @see features/dashboard/components/HoldingsList.tsx 보유종목 현재가 플래시
* @see features/dashboard/components/StockDetailPreview.tsx 상세 카드 현재가 플래시
*/
export function usePriceFlash(currentPrice: number, key?: string) {
const [flash, setFlash] = useState<{
val: number;
type: "up" | "down";
id: number;
} | null>(null);
const prevKeyRef = useRef<string | undefined>(key);
const prevPriceRef = useRef<number>(currentPrice);
const timerRef = useRef<number | null>(null);
useEffect(() => {
const keyChanged = prevKeyRef.current !== key;
if (keyChanged) {
prevKeyRef.current = key;
prevPriceRef.current = currentPrice;
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
const resetTimerId = window.setTimeout(() => {
setFlash(null);
}, 0);
return () => window.clearTimeout(resetTimerId);
}
const prevPrice = prevPriceRef.current;
const diff = currentPrice - prevPrice;
prevPriceRef.current = currentPrice;
if (prevPrice === 0 || Math.abs(diff) === 0) return;
// 플래시가 보이는 동안에는 새 플래시를 덮어쓰지 않아 화면 잔상이 지속되지 않게 합니다.
if (timerRef.current !== null) return;
setFlash({
val: diff,
type: diff > 0 ? "up" : "down",
id: Date.now(),
});
timerRef.current = window.setTimeout(() => {
setFlash(null);
timerRef.current = null;
}, FLASH_DURATION_MS);
}, [currentPrice, key]);
useEffect(() => {
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
}
};
}, []);
return flash;
}

View File

@@ -0,0 +1,141 @@
/**
* @file features/dashboard/types/dashboard.types.ts
* @description 대시보드(잔고/지수/보유종목) 전용 타입 정의
*/
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
export type DashboardMarket = "KOSPI" | "KOSDAQ";
/**
* 대시보드 잔고 요약
*/
export interface DashboardBalanceSummary {
totalAmount: number;
cashBalance: number;
totalDepositAmount: number;
totalProfitLoss: number;
totalProfitRate: number;
netAssetAmount: number;
evaluationAmount: number;
purchaseAmount: number;
loanAmount: number;
apiReportedTotalAmount: number;
apiReportedNetAssetAmount: number;
}
/**
* 대시보드 보유 종목 항목
*/
export interface DashboardHoldingItem {
symbol: string;
name: string;
market: DashboardMarket;
quantity: number;
averagePrice: number;
currentPrice: number;
evaluationAmount: number;
profitLoss: number;
profitRate: number;
}
/**
* 주문/매매 공통 매수/매도 구분
*/
export type DashboardTradeSide = "buy" | "sell" | "unknown";
/**
* 대시보드 주문내역 항목
*/
export interface DashboardOrderHistoryItem {
orderDate: string;
orderTime: string;
orderNo: string;
symbol: string;
name: string;
side: DashboardTradeSide;
orderTypeName: string;
orderPrice: number;
orderQuantity: number;
filledQuantity: number;
filledAmount: number;
averageFilledPrice: number;
remainingQuantity: number;
isCanceled: boolean;
}
/**
* 대시보드 매매일지 항목
*/
export interface DashboardTradeJournalItem {
tradeDate: string;
symbol: string;
name: string;
side: DashboardTradeSide;
buyQuantity: number;
buyAmount: number;
sellQuantity: number;
sellAmount: number;
realizedProfit: number;
realizedRate: number;
fee: number;
tax: number;
}
/**
* 대시보드 매매일지 요약
*/
export interface DashboardTradeJournalSummary {
totalRealizedProfit: number;
totalRealizedRate: number;
totalBuyAmount: number;
totalSellAmount: number;
totalFee: number;
totalTax: number;
}
/**
* 계좌 잔고 API 응답 모델
*/
export interface DashboardBalanceResponse {
source: "kis";
tradingEnv: KisTradingEnv;
summary: DashboardBalanceSummary;
holdings: DashboardHoldingItem[];
fetchedAt: string;
}
/**
* 시장 지수 항목
*/
export interface DashboardMarketIndexItem {
market: DashboardMarket;
code: string;
name: string;
price: number;
change: number;
changeRate: number;
}
/**
* 시장 지수 API 응답 모델
*/
export interface DashboardIndicesResponse {
source: "kis";
tradingEnv: KisTradingEnv;
items: DashboardMarketIndexItem[];
fetchedAt: string;
}
/**
* 주문내역/매매일지 API 응답 모델
*/
export interface DashboardActivityResponse {
source: "kis";
tradingEnv: KisTradingEnv;
orders: DashboardOrderHistoryItem[];
tradeJournal: DashboardTradeJournalItem[];
journalSummary: DashboardTradeJournalSummary;
warnings: string[];
fetchedAt: string;
}

Some files were not shown because too many files have changed in this diff Show More