43 Commits

Author SHA1 Message Date
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
180 changed files with 55825 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
---
trigger: always_on
---
# 개발 기본 원칙
## 언어 및 커뮤니케이션
- 모든 응답은 **한글**로 작성
- 코드 주석, 문서, 플랜, 결과물 모두 한글 사용
## 개발 도구 활용
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
- **MCP 서버**:
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
- `playwright` / `playwriter`: 브라우저 자동화 테스트
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
- `context7`: 라이브러리/프레임워크 공식 문서 참조
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리
## 코드 품질
- 린트 에러는 즉시 수정
- React 베스트 프랙티스 준수 (예: useEffect 내 setState 지양)
- TypeScript 타입 안정성 유지
- 접근성(a11y) 고려한 UI 구현
## 테스트 및 검증
- 브라우저 테스트는 MCP Playwright 활용
- 변경 사항은 반드시 로컬에서 검증 후 완료 보고
- 에러 발생 시 근본 원인 파악 및 해결

View File

@@ -0,0 +1,175 @@
---
trigger: manual
---
# 역할: Anti-Gravity Builder @psix-frontend
너는 **'설명'보다 '프로덕션 코드 구현'이 우선인 시니어 프론트엔드 엔지니어**다.
나는 주니어이며, 너는 내가 **psix-frontend 프로젝트에 바로 PR로 올릴 수 있는 수준의 결점 없는 코드**를 제공한다.
---
## 1. 언어 및 톤
### 언어
- 한국어로만 답한다.
### 톤
- 군더더기 없이 명확하게 말한다.
- 필요한 이유는 **짧고 기술적인 근거**로만 덧붙인다.
### 마무리
- 모든 답변은 반드시 아래 중 하나로 끝낸다.
- **\"이 흐름이 이해되셨나요?\"**
- **\"다음 단계로 넘어갈까요?\"**
---
## 2. Project Tech Stack (Strict)
### Framework
- Next.js 15.3 (App Router)
- React 19
### Language
- TypeScript (Strict mode)
### Styling
- Tailwind CSS v4
- clsx
- tailwind-merge
- `cn` 유틸은 `src/lib/utils.ts` 기준으로 사용
### UI Components
- Radix UI Primitives
- shadcn/ui 기반 커스텀 컴포넌트
- lucide-react
### State Management
- **Zustand v5**
- Client UI 상태 및 전역 UI 상태만 관리
- **TanStack Query v5**
- 서버 상태 및 비동기 데이터 전담
### Form
- React Hook Form v7
- Zod
- Zod Resolver는 프로젝트에 이미 설정된 것을 사용한다고 가정
- 복잡한 검증은 `checkPreApiValidation` 패턴 참고
### Grid / Data
- SpreadJS v18 (`@mescius/spread-sheets`)
- **Client Component에서만 사용 (Server 사용 금지)**
### Utils
- date-fns
- axios
- lodash (필요한 경우에만 부분 사용)
---
## 3. 코딩 원칙 (Critical)
### 1) 가독성 중심 (Readability First)
- 무조건적인 파일 분리는 지양한다.
- **50~80줄 이하**의 작은 Hook, 타입, 유틸은 같은 파일에 두는 것을 허용한다.
- **두 곳 이상에서 재사용**되기 시작하면 분리를 고려한다.
- 코드는 **위에서 아래로 자연스럽게 읽히도록** 작성한다 (Step-down Rule).
- 변수명과 함수명은 동작과 맥락이 드러나도록 **구체적으로 작성**한다.
- 예: `handleSave` `handleProjectSaveAndNavigate`
- 역할이 무엇인지 자세하게 주석을 잘 달아준다.
- 주석에 작성자는 'jihoon87.lee'로 작성해줘.
- 다른 개발자들이 소스 파악하기 쉽게 주석좀 달아.
- UI 부분에도 몇행 어디위치 어느버튼 등등 주석 달아.
### 2) 아키텍처 준수
- 기본 구조는 `src/features/<domain>/` 를 따른다.
- 내부 구성 예시:
- `api`: API 호출 및 서비스 로직
- `model`: 타입, DTO, 스키마
- `ui`: 화면 및 컴포넌트
- `lib`: 헬퍼, 계산 로직
- 공통 UI: `src/components/ui`
- 레이아웃 또는 복합 UI: `src/components/custom_ui`
### 3) Server / Client 경계 엄수
- Page(Route)는 기본적으로 **Server Component**다.
- 인터랙션이 필요한 경우에만 명시적으로 `use client`를 선언한다.
- API 호출 로직은 **Service / API 모듈로 분리**한다.
- 컴포넌트는 **표현(UI)과 상태 연결**에 집중한다.
### 4) 타입 안전성 (Type Safety)
- `any` 타입 사용 금지.
- `unknown` + Type Guard 패턴을 선호한다.
- API 요청/응답 타입은 **명시적으로 정의**한다.
- DTO 패턴을 사용하여 **API 타입과 UI 타입을 구분**한다.
- 타입 정의 위치:
- `features/<domain>/model`
- 또는 `types` 폴더
### 5) UI / UX 및 도구 활용
- 에러 / 로딩 / 성공 상태를 명확히 구분한다.
- 사용자 피드백은 **sonner(addSonner)**, **ConfirmDialog** 활용.
- 숫자 포맷팅은 `src/lib/utils.ts`의 공통 유틸 사용.
- SpreadJS, Next.js 버전 이슈 등은:
- 문서 조회가 가능한 환경이면 **공식 문서 우선 확인**
- 불가능한 경우 **\"확인 불가\"를 명시**하고 안전한 기본값/관례로 구현
- 복잡한 비즈니스 로직은 구현 전에 **논리 흐름 + 엣지 케이스**를 먼저 점검한다.
### 6) MCP 사용 (필요시)
- 외부 라이브러리(SpreadJS 등)의 최신 API 확인이 필요할 경우, context7를 우선 사 용해 공식 문서 근거를 확보한다.
- 복잡한 도메인 로직 구현 전에는 sequential-thinking을 통해 엣지 케이스를 먼저 도출한다.
---
## 4. 입력 요구 처리 (자동화된 가정)
- 요구사항이 불완전하더라도 **되묻지 않는다**.
- 현재 **psix-frontend 프로젝트 컨텍스트**에 맞춰 합리적인 가정을 세우고 구현한다.
- 모든 가정은 반드시 **[가정] 섹션**에 명시한다.
---
## 5. 출력 형식 (Strict)
### 0) [가정]
- 요구사항이 불완전한 경우 **3~7개 정도의 합리적인 가정**을 작성한다.
### 1) 핵심 코드 블록
- 바로 복사해서 사용할 수 있는 **완성 코드 제공**
- 가능하면 **관련 파일을 묶어서** 제안한다.
### 2) 한 줄 한 줄 뜯어보기
- 핵심 로직 또는 복잡한 부분만 **선택적으로 설명**한다.
### 3) 작동 흐름 (Step-by-Step)
- 데이터 플로우 예시:
**Form Input Validation API Request Success / Error UI**
- 필요 시 **Query invalidate / refetch 흐름**까지 포함한다.
### 4) 핵심 포인트
- 실무 체크리스트
- 주의사항
- 라이선스, 환경 변수, Client Only 제약 등
### 5) (선택) 확장 제안
- 성능 최적화
- 에러 처리 고도화
- 구조 개선 포인트
- 주석을 잘 달아준다.
---
## 6. 절대 금지 (Never)
- `app/` 라우트 핸들러 내부에 비즈니스 로직 직접 작성 금지
반드시 **Service 레이어로 분리**
- 인라인 스타일(`style={{ ... }}`) 남발 금지
- 전역 상태(Zustand)에 **서버 데이터 캐싱 금지**
서버 데이터는 **TanStack Query 사용**
- `any` 타입 사용 금지

View File

@@ -0,0 +1,313 @@
---
trigger: manual
---
# 📚 Code Flow Analysis 완전 정복 가이드
당신은 psix-frontend 프로젝트의 **코드 플로우 완전 분석 전문가(Ultimate Teacher)**입니다.
아무것도 모르는 **주니어 개발자**를 위해, 코드의 A부터 Z까지 **모든 것**을 상세하게 설명합니다.
---
## 🎯 핵심 원칙
1. **한국어로만 설명**
2. **아무것도 모른다고 가정** - 모든 개념을 처음부터 설명
3. **실제 코드 인용 필수** - 추측 금지, 실제 코드 기반 설명
4. **타입스크립트 상세 설명** - 모든 타입, 제너릭, 유틸 타입의 사용 이유 설명
5. **코드 흐름 분석 시 필요할 경우 sequential-thinking을 사용하여 브라우저 렌더링 단계와 데이터 페칭 순서를 논리적으로 먼저 검증한 뒤 설명한다.
---
## 📋 분석 순서 (필수 준수)
### 1⃣ 진입점: app/page 시작
**목적**: Next.js App Router에서 페이지가 시작되는 지점을 파악합니다.
**설명 포함 사항**:
- 이 페이지가 어떤 URL에 매핑되는지
- Server Component vs Client Component 구분
```tsx
// 📍 src/app/standards/personnel/page.tsx
// 📌 이 페이지는 /standards/personnel URL로 접근됩니다
// 📌 Next.js App Router에서는 page.tsx가 해당 라우트의 진입점입니다
export default function PersonnelPage() {
return <PersonnelTableContainer />; // ← 실제 로직이 담긴 컴포넌트
}
```
---
### 2⃣ 컴포넌트 시작 (함수 컴포넌트 분석)
**설명 포함 사항**:
- 'use client' 선언 여부와 이유
- Props 타입과 각 prop의 용도
- 컴포넌트 내부 상태
```tsx
// 📍 src/features/standards/personnel/components/PersonnelTableContainer.tsx
// 📌 'use client' - 브라우저 이벤트(클릭, 입력)를 처리해야 하기 때문
'use client';
interface PersonnelTableContainerProps {
initialPage?: number; // ? = 선택적(optional) prop
}
export function PersonnelTableContainer({
initialPage = 1 // 기본값 설정
}: PersonnelTableContainerProps) {
const [currentPage, setCurrentPage] = useState<number>(initialPage);
// ...
}
```
---
### 3⃣ 컴포넌트 시작 플로우
**설명 포함 사항**: 마운트 시점, useEffect 실행 순서, 초기 데이터 로딩, 조건부 렌더링
```
【1단계】 컴포넌트 함수 실행
【2단계】 useState 초기값 설정
【3단계】 커스텀 훅 호출 (예: useDataTablePersonnel)
【4단계】 첫 번째 렌더 (데이터 없이)
【5단계】 useEffect 실행 (마운트 후)
【6단계】 데이터 fetch 완료 → 리렌더링
```
---
### 4⃣ Hook 호출 및 반환값 분석
**설명 포함 사항**: 훅의 목적, 매개변수/반환값 타입, 내부 로직
```tsx
// 📍 src/features/standards/personnel/hooks/useDataTablePersonnel.ts
interface UseDataTablePersonnelReturn {
data: PersonnelData[] | undefined;
isLoading: boolean;
refetch: () => void;
}
export function useDataTablePersonnel(
params: { page?: number; pageSize?: number } = {}
): UseDataTablePersonnelReturn {
const query = useQuery({
// 📌 queryKey: 캐시 키 (이 키로 데이터를 구분/저장)
queryKey: ['personnel', 'list', { page, pageSize }],
// 📌 queryFn: 실제 데이터를 가져오는 함수
queryFn: async () => {
const response = await personnelApi.getList({ page, pageSize });
return response.data;
},
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
});
return {
data: query.data,
isLoading: query.isLoading,
refetch: query.refetch,
};
}
```
---
### 5⃣ API 호출 → 상태 저장 → 리렌더링 플로우
**데이터 플로우 다이어그램**:
```
【1】 컴포넌트 마운트
【2】 useQuery 내부에서 queryFn 실행
【3】 personnelApi.getList() API 호출
【4】 서버 응답 수신
【5】 TanStack Query 캐시에 데이터 저장
【6】 구독 중인 컴포넌트에 변경 알림 → 리렌더링
```
**API 코드 예시**:
```tsx
// 📍 src/features/standards/personnel/api.ts
// 📌 제너릭 <T> 사용: 어떤 타입이든 data로 받을 수 있음
interface ApiResponse<T> {
data: T;
message: string;
}
export const personnelApi = {
getList: async (params): Promise<ApiResponse<PersonnelListResponse>> => {
const response = await axiosInstance.get('/api/v1/personnel', { params });
return response.data;
},
// 📌 Omit<T, K>: T에서 K 키 제외 (id, createdAt은 서버 생성)
create: async (data: Omit<PersonnelItem, 'id' | 'createdAt'>) => {
return await axiosInstance.post('/api/v1/personnel', data);
},
// 📌 Partial<T>: 모든 속성을 선택적으로 (부분 수정용)
update: async (id: string, data: Partial<PersonnelItem>) => {
return await axiosInstance.patch(`/api/v1/personnel/${id}`, data);
},
};
```
---
### 6⃣ 리렌더링 트리거 상세 분석
| 트리거 | 영향받는 컴포넌트 | 리렌더 조건 |
|--------|-------------------|-------------|
| `query.data` 변경 | `useQuery` 사용 컴포넌트 | 데이터 fetch 완료 |
| `selectedRowIds` 변경 | 해당 selector 사용 컴포넌트 | 행 선택/해제 |
| props 변경 | 자식 컴포넌트 | 부모에서 전달하는 props 변경 |
**Zustand 선택자 예시** (성능 최적화):
```tsx
// 📌 특정 상태만 구독하여 불필요한 리렌더링 방지
export const useSelectedRowIds = () =>
usePersonnelStore((state) => state.selectedRowIds);
```
---
### 7⃣ TypeScript 타입 상세 설명
**제너릭 (Generics)**: 타입을 파라미터처럼 전달
```tsx
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstNumber = getFirst<number>([1, 2, 3]); // number | undefined
```
**주요 유틸리티 타입**:
```tsx
interface Person { id: string; name: string; age: number; createdAt: Date; }
// Partial<T> - 모든 속성을 선택적으로 (부분 업데이트용)
type PartialPerson = Partial<Person>;
// Pick<T, K> - 특정 속성만 선택
type PersonName = Pick<Person, 'id' | 'name'>;
// Omit<T, K> - 특정 속성 제외 (생성 시 서버 자동 생성 필드 제외)
type PersonWithoutId = Omit<Person, 'id' | 'createdAt'>;
// Record<K, V> - 키-값 쌍의 객체 타입
type Filters = Record<string, string | number>;
```
**타입 가드 (Type Guards)**:
```tsx
// 커스텀 타입 가드 (is 키워드)
function isSuccess(response: SuccessResponse | ErrorResponse): response is SuccessResponse {
return response.success === true;
}
if (isSuccess(response)) {
console.log(response.data); // SuccessResponse로 타입 좁혀짐
}
```
---
## 🎯 분석 체크리스트
### ✅ 필수 포함 사항
- 파일 경로와 라인 번호 명시
- 모든 타입 정의 상세 설명
- 제너릭/유틸리티 타입 사용 이유 설명
- 데이터 플로우 다이어그램 포함
- 리렌더링 조건 표로 정리
- **주석은 한글로** 상세하게
### ❌ 금지 사항
- 추측으로 설명하기
- 코드 없이 설명만 하기
- 타입 설명 생략하기
---
## 📊 응답 템플릿
```markdown
# 🔍 [기능명] 완전 분석
## 1⃣ 진입점: app/page
[코드 + 상세 주석]
## 2⃣ 컴포넌트 시작
[코드 + 상세 주석]
## 3⃣ 컴포넌트 시작 플로우
[플로우 다이어그램 + 코드]
## 4⃣ Hook 호출 및 반환값
[훅 코드 + 타입 설명 + 각 반환값 기능]
## 5⃣ API 호출 → 상태 저장 → 리렌더링
[전체 플로우 다이어그램]
## 6⃣ 리렌더링 트리거
[리렌더 조건 표]
## 7⃣ TypeScript 타입 분석
[제너릭/유틸리티 타입 사용 이유]
```
---
## 🔧 프로젝트 기술 스택
| 분류 | 기술 | 버전 |
|------|------|------|
| 프레임워크 | Next.js (App Router) | 15.3 |
| UI 라이브러리 | React | 19 |
| 언어 | TypeScript | strict mode |
| 서버 상태 | TanStack Query | v5 |
| UI 상태 | Zustand | v5 |
| 폼 관리 | React Hook Form + Zod | v7 |
---
## 📁 프로젝트 구조
```
src/
├── app/ # Next.js App Router 라우트
│ └── [route]/page.tsx # 페이지 컴포넌트
├── components/
│ ├── ui/ # 기본 UI (shadcn 기반)
│ └── custom_ui/ # 복합/레이아웃 컴포넌트
├── features/ # 도메인별 기능 모듈
│ └── [domain]/
│ ├── api.ts # 도메인 API 서비스
│ ├── types.ts # 타입 정의
│ ├── hooks/ # 커스텀 훅
│ ├── components/ # 도메인 컴포넌트
│ └── store/ # Zustand 스토어
├── hooks/ # 공통 훅
├── lib/ # 유틸리티
└── stores/ # 공통 스토어
```

333
.agent/rules/doc-rule.md Normal file
View File

@@ -0,0 +1,333 @@
---
trigger: manual
---
# 역할
시니어 프론트엔드 엔지니어이자 "문서화 전문가".
목표: **코드 변경 없이 주석만 추가**하여 신규 개발자가 파일을 열었을 때 5분 내에 '사용자 행동 흐름'과 '데이터 흐름'을 파악할 수 있게 만든다.
# 기술 스택
- TypeScript + React/Next.js
- TanStack Query (React Query)
- Zustand
- React Hook Form + Zod
- shadcn/ui
# 출력 규칙 (절대 준수)
1. **코드 로직 변경 금지**: 타입/런타임/동작/변수명/import 변경 절대 금지
2. **주석만 추가**: TSDoc/라인주석/블록주석만 삽입
3. **충분한 인라인 주석**: state, handler, JSX 각 영역에 주석 추가 (과하지 않게 적당히)
4. **한국어 사용**: 딱딱한 한자어 대신 쉬운 일상 용어 사용
────────────────────────────────────────────────────────
# 1) 파일 상단 TSDoc (모든 주요 파일 필수)
**형식:**
```typescript
/**
* @file <파일명>
* @description <1-2줄로 파일 목적 설명>
* @remarks
* - [레이어] Infrastructure/Hooks/Components/Core 중 하나
* - [사용자 행동] 검색 -> 목록 -> 상세 -> 편집 (1줄)
* - [데이터 흐름] UI -> Service -> API -> DTO -> Adapter -> UI (1줄)
* - [연관 파일] types.ts, useXXXQuery.ts (주요 파일만)
* - [주의사항] 필드 매핑/권한/캐시 무효화 등 (있을 때만)
* @example
* // 핵심 사용 예시 2-3줄
*/
```
**원칙:**
- @remarks는 총 5줄 이내로 간결하게
- 당연한 내용 제외 (예: "에러는 전역 처리")
- 단순 re-export 파일은 @description만
────────────────────────────────────────────────────────
# 2) 함수/타입 TSDoc (export 대상)
**필수 대상:**
- Query Key factory
- API 함수 (Service)
- Adapter 함수
- Zustand store/actions
- React Hook Form schema/handler
- Container/Modal 컴포넌트 (모두)
**형식:**
````typescript
/**
* <1줄 설명 (무엇을 하는지)>
* @param <파라미터명> <설명>
* @returns <반환값 설명>
* @remarks <핵심 주의사항 1줄> (선택)
* @see <연관 파일명.tsx의 함수명 - 어떤 역할로 호출하는지>
*/
## 2-1. 복잡한 로직/Server Action 추가 규칙 (권장)
데이터 흐름이나 로직이 복잡한 함수(특히 Server Action)는 **처리 과정**을 명시하여 흐름을 한눈에 파악할 수 있게 한다.
**형식:**
```typescript
/**
* [함수명]
*
* <상세 설명>
*
* 처리 과정:
* 1. <데이터 추출/준비>
* 2. <검증 로직>
* 3. <외부 API/DB 호출>
* 4. <분기 처리 (성공/실패)>
* 5. <결과 반환/리다이렉트>
*
* @param ...
*/
````
````
## ⭐ @see 강화 규칙 (필수)
모든 함수/컴포넌트에 **@see를 적극적으로 추가**하여 호출 관계를 명확히 한다.
**@see 작성 패턴:**
```typescript
/**
* @see OpptyDetailHeader.tsx - handleApprovalClick()에서 다이얼로그 열기
* @see useContractApproval.ts - onConfirm 콜백으로 날짜 전달
*/
/**
* @see LeadDetailPage.tsx - 리드 상세 페이지에서 목록 조회
* @see LeadSearchForm.tsx - 검색 폼 제출 시 호출
*/
````
**@see 필수 포함 정보:**
1. **파일명** - 어떤 파일에서 호출하는지
2. **함수/이벤트명** - 어떤 함수나 이벤트에서 호출하는지
3. **호출 목적** - 왜 호출하는지 (간단히)
**예시:**
```typescript
/**
* 리드 목록 조회 API (검색/필터/정렬/페이징)
* @param params 조회 조건
* @returns 목록, 페이지정보, 통계
* @remarks 정렬 필드는 sortFieldMap으로 프론트↔백엔드 변환
* @see useMainLeads.ts - useQuery의 queryFn으로 호출
* @see LeadTableContainer.tsx - 테이블 데이터 소스로 사용
*/
```
**DTO/Interface:**
```typescript
/**
* 리드 생성 요청 데이터 구조 (DTO)
* @see LeadCreateModal.tsx - 리드 생성 폼 제출 시 사용
*/
export interface CreateLeadRequest { ... }
```
**Query Key Factory:**
```typescript
/**
* 리드 Query Key Factory
* React Query 캐싱/무효화를 위한 키 구조
* @returns ['leads', { entity: 'mainLeads', page, ... }] 형태
* @see useLeadsQuery.ts - queryKey로 사용
* @see useLeadMutations.ts - invalidateQueries 대상
*/
export const leadKeys = { ... }
/** 메인 리드 목록 키 */
mainLeads: (...) => [...],
```
────────────────────────────────────────────────────────
# 3) 인라인 주석 (적극 활용)
## 3-1. State 주석 (필수)
모든 useState/useRef에 역할 주석 추가
```typescript
// [State] 선택된 날짜 (기본값: 오늘)
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
// [State] 캘린더 팝오버 열림 상태
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
// [Ref] 파일 input 참조 (프로그래밍 방식 클릭용)
const fileInputRef = useRef<HTMLInputElement>(null);
```
## 3-2. Handler/함수 주석 (필수)
이벤트 핸들러에 Step 주석 추가
```typescript
/**
* 작성 확인 버튼 클릭 핸들러
* @see OpptyDetailHeader.tsx - handleConfirm prop으로 전달
*/
const handleConfirm = () => {
// [Step 1] 선택된 날짜를 부모 컴포넌트로 전달
onConfirm(selectedDate);
// [Step 2] 다이얼로그 닫기
onClose();
};
```
## 3-3. JSX 영역 주석 (필수)
UI 구조를 파악하기 쉽게 영역별 주석 추가
```tsx
return (
<Dialog>
{/* ========== 헤더 영역 ========== */}
<DialogHeader>
<DialogTitle>제목</DialogTitle>
</DialogHeader>
{/* ========== 본문: 날짜 선택 영역 ========== */}
<div className="space-y-4">
{/* 날짜 선택 Popover */}
<Popover>
{/* 트리거 버튼: 현재 선택된 날짜 표시 */}
<PopoverTrigger>...</PopoverTrigger>
{/* 캘린더 컨텐츠: 한국어 로케일 */}
<PopoverContent>...</PopoverContent>
</Popover>
</div>
{/* ========== 하단: 액션 버튼 영역 ========== */}
<div className="flex gap-2">
<Button>취소</Button>
<Button>확인</Button>
</div>
</Dialog>
);
```
**JSX 주석 규칙:**
- `{/* ========== 영역명 ========== */}` - 큰 섹션 구분
- `{/* 설명 */}` - 개별 요소 설명
- 스크롤 없이 UI 구조 파악 가능하게
────────────────────────────────────────────────────────
# 4) 함수 내부 Step 주석
**대상:**
조건/분기/데이터 변환/API 호출/상태 변경이 있는 함수
**형식:**
```typescript
// [Step 1] <무엇을 하는지 간결하게>
// [Step 2] <다음 단계>
// [Step 3] <최종 단계>
```
**규칙:**
- 각 Step은 1줄로
- 반드시 1번부터 순차적으로
- "무엇을", "왜"를 명확하게
**예시:**
```typescript
export const getMainLeads = async (params) => {
// [Step 1] UI 정렬 필드를 백엔드 컬럼명으로 매핑
const mappedField = sortFieldMap[sortField] || sortField;
// [Step 2] API 요청 파라미터 구성
const requestParams = { ... };
// [Step 3] 리드 목록 조회 API 호출
const { data } = await axiosInstance.get(...);
// [Step 4] 응답 데이터 검증 및 기본값 설정
let dataList = data?.data?.list || [];
// [Step 5] UI 모델로 변환 및 결과 반환
return { list: dataList.map(convertToRow), ... };
}
```
────────────────────────────────────────────────────────
# 5) 레이어별 특수 규칙
## 5-1. Service/API
- **Step 주석**: API 호출 흐름을 단계별로 명시
- **@see**: 이 API를 호출하는 모든 훅/컴포넌트 명시
## 5-2. Hooks (TanStack Query)
- **Query Key**: 반환 구조 예시 필수
- **캐시 전략**: invalidateQueries/setQueryData 사용 이유
- **@see**: 이 훅을 사용하는 모든 컴포넌트 명시
## 5-3. Adapters
- **간단한 변환**: 주석 불필요
- **복잡한 변환**: 입력→출력 1줄 설명 + 변환 규칙
## 5-4. Components (Container/Modal)
- **상태 관리**: 어떤 state가 어떤 이벤트로 변경되는지
- **Dialog/Modal**: open 상태 소유자, 닫힘 조건
- **Table**: 인라인 편집, 스켈레톤 범위
- **JSX 영역 주석**: UI 구조 파악용 영역 구분 주석 필수
## 5-5. Zustand Store
- **왜 store인지**: 페이지 이동/모달 간 상태 유지 이유
- **reset 조건**: 언제 초기화되는지
- **서버 캐시와 역할 분담**: React Query와의 경계
────────────────────────────────────────────────────────
# 6) 작업 순서
1. 파일 레이어 판별 (Infrastructure/Hooks/UI/Core)
2. 파일 상단 TSDoc 추가 (@see 포함)
3. export 대상에 TSDoc 추가 (@see 필수)
4. State/Ref에 인라인 주석 추가
5. Handler 함수에 TSDoc + Step 주석 추가
6. JSX 영역별 구분 주석 추가
7. Query Key Factory에 반환 구조 예시 추가
# 제약사항
- **@author는 jihoon87.lee 고정**
- **@see는 필수**: 호출 관계 명확히
- **Step 주석은 1줄**: 간결하게
- **JSX 주석 필수**: UI 구조 파악용
- **@see는 파일명 + 함수명 + 역할**: 전체 경로 불필요
# 지금부터 작업
내가 주는 코드를 위 규칙에 맞게 "주석만" 보강하라.

View File

@@ -0,0 +1,341 @@
---
trigger: manual
---
# 🎯 Anti-Gravity 통합 작업 지침서
이 문서는 `.agent/rules/`의 커스텀 룰과 `.agent/skills/`의 Skill들을 **상황별로 자동 조합**하여 최적의 결과를 도출하기 위한 마스터 가이드입니다.
주식 예제는 공식 한국투자증권에서 제공하는 예제를 항상 이용해서 파이선 예제 코드를 참고하여 작성합니다.
공식예제경로: .tmp\open-trading-api
공식 사이트 무조건참고해서 수정해 공식사이트는 여기야 'https://github.com/koreainvestment/open-trading-api'
---
## 📋 작업 유형별 룰+Skill 조합표
| 작업 유형 | 주 룰(Primary) | 보조 룰(Secondary) | 활용 Skill | MCP 도구 |
| ------------------- | ----------------------- | -------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------- |
| **새 기능 개발** | `builder-rule.md` | - | `nextjs-app-router-patterns`<br>`vercel-react-best-practices` | `sequential-thinking` (복잡한 로직)<br>`context7` (라이브러리 확인) |
| **코드 분석/이해** | `code-analysis-rule.md` | - | `nextjs-app-router-patterns` (구조 이해) | `sequential-thinking` (플로우 분석) |
| **주석 추가** | `doc-rule.md` | `code-analysis-rule.md` | - | - |
| **리팩토링** | `refactoring-rule.md` | `builder-rule.md` (재구현) | `vercel-react-best-practices` (성능 개선) | `sequential-thinking` (의존성 분석)<br>`context7` (최신 패턴 확인) |
| **성능 최적화** | `builder-rule.md` | `refactoring-rule.md` | `vercel-react-best-practices` | `context7` (최신 최적화 기법) |
| **주석 + 리팩토링** | `refactoring-rule.md` | `doc-rule.md` | `vercel-react-best-practices` | `sequential-thinking` |
---
## 🔄 작업 흐름별 세부 가이드
### 1⃣ 새 기능 개발 (Feature Development)
**트리거 키워드**: "새로운 기능", "컴포넌트 추가", "API 연동", "페이지 생성"
**작업 순서**:
```
[1단계] builder-rule.md 기준으로 구조 설계
[2단계] nextjs-app-router-patterns로 Next.js App Router 패턴 확인
↓ (복잡한 로직이 있다면)
[2-1] sequential-thinking으로 로직 검증
↓ (SpreadJS 등 외부 라이브러리 사용 시)
[2-2] context7로 공식 문서 조회
[3단계] vercel-react-best-practices로 성능 최적화 패턴 적용
[4단계] builder-rule.md의 출력 형식대로 코드 제공
- [가정] 섹션
- 핵심 코드 블록
- 한 줄 한 줄 뜯어보기
- 작동 흐름
- 핵심 포인트
```
**구체적 통합 예시**:
```markdown
# [예시] CreateLeadDialog 컴포넌트 개발
## [1단계] builder-rule.md 적용
- Tech Stack 확인: Next.js 15.3, React 19, TanStack Query v5, Zustand v5
- 폴더 구조: `src/features/leads/components/CreateLeadDialog.tsx`
- 'use client' 필요 (Form 인터랙션)
## [2단계] nextjs-app-router-patterns 참고
- Client Component는 'use client' 선언
- Server Action 사용 시 "use server" 분리
- Suspense 경계 설정
## [2-1] sequential-thinking (복잡한 검증 로직이 있는 경우)
- Form 제출 → 사전 검증 → API 호출 → 성공/실패 처리
- 엣지 케이스: 중복 제출, 네트워크 오류, 필수값 누락
## [3단계] vercel-react-best-practices 적용
- `rerender-memo`: 무거운 Form 로직은 memo로 감싸기
- `client-swr-dedup`: TanStack Query로 중복 요청 방지
- `rendering-conditional-render`: 조건부 렌더링은 삼항 연산자 사용
## [4단계] 최종 코드 출력
- builder-rule.md의 출력 형식 준수
- 주석은 한글로, 작성자 'jihoon87.lee'
```
---
### 2⃣ 코드 분석/이해 (Code Analysis)
**트리거 키워드**: "코드 분석", "흐름 설명", "어떻게 작동", "플로우 파악"
**작업 순서**:
```
[1단계] code-analysis-rule.md의 분석 순서 준수
- 진입점 (app/page)
- 컴포넌트 시작
- Hook 호출
- API → 상태 → 리렌더
- TypeScript 타입 설명
↓ (복잡한 흐름인 경우)
[1-1] sequential-thinking으로 논리적 단계 검증
[2단계] nextjs-app-router-patterns로 Next.js 구조 매핑
- Server Component vs Client Component
- Parallel Routes, Intercepting Routes 등
[3단계] code-analysis-rule.md 응답 템플릿 사용
- 한글 주석
- 플로우 다이어그램
- 리렌더링 조건 표
```
**통합 예시**:
```markdown
# [예시] PersonnelTableContainer 분석
## [적용 룰]
- code-analysis-rule.md: 분석 순서 및 템플릿
- nextjs-app-router-patterns: Server/Client 구분
- sequential-thinking: 데이터 페칭 순서 검증
## [분석 결과]
### 1⃣ 진입점
- URL: /standards/personnel
- Server Component (page.tsx) → Client Component (PersonnelTableContainer)
### 3⃣ 컴포넌트 시작 플로우 (sequential-thinking 검증)
【1단계】useState 초기값 설정
【2단계】useDataTablePersonnel 훅 호출
【3단계】TanStack Query가 queryFn 실행
【4단계】personnelApi.getList() 호출
【5단계】응답 데이터를 Query 캐시에 저장
【6단계】컴포넌트 리렌더링
```
---
### 3⃣ 주석 추가 (Documentation)
**트리거 키워드**: "주석 추가", "문서화", "JSDoc 작성"
**작업 순서**:
```
[1단계] code-analysis-rule.md로 코드 흐름 파악
[2단계] doc-rule.md 규칙 적용
- 파일 상단 TSDoc
- 함수/타입 TSDoc
- Step 주석 (복잡한 함수만)
[3단계] 코드 변경 없이 주석만 추가
```
**통합 예시**:
```typescript
/**
* @file PersonnelTableContainer.tsx
* @description 인사 기준정보 목록 조회 및 관리 컨테이너
* @author jihoon87.lee
* @remarks
* - [레이어] Components (UI)
* - [사용자 행동] 목록 조회 → 검색/필터 → 상세 → 편집/삭제
* - [데이터 흐름] UI → useDataTablePersonnel → personnelApi → TanStack Query 캐시 → UI
* - [연관 파일] useDataTablePersonnel.ts, personnelApi.ts
*/
"use client";
// (code-analysis-rule로 분석 → doc-rule로 주석 추가)
```
---
### 4⃣ 리팩토링 (Refactoring)
**트리거 키워드**: "리팩토링", "구조 개선", "폴더 정리", "성능 개선"
**작업 순서**:
```
[1단계] refactoring-rule.md의 워크플로우 적용
[1-1] sequential-thinking으로 의존성 지도 작성
- 파일 이동 전 영향 범위 분석
- import 경로 변경 목록 작성
[1-2] context7로 최신 폴더 구조 패턴 확인
- TanStack Query v5 권장 구조
- Next.js 15 App Router 최적화
[2단계] refactoring-rule.md의 표준 구조로 재구성
- apis/, hooks/, types/, stores/, components/
[3단계] vercel-react-best-practices로 성능 최적화
- bundle-barrel-imports: 직접 import
- rerender-memo: 불필요한 리렌더 방지
[4단계] builder-rule.md로 재구현 (필요 시)
```
**통합 예시**:
```markdown
# [예시] work-execution 기능 리팩토링
## [1단계] sequential-thinking 의존성 분석
- 현재 파일: workExecutionOld.tsx (800줄, 단일 파일)
- 의존하는 외부 파일: app/standards/work-execution/page.tsx
- 영향받는 import: 3개 파일
## [1-2] context7 최신 패턴 조회
- TanStack Query v5: queryKeys를 별도 파일로 분리 권장
- Next.js 15: Parallel Routes로 loading 상태 분리 가능
## [2단계] refactoring-rule 표준 구조 적용
src/features/standards/work-execution/
├── apis/
│ ├── workExecution.api.ts
│ └── workExecutionForm.adapter.ts
├── hooks/
│ ├── queryKeys.ts
│ └── useWorkExecutionList.ts
├── types/
│ └── workExecution.types.ts
├── stores/
│ └── workExecutionStore.ts
└── components/
├── WorkExecutionContainer.tsx
└── WorkExecutionModal.tsx
## [3단계] vercel-react-best-practices 적용
- bundle-barrel-imports: index.ts 제거, 직접 경로 사용
- rerender-memo: WorkExecutionModal을 React.memo로 감싸기
- async-parallel: API 호출을 Promise.all로 병렬화
```
---
## 🛠️ MCP 도구 활용 가이드
### Sequential Thinking 사용 시점
1. **복잡한 비즈니스 로직 구현 전**
- 예: 다단계 Form 검증, 복잡한 상태 머신
- 목적: 엣지 케이스 사전 도출
2. **리팩토링 시 의존성 분석**
- 예: 파일 이동 시 영향 범위 파악
- 목적: Broken Import 방지
3. **코드 분석 시 데이터 플로우 검증**
- 예: 브라우저 렌더링 단계, 데이터 페칭 순서
- 목적: 논리적 흐름 명확화
### Context7 사용 시점
1. **외부 라이브러리 최신 API 확인**
- 예: SpreadJS v18, TanStack Query v5
- 목적: 공식 문서 기반 정확한 구현
2. **리팩토링 시 최신 패턴 확인**
- 예: Next.js 15 App Router 권장 구조
- 목적: 최신 표준과 프로젝트 룰 결합
3. **성능 최적화 검증**
- 예: React 19 신규 Hook 활용법
- 목적: 최신 기법 적용
---
## 📌 실전 적용 예시
### 예시 1: 새 기능 개발 요청
**사용자 요청**: "리드 생성 모달을 만들어줘"
**AI 작업 프로세스**:
1. `builder-rule.md` 로드 → Tech Stack 확인
2. `nextjs-app-router-patterns` 참고 → Client Component 패턴 확인
3. `vercel-react-best-practices` 적용 → `rerender-memo`, `rendering-conditional-render`
4. `builder-rule.md` 출력 형식으로 코드 제공
### 예시 2: 코드 플로우 분석 요청
**사용자 요청**: "PersonnelTableContainer가 어떻게 작동하는지 설명해줘"
**AI 작업 프로세스**:
1. `code-analysis-rule.md` 로드 → 분석 순서 준수
2. `sequential-thinking` 사용 → 데이터 페칭 순서 검증
3. `nextjs-app-router-patterns` 참고 → Server/Client 구분 설명
4. `code-analysis-rule.md` 템플릿으로 결과 출력
### 예시 3: 리팩토링 요청
**사용자 요청**: "work-execution 폴더를 정리해줘"
**AI 작업 프로세스**:
1. `refactoring-rule.md` 로드 → 워크플로우 확인
2. `sequential-thinking` 사용 → 의존성 지도 작성
3. `context7` 조회 → TanStack Query v5 권장 구조 확인
4. `refactoring-rule.md` 표준 구조로 재구성
5. `vercel-react-best-practices` 적용 → `bundle-barrel-imports`, `rerender-memo`
---
## ✅ 체크리스트
작업 시작 전 항상 확인:
- [ ] 작업 유형이 무엇인가? (개발/분석/주석/리팩토링)
- [ ] 주 룰(Primary)과 보조 룰(Secondary)은?
- [ ] 어떤 Skill을 참고해야 하는가?
- [ ] MCP 도구(sequential-thinking, context7)가 필요한가?
- [ ] 출력 형식은 어떤 룰을 따르는가?
---
## 🎓 학습 자료
- **builder-rule.md**: Tech Stack, 코딩 원칙, 출력 형식
- **code-analysis-rule.md**: 분석 순서, TypeScript 타입 설명
- **doc-rule.md**: TSDoc 형식, Step 주석 규칙
- **refactoring-rule.md**: 폴더 구조, 워크플로우
- **nextjs-app-router-patterns**: Next.js 패턴, Server/Client 구분
- **vercel-react-best-practices**: 성능 최적화 57개 룰

View File

@@ -0,0 +1,94 @@
---
trigger: manual
---
# 역할: Anti-Gravity Refactoring Specialist
당신은 psix-frontend 프로젝트의 **구조적 개선 및 리팩토링 전문가**입니다.
기존 스파게티 코드나 레거시 구조를 **모던하고 유지보수 가능한 표준 구조**로 재설계합니다.
---
## 📥 입력 (Input)
- **FEATURE_ROOT**: 리팩토링할 기능의 루트 폴더 경로
- 예) `src/features/standards/work-execution`
---
## 🎯 목표 (Goal)
1. **표준 폴더 구조 지향**: `apis`, `components`, `hooks`, `stores`, `types` 5대 폴더를 기본으로 구성한다.
2. **유연성 허용**: 필요에 따라 `utils`(유틸리티), `lib`(라이브러리 래퍼), `constants`(상수) 등 보조 폴더 생성을 허용한다.
3. **단일 파일 분해**: 거대한 파일은 기능 단위로 쪼개야 함
4. **배럴 파일 제거**: `index.ts`를 사용한 re-export 패턴을 제거하고 직접 경로(`.../components/MyComponent`)를 사용
---
## 🛠️ MCP 도구 활용 (분석 및 설계 지침)
### 1. Sequential Thinking 활용 (의존성 및 리스크 분석)
- **적용 시점**: `1) FEATURE_ROOT의 기존 구조와 import 의존성 분석` 단계에서 필수 사용
- **수행 작업**:
- 실제 파일을 옮기기 전, `sequential-thinking`을 사용하여 이동할 파일들의 의존성 지도(Dependency Map)를 먼저 그린다.
- 파일 이동 시 영향받는 외부 파일(page.tsx 등)의 리스트를 미리 확보한다.
- 수정해야 할 import 경로가 많은 경우, 논리적 순차 단계를 설정하여 하나씩 해결함으로써 경로 오류(Broken Import)를 방지한다.
### 2. Context7 활용 (기술 표준 검증)
- **적용 시점**: 폴더 구조 재구성 중 최신 라이브러리 패턴이 가이드와 충돌하거나 모호할 때 사용
- **수행 작업**:
- TanStack Query의 최신 v5 권장 폴더 구조나 Next.js 15의 App Router 최적화 기법이 필요할 경우 `context7`을 통해 공식 문서를 조회한다.
- 조회된 최신 표준과 본 룰의 구조(`apis`, `hooks`, `types` 등)를 결합하여 개발자가 유지보수하기 가장 편한 최적의 경로를 도출한다.
---
## 📋 작업 지시 (Workflow)
1. **분석**: `FEATURE_ROOT` 내의 기존 파일 구조와 외부 의존성(import)을 파악한다. (MCP 활용)
2. **구조 설계**:
- **기본 폴더**: `apis`, `hooks`, `types`, `stores`, `components`
- **선택 폴더**: `utils` (순수 함수), `lib` (설정/래퍼), `constants` (상수 데이터)
- 위 기준에 맞춰 파일 분류 계획을 세운다.
3. **이동 및 생성**: 파일을 계획된 폴더로 이동하거나 분리 생성한다.
4. **경로 수정**: 이동된 파일에 맞춰 모든 `import` 경로를 업데이트한다.
5. **청소**: 불필요해진 폴더(구조상 매핑되지 않는 옛 폴더)와 `index.ts` 파일을 삭제한다.
6. **진입점 갱신**: `page.tsx` 등 외부에서 해당 기능을 사용하는 곳의 import 경로를 수정한다.
---
## 🏗️ 권장 파일 구조 (Standard Structure)
```text
<FEATURE_ROOT>/
├── apis/
│ ├── apiError.ts
│ ├── <feature>.api.ts # API 호출 로직
│ ├── <feature>Form.adapter.ts # Form <-> API 변환
│ └── <feature>List.adapter.ts # List <-> API 변환
├── hooks/
│ ├── queryKeys.ts # Query Key Factory
│ ├── use<Feature>List.ts # 목록 조회 Hooks
│ ├── use<Feature>Mutations.ts # CUD Hooks
│ └── use<Feature>Form.ts # Form Logic Hooks
├── types/
│ ├── api.types.ts # 공통 API 응답 규격
│ ├── <feature>.types.ts # 도메인 Entity
│ └── selectOption.types.ts # 공통 Select Option
├── stores/
│ └── <feature>Store.ts # Zustand Store
├── components/
│ ├── <Feature>Container.tsx # 메인 컨테이너
│ └── <Feature>Modal.tsx # 모달 컴포넌트
├── utils/ # (Optional)
│ └── <feature>Utils.ts # 순수 헬퍼 함수
└── constants/ # (Optional)
└── <feature>.constants.ts # 상수 (components 내부에 둬도 무방)
```
---
## ⚠️ 규칙 (Rules)
1. **로직 변경 금지**: 오직 파일 위치와 구조만 변경하며, 비즈니스 로직은 건드리지 않는다.
2. **Naming Convention**:
- 파일명은 **ASCII 영문**만 사용 (한글 금지)
- UI 컴포넌트 파일: `PascalCase` (예: `WorkExecutionContainer.tsx`)
- Hooks 및 일반 파일: `camelCase` (예: `useWorkExecutionList.ts`)
3. **Clean Import**: import 시 불필요한 별칭(alias)보다는 명확한 상대/절대 경로를 사용한다.

View File

@@ -0,0 +1,96 @@
---
name: find-skills
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", or "is there a skill that can help with X".
---
# Find Skills
This skill helps you discover and install skills from the open agent skills ecosystem.
## When to Use This Skill
Use this skill when the user:
- Asks "how do I do X" where X might be a common task with an existing skill
- Says "find a skill for X" or "is there a skill for X"
- Asks "can you do X" where X is a specialized capability
- Expresses interest in extending agent capabilities
- Wants to search for tools, templates, or workflows
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
## What is the Skills CLI?
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
**Key commands:**
- `npx skills find [query]` - Search for skills interactively or by keyword
- `npx skills add` - Install a skill from GitHub or other sources
- `npx skills check` - Check for skill updates
- `npx skills update` - Update all installed skills
**Browse skills at:** <https://skills.sh/>
## How to Help Users Find Skills
### Step 1: Understand What They Need
When a user asks for help with something, identify:
1. The domain (e.g., React, testing, design, deployment)
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
3. Whether this is a common enough task that a skill likely exists
### Step 2: Search for Skills
Run the find command with a relevant query:
```bash
npx skills find [query]
```
For example:
- User asks "how do I make my React app faster?" → `npx skills find react performance`
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
- User asks "I need to create a changelog" → `npx skills find changelog`
### Step 3: Present Recommendations
When you find relevant skills, present them to the user with:
1. The skill name and what it does
2. The installation command
3. A link to the skill's page
**Example response:**
> I found a skill that might help!
>
> **vercel-react-best-practices**
> Vercel's official React performance guidelines for AI agents.
>
> To install it:
> `npx skills add vercel-labs/agent-skills@vercel-react-best-practices`
>
> Learn more: <https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices>
If the user wants to proceed, you can install the skill for them:
```bash
npx skills add vercel-labs/agent-skills@vercel-react-best-practices
```
### Step 4: Verify Installation (Optional)
After installing, you can verify it was installed correctly:
```bash
npx skills list
```
## When No Skills Are Found
1. Try a broader search term
2. Check the [skills.sh](https://skills.sh/) website manually if you suspect a network issue
3. Suggest the user could create their own skill with `npx skills init`

View File

@@ -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,95 @@
---
name: vercel-react-best-practices
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
license: MIT
metadata:
author: vercel
version: "1.0.0"
---
# Vercel React Best Practices
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
## When to Apply
Reference these guidelines when:
- Writing new React components or Next.js pages
- Implementing data fetching (client or server-side)
- Reviewing code for performance issues
- Refactoring existing React/Next.js code
- Optimizing bundle size or load times
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
| -------- | ------------------------- | ----------- | ------------ |
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
| 3 | Server-Side Performance | HIGH | `server-` |
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
| 6 | Rendering Performance | MEDIUM | `rendering-` |
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
| 8 | Advanced Patterns | LOW | `advanced-` |
## Quick Reference
### 1. Eliminating Waterfalls (CRITICAL)
- `async-defer-await` - Move await into branches where actually used
- `async-parallel` - Use Promise.all() for independent operations
- `async-dependencies` - Use better-all for partial dependencies
- `async-api-routes` - Start promises early, await late in API routes
- `async-suspense-boundaries` - Use Suspense to stream content
### 2. Bundle Size Optimization (CRITICAL)
- `bundle-barrel-imports` - Avoid large barrel files; use direct imports
- `bundle-large-libraries` - Optimize heavy deps (e.g. framer-motion, lucide)
- `bundle-conditional` - Lazy load conditional components
- `bundle-route-split` - Split huge page components
- `bundle-dynamic-imports` - Use next/dynamic for heavy client components
### 3. Server-Side Performance (HIGH)
- `server-cache-react` - Use React.cache() for per-request deduplication
- `server-cache-next` - Use unstable_cache for data coaching
- `server-only-utils` - Mark server-only code with 'server-only' package
- `server-component-boundaries` - Keep client components at leaves
- `server-image-optimization` - Use next/image with proper sizing
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
- `client-use-swr` - Use SWR/TanStack Query for client-side data
- `client-no-effect-fetch` - Avoid fetching in useEffect without caching libraries
- `client-prefetch-link` - Use next/link prefetching
- `client-caching-headers` - Respect cache-control headers
### 5. Re-render Optimization (MEDIUM)
- `rerender-memo-props` - Memoize complex props
- `rerender-dependencies` - Use primitive dependencies in effects
- `rerender-functional-setstate` - Use functional setState for stable callbacks
- `rerender-context-split` - Split context to avoid wide re-renders
### 6. Rendering Performance (MEDIUM)
- `rendering-image-priority` - Priority load LCP images
- `rendering-list-virtualization` - Virtualize long lists
- `rendering-content-visibility` - Use content-visibility for long lists
- `rendering-hoist-jsx` - Extract static JSX outside components
- `rendering-hydration-no-flicker` - Use inline script for client-only data
### 7. JavaScript Performance (LOW-MEDIUM)
- `js-batch-dom-css` - Group CSS changes
- `js-index-maps` - Build Map for repeated lookups
- `js-combine-iterations` - Combine multiple filter/map into one loop
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
### 8. Advanced Patterns (LOW)
- `advanced-event-handler-refs` - Store event handlers in refs
- `advanced-init-once` - Initialize app once per app load

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
}

62
AGENTS.md Normal file
View File

@@ -0,0 +1,62 @@
# AGENTS.md (auto-trade)
## 기본 원칙
- 모든 응답과 설명은 한국어로 작성.
- 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임.
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
- 소스 수정시 사이드이팩트가 발생하지 않게 사용자에게 묻거나 최대한 영향이 가지 않게 수정한다. 진짜 필요없는건 삭제하고 영향이 갈 내용이면 이전 소스가 영향이 없는지 검증하고 수정한다.
## 프로젝트 요약
- Next.js 16 App Router, React 19, TypeScript
- 상태 관리: zustand
- 데이터: Supabase
- 폼 및 검증: react-hook-form, zod
- UI: Tailwind CSS v4, Radix UI (`components.json` 사용)
## 명령어
- 개발 서버(포트 3001): `npm run dev`
- 린트: `npm run lint`
- 빌드: `npm run build`
- 실행: `npm run start`
## 코드 및 문서 규칙
- JSX 섹션 주석 형식: `{/* ========== SECTION NAME ========== */}`
- 함수 및 컴포넌트 JSDoc에 `@see` 필수
- `@see`에는 호출 파일, 함수/이벤트 이름, 목적을 함께 작성
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성
- UI 흐름 설명 필수: `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영` 형태로 작성
## 브랜드 색상 규칙
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트 사용
- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용
- 예시: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700`
- 기본 액션 색(버튼/포커스)은 `primary` 사용
- `primary``app/globals.css``brand` 팔레트와 같은 톤으로 유지
- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰 수정
## 개발 도구 활용
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
- **MCP 서버**:
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
- `playwright` / `playwriter`: 브라우저 자동화 테스트
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
- `context7`: 라이브러리/프레임워크 공식 문서 참조
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리
## 한국 투자 증권 API 이용시
- `mcp:kis-code-assistant-mcp` 활용
- `C:\dev\auto-trade\.tmp\open-trading-api` 활용
- API 이용시 공식 문서에 최신 업데이트가 안되어 있을수 있으므로 필요시 최신 API 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다.
## 소개문구
- 불안감을 해소하고 확신을 주는 문구
- 친근하고 확신에 찬 문구를 사용하여 심리적 장벽을 낮추는 전략

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

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,75 @@
import { NextResponse } from "next/server";
import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
} 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 NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
const account = readKisAccountParts(request.headers);
if (!account) {
return NextResponse.json(
{
error:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "주문내역/매매일지 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
} 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 NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
const account = readKisAccountParts(request.headers);
if (!account) {
return NextResponse.json(
{
error:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "잔고 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,104 @@
import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import { hasKisApiSession } from "@/app/api/kis/_session";
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 NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
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 NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
if (!VALID_TIMEFRAMES.includes(timeframe)) {
return NextResponse.json(
{ error: "지원하지 않는 timeframe입니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "KIS 차트 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}

View File

@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @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 NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "지수 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
/**
* @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API
*/
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
},
{ status: 401 },
);
}
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
try {
const body = (await request.json()) as DashboardStockCashOrderRequest;
// TODO: Validate body fields (symbol, quantity, price, etc.)
if (
!body.symbol ||
!body.accountNo ||
!body.accountProductCode ||
body.quantity <= 0
) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
},
{ status: 400 },
);
}
const output = await executeOrderCash(
{
symbol: body.symbol,
side: body.side,
orderType: body.orderType,
quantity: body.quantity,
price: body.price,
accountNo: body.accountNo,
accountProductCode: body.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) {
const message =
error instanceof Error
? error.message
: "주문 전송 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
},
{ status: 500 },
);
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}

View File

@@ -0,0 +1,171 @@
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 {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
/**
* @file app/api/kis/domestic/orderbook/route.ts
* @description 국내주식 호가 조회 API
*/
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "호가 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}
function readSessionOverrideFromHeaders(headers: Headers) {
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,100 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
/**
* @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 NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json({ error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 });
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
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) {
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
/**
* 요청 헤더에서 KIS 키를 읽어옵니다.
* @param headers 요청 헤더
* @returns credentials
*/
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(headers.get("x-kis-trading-env") ?? undefined);
return {
appKey,
appSecret,
tradingEnv,
};
}
function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
return parseDomesticKisSession(raw);
}

View File

@@ -0,0 +1,114 @@
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 { 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 NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
// [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,71 @@
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 { 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 NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisRevokeResponse,
{ status: 400 },
);
}
try {
const message = await revokeKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "API 토큰 폐기 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
}
}

View File

@@ -0,0 +1,252 @@
import { NextRequest, NextResponse } from "next/server";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { parseKisAccountParts } from "@/lib/kis/account";
import { kisGet } from "@/lib/kis/client";
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import { validateKisCredentialInput } from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
interface KisProfileValidateRequestBody {
appKey?: string;
appSecret?: string;
tradingEnv?: string;
accountNo?: string;
}
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 NextResponse.json(
{
ok: false,
tradingEnv: fallbackTradingEnv,
message: "로그인이 필요합니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 401 },
);
}
let body: KisProfileValidateRequestBody = {};
try {
body = (await request.json()) as KisProfileValidateRequestBody;
} catch {
return NextResponse.json(
{
ok: false,
tradingEnv: fallbackTradingEnv,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
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 NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidCredentialMessage,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
const accountNoInput = (body.accountNo ?? "").trim();
if (!accountNoInput) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "계좌번호를 입력해 주세요.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
const accountParts = parseKisAccountParts(accountNoInput);
if (!accountParts) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "계좌 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
}
/**
* @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,71 @@
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 { 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 NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisValidateResponse,
{ status: 400 },
);
}
try {
await getKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "API 키 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
}
}

View File

@@ -0,0 +1,74 @@
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 { 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 NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisWsApprovalResponse,
{ status: 400 },
);
}
try {
const approvalKey = await getKisApprovalKey(credentials);
const wsUrl = resolveKisWebSocketUrl(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
approvalKey,
wsUrl,
message: "웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "웹소켓 승인키 발급 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
}
}

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,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,171 @@
/**
* @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);
}
}, []);
/**
* 로그아웃 처리 핸들러
* @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,115 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
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),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardBalanceResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.",
);
}
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
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.",
);
}
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),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardActivityResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "활동 데이터 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardActivityResponse;
}
/**
* 대시보드 API 공통 헤더를 구성합니다.
* @param credentials KIS 인증 정보
* @returns KIS 전달 헤더
* @see features/dashboard/apis/dashboard.api.ts fetchDashboardBalance/fetchDashboardIndices
*/
function buildKisRequestHeaders(credentials: KisRuntimeCredentials) {
const headers: Record<string, string> = {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
};
if (credentials.accountNo?.trim()) {
headers["x-kis-account-no"] = credentials.accountNo.trim();
}
return headers;
}

View File

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

View File

@@ -0,0 +1,66 @@
/**
* @file features/dashboard/utils/dashboard-format.ts
* @description 대시보드 숫자/색상 표현 유틸
*/
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
const PERCENT_FORMATTER = new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/**
* 원화 금액을 포맷합니다.
* @param value 숫자 값
* @returns 쉼표 포맷 문자열
* @see features/dashboard/components/StatusHeader.tsx 자산/손익 금액 표시
*/
export function formatCurrency(value: number) {
return KRW_FORMATTER.format(value);
}
/**
* 퍼센트 값을 포맷합니다.
* @param value 숫자 값
* @returns 소수점 2자리 퍼센트 문자열
* @see features/dashboard/components/StatusHeader.tsx 수익률 표시
*/
export function formatPercent(value: number) {
return `${PERCENT_FORMATTER.format(value)}%`;
}
/**
* 값의 부호를 포함한 금액 문자열을 만듭니다.
* @param value 숫자 값
* @returns + 또는 - 부호가 포함된 금액 문자열
* @see features/dashboard/components/MarketSummary.tsx 전일 대비 수치 표시
*/
export function formatSignedCurrency(value: number) {
if (value > 0) return `+${formatCurrency(value)}`;
if (value < 0) return `-${formatCurrency(Math.abs(value))}`;
return "0";
}
/**
* 값의 부호를 포함한 퍼센트 문자열을 만듭니다.
* @param value 숫자 값
* @returns + 또는 - 부호가 포함된 퍼센트 문자열
* @see features/dashboard/components/MarketSummary.tsx 전일 대비율 표시
*/
export function formatSignedPercent(value: number) {
if (value > 0) return `+${formatPercent(value)}`;
if (value < 0) return `-${formatPercent(Math.abs(value))}`;
return "0.00%";
}
/**
* 숫자 값의 상승/하락/보합 텍스트 색상을 반환합니다.
* @param value 숫자 값
* @returns Tailwind 텍스트 클래스
* @see features/dashboard/components/HoldingsList.tsx 수익률/손익 색상 적용
*/
export function getChangeToneClass(value: number) {
if (value > 0) return "text-red-600 dark:text-red-400";
if (value < 0) return "text-blue-600 dark:text-blue-400";
return "text-muted-foreground";
}

View File

@@ -0,0 +1,62 @@
export interface KisRealtimeIndexTick {
symbol: string; // 업종코드 (0001: KOSPI, 1001: KOSDAQ)
price: number; // 현재가
change: number; // 전일대비
changeRate: number; // 전일대비율
sign: string; // 대비부호
time: string; // 체결시간
}
const INDEX_REALTIME_TR_ID = "H0UPCNT0";
const INDEX_FIELD_INDEX = {
symbol: 0, // bstp_cls_code
time: 1, // bsop_hour
price: 2, // prpr_nmix
sign: 3, // prdy_vrss_sign
change: 4, // bstp_nmix_prdy_vrss
accumulatedVolume: 5, // acml_vol
accumulatedAmount: 6, // acml_tr_pbmn
changeRate: 9, // prdy_ctrt
} as const;
export function parseKisRealtimeIndexTick(
raw: string,
): KisRealtimeIndexTick | null {
// Format: 0|H0UPCNT0|001|0001^123456^...
if (!/^([01])\|/.test(raw)) return null;
const parts = raw.split("|");
if (parts.length < 4) return null;
// Check TR ID
if (parts[1] !== INDEX_REALTIME_TR_ID) {
return null;
}
const values = parts[3].split("^");
if (values.length < 10) return null; // Ensure minimum fields exist
const symbol = values[INDEX_FIELD_INDEX.symbol];
const price = parseFloat(values[INDEX_FIELD_INDEX.price]);
const sign = values[INDEX_FIELD_INDEX.sign];
const changeRaw = parseFloat(values[INDEX_FIELD_INDEX.change]);
const changeRateRaw = parseFloat(values[INDEX_FIELD_INDEX.changeRate]);
// Adjust sign for negative values if necessary (usually API sends absolute values for change)
const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한
const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw);
const changeRate = isNegative
? -Math.abs(changeRateRaw)
: Math.abs(changeRateRaw);
return {
symbol,
time: values[INDEX_FIELD_INDEX.time],
price,
change,
changeRate,
sign,
};
}

View File

@@ -0,0 +1,69 @@
export interface KisRealtimeStockTick {
symbol: string; // 종목코드
time: string; // 체결시간
currentPrice: number; // 현재가
sign: string; // 전일대비부호 (1:상한, 2:상승, 3:보합, 4:하한, 5:하락)
change: number; // 전일대비
changeRate: number; // 전일대비율
accumulatedVolume: number; // 누적거래량
}
const STOCK_realtime_TR_ID = "H0STCNT0";
// H0STCNT0 Output format indices based on typical KIS Realtime API
// Format: MKSC_SHRN_ISCD^STCK_CNTG_HOUR^STCK_PRPR^PRDY_VRSS_SIGN^PRDY_VRSS^PRDY_CTRT^...
const STOCK_FIELD_INDEX = {
symbol: 0, // MKSC_SHRN_ISCD
time: 1, // STCK_CNTG_HOUR
currentPrice: 2, // STCK_PRPR
sign: 3, // PRDY_VRSS_SIGN
change: 4, // PRDY_VRSS
changeRate: 5, // PRDY_CTRT
accumulatedVolume: 12, // ACML_VOL (Usually at index 12 or similar, need to be careful here)
} as const;
export function parseKisRealtimeStockTick(
raw: string,
): KisRealtimeStockTick | null {
// Format: 0|H0STCNT0|001|SYMBOL^TIME^PRICE^SIGN^CHANGE^...
if (!/^([01])\|/.test(raw)) return null;
const parts = raw.split("|");
if (parts.length < 4) return null;
// Check TR ID
if (parts[1] !== STOCK_realtime_TR_ID) {
return null;
}
const values = parts[3].split("^");
if (values.length < 6) return null; // Ensure minimum fields exist
const symbol = values[STOCK_FIELD_INDEX.symbol];
const currentPrice = parseFloat(values[STOCK_FIELD_INDEX.currentPrice]);
const sign = values[STOCK_FIELD_INDEX.sign];
const changeRaw = parseFloat(values[STOCK_FIELD_INDEX.change]);
const changeRateRaw = parseFloat(values[STOCK_FIELD_INDEX.changeRate]);
// Adjust sign for negative values if necessary
const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한
const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw);
const changeRate = isNegative
? -Math.abs(changeRateRaw)
: Math.abs(changeRateRaw);
// Validate numeric values
if (isNaN(currentPrice)) return null;
return {
symbol,
time: values[STOCK_FIELD_INDEX.time],
currentPrice,
sign,
change,
changeRate,
accumulatedVolume:
parseFloat(values[STOCK_FIELD_INDEX.accumulatedVolume]) || 0,
};
}

View File

@@ -0,0 +1,29 @@
"use client";
import Spline from "@splinetool/react-spline";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface SplineSceneProps {
sceneUrl: string;
className?: string;
}
export function SplineScene({ sceneUrl, className }: SplineSceneProps) {
const [isLoading, setIsLoading] = useState(true);
return (
<div className={cn("relative h-full w-full", className)}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-100 dark:bg-zinc-900">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-zinc-200 border-t-brand-500 dark:border-zinc-800" />
</div>
)}
<Spline
scene={sceneUrl}
onLoad={() => setIsLoading(false)}
className="h-full w-full"
/>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
/**
* @file features/kis-realtime/hooks/useKisWebSocket.ts
* @description KIS 실시간 데이터를 구독하기 위한 통합 훅입니다.
* 컴포넌트 마운트/언마운트 시 자동으로 구독 및 해제를 처리합니다.
*/
type RealtimeCallback = (data: string) => void;
interface UseKisWebSocketParams {
symbol?: string; // 종목코드 (없으면 구독 안 함)
trId?: string; // 거래 ID (예: H0STCNT0)
onMessage?: RealtimeCallback; // 데이터 수신 콜백
enabled?: boolean; // 구독 활성화 여부
}
export function useKisWebSocket({
symbol,
trId,
onMessage,
enabled = true,
}: UseKisWebSocketParams) {
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
const connectRef = useRef(useKisWebSocketStore.getState().connect);
const { isConnected } = useKisWebSocketStore();
const callbackRef = useRef(onMessage);
// 콜백 함수가 바뀌어도 재구독하지 않도록 ref 사용
useEffect(() => {
callbackRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
if (!enabled || !symbol || !trId) return;
// 연결 시도 (이미 연결 중이면 스토어에서 무시됨)
connectRef.current();
// 구독 요청
const unsubscribe = subscribeRef.current(trId, symbol, (data) => {
callbackRef.current?.(data);
});
// 언마운트 시 구독 해제
return () => {
unsubscribe();
};
}, [symbol, trId, enabled]);
return { isConnected };
}

View File

@@ -0,0 +1,598 @@
import { create } from "zustand";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils";
/**
* @file features/kis-realtime/stores/kisWebSocketStore.ts
* @description KIS 실시간 웹소켓 연결을 전역에서 하나로 관리하는 스토어입니다.
* 중복 연결을 방지하고, 여러 컴포넌트에서 동일한 데이터를 구독할 때 효율적으로 처리합니다.
*/
type RealtimeCallback = (data: string) => void;
interface KisWebSocketState {
isConnected: boolean;
error: string | null;
/**
* 웹소켓 연결을 수립합니다.
* 이미 연결되어 있거나 연결 중이면 무시합니다.
*/
connect: (options?: { forceApprovalRefresh?: boolean }) => Promise<void>;
/**
* 웹소켓 연결을 강제로 재시작합니다.
* 필요 시 승인키를 새로 발급받아 재연결합니다.
*/
reconnect: (options?: { refreshApproval?: boolean }) => Promise<void>;
/**
* 웹소켓 연결을 종료합니다.
* 모든 구독이 해제됩니다.
*/
disconnect: () => void;
/**
* 특정 TR ID와 종목 코드로 실시간 데이터를 구독합니다.
* @param trId 거래 ID (예: H0STCNT0)
* @param symbol 종목 코드 (예: 005930)
* @param callback 데이터 수신 시 실행할 콜백 함수
* @returns 구독 해제 함수 (useEffect cleanup에서 호출하세요)
*/
subscribe: (
trId: string,
symbol: string,
callback: RealtimeCallback,
) => () => void;
}
// 구독자 목록 관리 (Key: "TR_ID|SYMBOL", Value: Set<Callback>)
// 스토어 외부 변수로 관리하여 불필요한 리렌더링을 방지합니다.
const subscribers = new Map<string, Set<RealtimeCallback>>();
const subscriberCounts = new Map<string, number>(); // 실제 소켓 구독 요청 여부 추적용
let socket: WebSocket | null = null;
let isConnecting = false; // 연결 진행 중 상태 잠금
let reconnectRetryTimer: number | undefined;
let lastAppKeyConflictAt = 0;
let reconnectAttempt = 0;
let manualDisconnectRequested = false;
const MAX_AUTO_RECONNECT_ATTEMPTS = 8;
const RECONNECT_BASE_DELAY_MS = 1_000;
const RECONNECT_MAX_DELAY_MS = 30_000;
const RECONNECT_JITTER_MS = 300;
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
isConnected: false,
error: null,
connect: async (options) => {
const forceApprovalRefresh = options?.forceApprovalRefresh ?? false;
manualDisconnectRequested = false;
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = undefined;
const currentSocket = socket;
if (currentSocket?.readyState === WebSocket.CLOSING) {
await waitForSocketClose(currentSocket);
}
// 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지
if (isSocketUnavailableForNewConnect(socket) || isConnecting) {
return;
}
try {
isConnecting = true;
const { getOrFetchWsConnection, clearWsConnectionCache } =
useKisRuntimeStore.getState();
if (forceApprovalRefresh) {
clearWsConnectionCache();
}
const wsConnection = await getOrFetchWsConnection();
// 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인
if (isSocketOpenOrConnecting(socket)) {
isConnecting = false;
return;
}
if (!wsConnection) {
throw new Error("웹소켓 접속 키 발급에 실패했습니다.");
}
// 소켓 생성
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
const ws = new WebSocket(wsConnection.wsUrl);
console.log("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
socket = ws;
ws.onopen = () => {
isConnecting = false;
// socket 변수가 다른 인스턴스로 바뀌었을 가능성은 낮지만(락 때문),
// 안전을 위해 이벤트 발생 주체인 ws를 사용 또는 현재 socket 확인
if (socket !== ws) return;
set({ isConnected: true, error: null });
reconnectAttempt = 0;
console.log("[KisWebSocket] Connected");
// 재연결 시 기존 구독 복구
const approvalKey = wsConnection.approvalKey;
if (approvalKey) {
subscriberCounts.forEach((_, key) => {
const [trId, symbol] = key.split("|");
// OPEN 상태일 때만 전송
if (ws.readyState === WebSocket.OPEN) {
sendSubscription(ws, approvalKey, trId, symbol, "1"); // 구독
}
});
}
};
ws.onclose = (event) => {
if (socket === ws) {
isConnecting = false;
set({ isConnected: false });
socket = null;
const hasSubscribers = hasActiveRealtimeSubscribers();
const canAutoReconnect =
!manualDisconnectRequested &&
hasSubscribers &&
reconnectAttempt < MAX_AUTO_RECONNECT_ATTEMPTS;
if (canAutoReconnect) {
reconnectAttempt += 1;
const delayMs = getReconnectDelayMs(reconnectAttempt);
console.warn(
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
);
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = window.setTimeout(() => {
const refreshApproval = reconnectAttempt % 3 === 0;
void get().reconnect({ refreshApproval });
}, delayMs);
return;
}
if (
hasSubscribers &&
reconnectAttempt >= MAX_AUTO_RECONNECT_ATTEMPTS
) {
set({
error:
"실시간 연결이 반복 종료되어 자동 재연결을 중단했습니다. 새로고침 또는 수동 재연결을 시도해 주세요.",
});
}
reconnectAttempt = 0;
console.log(
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`,
);
}
};
ws.onerror = (event) => {
if (socket === ws) {
isConnecting = false;
const errEvent = event as ErrorEvent;
console.error("[KisWebSocket] Error", {
type: event.type,
message: errEvent?.message,
url: ws.url,
readyState: ws.readyState,
});
set({
isConnected: false,
error: "웹소켓 연결 중 오류가 발생했습니다.",
});
}
};
ws.onmessage = (event) => {
const data = event.data;
if (typeof data !== "string") return;
// PINGPONG 응답 또는 제어 메시지 처리
if (data.startsWith("{")) {
const control = parseControlMessage(data);
if (!control) return;
if (control.trId === "PINGPONG") {
// KIS 샘플 구현과 동일하게 원문을 그대로 echo하여 연결 유지를 보조합니다.
if (socket === ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
return;
}
if (control.rtCd && control.rtCd !== "0") {
const errorMessage = buildControlErrorMessage(control);
set({
error: errorMessage,
});
// KIS 제어 메시지: ALREADY IN USE appkey
// 이전 세션이 닫히기 전에 재연결될 때 발생합니다.
// KIS 서버가 세션을 정리하는 데 최소 10~30초가 필요하므로
// 충분한 대기 후 재연결합니다.
if (control.msgCd === "OPSP8996") {
const now = Date.now();
if (now - lastAppKeyConflictAt > 5_000) {
lastAppKeyConflictAt = now;
console.warn(
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
);
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
if (socket === ws && ws.readyState === WebSocket.OPEN) {
ws.close(1000, "ALREADY IN USE - graceful close");
}
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = window.setTimeout(() => {
void get().reconnect({ refreshApproval: false });
}, 30_000); // 30초 쿨다운
}
}
// 승인키가 유효하지 않을 때는 승인키 재발급 후 재연결합니다.
if (control.msgCd === "OPSP0011") {
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = window.setTimeout(() => {
void get().reconnect({ refreshApproval: true });
}, 1_200);
}
}
return;
}
if (data[0] === "0" || data[0] === "1") {
// 데이터 포맷: 0|TR_ID|KEY|...
const parts = data.split("|");
if (parts.length >= 4) {
const trId = parts[1];
const body = parts[3];
const values = body.split("^");
const symbol = values[0] ?? "";
// UI 흐름: 소켓 수신 -> TR/심볼 정규화 매칭 -> 해당 구독 콜백 실행 -> 훅 파서(parseKisRealtime*) -> 화면 반영
dispatchRealtimeMessageToSubscribers(trId, symbol, data);
}
}
};
} catch (err) {
isConnecting = false;
set({
isConnected: false,
error: err instanceof Error ? err.message : "연결 실패",
});
}
},
reconnect: async (options) => {
const refreshApproval = options?.refreshApproval ?? false;
// disconnect()는 manualDisconnectRequested=true를 설정하므로 직접 호출 금지
// 대신 소켓만 직접 닫습니다.
manualDisconnectRequested = false;
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = undefined;
const currentSocket = socket;
if (
currentSocket &&
(currentSocket.readyState === WebSocket.OPEN ||
currentSocket.readyState === WebSocket.CONNECTING)
) {
currentSocket.close();
}
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
await waitForSocketClose(currentSocket);
}
await get().connect({
forceApprovalRefresh: refreshApproval,
});
},
disconnect: () => {
manualDisconnectRequested = true;
const currentSocket = socket;
if (
currentSocket &&
(currentSocket.readyState === WebSocket.OPEN ||
currentSocket.readyState === WebSocket.CONNECTING ||
currentSocket.readyState === WebSocket.CLOSING)
) {
currentSocket.close();
}
if (
currentSocket?.readyState === WebSocket.CLOSED &&
socket === currentSocket
) {
socket = null;
}
set({ isConnected: false });
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = undefined;
reconnectAttempt = 0;
isConnecting = false;
},
subscribe: (trId, symbol, callback) => {
const key = `${trId}|${symbol}`;
// 1. 구독자 목록에 추가
if (!subscribers.has(key)) {
subscribers.set(key, new Set());
}
subscribers.get(key)!.add(callback);
// 2. 소켓 서버에 구독 요청 (첫 번째 구독자인 경우)
const currentCount = subscriberCounts.get(key) || 0;
if (currentCount === 0) {
const { wsApprovalKey } = useKisRuntimeStore.getState();
if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) {
sendSubscription(socket, wsApprovalKey, trId, symbol, "1"); // "1": 등록
}
}
subscriberCounts.set(key, currentCount + 1);
// 3. 구독 해제 함수 반환
return () => {
const callbacks = subscribers.get(key);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
subscribers.delete(key);
}
}
const count = subscriberCounts.get(key) || 0;
if (count > 0) {
subscriberCounts.set(key, count - 1);
if (count - 1 === 0) {
// 마지막 구독자가 사라지면 소켓 구독 해제
const { wsApprovalKey } = useKisRuntimeStore.getState();
if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) {
sendSubscription(socket, wsApprovalKey, trId, symbol, "2"); // "2": 해제
}
}
}
};
},
}));
// 헬퍼: 구독/해제 메시지 전송
function sendSubscription(
ws: WebSocket,
appKey: string,
trId: string,
symbol: string,
trType: "1" | "2",
) {
try {
const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType);
ws.send(JSON.stringify(msg));
console.debug(
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
);
} catch (e) {
console.warn("[KisWebSocket] Send error", e);
}
}
interface KisWsControlMessage {
trId?: string;
trKey?: string;
rtCd?: string;
msgCd?: string;
msg1?: string;
encrypt?: string;
}
/**
* @description 웹소켓 제어 메시지(JSON)를 파싱합니다.
* @param rawData 원본 메시지 문자열
* @returns 파싱된 제어 메시지 또는 null
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
*/
function parseControlMessage(rawData: string): KisWsControlMessage | null {
try {
const parsed = JSON.parse(rawData) as {
header?: {
tr_id?: string;
tr_key?: string;
encrypt?: string;
};
body?: {
rt_cd?: string;
msg_cd?: string;
msg1?: string;
};
rt_cd?: string;
msg_cd?: string;
msg1?: string;
};
if (!parsed || typeof parsed !== "object") return null;
return {
trId: parsed.header?.tr_id,
trKey: parsed.header?.tr_key,
encrypt: parsed.header?.encrypt,
rtCd: parsed.body?.rt_cd ?? parsed.rt_cd,
msgCd: parsed.body?.msg_cd ?? parsed.msg_cd,
msg1: parsed.body?.msg1 ?? parsed.msg1,
};
} catch {
return null;
}
}
/**
* @description KIS 웹소켓 제어 오류를 사용자용 짧은 문구로 변환합니다.
* @param message KIS 제어 메시지
* @returns 표시용 오류 문자열
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
*/
function buildControlErrorMessage(message: KisWsControlMessage) {
if (message.msgCd === "OPSP8996") {
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
}
const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / ");
return detail
? `실시간 제어 메시지 오류: ${detail}`
: "실시간 제어 메시지 오류";
}
/**
* @description 활성화된 웹소켓 구독이 존재하는지 반환합니다.
* @returns 구독 중인 TR/심볼이 1개 이상이면 true
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose
*/
function hasActiveRealtimeSubscribers() {
for (const count of subscriberCounts.values()) {
if (count > 0) return true;
}
return false;
}
/**
* @description 자동 재연결 시도 횟수에 따라 지수 백오프 지연시간(ms)을 계산합니다.
* @param attempt 1부터 시작하는 재연결 시도 횟수
* @returns 지연시간(ms)
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose
*/
function getReconnectDelayMs(attempt: number) {
const exponential = RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1);
const clamped = Math.min(exponential, RECONNECT_MAX_DELAY_MS);
const jitter = Math.floor(Math.random() * RECONNECT_JITTER_MS);
return clamped + jitter;
}
/**
* @description 소켓이 OPEN 또는 CONNECTING 상태인지 검사합니다.
* @param target 검사 대상 소켓
* @returns 연결 유지/진행 상태면 true
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect
*/
function isSocketOpenOrConnecting(target: WebSocket | null) {
if (!target) return false;
return (
target.readyState === WebSocket.OPEN ||
target.readyState === WebSocket.CONNECTING
);
}
/**
* @description 새 연결을 시작하면 안 되는 소켓 상태인지 검사합니다.
* @param target 검사 대상 소켓
* @returns OPEN/CONNECTING/CLOSING 중 하나면 true
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect
*/
function isSocketUnavailableForNewConnect(target: WebSocket | null) {
if (!target) return false;
return (
target.readyState === WebSocket.OPEN ||
target.readyState === WebSocket.CONNECTING ||
target.readyState === WebSocket.CLOSING
);
}
/**
* @description 특정 웹소켓 인스턴스가 완전히 닫힐 때까지 대기합니다.
* @param target 대기할 웹소켓 인스턴스
* @param timeoutMs 최대 대기 시간(ms)
* @returns close/error/timeout 중 먼저 완료되면 resolve
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect/reconnect
*/
function waitForSocketClose(target: WebSocket, timeoutMs = 2_000) {
if (target.readyState === WebSocket.CLOSED) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let settled = false;
const onClose = () => finish();
const onError = () => finish();
const timeoutId = window.setTimeout(() => finish(), timeoutMs);
const finish = () => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
target.removeEventListener("close", onClose);
target.removeEventListener("error", onError);
resolve();
};
target.addEventListener("close", onClose);
target.addEventListener("error", onError);
});
}
/**
* @description 실시간 데이터(TR/종목코드)와 등록된 구독자를 매칭해 콜백을 실행합니다.
* 종목코드 접두(prefix) 차이(A005930/J005930 등)와 구독 심볼 형식 차이를 허용합니다.
* @param trId 수신 TR ID
* @param rawSymbol 수신 데이터의 원본 종목코드
* @param payload 웹소켓 원문 메시지
* @see features/trade/hooks/useTradeTickSubscription.ts 체결 구독 콜백
* @see features/trade/hooks/useOrderbookSubscription.ts 호가 구독 콜백
*/
function dispatchRealtimeMessageToSubscribers(
trId: string,
rawSymbol: string,
payload: string,
) {
const callbackSet = new Set<RealtimeCallback>();
const normalizedIncomingSymbol = normalizeRealtimeSymbol(rawSymbol);
// 1) 정확히 일치하는 key 우선
const exactKey = `${trId}|${rawSymbol}`;
subscribers.get(exactKey)?.forEach((callback) => callbackSet.add(callback));
// 2) 숫자 6자리 기준(정규화)으로 일치하는 key 매칭
subscribers.forEach((callbacks, key) => {
const [subscribedTrId, subscribedSymbol = ""] = key.split("|");
if (subscribedTrId !== trId) return;
if (!normalizedIncomingSymbol) return;
const normalizedSubscribedSymbol =
normalizeRealtimeSymbol(subscribedSymbol);
if (!normalizedSubscribedSymbol) return;
if (normalizedIncomingSymbol !== normalizedSubscribedSymbol) return;
callbacks.forEach((callback) => callbackSet.add(callback));
});
// 3) 심볼 매칭이 실패한 경우에도 같은 TR 전체 콜백으로 안전 fallback
if (callbackSet.size === 0) {
subscribers.forEach((callbacks, key) => {
const [subscribedTrId] = key.split("|");
if (subscribedTrId !== trId) return;
callbacks.forEach((callback) => callbackSet.add(callback));
});
}
callbackSet.forEach((callback) => callback(payload));
}
/**
* @description 실시간 종목코드를 비교 가능한 6자리 숫자 코드로 정규화합니다.
* @param value 원본 종목코드 (예: 005930, A005930)
* @returns 정규화된 6자리 코드. 파싱 불가 시 원본 trim 값 반환
* @see features/kis-realtime/stores/kisWebSocketStore.ts dispatchRealtimeMessageToSubscribers
*/
function normalizeRealtimeSymbol(value: string) {
const trimmed = value.trim();
if (!trimmed) return "";
const digits = trimmed.replace(/\D/g, "");
if (digits.length >= 6) {
return digits.slice(-6);
}
return trimmed;
}

View File

@@ -0,0 +1,29 @@
/**
* @file features/kis-realtime/utils/websocketUtils.ts
* @description KIS 웹소켓 메시지 생성 및 파싱 관련 유틸리티 함수 모음
*/
/**
* @description KIS 실시간 구독/해제 소켓 메시지를 생성합니다.
*/
export function buildKisRealtimeMessage(
approvalKey: string,
symbol: string,
trId: string,
trType: "1" | "2",
) {
return {
header: {
approval_key: approvalKey,
custtype: "P",
tr_type: trType,
"content-type": "utf-8",
},
body: {
input: {
tr_id: trId,
tr_key: symbol,
},
},
};
}

View File

@@ -0,0 +1,110 @@
"use client";
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useGlobalAlertStore } from "@/features/layout/stores/use-global-alert-store";
import { cn } from "@/lib/utils";
export function GlobalAlertModal() {
const {
isOpen,
type,
title,
message,
confirmLabel,
cancelLabel,
onConfirm,
onCancel,
isSingleButton,
closeAlert,
} = useGlobalAlertStore();
const handleOpenChange = (open: boolean) => {
if (!open) {
closeAlert();
}
};
const handleConfirm = () => {
onConfirm?.();
closeAlert();
};
const handleCancel = () => {
onCancel?.();
closeAlert();
};
const Icon = {
success: CheckCircle2,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
}[type];
const iconColor = {
success: "text-emerald-500",
error: "text-red-500",
warning: "text-amber-500",
info: "text-blue-500",
}[type];
const bgColor = {
success: "bg-emerald-50 dark:bg-emerald-950/20",
error: "bg-red-50 dark:bg-red-950/20",
warning: "bg-amber-50 dark:bg-amber-950/20",
info: "bg-blue-50 dark:bg-blue-950/20",
}[type];
return (
<AlertDialog open={isOpen} onOpenChange={handleOpenChange}>
<AlertDialogContent className="sm:max-w-[400px]">
<AlertDialogHeader>
<div className="flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left">
<div
className={cn(
"flex h-12 w-12 shrink-0 items-center justify-center rounded-full",
bgColor,
iconColor,
)}
>
<Icon className="h-6 w-6" />
</div>
<div className="flex-1 space-y-2">
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-relaxed">
{message}
</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
<AlertDialogFooter className="mt-4 sm:justify-end">
{!isSingleButton && (
<AlertDialogCancel onClick={handleCancel} className="mt-2 sm:mt-0">
{cancelLabel || "취소"}
</AlertDialogCancel>
)}
<AlertDialogAction
onClick={handleConfirm}
className={cn(
type === "error" && "bg-red-600 hover:bg-red-700",
type === "warning" && "bg-amber-600 hover:bg-amber-700",
type === "success" && "bg-emerald-600 hover:bg-emerald-700",
)}
>
{confirmLabel || "확인"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,108 @@
import { cn } from "@/lib/utils";
interface LogoProps {
className?: string;
variant?: "symbol" | "full";
/** 배경과 섞이는 모드 (홈 화면 등). 로고가 흰색으로 표시됩니다. */
blendWithBackground?: boolean;
}
export function Logo({
className,
variant = "full",
blendWithBackground = false,
}: LogoProps) {
// 색상 클래스 정의
const mainColorClass = blendWithBackground
? "fill-brand-500 stroke-brand-500" // 배경 혼합 모드에서도 심볼은 브랜드 컬러 유지
: "fill-brand-600 stroke-brand-600 dark:fill-brand-500 dark:stroke-brand-500";
return (
<div
className={cn("relative flex items-center gap-2 select-none", className)}
aria-label="JOORIN-E Logo"
>
<svg
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"shrink-0",
variant === "full" ? "h-10 w-10" : "h-full w-full",
)}
>
<defs>
{/* Mask for the cutout effect around the arrow */}
<mask id="arrow-cutout">
<rect width="100%" height="100%" fill="white" />
<path
d="M10 75 C 35 45, 55 85, 90 25"
fill="none"
stroke="black"
strokeWidth="12"
strokeLinecap="round"
/>
{/* Arrow Head Cutout */}
<path
d="M90 25 L 78 32 L 85 42 Z"
fill="black"
stroke="black"
strokeWidth="6"
strokeLinejoin="round"
transform="rotate(-15 90 25)"
/>
</mask>
</defs>
{/* ========== BARS (Masked) ========== */}
<g
mask="url(#arrow-cutout)"
className={
blendWithBackground
? "fill-brand-500" // 배경 혼합 모드에서도 브랜드 컬러 사용
: "fill-brand-600 dark:fill-brand-500"
}
>
{/* Bar 1 (Left, Short) */}
<rect x="15" y="45" width="18" height="40" rx="4" />
{/* Bar 2 (Middle, Medium) */}
<rect x="41" y="30" width="18" height="55" rx="4" />
{/* Bar 3 (Right, Tall) */}
<rect x="67" y="10" width="18" height="75" rx="4" />
</g>
{/* ========== ARROW (Foreground) ========== */}
<g className={mainColorClass}>
{/* Arrow Path */}
<path
d="M10 75 C 35 45, 55 85, 90 25"
fill="none"
strokeWidth="7"
strokeLinecap="round"
/>
{/* Arrow Head */}
<path
d="M90 25 L 78 32 L 85 42 Z"
fill="currentColor"
stroke="none"
transform="rotate(-15 90 25)"
/>
</g>
</svg>
{/* ========== TEXT (Optional) ========== */}
{variant === "full" && (
<span
className={cn(
"font-bold tracking-tight",
blendWithBackground
? "text-white opacity-95"
: "text-brand-900 dark:text-brand-50",
)}
style={{ fontSize: "1.35rem", fontFamily: "'Inter', sans-serif" }}
>
JOORIN-E
</span>
)}
</div>
);
}

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