diff --git a/.github/ISSUE_TEMPLATE/chore.yml b/.github/ISSUE_TEMPLATE/chore.yml new file mode 100644 index 0000000..73da26c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore.yml @@ -0,0 +1,24 @@ +name: '[ Chore ] 환경 설정' +description: 빌드, 패키지, 설정 등의 작업 사항을 작성해주세요. +title: '[ Chore ] ' +body: + - type: markdown + attributes: + value: | + 작성 예시 : "[ Chore ] ESLint 설정 추가" + - type: textarea + id: chore-description + attributes: + label: 🛠 작업 내용 + description: 어떤 설정 또는 환경 관련 작업을 하려는지 작성해주세요. + placeholder: 작업 목적과 내용을 작성해주세요. + validations: + required: true + - type: textarea + id: chore-list + attributes: + label: 📝 To-do + description: 작업에 필요한 세부 항목을 체크리스트로 작성해주세요. + placeholder: 예) - [ ] tailwind.config.js 수정\n- [ ] .env 추가 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..0b53945 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,24 @@ +name: '[ Feat ] 기능 추가' +description: 기능 추가 작업 사항을 작성해주세요. +title: '[ Feat ] ' +body: + - type: markdown + attributes: + value: | + 작성 예시 : "[ Feat ] 로그인 기능 구현" + - type: textarea + id: feat-description + attributes: + label: 🛠 Issue + description: 어떠한 기능을 추가하시는 건지 작성해주세요. + placeholder: 설명을 작성해주세요. + validations: + required: true + - type: textarea + id: feat-list + attributes: + label: 📝 To-do + description: 작업에 필요한 목록을 작성해주세요. + placeholder: 목록을 작성해주세요. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/fix.yml b/.github/ISSUE_TEMPLATE/fix.yml new file mode 100644 index 0000000..6bc91ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/fix.yml @@ -0,0 +1,24 @@ +name: '[ Fix ] 버그 수정' +description: 수정이 필요한 버그 사항을 작성해주세요. +title: '[ Fix ] ' +body: + - type: markdown + attributes: + value: | + 작성 예시 : "[ Fix ] 로그인 시 비정상 응답 처리" + - type: textarea + id: fix-description + attributes: + label: 🛠 Issue + description: 어떤 문제가 발생했는지 구체적으로 작성해주세요. + placeholder: 버그에 대한 설명을 작성해주세요. + validations: + required: true + - type: textarea + id: fix-list + attributes: + label: 📝 해결 방안 및 작업 목록 + description: 수정에 필요한 항목을 체크리스트 형식으로 작성해주세요. + placeholder: 예) - [ ] API 오류 로그 확인\n- [ ] 예외 처리 로직 추가 + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..df05fac --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## 📌 연관된 이슈 번호 + +- closes #123 + +## 🌱 주요 변경 사항 + +- blabla + +## 📸 스크린샷 (선택) + +| 기능 | 스크린샷 | +| ---- | ---------------------- | +| 설명 | 스크린샷(png 또는 gif) | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..74eb163 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI - Build Check + +on: + pull_request: + branches: + - main + - develop + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Use Node.js (18.x) + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install dependencies + run: npm ci + + - name: Lint check + run: npm run lint + + - name: Type check (if using TypeScript) + run: npm run type-check + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index f6c5302..e3df48f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,9 @@ yarn-error.log* next-env.d.ts .idea -package-lock.json \ No newline at end of file +/lib/generated/prisma + +/generated/prisma + +*storybook.log +storybook-static diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..36158d9 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no-install commitlint --edit "$1" \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.prettierrc b/.prettierrc index 787be31..8052417 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,20 @@ { + "plugins": [ + "prettier-plugin-tailwindcss", + "@ianvs/prettier-plugin-sort-imports" + ], "semi": true, + "jsxSingleQuote": true, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", - "printWidth": 80 -} \ No newline at end of file + "printWidth": 80, + "importOrder": [ + "^react", + "^next", + "", + "^@/components/(.*)$", + "^@/lib/(.*)$", + "^[./]" + ] +} diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..bd9eb34 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,25 @@ +import type { StorybookConfig } from '@storybook/nextjs-vite'; +import { mergeConfig } from 'vite'; +import svgr from 'vite-plugin-svgr'; + +const config: StorybookConfig = { + stories: [ + '../stories/**/*.mdx', + '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)', + ], + addons: [ + '@chromatic-com/storybook', + '@storybook/addon-docs', + '@storybook/addon-a11y', + '@storybook/addon-vitest', + ], + framework: { + name: '@storybook/nextjs-vite', + options: {}, + }, + + viteFinal: async (config) => mergeConfig(config, { plugins: [svgr()] }), + + staticDirs: ['../public'], +}; +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 0000000..7f8f09c --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,22 @@ +import type { Preview } from '@storybook/nextjs-vite'; +import '../app/globals.css'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + }, +}; + +export default preview; diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts new file mode 100644 index 0000000..e8c7ed3 --- /dev/null +++ b/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; +import { setProjectAnnotations } from '@storybook/nextjs-vite'; +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/README.md b/README.md index e215bc4..b8a413b 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,243 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# ISAID -## Getting Started -First, run the development server: +> **ISAID**는 MZ세대를 위한 +> **맞춤형 ISA 포트폴리오 분석 서비스**입니다. +> +> +> 최근 MZ세대를 중심으로 투자에 대한 관심은 높아졌지만, +> ISA 계좌의 구조나 세제 혜택, ETF 포트폴리오 구성은 여전히 복잡하고 진입장벽이 높습니다. +> ISAID는 이러한 복잡함을 덜고, 누구나 직관적으로 투자 흐름을 파악할 수 있도록 기획되었습니다. +> -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. -## Learn More -To learn more about Next.js, take a look at the following resources: -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +🔗 배포주소 : [https://isaid.site/](https://isaid.site/) -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! -## Deploy on Vercel +--- -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +## System Architecture +

+ +

+ +
+ +
+ +## ERD +

+ +

+ +
+
+ + +## 화면 구성 + +| 첫 화면 | 홈 화면 | ETF 챌린지 | 데일리 퀴즈 | +|--|--|--|--| +| ![](https://github.com/user-attachments/assets/27578b73-4b72-42c9-b821-cd61a79c489d) | ![](https://github.com/user-attachments/assets/e3f7045b-e034-4d21-a977-50550e1d1f49) | ![](https://github.com/user-attachments/assets/335916e3-d50b-4185-8279-6c5e7c30bb53) | ![](https://github.com/user-attachments/assets/4c2574e1-8dcd-42d7-92b4-40574c3ed9fa) | + +| ETF 투자성향 테스트 | ETF 추천 | ETF 상세보기 | 마이페이지 | +|--|--|--|--| +| ![](https://github.com/user-attachments/assets/c1b5582a-6062-413b-95f1-6861fa2b81fb) | ![](https://github.com/user-attachments/assets/10b619a3-d3e3-4fe9-8b72-e9d5fcf7362c) | ![](https://github.com/user-attachments/assets/19ede02e-96be-442b-bb29-88441c4d597d) | ![](https://github.com/user-attachments/assets/46c26371-cec6-4c5b-a360-1aa8c24133b4) | + +| ETF 투자 성향 테스트 | 금융 가이드 | 카테고리별 가이드 | 숏츠 가이드 | +|--|--|--|--| +| ![](https://github.com/user-attachments/assets/935a03f0-4ed8-48cc-ad3f-7ebdb9823b75) | ![](https://github.com/user-attachments/assets/b6e9fdb4-95a6-49fd-818a-1325b4d65b67) | ![](https://github.com/user-attachments/assets/1cd818bf-604f-467b-b4b6-e7f63a94aeaa) | ![](https://github.com/user-attachments/assets/4e94493e-8d9b-4b53-ae05-5762309f76c0) | + + + +
+
+ +## ⚙️ 주요 기능 요약 + +| 분류 | 기능 | 설명 | +|------|------|------| +| 사용자 | 회원가입 / 로그인 | 자체 인증 기반 회원 시스템 (Credentials + JWT) | +| ISA 계좌 | ISA 계좌 등록/삭제 | 사용자는 ISA 계좌를 등록하고 삭제할 수 있음 | +| ISA 계좌 | 수익률 조회 | 월별 기준으로 자산 수익률을 계산해 제공 | +| ISA 계좌 | 포트폴리오 리밸런싱 | 기존 보유 ETF 기준으로 리밸런싱을 추천 | +| ISA 계좌 | 절세 계산기 | 예상 절세 효과 및 만기 후 수령액 시뮬레이션 | +| ETF | 테마 / 카테고리별 ETF 조회 | 다양한 조건(테마/카테고리)으로 ETF 탐색 가능 | +| ETF | 투자 성향 테스트 | 투자 성향을 진단하고 맞춤 ETF를 추천 | +| ETF | ETF 차트 및 구성 | 종목별 구성 비중 및 수익률 차트 제공 | +| 퀴즈 | 금융 퀴즈 | 퀴즈로 금융 지식을 점검하고 캘린더에 기록 | +| 챌린지 | 퀴즈 보상 시스템 | 일정 조건을 달성하면 보상 수령 가능 | + + +
+
+ +## 📁 폴더 구조 +| app/ | App Router 기반 페이지, 액션, API 라우트 구성 | +| --- | --- | +| components/ | 서비스 전반적으로 사용되는, 재사용 가능한 공통 UI 컴포넌트 | +| context/ | 클라이언트 상태 공유 (예: 헤더 컨텍스트) | +| services/ | 핵심 알고리즘 계층 (ETF 추천, 리밸런싱 등) | +| lib/ | DB 접근, API 호출, 인증 등 로직 | +| data/ | 선택지, 기본값, 샘플 데이터 | +| types/ | 공통 타입 정의 | +| __tests__/, __mocks__/ | 테스트 및 의존성 모킹 코드 | +| prisma/ | 데이터베이스 모델 및 마이그레이션 | +| public/ | 정적 파일 (폰트, 이미지 등) | +| 설정 파일들 | 린트, 프리티어, 타입스크립트, 패키지 관리 설정 | +
+
+ + +## 🛠 Tech Stack + +### Platforms & Languages + + + + +### Deployment & Version Control + + + +### Development Environment + + + +### Communication & Management + + + + +
+
+ +## 📌 API 문서 요약 + + + +### 👤 사용자 (User) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/auth/join` | 회원가입 | +| `POST` | `/api/auth/callback/credentials` | 로그인 (Credential 기반) | +| `PATCH` | `/api/user/update` | 회원정보 수정 | +| `DELETE` | `/api/user/delete` | 회원 탈퇴 | +| `GET` | `/api/user/me` | 내 정보 조회 | +| `POST` | `/api/user/verify-pin` | PIN 인증 | +| `PATCH` | `/api/user/reward-agree` | 보상 동의 | + + + +### 💹 ETF + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `GET` | `/api/etf/theme/{themeSlug}` | 테마별 ETF 리스트 조회 | +| `GET` | `/api/etf/category/{categoryId}` | 카테고리별 ETF 리스트 조회 | +| `GET` | `/api/etf/recommend` | 사용자 맞춤 ETF 추천 | +| `GET` | `/api/etf/{etfId}` | 개별 ETF 상세 정보 조회 | +| `GET` | `/api/etf/{etfId}/chart?range={period}` | ETF 기간별 차트 데이터 조회 | +| `GET` | `/api/etf/{etfId}/pdf` | ETF 구성 비중 PDF 반환 | +| `GET` | `/api/etf/portfolio` | 보유 ETF 상세 조회 | +| `GET` | `/api/etf/mbti` | 투자 성향 및 선호 카테고리 조회 | +| `POST` | `/api/etf/mbti` | 투자 성향 및 선호 카테고리 저장 | + + + +### 💼 ISA + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/isa` | ISA 계좌 등록 | +| `GET` | `/api/isa` | ISA 계좌 목록 조회 | +| `DELETE` | `/api/isa` | ISA 계좌 삭제 | +| `GET` | (서버액션) | 수익률 조회 | +| `GET` | (서버액션) | 포트폴리오 조회 | +| `POST` | `/api/isa/save` | 절세 계산기 | +| `GET` | `/api/isa/rebalancing` | 포트폴리오 리밸런싱 추천 | + + +### 🧠 금융 퀴즈 (Quiz) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `GET` | `/api/quiz/question` | 퀴즈 가져오기 | +| `POST` | `/api/quiz/question/submit` | 정답 제출 및 캘린더 기록 | +| `GET` | `/api/quiz/calendar` | 퀴즈 달력 조회 | + + + +### 🏆 챌린지 (Challenge) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/challenge/claim` | 보상 수령 요청 | + +
+
+ +## 🧪 테스트 + +ISAID 프로젝트는 포괄적인 테스트 스위트를 제공하여 코드의 안정성과 신뢰성을 보장합니다. + +### API 테스트 + +| 테스트 파일 | API 경로 | 설명 | +|-------------|----------|------| +| `challenge-claim-api.test.ts` | `/api/challenge/claim` | 챌린지 보상 청구 API | +| `etf-recommend-api.test.ts` | `/api/etf/recommend` | ETF 추천 API | +| `etf-test-api.test.ts` | `/api/etf/mbti` | ETF 투자 성향 테스트 API | +| `isa-rebalancing-api.test.ts` | `/api/isa/rebalancing` | ISA 포트폴리오 리밸런싱 API | + +
+ +### 서비스 테스트 + +| 테스트 파일 | 서비스 | 설명 | +|-------------|--------|------| +| `etf-recommend-service.test.ts` | `EtfRecommendService` | ETF 추천 알고리즘 및 위험등급 분류 | +| `etf-test-service.test.ts` | `EtfTestService` | 투자 성향 분석 및 테스트 결과 처리 | +| `challenge-claim.test.ts` | `ChallengeClaimService` | 챌린지 보상 청구 조건 검증 | +| `challenge-status.test.ts` | `ChallengeStatusService` | 챌린지 진행 상태 확인 | +| `isa-rebalancing-service.test.ts` | `IsaRebalancingService` | ISA 포트폴리오 리밸런싱 전략 | +| `get-isa-portfolio.test.ts` | `GetIsaPortfolioService` | ISA 포트폴리오 조회 | +| `get-monthly-returns.test.ts` | `GetMonthlyReturnsService` | 월별 수익률 계산 | +| `tax-saving.test.ts` | `TaxSavingService` | 세금 절약 효과 계산 | + +
+ +### 헬퍼 함수 + +| 헬퍼 파일 | 설명 | +|-----------|------| +| `etf-recommend-helpers.ts` | ETF 추천 테스트용 모의 데이터 생성 | +| `etf-test-helpers.ts` | ETF 테스트 관련 헬퍼 함수 | +| `rebalancing-helpers.ts` | 리밸런싱 테스트 헬퍼 함수 | + +
+ +### 테스트 환경 + +| 항목 | 설명 | +|------|------| +| **테스트 프레임워크** | Jest | +| **실행 환경** | Node.js (`@jest-environment node`) | +| **모킹** | Prisma, NextAuth, 외부 서비스 | +| **스냅샷 테스트** | UI 컴포넌트 스냅샷 테스트 지원 | + +
+
+ +## 팀원 소개 +| | | | | | | +|:--:|:--:|:--:|:--:|:--:|:--:| +| **Yoonseo**
[@dbstj0403](https://github.com/dbstj0403)
| **Hyejeong Son**
[@HyejeongSon](https://github.com/HyejeongSon)
| **Hyo-joon**
[@hyo-joon](https://github.com/hyo-joon)
| **Jin Lee**
[@jjinleee](https://github.com/jjinleee)
| **Gibo Kim**
[@KimGiii](https://github.com/KimGiii)
| **Heegun Kwak**
[@VarGun](https://github.com/VarGun)
| diff --git a/__mocks__/auth-options.ts b/__mocks__/auth-options.ts new file mode 100644 index 0000000..53a3903 --- /dev/null +++ b/__mocks__/auth-options.ts @@ -0,0 +1,10 @@ +export const authOptions = { + // 테스트에 필요한 최소한의 구조만 포함 + pages: { + signIn: '/login', + }, + callbacks: { + session: jest.fn(), + jwt: jest.fn(), + }, +}; diff --git a/__mocks__/next-auth.ts b/__mocks__/next-auth.ts new file mode 100644 index 0000000..8ad6b83 --- /dev/null +++ b/__mocks__/next-auth.ts @@ -0,0 +1,8 @@ +export const getServerSession = jest.fn(() => + Promise.resolve({ + user: { + id: '5', // 기본 테스트용 유저 ID + email: 'test@test.com', + }, + }) +); diff --git a/__mocks__/prisma-factory.ts b/__mocks__/prisma-factory.ts new file mode 100644 index 0000000..1bfb219 --- /dev/null +++ b/__mocks__/prisma-factory.ts @@ -0,0 +1,148 @@ +type MockFn = { + [P in keyof T]: jest.Mock; +}; + +/** + * 기본 Prisma Mock 생성 + */ +export const createPrismaMock = (overrides = {}) => { + const baseMock = { + user: { + findUnique: jest.fn(), + }, + $transaction: jest.fn(), + }; + + return { + ...baseMock, + ...overrides, + }; +}; + +/** + * ETF 테스트용 Prisma Mock 생성 + */ +export const createEtfTestPrismaMock = (overrides = {}) => { + const baseMock = { + user: { + findUnique: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + investmentProfile: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + etfCategory: { + findMany: jest.fn(), + findUnique: jest.fn(), + }, + userEtfCategory: { + findMany: jest.fn(), + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + etf: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + $transaction: jest.fn(), + }; + + return { + ...baseMock, + ...overrides, + }; +}; + +/** + * 챌린지 테스트용 Prisma Mock 생성 + */ +export const createChallengePrismaMock = (overrides = {}) => { + const baseMock = { + user: { + findUnique: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + challenge: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + userChallengeClaim: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, + userChallengeProgress: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + upsert: jest.fn(), + }, + etf: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + etfDailyTrading: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + eTFTransaction: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + eTFHolding: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + }, + isaAccount: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + $transaction: jest.fn(), + }; + + return { + ...baseMock, + ...overrides, + }; +}; + +// 타입 정의 +export type BasePrismaMock = ReturnType; +export type EtfTestPrismaMock = ReturnType; +export type ChallengePrismaMock = ReturnType; +export type PrismaMockInstance = + | BasePrismaMock + | EtfTestPrismaMock + | ChallengePrismaMock; diff --git a/__mocks__/prisma.ts b/__mocks__/prisma.ts new file mode 100644 index 0000000..cf5ce74 --- /dev/null +++ b/__mocks__/prisma.ts @@ -0,0 +1,35 @@ +import { + createChallengePrismaMock, + createEtfTestPrismaMock, + createPrismaMock, +} from './prisma-factory'; + +// Jest에서 사용할 수 있도록 전역 mock 인스턴스 생성 +let mockPrismaInstance = createPrismaMock(); + +export const prisma = mockPrismaInstance; + +// 테스트에서 mock을 재설정할 수 있는 헬퍼 함수 +export const resetPrismaMock = () => { + mockPrismaInstance = createPrismaMock(); + return mockPrismaInstance; +}; + +// 특화된 mock으로 재설정하는 함수들 +export const resetWithEtfTestPrismaMock = () => { + mockPrismaInstance = createEtfTestPrismaMock(); + return mockPrismaInstance; +}; + +export const resetWithChallengePrismaMock = () => { + mockPrismaInstance = createChallengePrismaMock(); + return mockPrismaInstance; +}; + +// 커스텀 overrides로 재설정 +export const resetWithCustomMock = (overrides = {}) => { + mockPrismaInstance = createPrismaMock(overrides); + return mockPrismaInstance; +}; + +export const getPrismaMock = () => mockPrismaInstance; diff --git a/__tests__/api/challenge-claim-api.test.ts b/__tests__/api/challenge-claim-api.test.ts new file mode 100644 index 0000000..e468760 --- /dev/null +++ b/__tests__/api/challenge-claim-api.test.ts @@ -0,0 +1,188 @@ +/** + * @jest-environment node + */ +import { getServerSession } from 'next-auth'; +import { resetWithChallengePrismaMock } from '@/__mocks__/prisma'; +import { POST } from '@/app/api/challenge/claim/route'; +import { claimChallengeReward } from '@/services/challenge/challenge-claim'; +import { canClaimChallenge } from '@/services/challenge/challenge-status'; + +// Mock dependencies +jest.mock('next-auth'); +jest.mock('@/services/challenge/challenge-status'); +jest.mock('@/services/challenge/challenge-claim'); +jest.mock('@/lib/prisma', () => { + const { getPrismaMock } = require('@/__mocks__/prisma'); + return { + get prisma() { + return getPrismaMock(); + }, + }; +}); + +const mockGetServerSession = getServerSession as jest.MockedFunction< + typeof getServerSession +>; +const mockCanClaimChallenge = canClaimChallenge as jest.MockedFunction< + typeof canClaimChallenge +>; +const mockClaimChallengeReward = claimChallengeReward as jest.MockedFunction< + typeof claimChallengeReward +>; + +describe('/api/challenge/claim', () => { + let mockPrisma: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma = resetWithChallengePrismaMock(); + }); + + describe('POST', () => { + it('인증되지 않은 경우 401을 반환한다', async () => { + mockGetServerSession.mockResolvedValue(null); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.message).toBe('Unauthorized'); + }); + + it('challengeId가 없는 경우 400을 반환한다', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({}), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe('Challenge ID is required'); + }); + + it('챌린지 보상을 정상적으로 수령하면 200을 반환한다', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const mockTx = mockPrisma; + return await callback(mockTx); + }); + + mockCanClaimChallenge.mockResolvedValue({ canClaim: true }); + mockClaimChallengeReward.mockResolvedValue({ + success: true, + message: 'Reward claimed successfully', + transactionId: BigInt(123), + }); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.message).toBe('Reward claimed successfully'); + expect(data.transactionId).toBe('123'); + + expect(mockCanClaimChallenge).toHaveBeenCalledWith( + BigInt(1), + BigInt(1), + mockPrisma + ); + expect(mockClaimChallengeReward).toHaveBeenCalledWith( + { challengeId: BigInt(1), userId: BigInt(1) }, + mockPrisma + ); + }); + + it('보상을 받을 수 없는 챌린지인 경우 500을 반환한다', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const mockTx = mockPrisma; + return await callback(mockTx); + }); + + mockCanClaimChallenge.mockResolvedValue({ + canClaim: false, + reason: 'Already claimed', + }); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.message).toBe('Already claimed'); + }); + + it('보상 처리 중 실패한 경우 400을 반환한다', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const mockTx = mockPrisma; + return await callback(mockTx); + }); + + mockCanClaimChallenge.mockResolvedValue({ canClaim: true }); + mockClaimChallengeReward.mockResolvedValue({ + success: false, + message: 'ISA account not found', + }); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe('ISA account not found'); + }); + + it('예상치 못한 오류가 발생하면 500을 반환한다', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + mockPrisma.$transaction.mockRejectedValue(new Error('Database error')); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.message).toBe('Database error'); + }); + }); +}); diff --git a/__tests__/api/etf-recommend-api.test.ts b/__tests__/api/etf-recommend-api.test.ts new file mode 100644 index 0000000..7c90318 --- /dev/null +++ b/__tests__/api/etf-recommend-api.test.ts @@ -0,0 +1,272 @@ +/** + * @jest-environment node + */ +import { getServerSession } from 'next-auth'; +import { NextRequest } from 'next/server'; +import { GET } from '@/app/api/etf/recommend/route'; + +// 모킹 설정 +jest.mock('next-auth'); +jest.mock('@/services/etf/etf-recommend-service', () => ({ + EtfRecommendService: jest.fn(), + InvestmentProfileNotFoundError: class InvestmentProfileNotFoundError extends Error { + constructor() { + super('투자 성향 테스트를 먼저 완료해주세요.'); + this.name = 'InvestmentProfileNotFoundError'; + } + }, + NoEtfDataError: class NoEtfDataError extends Error { + constructor() { + super('추천할 수 있는 ETF가 없습니다.'); + this.name = 'NoEtfDataError'; + } + }, + NoTradingDataError: class NoTradingDataError extends Error { + constructor() { + super('거래 데이터가 있는 ETF가 없습니다.'); + this.name = 'NoTradingDataError'; + } + }, +})); + +const mockEtfRecommendService = { + getRecommendations: jest.fn(), +}; + +const { + EtfRecommendService, + InvestmentProfileNotFoundError, + NoEtfDataError, + NoTradingDataError, +} = require('@/services/etf/etf-recommend-service'); + +(EtfRecommendService as jest.Mock).mockImplementation( + () => mockEtfRecommendService +); + +describe('GET /api/etf/recommend', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('인증 테스트', () => { + it('인증되지 않은 사용자는 401 에러를 반환한다', async () => { + // Given + (getServerSession as jest.Mock).mockResolvedValue(null); + + // When + const req = new NextRequest('http://localhost:3000/api/etf/recommend'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(401); + expect(json.message).toBe('인증된 사용자만 접근 가능합니다.'); + }); + + it('세션에 사용자 ID가 없으면 401 에러를 반환한다', async () => { + // Given + (getServerSession as jest.Mock).mockResolvedValue({ + user: {}, + }); + + // When + const req = new NextRequest('http://localhost:3000/api/etf/recommend'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(401); + expect(json.message).toBe('인증된 사용자만 접근 가능합니다.'); + }); + }); + + describe('서비스 로직 테스트', () => { + beforeEach(() => { + (getServerSession as jest.Mock).mockResolvedValue({ + user: { id: '61' }, + }); + }); + + it('투자 성향이 없으면 400 에러를 반환한다', async () => { + // Given + mockEtfRecommendService.getRecommendations.mockRejectedValue( + new InvestmentProfileNotFoundError() + ); + + // When + const req = new NextRequest('http://localhost:3000/api/etf/recommend'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(400); + expect(json.message).toBe('투자 성향 테스트를 먼저 완료해주세요.'); + expect(mockEtfRecommendService.getRecommendations).toHaveBeenCalledWith( + BigInt('61'), + 10 + ); + }); + + it('ETF 데이터가 없으면 404 에러를 반환한다', async () => { + // Given + mockEtfRecommendService.getRecommendations.mockRejectedValue( + new NoEtfDataError() + ); + + // When + const req = new NextRequest('http://localhost:3000/api/etf/recommend'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(404); + expect(json.message).toBe('추천할 수 있는 ETF가 없습니다.'); + }); + + it('거래 데이터가 있는 ETF가 없으면 404 에러를 반환한다', async () => { + // Given + mockEtfRecommendService.getRecommendations.mockRejectedValue( + new NoTradingDataError() + ); + + // When + const req = new NextRequest('http://localhost:3000/api/etf/recommend'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(404); + expect(json.message).toBe('거래 데이터가 있는 ETF가 없습니다.'); + }); + + it('성공적으로 ETF 추천 결과를 반환한다', async () => { + // Given + const mockResult = { + recommendations: [ + { + etfId: '1', + issueCode: 'TEST001', + issueName: '테스트 ETF', + category: '주식형', + score: 85.5, + riskGrade: 3, + flucRate: 2.5, + metrics: { + sharpeRatio: 1.2, + totalFee: 0.3, + tradingVolume: 1000000, + netAssetValue: 50000000000, + trackingError: 0.5, + divergenceRate: 0.2, + volatility: 0.15, + normalizedVolatility: 0.6, + }, + reasons: [ + { + title: '낮은 총보수', + description: '0.3%의 낮은 총보수로 비용 효율성이 우수합니다.', + }, + ], + }, + ], + userProfile: { + investType: 'MODERATE', + totalEtfsAnalyzed: 100, + filteredEtfsCount: 50, + }, + weights: { + sharpeRatio: 0.1, + totalFee: 0.25, + tradingVolume: 0.15, + netAssetValue: 0.2, + trackingError: 0.1, + divergenceRate: 0.1, + volatility: 0.1, + }, + debug: { + allowedRiskGrades: [2, 3, 4], + totalEtfsBeforeFilter: 100, + totalEtfsAfterFilter: 50, + }, + }; + + mockEtfRecommendService.getRecommendations.mockResolvedValue(mockResult); + + // When + const req = new NextRequest('http://localhost:3000/api/etf/recommend'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(200); + expect(json.message).toBe('ETF 추천 성공'); + expect(json.data).toEqual(mockResult); + expect(mockEtfRecommendService.getRecommendations).toHaveBeenCalledWith( + BigInt('61'), + 10 + ); + }); + }); + + describe('에러 처리 테스트', () => { + beforeEach(() => { + (getServerSession as jest.Mock).mockResolvedValue({ + user: { id: '61' }, + }); + }); + + it('데이터베이스 오류 시 500 에러를 반환한다', async () => { + // Given + const dbError = new Error('Database connection failed'); + mockEtfRecommendService.getRecommendations.mockRejectedValue(dbError); + + // When + const req = new NextRequest('http://localhost:3000/api/etf/recommend'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(500); + expect(json.message).toBe( + '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + ); + }); + + it('서비스 오류 시 500 에러를 반환한다', async () => { + // Given + const serviceError = new Error('Service error occurred'); + mockEtfRecommendService.getRecommendations.mockRejectedValue( + serviceError + ); + + // When + const req = new NextRequest('http://localhost:3000/api/etf/recommend'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(500); + expect(json.message).toBe( + '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + ); + }); + + it('알 수 없는 오류 시 500 에러를 반환한다', async () => { + // Given + const unknownError = new Error('Unknown error'); + mockEtfRecommendService.getRecommendations.mockRejectedValue( + unknownError + ); + + // When + const req = new NextRequest('http://localhost:3000/api/etf/recommend'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(500); + expect(json.message).toBe('서버 오류가 발생했습니다.'); + }); + }); +}); diff --git a/__tests__/api/etf-test-api.test.ts b/__tests__/api/etf-test-api.test.ts new file mode 100644 index 0000000..ddb9b63 --- /dev/null +++ b/__tests__/api/etf-test-api.test.ts @@ -0,0 +1,295 @@ +/** + * @jest-environment node + */ +import { getServerSession } from 'next-auth'; +import { NextRequest } from 'next/server'; +import { resetWithEtfTestPrismaMock } from '@/__mocks__/prisma'; +import { createEtfTestPrismaMock } from '@/__mocks__/prisma-factory'; +import { GET, POST } from '@/app/api/etf/mbti/route'; +import { InvestType } from '@prisma/client'; +import { + createMockEtfCategories, + createMockSession, + createValidMbtiRequest, + mockInvestmentProfileResult, + mockUserEtfCategoriesResult, +} from '../helpers/etf-test-helpers'; + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})); + +jest.mock('@/lib/prisma', () => { + const { getPrismaMock } = require('@/__mocks__/prisma'); + return { + get prisma() { + return getPrismaMock(); + }, + }; +}); + +let mockPrisma: ReturnType; +const mockGetServerSession = getServerSession as jest.Mock; + +describe('/api/etf/mbti', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma = resetWithEtfTestPrismaMock() as ReturnType< + typeof createEtfTestPrismaMock + >; + }); + + describe('POST', () => { + it('프로필 없으면 create가 호출되며 MBTI 결과가 정상적으로 저장된다', async () => { + const mockSession = createMockSession('5'); + const validRequest = createValidMbtiRequest(); + const mockCategories = createMockEtfCategories(); + + mockGetServerSession.mockResolvedValue(mockSession); + + const filteredCategories = mockCategories.filter((c) => + validRequest.preferredCategories.includes(c.fullPath) + ); + + const mockFindUnique = jest.fn().mockResolvedValue(null); + const mockCreate = jest.fn(); + const mockUpdate = jest.fn(); // 호출 X + const mockFindMany = jest.fn().mockResolvedValue(filteredCategories); + const mockDeleteMany = jest.fn(); + const mockCreateMany = jest.fn(); + + const mockTransaction = jest.fn().mockImplementation(async (callback) => { + const mockTx = { + investmentProfile: { + findUnique: mockFindUnique, + create: mockCreate, + update: mockUpdate, + }, + etfCategory: { findMany: mockFindMany }, + userEtfCategory: { + deleteMany: mockDeleteMany, + createMany: mockCreateMany, + }, + }; + return await callback(mockTx as any); + }); + + mockPrisma.$transaction = mockTransaction; + + const request = new NextRequest('http://localhost:3000/api/etf/mbti', { + method: 'POST', + body: JSON.stringify(validRequest), + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + message: '투자 성향 및 선호 카테고리 업데이트 성공', + }); + + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { userId: BigInt(mockSession.user.id) }, + }); + expect(mockCreate).toHaveBeenCalledWith({ + data: { + userId: BigInt(mockSession.user.id), + investType: validRequest.investType, + }, + }); + expect(mockUpdate).not.toHaveBeenCalled(); + + expect(mockFindMany).toHaveBeenCalledWith({ + where: { fullPath: { in: validRequest.preferredCategories } }, + select: { id: true, fullPath: true }, + }); + + expect(mockDeleteMany).toHaveBeenCalledWith({ + where: { userId: BigInt(mockSession.user.id) }, + }); + + expect(mockCreateMany).toHaveBeenCalledWith({ + data: filteredCategories.map((category) => ({ + userId: BigInt(mockSession.user.id), + etfCategoryId: category.id, + })), + }); + }); + + it('프로필 있으면 update가 호출되며 MBTI 결과가 정상적으로 저장된다', async () => { + const mockSession = createMockSession('5'); + const validRequest = createValidMbtiRequest(); + const mockCategories = createMockEtfCategories(); + + mockGetServerSession.mockResolvedValue(mockSession); + + const filteredCategories = mockCategories.filter((c) => + validRequest.preferredCategories.includes(c.fullPath) + ); + + const mockFindUnique = jest.fn().mockResolvedValue({ userId: 5 }); + const mockCreate = jest.fn(); // 호출 X + const mockUpdate = jest.fn(); + const mockFindMany = jest.fn().mockResolvedValue(filteredCategories); + const mockDeleteMany = jest.fn(); + const mockCreateMany = jest.fn(); + + const mockTransaction = jest.fn().mockImplementation(async (callback) => { + const mockTx = { + investmentProfile: { + findUnique: mockFindUnique, + create: mockCreate, + update: mockUpdate, + }, + etfCategory: { findMany: mockFindMany }, + userEtfCategory: { + deleteMany: mockDeleteMany, + createMany: mockCreateMany, + }, + }; + return await callback(mockTx as any); + }); + + mockPrisma.$transaction = mockTransaction; + + const request = new NextRequest('http://localhost:3000/api/etf/mbti', { + method: 'POST', + body: JSON.stringify(validRequest), + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalledWith({ + where: { userId: BigInt(mockSession.user.id) }, + data: { investType: validRequest.investType }, + }); + }); + + it('인증되지 않은 사용자에 대해 401을 반환한다', async () => { + mockGetServerSession.mockResolvedValue(null); + + const request = new NextRequest('http://localhost:3000/api/etf/mbti', { + method: 'POST', + body: JSON.stringify(createValidMbtiRequest()), + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data).toEqual({ message: '인증된 사용자만 접근 가능합니다.' }); + }); + + it('유효하지 않은 요청 데이터에 대해 400을 반환한다', async () => { + const mockSession = createMockSession('1'); + mockGetServerSession.mockResolvedValue(mockSession); + + const invalidRequest = { + investType: 'INVALID_TYPE', + preferredCategories: [], + }; + + const request = new NextRequest('http://localhost:3000/api/etf/mbti', { + method: 'POST', + body: JSON.stringify(invalidRequest), + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('유효하지 않은'); + }); + + it('존재하지 않는 카테고리에 대해 400을 반환한다', async () => { + const mockSession = createMockSession('1'); + mockGetServerSession.mockResolvedValue(mockSession); + + const invalidRequest = { + investType: InvestType.MODERATE, + preferredCategories: ['존재하지않는카테고리'], + }; + + const mockTransaction = jest.fn().mockImplementation(async (callback) => { + const mockTx = { + investmentProfile: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn(), + update: jest.fn(), + }, + etfCategory: { findMany: jest.fn().mockResolvedValue([]) }, + userEtfCategory: { + deleteMany: jest.fn(), + createMany: jest.fn(), + }, + }; + return await callback(mockTx as any); + }); + + mockPrisma.$transaction = mockTransaction; + + const request = new NextRequest('http://localhost:3000/api/etf/mbti', { + method: 'POST', + body: JSON.stringify(invalidRequest), + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('유효하지 않은 카테고리'); + }); + }); + + describe('GET', () => { + it('정상적으로 사용자 투자 프로필을 반환한다', async () => { + const mockSession = createMockSession('1'); + + mockGetServerSession.mockResolvedValue(mockSession); + mockPrisma.investmentProfile.findUnique.mockResolvedValue( + mockInvestmentProfileResult + ); + mockPrisma.user.findUnique.mockResolvedValue(mockUserEtfCategoriesResult); + + const request = new NextRequest('http://localhost:3000/api/etf/mbti', { + method: 'GET', + }); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + investType: InvestType.CONSERVATIVE, + preferredCategories: [ + { id: '6', fullPath: '주식-업종섹터-금융' }, + { id: '11', fullPath: '주식-업종섹터-정보기술' }, + { id: '10', fullPath: '주식-업종섹터-헬스케어' }, + ], + }); + }); + + it('인증되지 않은 사용자에 대해 401을 반환한다', async () => { + mockGetServerSession.mockResolvedValue(null); + + const request = new NextRequest('http://localhost:3000/api/etf/mbti', { + method: 'GET', + }); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data).toEqual({ message: '인증된 사용자만 접근 가능합니다.' }); + }); + }); +}); diff --git a/__tests__/api/isa-rebalancing-api.test.ts b/__tests__/api/isa-rebalancing-api.test.ts new file mode 100644 index 0000000..75872c6 --- /dev/null +++ b/__tests__/api/isa-rebalancing-api.test.ts @@ -0,0 +1,182 @@ +/** + * @jest-environment node + */ +import { getServerSession } from 'next-auth'; +import { NextRequest } from 'next/server'; +import { GET } from '@/app/api/isa/rebalancing/route'; + +// 모킹 설정 +jest.mock('next-auth'); +jest.mock('@/services/isa/rebalancing-service', () => ({ + RebalancingService: jest.fn(), + InvestmentProfileNotFoundError: class InvestmentProfileNotFoundError extends Error { + constructor() { + super('투자 성향 정보가 없습니다.'); + this.name = 'InvestmentProfileNotFoundError'; + } + }, + ISAAccountNotFoundError: class ISAAccountNotFoundError extends Error { + constructor() { + super('ISA 계좌 정보가 없습니다.'); + this.name = 'ISAAccountNotFoundError'; + } + }, +})); + +const mockRebalancingService = { + getRebalancingRecommendation: jest.fn(), +}; + +const { + RebalancingService, + InvestmentProfileNotFoundError, + ISAAccountNotFoundError, +} = require('@/services/isa/rebalancing-service'); + +(RebalancingService as jest.Mock).mockImplementation( + () => mockRebalancingService +); + +describe('GET /api/isa/rebalancing', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('인증 테스트', () => { + it('인증되지 않은 사용자는 401 에러를 반환한다', async () => { + // Given + (getServerSession as jest.Mock).mockResolvedValue(null); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(401); + expect(json.message).toBe('인증되지 않은 사용자입니다.'); + }); + + it('세션에 사용자 ID가 없으면 401 에러를 반환한다', async () => { + // Given + (getServerSession as jest.Mock).mockResolvedValue({ + user: {}, + }); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(401); + expect(json.message).toBe('인증되지 않은 사용자입니다.'); + }); + }); + + describe('서비스 로직 테스트', () => { + beforeEach(() => { + (getServerSession as jest.Mock).mockResolvedValue({ + user: { id: '123' }, + }); + }); + + it('투자 성향 정보가 없으면 404 에러를 반환한다', async () => { + // Given + mockRebalancingService.getRebalancingRecommendation.mockRejectedValue( + new InvestmentProfileNotFoundError() + ); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(404); + expect(json.message).toBe('투자 성향 정보가 없습니다.'); + expect( + mockRebalancingService.getRebalancingRecommendation + ).toHaveBeenCalledWith(BigInt('123')); + }); + + it('ISA 계좌 정보가 없으면 404 에러를 반환한다', async () => { + // Given + mockRebalancingService.getRebalancingRecommendation.mockRejectedValue( + new ISAAccountNotFoundError() + ); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(404); + expect(json.message).toBe('ISA 계좌 정보가 없습니다.'); + }); + + it('성공적으로 리밸런싱 추천 결과를 반환한다', async () => { + // Given + const mockResult = { + recommendedPortfolio: [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + score: 85.5, + rebalancingOpinions: [ + { + category: '국내 주식', + userPercentage: 30, + recommendedPercentage: 25, + opinion: '비중 축소 필요', + detail: '국내 주식 비중이 권장수준보다 5.0%p 높습니다.', + }, + { + category: '해외 주식', + userPercentage: 20, + recommendedPercentage: 25, + opinion: '비중 확대 필요', + detail: + '해외 주식 비중이 권장수준보다 5.0%p 낮습니다. 해당 자산군에 대한 투자를 늘리는 것을 추천합니다.', + }, + ], + }; + + mockRebalancingService.getRebalancingRecommendation.mockResolvedValue( + mockResult + ); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(200); + expect(json).toEqual(mockResult); + expect( + mockRebalancingService.getRebalancingRecommendation + ).toHaveBeenCalledWith(BigInt('123')); + }); + + it('예상치 못한 에러가 발생하면 500 에러를 반환한다', async () => { + // Given + mockRebalancingService.getRebalancingRecommendation.mockRejectedValue( + new Error('예상치 못한 에러') + ); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(500); + expect(json.message).toBe('서버 오류가 발생했습니다.'); + }); + }); +}); diff --git a/__tests__/helpers/etf-recommend-helpers.ts b/__tests__/helpers/etf-recommend-helpers.ts new file mode 100644 index 0000000..64cce2f --- /dev/null +++ b/__tests__/helpers/etf-recommend-helpers.ts @@ -0,0 +1,198 @@ +import { + EtfData, + EtfRecommendationResponse, + MetricsData, + ProcessedEtfData, + WeightsData, +} from '@/services/etf/etf-recommend-service'; +import { InvestType } from '@prisma/client'; + +// Mock ETF 데이터 생성 +export const createMockEtfData = ( + overrides: Partial = {} +): EtfData => ({ + id: BigInt(1), + issueCode: 'TEST001', + issueName: '테스트 ETF', + return1y: '0.08', + etfTotalFee: '0.25', + netAssetTotalAmount: '2000000000', + traceErrRate: '0.08', + divergenceRate: '0.03', + volatility: '0.12', + category: { + fullPath: '주식/국내', + }, + tradings: [ + { + accTotalValue: '1000000000', + flucRate: '0.02', + }, + { + accTotalValue: '1200000000', + flucRate: '0.01', + }, + ], + ...overrides, +}); + +// Mock Processed ETF 데이터 생성 +export const createMockProcessedEtfData = ( + overrides: Partial = {} +): ProcessedEtfData => ({ + id: BigInt(1), + issueCode: 'TEST001', + issueName: '테스트 ETF', + category: { + fullPath: '주식/국내', + }, + processedData: { + return1y: 0.08, + etfTotalFee: 0.25, + netAssetTotalAmount: 2000000000, + traceErrRate: 0.08, + divergenceRate: 0.03, + volatility: 0.12, + riskGrade: 3, + avgTradingVolume: 1100000000, + flucRate: 0.02, + }, + ...overrides, +}); + +// Mock Metrics 데이터 생성 +export const createMockMetricsData = ( + overrides: Partial = {} +): MetricsData => ({ + return1y: { min: 0.05, max: 0.15 }, + etfTotalFee: { min: 0.2, max: 0.5 }, + netAssetTotalAmount: { min: 1000000000, max: 5000000000 }, + traceErrRate: { min: 0.05, max: 0.15 }, + divergenceRate: { min: 0.01, max: 0.1 }, + volatility: { min: 0.1, max: 0.2 }, + tradingVolume: { min: 500000000, max: 2000000000 }, + ...overrides, +}); + +// Mock Weights 데이터 생성 +export const createMockWeightsData = ( + overrides: Partial = {} +): WeightsData => ({ + sharpeRatio: 0.15, + totalFee: 0.2, + tradingVolume: 0.15, + netAssetValue: 0.15, + trackingError: 0.1, + divergenceRate: 0.1, + volatility: 0.15, + ...overrides, +}); + +// Mock ETF 추천 응답 생성 +export const createMockEtfRecommendationResponse = ( + overrides: Partial = {} +): EtfRecommendationResponse => ({ + etfId: '1', + issueCode: 'TEST001', + issueName: '테스트 ETF', + category: '주식/국내', + score: 0.75, + riskGrade: 3, + flucRate: 0.02, + metrics: { + sharpeRatio: 0.8, + totalFee: 0.25, + tradingVolume: 1100000000, + netAssetValue: 2000000000, + trackingError: 0.08, + divergenceRate: 0.03, + volatility: 0.12, + normalizedVolatility: 0.6, + }, + reasons: [ + { + title: '중위험 등급', + description: + '중위험 등급(리스크 등급 3)으로, 가격 변동성이 낮고 안정적인 운용이 기대됩니다.', + }, + ], + ...overrides, +}); + +// 다양한 투자 성향별 테스트 데이터 +export const createConservativeEtfData = (): EtfData => + createMockEtfData({ + issueCode: 'CONS001', + issueName: '보수형 ETF', + volatility: '0.08', // 저위험 + etfTotalFee: '0.15', // 낮은 수수료 + }); + +export const createAggressiveEtfData = (): EtfData => + createMockEtfData({ + issueCode: 'AGGR001', + issueName: '공격형 ETF', + volatility: '0.25', // 고위험 + return1y: '0.15', // 높은 수익률 + }); + +// 테스트용 ETF 목록 생성 +export const createMockEtfList = (count: number = 5): EtfData[] => { + return Array.from({ length: count }, (_, index) => + createMockEtfData({ + id: BigInt(index + 1), + issueCode: `TEST${String(index + 1).padStart(3, '0')}`, + issueName: `테스트 ETF ${index + 1}`, + return1y: String(0.05 + index * 0.02), + volatility: String(0.1 + index * 0.02), + }) + ); +}; + +// 테스트용 Processed ETF 목록 생성 +export const createMockProcessedEtfList = ( + count: number = 5 +): ProcessedEtfData[] => { + return Array.from({ length: count }, (_, index) => + createMockProcessedEtfData({ + id: BigInt(index + 1), + issueCode: `TEST${String(index + 1).padStart(3, '0')}`, + issueName: `테스트 ETF ${index + 1}`, + processedData: { + return1y: 0.05 + index * 0.02, + etfTotalFee: 0.2 + index * 0.05, + netAssetTotalAmount: 1000000000 + index * 500000000, + traceErrRate: 0.05 + index * 0.01, + divergenceRate: 0.01 + index * 0.01, + volatility: 0.1 + index * 0.02, + riskGrade: Math.max(1, 5 - index), // 위험등급 다양화 + avgTradingVolume: 500000000 + index * 200000000, + flucRate: 0.01 + index * 0.005, + }, + }) + ); +}; + +// 투자 성향별 허용 위험등급 테스트 데이터 +export const getTestRiskGradesByInvestType = ( + investType: InvestType +): number[] => { + const riskGradeMap = { + CONSERVATIVE: [4, 5], + MODERATE: [3, 4, 5], + NEUTRAL: [2, 3, 4, 5], + ACTIVE: [1, 2, 3, 4, 5], + AGGRESSIVE: [1, 2, 3, 4, 5], + }; + return riskGradeMap[investType] || [3, 4, 5]; +}; + +// 테스트용 사용자 ID +export const TEST_USER_ID = BigInt(61); + +// 테스트용 에러 메시지 +export const ERROR_MESSAGES = { + INVESTMENT_PROFILE_NOT_FOUND: '투자 성향 테스트를 먼저 완료해주세요.', + NO_ETF_DATA: '추천할 수 있는 ETF가 없습니다.', + NO_TRADING_DATA: '거래 데이터가 있는 ETF가 없습니다.', +}; diff --git a/__tests__/helpers/etf-test-helpers.ts b/__tests__/helpers/etf-test-helpers.ts new file mode 100644 index 0000000..ac3048b --- /dev/null +++ b/__tests__/helpers/etf-test-helpers.ts @@ -0,0 +1,113 @@ +import { InvestType } from '@prisma/client'; + +// 세션 생성 +export const createMockSession = (userId = '5') => ({ + user: { + id: userId, + email: 'test@test.com', + name: '테스트 사용자', + }, + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), +}); + +// 투자 성향 조회 mock 응답 +export const mockInvestmentProfileResult = { + investType: InvestType.CONSERVATIVE, +}; + +// 사용자 선호 ETF 카테고리 조회 mock 응답 +export const mockUserEtfCategoriesResult = { + userEtfCategories: [ + { etfCategory: { id: 6, fullPath: '주식-업종섹터-금융' } }, + { etfCategory: { id: 11, fullPath: '주식-업종섹터-정보기술' } }, + { etfCategory: { id: 10, fullPath: '주식-업종섹터-헬스케어' } }, + ], +}; + +// 전체 ETF 카테고리 mock 리스트 +export const createMockEtfCategories = () => [ + { id: 1, fullPath: '주식-시장대표' }, + { id: 2, fullPath: '주식-업종섹터' }, + { id: 3, fullPath: '주식-업종섹터-건설' }, + { id: 4, fullPath: '주식-업종섹터-중공업' }, + { id: 5, fullPath: '주식-업종섹터-산업재' }, + { id: 6, fullPath: '주식-업종섹터-금융' }, + { id: 7, fullPath: '주식-업종섹터-에너지화학' }, + { id: 8, fullPath: '주식-업종섹터-경기소비재' }, + { id: 9, fullPath: '주식-업종섹터-생활소비재' }, + { id: 10, fullPath: '주식-업종섹터-헬스케어' }, + { id: 11, fullPath: '주식-업종섹터-정보기술' }, + { id: 12, fullPath: '주식-업종섹터-철강소재' }, + { id: 13, fullPath: '주식-업종섹터-업종테마' }, + { id: 14, fullPath: '주식-업종섹터-커뮤니케이션서비스' }, + { id: 15, fullPath: '주식-전략-가치' }, + { id: 16, fullPath: '주식-전략-성장' }, + { id: 17, fullPath: '주식-전략-배당' }, + { id: 18, fullPath: '주식-전략-변동성' }, + { id: 19, fullPath: '주식-전략-구조화' }, + { id: 20, fullPath: '주식-전략-기업그룹' }, + { id: 21, fullPath: '주식-전략-전략테마' }, + { id: 22, fullPath: '주식-전략-혼합/퀀트' }, + { id: 23, fullPath: '주식-규모-대형주' }, + { id: 24, fullPath: '주식-규모-중형주' }, + { id: 25, fullPath: '혼합자산' }, + { id: 26, fullPath: '혼합자산-주식+채권' }, +]; + +// MBTI 제출 요청 - 유효한 데이터 +export const createValidMbtiRequest = () => ({ + investType: InvestType.MODERATE, + preferredCategories: [ + '주식-업종섹터-금융', + '주식-업종섹터-정보기술', + '주식-업종섹터-헬스케어', + ], +}); + +// Prisma Mock 객체 생성 +export const createMbtiServiceMock = () => ({ + investmentProfile: { + upsert: jest.fn(), + findUnique: jest.fn(), + }, + etfCategory: { + findMany: jest.fn(), + }, + userEtfCategory: { + deleteMany: jest.fn(), + createMany: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + $transaction: jest.fn(), +}); + +// Prisma 트랜잭션 mock 생성 함수 +export const createTestTransactionMock = ({ + profileExists = false, + preferredCategories = [], +}: { + profileExists?: boolean; + preferredCategories?: string[]; // 상위에서 전달받음 +} = {}) => { + const allMockCategories = createMockEtfCategories(); + const filteredCategories = allMockCategories.filter((c) => + preferredCategories.includes(c.fullPath) + ); + + return { + investmentProfile: { + findUnique: jest.fn().mockResolvedValue(profileExists ? { id: 1 } : null), + create: jest.fn(), + update: jest.fn(), + }, + etfCategory: { + findMany: jest.fn().mockResolvedValue(filteredCategories), + }, + userEtfCategory: { + deleteMany: jest.fn(), + createMany: jest.fn(), + }, + }; +}; diff --git a/__tests__/helpers/rebalancing-helpers.ts b/__tests__/helpers/rebalancing-helpers.ts new file mode 100644 index 0000000..4bd91b3 --- /dev/null +++ b/__tests__/helpers/rebalancing-helpers.ts @@ -0,0 +1,217 @@ +import { + InvestType, + UserHoldingDetails, + UserPortfolio, +} from '@/services/isa/rebalancing-service'; +import { InvestType as PrismaInvestType } from '@prisma/client'; + +export const TEST_USER_ID = BigInt('123'); + +export const ERROR_MESSAGES = { + INVESTMENT_PROFILE_NOT_FOUND: '투자 성향 정보가 없습니다.', + ISA_ACCOUNT_NOT_FOUND: 'ISA 계좌 정보가 없습니다.', +}; + +export function createMockInvestmentProfile( + investType: PrismaInvestType = PrismaInvestType.MODERATE +) { + return { + userId: TEST_USER_ID, + investType, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +export function createMockISAAccount() { + return { + id: BigInt(1), + userId: TEST_USER_ID, + accountNumber: '1234567890', + generalHoldingSnapshots: [], + generalHoldings: [], + etfHoldingSnapshots: [], + etfHoldings: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +export function createMockGeneralHolding( + instrumentType: 'BOND' | 'FUND' | 'ELS', + totalCost: number, + productName?: string +) { + return { + totalCost, + product: { + instrumentType, + productName: productName || `${instrumentType} 상품`, + }, + }; +} + +export function createMockEtfHoldingSnapshot( + etfId: bigint, + evaluatedAmount: number, + idxMarketType: '국내' | '해외' | '국내&해외', + issueNameKo?: string +) { + return { + evaluatedAmount, + etf: { + id: etfId, + issueNameKo: issueNameKo || `${idxMarketType} ETF`, + idxMarketType, + }, + }; +} + +export function createMockEtfHolding( + etfId: bigint, + quantity: number, + avgCost: number, + idxMarketType: '국내' | '해외' | '국내&해외', + issueNameKo?: string, + currentPrice?: number +) { + return { + etfId, + quantity: { + toNumber: () => quantity, + mul: (price: any) => ({ toNumber: () => quantity * price.toNumber() }), + }, + avgCost: { + toNumber: () => avgCost, + mul: (qty: any) => ({ toNumber: () => avgCost * qty.toNumber() }), + }, + etf: { + id: etfId, + issueNameKo: issueNameKo || `${idxMarketType} ETF`, + idxMarketType, + tradings: currentPrice + ? [ + { + tddClosePrice: { toNumber: () => currentPrice }, + }, + ] + : [], + }, + }; +} + +export function createMockUserHolding( + etfId?: bigint, + name = '테스트 자산', + totalCost = 1000000, + currentValue = 1100000, + categoryPath = '국내 주식', + assetType: 'ETF' | 'BOND' | 'FUND' | 'ELS' | 'CASH' = 'ETF' +): UserHoldingDetails { + const profitOrLoss = currentValue - totalCost; + const returnRate = totalCost > 0 ? (profitOrLoss / totalCost) * 100 : 0; + + return { + etfId, + name, + totalCost, + currentValue, + profitOrLoss, + returnRate, + categoryPath, + assetType, + }; +} + +export function createMockUserPortfolio( + category: string, + percentage: number, + totalValue: number, + profitOrLoss = 0, + returnRate = 0 +): UserPortfolio { + return { + category, + percentage, + totalValue, + profitOrLoss, + returnRate, + }; +} + +export function createMockRebalancingResponse( + investType: InvestType = InvestType.MODERATE, + score = 85.5 +) { + return { + recommendedPortfolio: [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + score, + rebalancingOpinions: [ + { + category: '국내 주식', + userPercentage: 30, + recommendedPercentage: 25, + opinion: '비중 축소 필요', + detail: '국내 주식 비중이 권장수준보다 5.0%p 높습니다.', + }, + { + category: '해외 주식', + userPercentage: 20, + recommendedPercentage: 25, + opinion: '비중 확대 필요', + detail: + '해외 주식 비중이 권장수준보다 5.0%p 낮습니다. 해당 자산군에 대한 투자를 늘리는 것을 추천합니다.', + }, + ], + }; +} + +export function getTestRecommendedPortfolioByInvestType( + investType: InvestType +) { + const portfolios = { + [InvestType.CONSERVATIVE]: [ + { category: '국내 주식', percentage: 10 }, + { category: '해외 주식', percentage: 10 }, + { category: '채권', percentage: 60 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 15 }, + ], + [InvestType.MODERATE]: [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + [InvestType.NEUTRAL]: [ + { category: '국내 주식', percentage: 30 }, + { category: '해외 주식', percentage: 30 }, + { category: '채권', percentage: 30 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + [InvestType.ACTIVE]: [ + { category: '국내 주식', percentage: 35 }, + { category: '해외 주식', percentage: 35 }, + { category: '채권', percentage: 20 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + [InvestType.AGGRESSIVE]: [ + { category: '국내 주식', percentage: 40 }, + { category: '해외 주식', percentage: 40 }, + { category: '채권', percentage: 10 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + }; + + return portfolios[investType]; +} diff --git a/__tests__/services/challenge-claim.test.ts b/__tests__/services/challenge-claim.test.ts new file mode 100644 index 0000000..1ace22b --- /dev/null +++ b/__tests__/services/challenge-claim.test.ts @@ -0,0 +1,354 @@ +import { createChallengePrismaMock } from '@/__mocks__/prisma-factory'; +import { claimChallengeReward } from '@/services/challenge/challenge-claim'; +import { Prisma } from '@prisma/client'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +describe('Challenge Claim Service', () => { + let mockTx: any; + const userId = BigInt(1); + const challengeId = BigInt(1); + const etfId = BigInt(1); + const isaAccountId = BigInt(1); + const today = dayjs().tz('Asia/Seoul').startOf('day'); + + beforeEach(() => { + mockTx = createChallengePrismaMock(); + }); + + it('ONCE 타입 챌린지 보상을 정상적으로 수령한다', async () => { + // Arrange + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(10), + challengeType: 'ONCE', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + const mockLatestTrading = { + tddClosePrice: new Prisma.Decimal(1000), + }; + + const mockTransaction = { + id: BigInt(1), + isaAccountId, + etfId, + quantity: new Prisma.Decimal(10), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal(1000), + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(mockLatestTrading); + mockTx.userChallengeClaim.create.mockResolvedValue({}); + mockTx.userChallengeProgress.updateMany.mockResolvedValue({}); + mockTx.eTFTransaction.create.mockResolvedValue(mockTransaction); + mockTx.eTFHolding.findUnique.mockResolvedValue(null); + mockTx.eTFHolding.upsert.mockResolvedValue({}); + + // Act + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + // Assert + expect(result.success).toBe(true); + expect(result.message).toBe('Reward claimed successfully'); + expect(result.transactionId).toBe(BigInt(1)); + + expect(mockTx.userChallengeClaim.create).toHaveBeenCalledWith({ + data: { + userId, + challengeId, + claimDate: expect.any(Date), + }, + }); + + expect(mockTx.eTFTransaction.create).toHaveBeenCalledWith({ + data: { + isaAccountId, + etfId, + quantity: new Prisma.Decimal(10), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal(1000), + transactionAt: expect.any(Date), + }, + }); + + // ONCE 타입은 진행도 초기화하지 않음 + expect(mockTx.userChallengeProgress.updateMany).not.toHaveBeenCalled(); + }); + + it('DAILY 타입 챌린지 보상을 정상적으로 수령하고 진행도를 초기화한다', async () => { + // Arrange + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(5), + challengeType: 'DAILY', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + const mockLatestTrading = { + tddClosePrice: new Prisma.Decimal(2000), + }; + + const mockTransaction = { + id: BigInt(2), + isaAccountId, + etfId, + quantity: new Prisma.Decimal(5), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal(2000), + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(mockLatestTrading); + mockTx.userChallengeClaim.create.mockResolvedValue({}); + mockTx.userChallengeProgress.updateMany.mockResolvedValue({}); + mockTx.eTFTransaction.create.mockResolvedValue(mockTransaction); + mockTx.eTFHolding.findUnique.mockResolvedValue(null); + mockTx.eTFHolding.upsert.mockResolvedValue({}); + + // Act + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + // Assert + expect(result.success).toBe(true); + expect(result.message).toBe('Reward claimed successfully'); + expect(result.transactionId).toBe(BigInt(2)); + + // DAILY 타입은 진행도 초기화 + expect(mockTx.userChallengeProgress.updateMany).toHaveBeenCalledWith({ + where: { userId, challengeId }, + data: { progressVal: 0 }, + }); + }); + + it('챌린지를 찾을 수 없으면 에러를 반환한다', async () => { + mockTx.challenge.findUnique.mockResolvedValue(null); + + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + expect(result.success).toBe(false); + expect(result.message).toBe('Challenge not found'); + }); + + it('ISA 계좌를 찾을 수 없으면 에러를 반환한다', async () => { + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(10), + challengeType: 'ONCE', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: null, + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + expect(result.success).toBe(false); + expect(result.message).toBe('ISA account not found'); + }); + + it('최신 ETF 가격을 찾을 수 없으면 에러를 반환한다', async () => { + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(10), + challengeType: 'ONCE', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(null); + + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + expect(result.success).toBe(false); + expect(result.message).toBe('Latest ETF price not found'); + }); + + it('기존 ETF 보유 내역이 있는 경우 올바르게 처리한다', async () => { + // Arrange + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(5), + challengeType: 'DAILY', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + const mockLatestTrading = { + tddClosePrice: new Prisma.Decimal(2000), + }; + + const mockExistingHolding = { + quantity: new Prisma.Decimal(10), + avgCost: new Prisma.Decimal(1500), + }; + + const mockTransaction = { + id: BigInt(2), + isaAccountId, + etfId, + quantity: new Prisma.Decimal(5), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal(2000), + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(mockLatestTrading); + mockTx.userChallengeClaim.create.mockResolvedValue({}); + mockTx.userChallengeProgress.updateMany.mockResolvedValue({}); + mockTx.eTFTransaction.create.mockResolvedValue(mockTransaction); + mockTx.eTFHolding.findUnique.mockResolvedValue(mockExistingHolding); + mockTx.eTFHolding.upsert.mockResolvedValue({}); + + // Act + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + // Assert + expect(result.success).toBe(true); + expect(mockTx.userChallengeProgress.updateMany).toHaveBeenCalledWith({ + where: { userId, challengeId }, + data: { progressVal: 0 }, + }); + + // 평균 단가 계산 검증: (10 * 1500 + 5 * 2000) / 15 = 1666.67 + const expectedAvgCost = new Prisma.Decimal(15000) + .add(new Prisma.Decimal(10000)) + .div(new Prisma.Decimal(15)); + + expect(mockTx.eTFHolding.upsert).toHaveBeenCalledWith({ + where: { + isaAccountId_etfId: { + isaAccountId, + etfId, + }, + }, + update: { + quantity: { increment: new Prisma.Decimal(5) }, + avgCost: expectedAvgCost, + updatedAt: expect.any(Date), + }, + create: { + isaAccountId, + etfId, + quantity: new Prisma.Decimal(5), + avgCost: expectedAvgCost, + acquiredAt: expect.any(Date), + updatedAt: expect.any(Date), + }, + }); + }); + + it('수량과 가격이 소수일 경우 평균 단가를 올바르게 계산한다', async () => { + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal('2.5'), + challengeType: 'DAILY', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + const mockLatestTrading = { + tddClosePrice: new Prisma.Decimal('1500.75'), + }; + + const mockExistingHolding = { + quantity: new Prisma.Decimal('3.5'), + avgCost: new Prisma.Decimal('1400.25'), + }; + + const mockTransaction = { + id: BigInt(4), + isaAccountId, + etfId, + quantity: new Prisma.Decimal('2.5'), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal('1500.75'), + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(mockLatestTrading); + mockTx.userChallengeClaim.create.mockResolvedValue({}); + mockTx.userChallengeProgress.updateMany.mockResolvedValue({}); + mockTx.eTFTransaction.create.mockResolvedValue(mockTransaction); + mockTx.eTFHolding.findUnique.mockResolvedValue(mockExistingHolding); + mockTx.eTFHolding.upsert.mockResolvedValue({}); + + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + expect(result.success).toBe(true); + expect(result.transactionId).toBe(BigInt(4)); + + // 평균 단가 검증 + const totalQty = mockExistingHolding.quantity.add(mockChallenge.quantity); // 3.5 + 2.5 = 6.0 + const totalCost = mockExistingHolding.avgCost + .mul(mockExistingHolding.quantity) // 1400.25 * 3.5 + .add(mockChallenge.quantity.mul(mockLatestTrading.tddClosePrice)); // 2.5 * 1500.75 + const expectedAvgCost = totalCost.div(totalQty); // (4900.875 + 3751.875) / 6.0 = 8652.75 / 6.0 + + expect(mockTx.eTFHolding.upsert).toHaveBeenCalledWith({ + where: { + isaAccountId_etfId: { + isaAccountId, + etfId, + }, + }, + update: { + quantity: { increment: new Prisma.Decimal('2.5') }, + avgCost: expectedAvgCost, + updatedAt: expect.any(Date), + }, + create: { + isaAccountId, + etfId, + quantity: new Prisma.Decimal('2.5'), + avgCost: expectedAvgCost, + acquiredAt: expect.any(Date), + updatedAt: expect.any(Date), + }, + }); + }); +}); diff --git a/__tests__/services/challenge-status.test.ts b/__tests__/services/challenge-status.test.ts new file mode 100644 index 0000000..cc0421e --- /dev/null +++ b/__tests__/services/challenge-status.test.ts @@ -0,0 +1,253 @@ +import { createChallengePrismaMock } from '@/__mocks__/prisma-factory'; +import { + calculateChallengeStatus, + canClaimChallenge, + type ChallengeWithProgress, +} from '@/services/challenge/challenge-status'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +describe('Challenge Status Service', () => { + const userId = BigInt(1); + const today = dayjs().tz('Asia/Seoul').startOf('day'); + + describe('calculateChallengeStatus', () => { + it('이미 보상을 수령한 챌린지는 CLAIMED를 반환한다', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [{ claimDate: new Date() }], + userChallengeProgresses: [ + { progressVal: 1, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('CLAIMED'); + }); + + it('ONCE 타입 챌린지는 진행도가 1 이상이면 ACHIEVABLE을 반환한다', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [ + { progressVal: 1, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('ACHIEVABLE'); + }); + + it('ONCE 타입 챌린지는 진행도가 1 미만이면 INCOMPLETE를 반환한다', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [ + { progressVal: 0, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('INCOMPLETE'); + }); + + it('STREAK 타입 챌린지는 진행도 7 이상이고 오늘 갱신됐으면 ACHIEVABLE을 반환한다', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'STREAK', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 7, + createdAt: new Date(), + updatedAt: today.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('ACHIEVABLE'); + }); + + it('STREAK 타입 챌린지는 진행도 7 이상이어도 오늘 갱신되지 않았으면 INCOMPLETE를 반환한다', () => { + const yesterday = today.subtract(1, 'day'); + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'STREAK', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 7, + createdAt: new Date(), + updatedAt: yesterday.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('INCOMPLETE'); + }); + + it('DAILY 타입 챌린지는 진행도가 0보다 크고 오늘 갱신됐으면 ACHIEVABLE을 반환한다', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'DAILY', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 1, + createdAt: new Date(), + updatedAt: today.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('ACHIEVABLE'); + }); + + it('DAILY 타입 챌린지는 진행도가 0보다 크고 오늘 생성됐으면 ACHIEVABLE을 반환한다', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'DAILY', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 1, + createdAt: today.toDate(), + updatedAt: null, + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('ACHIEVABLE'); + }); + + it('DAILY 타입 챌린지는 오늘 이미 보상을 수령했으면 CLAIMED를 반환한다', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'DAILY', + userChallengeClaims: [{ claimDate: today.toDate() }], + userChallengeProgresses: [ + { + progressVal: 1, + createdAt: new Date(), + updatedAt: today.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('CLAIMED'); + }); + + it('DAILY 타입 챌린지는 진행도가 0이면 오늘 갱신됐더라도 INCOMPLETE를 반환한다', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'DAILY', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 0, + createdAt: new Date(), + updatedAt: today.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('INCOMPLETE'); + }); + + it('진행도 정보가 없으면 INCOMPLETE를 반환한다', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('INCOMPLETE'); + }); + }); + + describe('canClaimChallenge', () => { + let mockTx: any; + + beforeEach(() => { + mockTx = createChallengePrismaMock(); + }); + + it('챌린지를 찾을 수 없으면 false를 반환하고 이유를 제공한다', async () => { + mockTx.challenge.findUnique.mockResolvedValue(null); + + const result = await canClaimChallenge(BigInt(1), userId, mockTx); + + expect(result.canClaim).toBe(false); + expect(result.reason).toBe('Challenge not found'); + }); + + it('이미 보상을 수령한 챌린지는 false를 반환하고 이유를 제공한다', async () => { + const mockChallenge = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [{ claimDate: new Date() }], + userChallengeProgresses: [ + { progressVal: 1, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + + const result = await canClaimChallenge(BigInt(1), userId, mockTx); + + expect(result.canClaim).toBe(false); + expect(result.reason).toBe('Already claimed'); + }); + + it('보상이 가능한 챌린지는 true를 반환한다', async () => { + const mockChallenge = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [ + { progressVal: 1, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + + const result = await canClaimChallenge(BigInt(1), userId, mockTx); + + expect(result.canClaim).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it('완료되지 않은 챌린지는 false를 반환하고 이유를 제공한다', async () => { + const mockChallenge = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [ + { progressVal: 0, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + + const result = await canClaimChallenge(BigInt(1), userId, mockTx); + + expect(result.canClaim).toBe(false); + expect(result.reason).toBe('Challenge not completed'); + }); + }); +}); diff --git a/__tests__/services/etf-recommend-service.test.ts b/__tests__/services/etf-recommend-service.test.ts new file mode 100644 index 0000000..92acee6 --- /dev/null +++ b/__tests__/services/etf-recommend-service.test.ts @@ -0,0 +1,447 @@ +/** + * @jest-environment node + */ +import { + calculateSharpeRatio, + classifyRiskGrade, + EtfRecommendService, + generateReasons, + getAllowedRiskGrades, + getRiskBasedWeights, + InvestmentProfileNotFoundError, + NoEtfDataError, + normalize, + normalizeVolatilityByRiskGrade, + NoTradingDataError, +} from '@/services/etf/etf-recommend-service'; +import { EtfTestService } from '@/services/etf/etf-test-service'; +import { InvestType } from '@prisma/client'; +import { + createAggressiveEtfData, + createConservativeEtfData, + createMockEtfData, + createMockEtfList, + createMockEtfRecommendationResponse, + createMockMetricsData, + createMockProcessedEtfData, + createMockProcessedEtfList, + createMockWeightsData, + ERROR_MESSAGES, + getTestRiskGradesByInvestType, + TEST_USER_ID, +} from '../helpers/etf-recommend-helpers'; + +// Mock EtfTestService +const mockEtfTestService = { + getUserInvestType: jest.fn(), +} as unknown as jest.Mocked; + +// Mock Prisma Client +const mockPrismaClient = { + etf: { + findMany: jest.fn(), + }, +} as any; + +describe('EtfRecommendService', () => { + let etfRecommendService: EtfRecommendService; + + beforeEach(() => { + jest.clearAllMocks(); + etfRecommendService = new EtfRecommendService({ + etfTestService: mockEtfTestService, + prismaClient: mockPrismaClient, + }); + }); + + describe('getRecommendations', () => { + it('정상적으로 ETF 추천을 반환한다', async () => { + // Given + // MODERATE 투자자는 위험등급 3,4,5 허용 + // 변동성 0.03 (월간) → 연간 약 10.4% → 4등급 (저위험) + // 변동성 0.04 (월간) → 연간 약 13.8% → 3등급 (중위험) + // 변동성 0.02 (월간) → 연간 약 6.9% → 5등급 (초저위험) + const mockEtfs = [ + createMockEtfData({ + id: BigInt(1), + issueCode: 'TEST001', + issueName: '저위험 ETF', + volatility: '0.03', // 4등급 + return1y: '0.06', + }), + createMockEtfData({ + id: BigInt(2), + issueCode: 'TEST002', + issueName: '중위험 ETF', + volatility: '0.04', // 3등급 + return1y: '0.08', + }), + createMockEtfData({ + id: BigInt(3), + issueCode: 'TEST003', + issueName: '초저위험 ETF', + volatility: '0.02', // 5등급 + return1y: '0.04', + }), + ]; + + mockEtfTestService.getUserInvestType.mockResolvedValue( + InvestType.MODERATE + ); + mockPrismaClient.etf.findMany.mockResolvedValue(mockEtfs); + + // When + const result = await etfRecommendService.getRecommendations( + TEST_USER_ID, + 2 + ); + + // Then + expect(result.recommendations).toHaveLength(2); + expect(result.userProfile.investType).toBe(InvestType.MODERATE); + expect(result.userProfile.totalEtfsAnalyzed).toBe(3); + expect(result.userProfile.filteredEtfsCount).toBe(3); + expect(result.debug.allowedRiskGrades).toEqual([3, 4, 5]); + expect(mockEtfTestService.getUserInvestType).toHaveBeenCalledWith( + TEST_USER_ID + ); + }); + + it('투자 성향이 없으면 InvestmentProfileNotFoundError를 던진다', async () => { + // Given + mockEtfTestService.getUserInvestType.mockResolvedValue(null); + + // When & Then + await expect( + etfRecommendService.getRecommendations(TEST_USER_ID) + ).rejects.toThrow(InvestmentProfileNotFoundError); + await expect( + etfRecommendService.getRecommendations(TEST_USER_ID) + ).rejects.toThrow(ERROR_MESSAGES.INVESTMENT_PROFILE_NOT_FOUND); + }); + + it('ETF 데이터가 없으면 NoEtfDataError를 던진다', async () => { + // Given + mockEtfTestService.getUserInvestType.mockResolvedValue( + InvestType.MODERATE + ); + mockPrismaClient.etf.findMany.mockResolvedValue([]); + + // When & Then + await expect( + etfRecommendService.getRecommendations(TEST_USER_ID) + ).rejects.toThrow(NoEtfDataError); + await expect( + etfRecommendService.getRecommendations(TEST_USER_ID) + ).rejects.toThrow(ERROR_MESSAGES.NO_ETF_DATA); + }); + + it('거래 데이터가 있는 ETF가 없으면 NoTradingDataError를 던진다', async () => { + // Given + const etfWithoutTradingData = createMockEtfData({ tradings: [] }); + mockEtfTestService.getUserInvestType.mockResolvedValue( + InvestType.MODERATE + ); + mockPrismaClient.etf.findMany.mockResolvedValue([etfWithoutTradingData]); + + // When & Then + await expect( + etfRecommendService.getRecommendations(TEST_USER_ID) + ).rejects.toThrow(NoTradingDataError); + await expect( + etfRecommendService.getRecommendations(TEST_USER_ID) + ).rejects.toThrow(ERROR_MESSAGES.NO_TRADING_DATA); + }); + + it('투자 성향에 맞지 않는 위험등급 ETF는 필터링된다', async () => { + // Given + const conservativeEtf = createConservativeEtfData(); // 위험등급 4-5 + const aggressiveEtf = createAggressiveEtfData(); // 위험등급 1-2 + mockEtfTestService.getUserInvestType.mockResolvedValue( + InvestType.CONSERVATIVE + ); + mockPrismaClient.etf.findMany.mockResolvedValue([ + conservativeEtf, + aggressiveEtf, + ]); + + // When + const result = await etfRecommendService.getRecommendations(TEST_USER_ID); + + // Then + expect(result.recommendations.length).toBeLessThanOrEqual(2); + // 보수적 투자자는 저위험 ETF만 추천받아야 함 + result.recommendations.forEach((rec) => { + expect(rec.riskGrade).toBeGreaterThanOrEqual(4); + }); + }); + }); + + describe('getEtfData', () => { + it('정상적으로 ETF 데이터를 조회한다', async () => { + // Given + const mockEtfs = createMockEtfList(2); + mockPrismaClient.etf.findMany.mockResolvedValue(mockEtfs); + + // When + const result = await etfRecommendService.getEtfData(); + + // Then + expect(result).toHaveLength(2); + expect(mockPrismaClient.etf.findMany).toHaveBeenCalledWith({ + where: { + AND: [ + { return1y: { not: null } }, + { etfTotalFee: { not: null } }, + { netAssetTotalAmount: { not: null } }, + { traceErrRate: { not: null } }, + { divergenceRate: { not: null } }, + { volatility: { not: null } }, + { volatility: { not: '' } }, + ], + }, + include: { + category: { select: { fullPath: true } }, + tradings: { + where: { + baseDate: { gte: expect.any(Date) }, + accTotalValue: { gt: 0 }, + }, + select: { + accTotalValue: true, + flucRate: true, + }, + orderBy: { baseDate: 'desc' }, + take: 30, + }, + }, + orderBy: { netAssetTotalAmount: 'desc' }, + take: 400, + }); + }); + }); + + describe('processEtfData', () => { + it('ETF 데이터를 올바르게 처리한다', () => { + // Given + const mockEtfs = createMockEtfList(2); + + // When + const result = etfRecommendService.processEtfData(mockEtfs); + + // Then + expect(result).toHaveLength(2); + result.forEach((processedEtf, index) => { + expect(processedEtf.id).toBe(BigInt(index + 1)); + expect(processedEtf.processedData.return1y).toBe(0.05 + index * 0.02); + expect(processedEtf.processedData.volatility).toBe(0.1 + index * 0.02); + expect(processedEtf.processedData.riskGrade).toBeDefined(); + expect(processedEtf.processedData.avgTradingVolume).toBeGreaterThan(0); + expect(processedEtf.processedData.flucRate).toBeDefined(); + }); + }); + + it('거래 데이터가 없는 ETF도 처리한다', () => { + // Given + const etfWithoutTrading = createMockEtfData({ tradings: [] }); + + // When + const result = etfRecommendService.processEtfData([etfWithoutTrading]); + + // Then + expect(result[0].processedData.avgTradingVolume).toBe(0); + expect(result[0].processedData.flucRate).toBe(0); + }); + }); + + describe('calculateMetrics', () => { + it('정규화를 위한 메트릭스를 올바르게 계산한다', () => { + // Given + const processedEtfs = createMockProcessedEtfList(3); + + // When + const result = etfRecommendService.calculateMetrics(processedEtfs); + + // Then + expect(result.return1y.min).toBeLessThan(result.return1y.max); + expect(result.etfTotalFee.min).toBeLessThan(result.etfTotalFee.max); + expect(result.netAssetTotalAmount.min).toBeLessThan( + result.netAssetTotalAmount.max + ); + expect(result.traceErrRate.min).toBeLessThan(result.traceErrRate.max); + expect(result.divergenceRate.min).toBeLessThan(result.divergenceRate.max); + expect(result.volatility.min).toBeLessThan(result.volatility.max); + expect(result.tradingVolume.min).toBeLessThan(result.tradingVolume.max); + }); + + it('빈 배열에 대해서도 안전하게 처리한다', () => { + // Given + const emptyArray: any[] = []; + + // When + const result = etfRecommendService.calculateMetrics(emptyArray); + + // Then + expect(result.tradingVolume.min).toBe(0); + expect(result.tradingVolume.max).toBe(0); + }); + }); + + describe('calculateEtfScores', () => { + it('ETF 점수를 올바르게 계산한다', () => { + // Given + const processedEtfs = createMockProcessedEtfList(3); + const metrics = createMockMetricsData(); + const weights = createMockWeightsData(); + const allowedRiskGrades = [3, 4, 5]; + const investType = InvestType.MODERATE; + + // When + const result = etfRecommendService.calculateEtfScores( + processedEtfs, + metrics, + weights, + allowedRiskGrades, + investType + ); + + // Then + expect(result).toHaveLength(3); + result.forEach((etf) => { + expect(etf.score).toBeGreaterThanOrEqual(0); + expect(etf.score).toBeLessThanOrEqual(1); + expect(etf.metrics.sharpeRatio).toBeDefined(); + expect(etf.metrics.totalFee).toBeDefined(); + expect(etf.metrics.tradingVolume).toBeDefined(); + expect(etf.metrics.netAssetValue).toBeDefined(); + expect(etf.metrics.trackingError).toBeDefined(); + expect(etf.metrics.divergenceRate).toBeDefined(); + expect(etf.metrics.volatility).toBeDefined(); + expect(etf.metrics.normalizedVolatility).toBeDefined(); + expect(etf.reasons).toBeInstanceOf(Array); + }); + }); + + it('허용되지 않는 위험등급 ETF는 필터링한다', () => { + // Given + const processedEtfs = createMockProcessedEtfList(3); + const metrics = createMockMetricsData(); + const weights = createMockWeightsData(); + const allowedRiskGrades = [4, 5]; // 보수적 투자자 + const investType = InvestType.CONSERVATIVE; + + // When + const result = etfRecommendService.calculateEtfScores( + processedEtfs, + metrics, + weights, + allowedRiskGrades, + investType + ); + + // Then + // 일부 ETF는 위험등급이 맞지 않아 필터링될 수 있음 + result.forEach((etf) => { + expect(allowedRiskGrades).toContain(etf.riskGrade); + }); + }); + }); +}); + +describe('Utility Functions', () => { + describe('classifyRiskGrade', () => { + it('변동성에 따라 올바른 위험등급을 반환한다', () => { + expect(classifyRiskGrade(0.01)).toBe(5); // 초저위험 + expect(classifyRiskGrade(0.025)).toBe(4); // 저위험 + expect(classifyRiskGrade(0.043)).toBe(3); // 중위험 + expect(classifyRiskGrade(0.057)).toBe(2); // 고위험 + expect(classifyRiskGrade(0.06)).toBe(1); // 초고위험 + }); + }); + + describe('calculateSharpeRatio', () => { + it('올바른 샤프비율을 계산한다', () => { + const result = calculateSharpeRatio(0.1, 0.15, 3); + expect(result).toBeCloseTo((0.1 - 0.03) / 0.15, 2); + }); + + it('변동성이 0이면 0을 반환한다', () => { + expect(calculateSharpeRatio(0.1, 0, 3)).toBe(0); + }); + }); + + describe('getRiskBasedWeights', () => { + it('투자 성향에 따라 올바른 가중치를 반환한다', () => { + const conservative = getRiskBasedWeights(InvestType.CONSERVATIVE); + const aggressive = getRiskBasedWeights(InvestType.AGGRESSIVE); + + expect(conservative.totalFee).toBeGreaterThan(aggressive.totalFee); + expect(conservative.sharpeRatio).toBeLessThan(aggressive.sharpeRatio); + expect(conservative.tradingVolume).toBeLessThan(aggressive.tradingVolume); + }); + + it('알 수 없는 투자 성향에 대해 NEUTRAL 가중치를 반환한다', () => { + const result = getRiskBasedWeights('UNKNOWN' as InvestType); + const neutral = getRiskBasedWeights(InvestType.NEUTRAL); + expect(result).toEqual(neutral); + }); + }); + + describe('getAllowedRiskGrades', () => { + it('투자 성향에 따라 올바른 허용 위험등급을 반환한다', () => { + expect(getAllowedRiskGrades(InvestType.CONSERVATIVE)).toEqual([4, 5]); + expect(getAllowedRiskGrades(InvestType.MODERATE)).toEqual([3, 4, 5]); + expect(getAllowedRiskGrades(InvestType.NEUTRAL)).toEqual([2, 3, 4, 5]); + expect(getAllowedRiskGrades(InvestType.ACTIVE)).toEqual([1, 2, 3, 4, 5]); + expect(getAllowedRiskGrades(InvestType.AGGRESSIVE)).toEqual([ + 1, 2, 3, 4, 5, + ]); + }); + }); + + describe('normalize', () => { + it('값을 0-1 범위로 정규화한다', () => { + expect(normalize(5, 0, 10)).toBe(0.5); + expect(normalize(0, 0, 10)).toBe(0); + expect(normalize(10, 0, 10)).toBe(1); + }); + + it('최대값과 최소값이 같으면 0.5를 반환한다', () => { + expect(normalize(5, 5, 5)).toBe(0.5); + }); + }); + + describe('normalizeVolatilityByRiskGrade', () => { + it('위험등급에 따라 올바른 정규화된 값을 반환한다', () => { + expect(normalizeVolatilityByRiskGrade(1)).toBe(1.0); // 초고위험 = 0.0 + expect(normalizeVolatilityByRiskGrade(3)).toBe(0.6); // 중위험 = 0.6 + expect(normalizeVolatilityByRiskGrade(5)).toBe(0.2); // 초저위험 = 1.0 + }); + }); + + describe('generateReasons', () => { + it('ETF 특성에 따라 추천 이유를 생성한다', () => { + const etf = createMockEtfData(); + const metrics = { + sharpeRatio: 1.2, + totalFee: 0.2, + tradingVolume: 1500000000, + netAssetValue: 150000000000, + trackingError: 0.3, + divergenceRate: 0.2, + volatility: 0.12, + }; + const investType = InvestType.MODERATE; + const riskGrade = 3; + + const reasons = generateReasons(etf, metrics, investType, riskGrade); + + expect(reasons).toBeInstanceOf(Array); + expect(reasons.length).toBeGreaterThan(0); + reasons.forEach((reason) => { + expect(reason.title).toBeDefined(); + expect(reason.description).toBeDefined(); + }); + }); + }); +}); diff --git a/__tests__/services/etf-test-service.test.ts b/__tests__/services/etf-test-service.test.ts new file mode 100644 index 0000000..9e8d756 --- /dev/null +++ b/__tests__/services/etf-test-service.test.ts @@ -0,0 +1,282 @@ +/** + * @jest-environment node + */ +import { createEtfTestPrismaMock } from '@/__mocks__/prisma-factory'; +import { EtfTestService } from '@/services/etf/etf-test-service'; +import { InvestType } from '@prisma/client'; +import { + createMockEtfCategories, + createTestTransactionMock, + createValidMbtiRequest, + mockInvestmentProfileResult, + mockUserEtfCategoriesResult, +} from '../helpers/etf-test-helpers'; + +let mockPrisma: ReturnType; + +jest.mock('@/lib/prisma', () => ({ + get prisma() { + return mockPrisma; + }, +})); + +describe('EtfTestService', () => { + let etfMbtiService: EtfTestService; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma = createEtfTestPrismaMock(); + etfMbtiService = new EtfTestService(); + }); + + describe('saveMbtiResult', () => { + const validParams = { + userId: BigInt(5), + investType: InvestType.MODERATE, + preferredCategories: [ + '주식-업종섹터-금융', + '주식-업종섹터-정보기술', + '주식-업종섹터-헬스케어', + ], + }; + + it('첫 저장 시 전체 흐름 테스트', async () => { + const mockCategories = createMockEtfCategories(); + const filteredCategories = mockCategories.filter((c) => + validParams.preferredCategories.includes(c.fullPath) + ); + + const mockFindUnique = jest.fn().mockResolvedValue(null); // 최초 저장 + const mockCreate = jest.fn(); + const mockFindMany = jest.fn().mockResolvedValue(filteredCategories); + const mockDeleteMany = jest.fn(); + const mockCreateMany = jest.fn(); + + const mockTransaction = jest.fn().mockImplementation(async (callback) => { + const mockTx = { + investmentProfile: { + findUnique: mockFindUnique, + create: mockCreate, + update: jest.fn(), // 호출 안 됨 + }, + etfCategory: { findMany: mockFindMany }, + userEtfCategory: { + deleteMany: mockDeleteMany, + createMany: mockCreateMany, + }, + }; + return await callback(mockTx as any); + }); + + mockPrisma.$transaction = mockTransaction; + + await etfMbtiService.saveMbtiResult(validParams); + + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { userId: validParams.userId }, + }); + expect(mockCreate).toHaveBeenCalledWith({ + data: { + userId: validParams.userId, + investType: validParams.investType, + }, + }); + expect(mockFindMany).toHaveBeenCalledWith({ + where: { fullPath: { in: validParams.preferredCategories } }, + select: { id: true, fullPath: true }, + }); + expect(mockDeleteMany).toHaveBeenCalledWith({ + where: { userId: validParams.userId }, + }); + expect(mockCreateMany).toHaveBeenCalledWith({ + data: filteredCategories.map((category) => ({ + userId: validParams.userId, + etfCategoryId: category.id, + })), + }); + }); + + describe('Single Responsibility Tests', () => { + it('프로필 없으면 create만 호출된다', async () => { + const tx = createTestTransactionMock({ + profileExists: false, + preferredCategories: validParams.preferredCategories, + }); + mockPrisma.$transaction.mockImplementation(async (cb) => cb(tx as any)); + await etfMbtiService.saveMbtiResult(validParams); + expect(tx.investmentProfile.create).toHaveBeenCalled(); + expect(tx.investmentProfile.update).not.toHaveBeenCalled(); + }); + + it('프로필 있으면 update만 호출된다', async () => { + const tx = createTestTransactionMock({ + profileExists: true, + preferredCategories: validParams.preferredCategories, + }); + mockPrisma.$transaction.mockImplementation(async (cb) => cb(tx as any)); + await etfMbtiService.saveMbtiResult(validParams); + expect(tx.investmentProfile.update).toHaveBeenCalled(); + expect(tx.investmentProfile.create).not.toHaveBeenCalled(); + }); + + it('항상 deleteMany가 호출된다', async () => { + const tx = createTestTransactionMock({ + preferredCategories: validParams.preferredCategories, + }); + mockPrisma.$transaction.mockImplementation(async (cb) => cb(tx as any)); + await etfMbtiService.saveMbtiResult(validParams); + expect(tx.userEtfCategory.deleteMany).toHaveBeenCalledWith({ + where: { userId: validParams.userId }, + }); + }); + + it('카테고리 유효 시 createMany가 호출된다', async () => { + const filteredCategories = createMockEtfCategories().filter((c) => + validParams.preferredCategories.includes(c.fullPath) + ); + + const tx = createTestTransactionMock({ + preferredCategories: validParams.preferredCategories, + }); + + mockPrisma.$transaction.mockImplementation(async (cb) => cb(tx as any)); + await etfMbtiService.saveMbtiResult(validParams); + + expect(tx.userEtfCategory.createMany).toHaveBeenCalledWith({ + data: filteredCategories.map((c) => ({ + userId: validParams.userId, + etfCategoryId: c.id, + })), + }); + }); + }); + + it('유효하지 않은 투자 성향에 대해 에러를 던진다', async () => { + const invalidParams = { + ...validParams, + investType: 'INVALID_TYPE' as InvestType, + }; + + await expect( + etfMbtiService.saveMbtiResult(invalidParams) + ).rejects.toThrow('유효하지 않은 투자 성향입니다.'); + }); + + it('빈 선호 카테고리에 대해 에러를 던진다', async () => { + const invalidParams = { + ...validParams, + preferredCategories: [], + }; + + await expect( + etfMbtiService.saveMbtiResult(invalidParams) + ).rejects.toThrow('최소 하나의 선호 카테고리를 선택해주세요.'); + }); + + it('존재하지 않는 카테고리에 대해 에러를 던진다', async () => { + const invalidParams = { + ...validParams, + preferredCategories: ['존재하지않는카테고리'], + }; + + const mockTransaction = jest.fn().mockImplementation(async (callback) => { + const mockTx = { + investmentProfile: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn(), + update: jest.fn(), + }, + etfCategory: { findMany: jest.fn().mockResolvedValue([]) }, + userEtfCategory: { + deleteMany: jest.fn(), + createMany: jest.fn(), + }, + }; + return await callback(mockTx as any); + }); + + mockPrisma.$transaction = mockTransaction; + + await expect( + etfMbtiService.saveMbtiResult(invalidParams) + ).rejects.toThrow('유효하지 않은 카테고리: 존재하지않는카테고리'); + }); + }); + + describe('getUserInvestmentProfile', () => { + it('정상적으로 사용자 투자 프로필을 반환한다', async () => { + const userId = BigInt(5); + mockPrisma.investmentProfile.findUnique.mockResolvedValue( + mockInvestmentProfileResult + ); + mockPrisma.user.findUnique.mockResolvedValue(mockUserEtfCategoriesResult); + + const result = await etfMbtiService.getUserInvestmentProfile(userId); + + expect(result).toEqual({ + investType: InvestType.CONSERVATIVE, + preferredCategories: [ + { id: 6, fullPath: '주식-업종섹터-금융' }, + { id: 11, fullPath: '주식-업종섹터-정보기술' }, + { id: 10, fullPath: '주식-업종섹터-헬스케어' }, + ], + }); + }); + + it('프로필이 없을 때 investType=null, preferredCategories=[] 반환한다', async () => { + const userId = BigInt(5); + mockPrisma.investmentProfile.findUnique.mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(null); + + const result = await etfMbtiService.getUserInvestmentProfile(userId); + + expect(result).toEqual({ + investType: null, + preferredCategories: [], + }); + }); + }); + + describe('validateRequestBody', () => { + it('유효한 요청 데이터를 정상적으로 파싱한다', () => { + const validRequest = createValidMbtiRequest(); + + const result = EtfTestService.validateRequestBody(validRequest); + + expect(result).toEqual({ + investType: InvestType.MODERATE, + preferredCategories: [ + '주식-업종섹터-금융', + '주식-업종섹터-정보기술', + '주식-업종섹터-헬스케어', + ], + }); + }); + + it('빈 요청 본문에 대해 에러를 던진다', () => { + expect(() => EtfTestService.validateRequestBody(null)).toThrow( + '요청 본문이 유효하지 않습니다.' + ); + }); + + it('투자 성향이 없을 때 에러를 던진다', () => { + const invalidRequest = { + preferredCategories: ['주식-업종섹터-금융'], + }; + + expect(() => EtfTestService.validateRequestBody(invalidRequest)).toThrow( + '투자 성향이 필요합니다.' + ); + }); + + it('선호 카테고리가 없을 때 에러를 던진다', () => { + const invalidRequest = { + investType: InvestType.MODERATE, + }; + + expect(() => EtfTestService.validateRequestBody(invalidRequest)).toThrow( + '선호 카테고리가 필요합니다.' + ); + }); + }); +}); diff --git a/__tests__/services/get-isa-portfolio.test.ts b/__tests__/services/get-isa-portfolio.test.ts new file mode 100644 index 0000000..5f29564 --- /dev/null +++ b/__tests__/services/get-isa-portfolio.test.ts @@ -0,0 +1,121 @@ +import { getServerSession } from 'next-auth'; +import { getISAPortfolio } from '@/app/actions/get-isa-portfolio'; +import { prisma } from '@/lib/prisma'; + +// next-auth의 getServerSession 함수 모킹 +jest.mock('next-auth'); +// prisma 클라이언트의 iSAAccount 모델 모킹 +jest.mock('@/lib/prisma', () => ({ + prisma: { + iSAAccount: { + findUnique: jest.fn(), + }, + }, +})); + +describe('getISAPortfolio', () => { + // 각 테스트 전 모킹된 함수들의 호출 기록 초기화 + beforeEach(() => { + jest.clearAllMocks(); + }); + + // 세션이 없을 경우 'Unauthorized' 에러가 발생하는지 테스트 + it('throws if no session is found', async () => { + (getServerSession as jest.Mock).mockResolvedValue(null); + await expect(getISAPortfolio('2025-06')).rejects.toThrow('Unauthorized'); + }); + + // ISA 계좌에서 각 자산의 비율이 정확히 계산되는지 검증 + it('returns correct portfolio breakdown', async () => { + // 로그인된 유저 세션 모킹 + (getServerSession as jest.Mock).mockResolvedValue({ + user: { id: '1' }, + }); + + // prisma iSAAccount.findUnique 함수가 반환할 모킹 데이터 설정 + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + generalHoldingSnapshots: [], + generalHoldings: [ + { + totalCost: 1000000, + product: { instrumentType: 'BOND' }, + }, + { + totalCost: 2000000, + product: { instrumentType: 'FUND' }, + }, + { + totalCost: 3000000, + product: { instrumentType: 'ELS' }, + }, + ], + etfHoldingSnapshots: [ + { + evaluatedAmount: 4000000, + etf: { idxMarketType: '국내' }, + }, + { + evaluatedAmount: 5000000, + etf: { idxMarketType: '해외' }, + }, + { + evaluatedAmount: 6000000, + etf: { idxMarketType: '국내&해외' }, + }, + ], + }); + + // 실제 함수 호출 및 결과 저장 + const result = await getISAPortfolio('2025-06'); + + // 반환된 포트폴리오가 예상한 카테고리별 금액과 비율로 정확히 계산되었는지 검증 + expect(result).toEqual([ + { category: '채권', value: 1000000, percentage: 4.8 }, + { category: '펀드', value: 2000000, percentage: 9.5 }, + { category: 'ELS', value: 3000000, percentage: 14.3 }, + { category: '국내 ETF', value: 4000000, percentage: 19.0 }, + { category: '해외 ETF', value: 5000000, percentage: 23.8 }, + { category: '국내&해외 ETF', value: 6000000, percentage: 28.6 }, + ]); + }); + + // 반환된 포트폴리오의 전체 구조가 스냅샷과 일치하는지 확인 + it('matches snapshot for consistent portfolio structure', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { id: '1' } }); + + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + generalHoldingSnapshots: [], + generalHoldings: [ + { + totalCost: 1000000, + product: { instrumentType: 'BOND' }, + }, + { + totalCost: 2000000, + product: { instrumentType: 'FUND' }, + }, + { + totalCost: 3000000, + product: { instrumentType: 'ELS' }, + }, + ], + etfHoldingSnapshots: [ + { + evaluatedAmount: 4000000, + etf: { idxMarketType: '국내' }, + }, + { + evaluatedAmount: 5000000, + etf: { idxMarketType: '해외' }, + }, + { + evaluatedAmount: 6000000, + etf: { idxMarketType: '국내&해외' }, + }, + ], + }); + + const result = await getISAPortfolio('2025-06'); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/__tests__/services/get-monthly-returns.test.ts b/__tests__/services/get-monthly-returns.test.ts new file mode 100644 index 0000000..80d0c0d --- /dev/null +++ b/__tests__/services/get-monthly-returns.test.ts @@ -0,0 +1,285 @@ +import { getServerSession } from 'next-auth'; +import { getMonthlyReturns } from '@/app/actions/get-monthly-returns'; +import { authOptions } from '@/lib/auth-options'; +import { prisma } from '@/lib/prisma'; + +jest.mock('next-auth'); +jest.mock('@/lib/prisma', () => ({ + prisma: { + iSAAccount: { + findUnique: jest.fn(), + }, + monthlyReturn: { + findMany: jest.fn(), + }, + eTFHoldingSnapshot: { + findMany: jest.fn(), + }, + generalHoldingSnapshot: { + aggregate: jest.fn(), + findMany: jest.fn(), + }, + }, +})); + +// getMonthlyReturns 함수 테스트 +// 목적: 세션, ISA 계좌 여부, ETF 및 일반 자산 평가금액을 기반으로 총 평가금액과 수익률을 계산하는지 검증 + +describe('getMonthlyReturns', () => { + const mockUserId = 1; + + beforeEach(() => { + jest.clearAllMocks(); // 모든 mock 초기화 + // 기본 세션 및 ISA 계좌 모킹 함수 + (getServerSession as jest.Mock).mockResolvedValue({ + user: { id: mockUserId.toString() }, + }); + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + id: mockUserId, + }); + }); + + // 헬퍼 함수: 기본 모킹 세팅 변경 + function setupMockData({ + session, + isaAccount, + monthlyReturns, + etfSnapshots, + generalSnapshot, + }: { + session?: any; + isaAccount?: any; + monthlyReturns?: any[]; + etfSnapshots?: any[]; + generalSnapshot?: any; + }) { + if (session !== undefined) { + (getServerSession as jest.Mock).mockResolvedValue(session); + } + if (isaAccount !== undefined) { + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue(isaAccount); + } + if (monthlyReturns !== undefined) { + (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue( + monthlyReturns + ); + } + if (etfSnapshots !== undefined) { + (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue( + etfSnapshots + ); + } + if (generalSnapshot !== undefined) { + (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue( + generalSnapshot + ); + } + } + + // 헬퍼 함수: 월별 수익률 객체 생성 + function createMockMonthlyReturn(entireProfit: number) { + return { baseDate: new Date('2025-06-30T00:00:00Z'), entireProfit }; + } + + // 헬퍼 함수: ETF 스냅샷 객체 생성 + function createMockETFSnapshot(evaluatedAmount: number, etfId = 1) { + return { evaluatedAmount, snapshotDate: new Date(), etfId }; + } + + // 헬퍼 함수: 일반 스냅샷 객체 생성 + function createMockGeneralSnapshot(evaluatedAmount: number) { + return { _sum: { evaluatedAmount } }; + } + + // 헬퍼 함수: 예상 값 계산 + function calculateExpectedValues(etfAmount: number, generalAmount: number) { + const totalAmount = etfAmount + generalAmount; + const invested = 17000000; // 고정 투자원금 + const profit = totalAmount - invested; + return { totalAmount, profit }; + } + + describe('에러 케이스', () => { + it('로그인하지 않은 경우 오류를 발생시킵니다', async () => { + // given + setupMockData({ session: null }); + + // when & then + await expect(getMonthlyReturns('6')).rejects.toThrow( + '로그인이 필요합니다.' + ); + }); + + it('ISA 계좌가 없을 때 오류를 발생시킵니다', async () => { + // given + setupMockData({ isaAccount: null }); + + // when & then + await expect(getMonthlyReturns('6')).rejects.toThrow( + 'ISA 계좌가 없습니다.' + ); + }); + }); + + describe('정확한 평가금액 및 평가수익 계산', () => { + it('ETF 500만원 + 일반 1200만원 → 총 1700만원, 평가수익 0원', async () => { + // given + const etfAmount = 5_000_000; + const generalAmount = 12_000_000; + const { totalAmount, profit } = calculateExpectedValues( + etfAmount, + generalAmount + ); + + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.12)], + etfSnapshots: [createMockETFSnapshot(etfAmount)], + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); + + // when + const result = await getMonthlyReturns('6'); + + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); + expect(result).toEqual({ + returns: [{ '2025-06-30': 12.0 }], + evaluatedAmount: totalAmount, + evaluatedProfit: profit, + }); + }); + + it('ETF 600만원 + 일반 1400만원 → 총 2000만원, 평가수익 300만원', async () => { + // given + const etfAmount = 6_000_000; + const generalAmount = 14_000_000; + const { totalAmount, profit } = calculateExpectedValues( + etfAmount, + generalAmount + ); + + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.15)], + etfSnapshots: [createMockETFSnapshot(etfAmount)], + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); + + // when + const result = await getMonthlyReturns('6'); + + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); + expect(result).toEqual({ + returns: [{ '2025-06-30': 15.0 }], + evaluatedAmount: totalAmount, + evaluatedProfit: profit, + }); + }); + }); + + describe('수익률 공식 검증', () => { + it('수익률 공식 (E - B - C) / (B + 0.5 * C) 계산을 실제 테스트 코드에서 검증', async () => { + // given + const B = 15_000_000; + const C = 1_000_000; + const E = 17_500_000; + + const expectedRate = (E - B - C) / (B + 0.5 * C); + const expectedPercent = Number((expectedRate * 100).toFixed(2)); // 9.68 + + const etfAmount = 10_000_000; + const generalAmount = 7_500_000; + + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(expectedRate)], + etfSnapshots: [createMockETFSnapshot(etfAmount)], + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); + + // when + const result = await getMonthlyReturns('6'); + + // then + expect(result.returns).toEqual([{ '2025-06-30': expectedPercent }]); + expect(result.evaluatedAmount).toBe(E); + }); + }); + + describe('다양한 시나리오 테스트', () => { + it('ETF만 있고 일반 자산이 없는 경우', async () => { + // given + const etfAmount = 8_000_000; + const generalAmount = 0; + const { totalAmount, profit } = calculateExpectedValues( + etfAmount, + generalAmount + ); + + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.1)], + etfSnapshots: [createMockETFSnapshot(etfAmount)], + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); + + // when + const result = await getMonthlyReturns('6'); + + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); + }); + + it('일반 자산만 있고 ETF가 없는 경우', async () => { + // given + const etfAmount = 0; + const generalAmount = 9_000_000; + const { totalAmount, profit } = calculateExpectedValues( + etfAmount, + generalAmount + ); + + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.08)], + etfSnapshots: [], + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); + + // when + const result = await getMonthlyReturns('6'); + + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); + }); + + it('여러 개의 ETF 스냅샷이 있는 경우', async () => { + // given + const etfSnapshots = [ + createMockETFSnapshot(3_000_000, 1), + createMockETFSnapshot(2_000_000, 2), + createMockETFSnapshot(1_500_000, 3), + ]; + const generalAmount = 10_500_000; + const totalEtfAmount = 6_500_000; + const { totalAmount, profit } = calculateExpectedValues( + totalEtfAmount, + generalAmount + ); + + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.12)], + etfSnapshots, + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); + + // when + const result = await getMonthlyReturns('6'); + + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); + }); + }); +}); diff --git a/__tests__/services/isa-rebalancing-service.test.ts b/__tests__/services/isa-rebalancing-service.test.ts new file mode 100644 index 0000000..37226a5 --- /dev/null +++ b/__tests__/services/isa-rebalancing-service.test.ts @@ -0,0 +1,367 @@ +/** + * @jest-environment node + */ +import { + InvestmentProfileNotFoundError, + InvestType, + ISAAccountNotFoundError, + RebalancingService, + UserHoldingDetails, + UserPortfolio, +} from '@/services/isa/rebalancing-service'; +import { InvestType as PrismaInvestType } from '@prisma/client'; +import { + createMockEtfHoldingSnapshot, + createMockGeneralHolding, + createMockInvestmentProfile, + createMockISAAccount, + ERROR_MESSAGES, + TEST_USER_ID, +} from '../helpers/rebalancing-helpers'; + +// Mock Prisma Client +const mockPrismaClient = { + investmentProfile: { + findUnique: jest.fn(), + }, + iSAAccount: { + findUnique: jest.fn(), + }, +} as any; + +describe('RebalancingService', () => { + let rebalancingService: RebalancingService; + + beforeEach(() => { + jest.clearAllMocks(); + rebalancingService = new RebalancingService(mockPrismaClient); + }); + + describe('getRebalancingRecommendation', () => { + it('투자 성향 정보가 없으면 InvestmentProfileNotFoundError를 던진다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue(null); + + // When & Then + await expect( + rebalancingService.getRebalancingRecommendation(TEST_USER_ID) + ).rejects.toThrow(InvestmentProfileNotFoundError); + await expect( + rebalancingService.getRebalancingRecommendation(TEST_USER_ID) + ).rejects.toThrow(ERROR_MESSAGES.INVESTMENT_PROFILE_NOT_FOUND); + }); + + it('ISA 계좌 정보가 없으면 ISAAccountNotFoundError를 던진다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.MODERATE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue(null); + + // When & Then + await expect( + rebalancingService.getRebalancingRecommendation(TEST_USER_ID) + ).rejects.toThrow(ISAAccountNotFoundError); + await expect( + rebalancingService.getRebalancingRecommendation(TEST_USER_ID) + ).rejects.toThrow(ERROR_MESSAGES.ISA_ACCOUNT_NOT_FOUND); + }); + + it('자산이 없으면 빈 포트폴리오와 0점을 반환한다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.MODERATE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue( + createMockISAAccount() + ); + + // When + const result = + await rebalancingService.getRebalancingRecommendation(TEST_USER_ID); + + // Then + expect(result.score).toBe(0); + expect(result.rebalancingOpinions).toHaveLength(0); + expect(result.recommendedPortfolio).toEqual([ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ]); + }); + + it('ETF 스냅샷 데이터가 있으면 스냅샷 데이터를 사용한다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.MODERATE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue({ + ...createMockISAAccount(), + etfHoldingSnapshots: [ + createMockEtfHoldingSnapshot(BigInt(1), 1000000, '국내', '국내 ETF'), + createMockEtfHoldingSnapshot(BigInt(2), 2000000, '해외', '해외 ETF'), + ], + }); + + // When + const result = + await rebalancingService.getRebalancingRecommendation(TEST_USER_ID); + + // Then + expect(result.score).toBeGreaterThan(0); + expect(result.rebalancingOpinions).toHaveLength(5); + + // 국내 주식과 해외 주식 비중이 각각 33.3% 정도여야 함 (300만원 중 100만원, 200만원) + const domesticStock = result.rebalancingOpinions.find( + (op) => op.category === '국내 주식' + ); + const foreignStock = result.rebalancingOpinions.find( + (op) => op.category === '해외 주식' + ); + + expect(domesticStock?.userPercentage).toBeCloseTo(33.3, 1); + expect(foreignStock?.userPercentage).toBeCloseTo(66.7, 1); + }); + + it('일반 자산(채권, 펀드, ELS)을 올바르게 처리한다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.CONSERVATIVE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue({ + ...createMockISAAccount(), + generalHoldings: [ + createMockGeneralHolding('BOND', 5000000, '국채'), + createMockGeneralHolding('FUND', 2000000, '펀드'), + createMockGeneralHolding('ELS', 1000000, 'ELS 상품'), + ], + }); + + // When + const result = + await rebalancingService.getRebalancingRecommendation(TEST_USER_ID); + + // Then + expect(result.score).toBeGreaterThan(0); + + // 보수적 포트폴리오: 채권 60%, 펀드 15%, ELS 5% + const bond = result.rebalancingOpinions.find( + (op) => op.category === '채권' + ); + const fund = result.rebalancingOpinions.find( + (op) => op.category === '펀드' + ); + const els = result.rebalancingOpinions.find( + (op) => op.category === 'ELS' + ); + + expect(bond?.userPercentage).toBeCloseTo(62.5, 1); // 500만원 / 800만원 + expect(fund?.userPercentage).toBeCloseTo(25, 1); // 200만원 / 800만원 + expect(els?.userPercentage).toBeCloseTo(12.5, 1); // 100만원 / 800만원 + }); + + it('투자 성향에 따라 다른 권장 포트폴리오를 반환한다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.AGGRESSIVE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue( + createMockISAAccount() + ); + + // When + const result = + await rebalancingService.getRebalancingRecommendation(TEST_USER_ID); + + // Then + expect(result.recommendedPortfolio).toEqual([ + { category: '국내 주식', percentage: 40 }, + { category: '해외 주식', percentage: 40 }, + { category: '채권', percentage: 10 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ]); + }); + }); + + describe('calculateScore', () => { + it('완벽한 매칭일 때 100점을 반환한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 25, + totalValue: 2500000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: '해외 주식', + percentage: 25, + totalValue: 2500000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: '채권', + percentage: 40, + totalValue: 4000000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: 'ELS', + percentage: 5, + totalValue: 500000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: '펀드', + percentage: 5, + totalValue: 500000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ]; + + // When + const score = (rebalancingService as any).calculateScore( + userPortfolio, + recommendedPortfolio + ); + + // Then + expect(score).toBe(100); + }); + + it('차이가 클수록 낮은 점수를 반환한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 50, + totalValue: 5000000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: '해외 주식', + percentage: 50, + totalValue: 5000000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + ]; + + // When + const score = (rebalancingService as any).calculateScore( + userPortfolio, + recommendedPortfolio + ); + + // Then + expect(score).toBeLessThan(100); + expect(score).toBeGreaterThan(0); + }); + }); + + describe('generateRebalancingOpinions', () => { + it('적정 비중일 때 적절한 의견을 생성한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 25, + totalValue: 2500000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [{ category: '국내 주식', percentage: 25 }]; + const userHoldings: UserHoldingDetails[] = []; + + // When + const opinions = (rebalancingService as any).generateRebalancingOpinions( + userPortfolio, + recommendedPortfolio, + userHoldings + ); + + // Then + expect(opinions).toHaveLength(1); + expect(opinions[0].opinion).toBe('적정 비중'); + expect(opinions[0].userPercentage).toBe(25); + expect(opinions[0].recommendedPercentage).toBe(25); + }); + + it('비중이 높을 때 축소 의견을 생성한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 35, + totalValue: 3500000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [{ category: '국내 주식', percentage: 25 }]; + const userHoldings: UserHoldingDetails[] = []; + + // When + const opinions = (rebalancingService as any).generateRebalancingOpinions( + userPortfolio, + recommendedPortfolio, + userHoldings + ); + + // Then + expect(opinions).toHaveLength(1); + expect(opinions[0].opinion).toBe('비중 축소 필요'); + expect(opinions[0].userPercentage).toBe(35); + expect(opinions[0].recommendedPercentage).toBe(25); + }); + + it('비중이 낮을 때 확대 의견을 생성한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 15, + totalValue: 1500000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [{ category: '국내 주식', percentage: 25 }]; + const userHoldings: UserHoldingDetails[] = []; + + // When + const opinions = (rebalancingService as any).generateRebalancingOpinions( + userPortfolio, + recommendedPortfolio, + userHoldings + ); + + // Then + expect(opinions).toHaveLength(1); + expect(opinions[0].opinion).toBe('비중 확대 필요'); + expect(opinions[0].userPercentage).toBe(15); + expect(opinions[0].recommendedPercentage).toBe(25); + }); + }); +}); diff --git a/__tests__/services/tax-saving.test.ts b/__tests__/services/tax-saving.test.ts new file mode 100644 index 0000000..b979621 --- /dev/null +++ b/__tests__/services/tax-saving.test.ts @@ -0,0 +1,172 @@ +import { getServerSession } from 'next-auth'; +import { taxSaving } from '@/app/actions/tax-saving'; +import { prisma } from '@/lib/prisma'; + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })); +jest.mock('@/lib/prisma', () => ({ + prisma: { + iSAAccount: { findUnique: jest.fn() }, + generalTransaction: { aggregate: jest.fn() }, + eTFTransaction: { aggregate: jest.fn() }, + eTFHolding: { findMany: jest.fn() }, + etfDailyTrading: { findFirst: jest.fn() }, + }, +})); + +beforeAll(() => { + jest + .spyOn(Date, 'now') + .mockReturnValue(new Date('2025-06-15T00:00:00Z').getTime()); +}); +afterAll(() => jest.restoreAllMocks()); + +describe('taxSaving – 두 시나리오 (usedLimit capped but isaTax on full overflow)', () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { id: '1' } }); + + it('1. 일반형 / grossDiv=20만원, estDiv=10만원, gain=500만원', async () => { + // 셋업 + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 1, + accountType: '일반형', + }); + // grossDiv + (prisma.generalTransaction.aggregate as jest.Mock) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 200_000 } } }) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } }); + // estDiv = 10M*2%*6/12 =100k + (prisma.eTFHolding.findMany as jest.Mock).mockResolvedValue([ + { + etfId: 1, + avgCost: { toNumber: () => 10_000_000 }, + quantity: { toNumber: () => 1 }, + etf: { idxMarketType: '국내' }, + }, + ]); + (prisma.etfDailyTrading.findFirst as jest.Mock).mockResolvedValue({ + tddClosePrice: { toNumber: () => 10_000_000 }, + }); + // 해외 gain + (prisma.eTFTransaction.aggregate as jest.Mock) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 5_000_000 } } }) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } }); + + const res = await taxSaving(); + + // 계산 + const grossDiv = 200_000; + const estDiv = 100_000; + const gain = 5_000_000; + const sum = grossDiv + estDiv + gain; // 5_300_000 + const tax = sum * 0.154; // 816200 + const limit = 2_000_000; + const used = limit; // capped + const remaining = 0; + const isaBase = sum - limit; // 3_300_000 + const isaTax = isaBase * 0.099; // 326700 + const saved = tax - isaTax; // 489500 + + expect(res.limit).toBe(limit); + expect(res.totalTaxableGeneral).toBe(sum); + expect(res.generalAccountTax).toBeCloseTo(tax, 2); + expect(res.usedLimit).toBe(used); + expect(res.remainingTaxFreeLimit).toBe(remaining); + expect(res.isaTax).toBeCloseTo(isaTax, 2); + expect(res.savedTax).toBeCloseTo(saved, 2); + }); + + it('2. 서민형 / grossDiv=10만원, estDiv=20만원, gain=600만원', async () => { + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 2, + accountType: '서민형', + }); + (prisma.generalTransaction.aggregate as jest.Mock) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 100_000 } } }) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } }); + (prisma.eTFHolding.findMany as jest.Mock).mockResolvedValue([ + { + etfId: 1, + avgCost: { toNumber: () => 20_000_000 }, + quantity: { toNumber: () => 1 }, + etf: { idxMarketType: '해외' }, + }, + ]); + (prisma.etfDailyTrading.findFirst as jest.Mock).mockResolvedValue({ + tddClosePrice: { toNumber: () => 20_000_000 }, + }); + (prisma.eTFTransaction.aggregate as jest.Mock) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 6_000_000 } } }) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } }); + + const res = await taxSaving(); + + const grossDiv = 100_000; + const estDiv = 200_000; + const gain = 6_000_000; + const sum = grossDiv + estDiv + gain; // 6_300_000 + const tax = sum * 0.154; // 970200 + const limit = 4_000_000; + const used = limit; // capped + const remaining = 0; + const isaBase = sum - limit; // 2_300_000 + const isaTax = isaBase * 0.099; // 227700 + const saved = tax - isaTax; // 742500 + + expect(res.limit).toBe(limit); + expect(res.totalTaxableGeneral).toBe(sum); + expect(res.generalAccountTax).toBeCloseTo(tax, 2); + expect(res.usedLimit).toBe(used); + expect(res.remainingTaxFreeLimit).toBe(remaining); + expect(res.isaTax).toBeCloseTo(isaTax, 2); + expect(res.savedTax).toBeCloseTo(saved, 2); + }); + + it('3. 일반형 / grossDiv=50만원, estDiv=50만원, gain=20만원 (no cap)', async () => { + // 계좌 타입 + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 3, + accountType: '일반형', + }); + // grossDiv = 500k + (prisma.generalTransaction.aggregate as jest.Mock) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 500_000 } } }) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } }); + // estDiv = 5M*2%*6/12 = 50k + (prisma.eTFHolding.findMany as jest.Mock).mockResolvedValue([ + { + etfId: 1, + avgCost: { toNumber: () => 5_000_000 }, + quantity: { toNumber: () => 1 }, + etf: { idxMarketType: '국내' }, + }, + ]); + (prisma.etfDailyTrading.findFirst as jest.Mock).mockResolvedValue({ + tddClosePrice: { toNumber: () => 5_000_000 }, + }); + // realizedOver = 200k + (prisma.eTFTransaction.aggregate as jest.Mock) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 200_000 } } }) + .mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } }); + + const res = await taxSaving(); + + // 올바른 계산 + const grossDiv = 500_000; + const estDiv = 50_000; + const gain = 200_000; + const sum = grossDiv + estDiv + gain; // 750_000 + const tax = sum * 0.154; // 115,500 + const limit = 2_000_000; + const used = sum; // 750_000 + const remaining = limit - used; // 1_250_000 + const isaTax = 0; // 한도 내 전부 비과세 + const saved = tax; // 115,500 + + expect(res.limit).toBe(limit); + expect(res.totalTaxableGeneral).toBe(sum); + expect(res.generalAccountTax).toBeCloseTo(tax, 2); + expect(res.usedLimit).toBe(used); + expect(res.remainingTaxFreeLimit).toBe(remaining); + expect(res.isaTax).toBe(isaTax); + expect(res.savedTax).toBeCloseTo(saved, 2); + }); +}); diff --git a/app/(routes)/(auth)/_component/header.tsx b/app/(routes)/(auth)/_component/header.tsx new file mode 100644 index 0000000..f90724d --- /dev/null +++ b/app/(routes)/(auth)/_component/header.tsx @@ -0,0 +1,10 @@ +import ArrowLeft from '@/public/images/arrow-left.svg'; + +export const AuthHeader = () => { + return ( +
+ +
+ ); +}; +export default AuthHeader; diff --git a/app/(routes)/(auth)/layout.tsx b/app/(routes)/(auth)/layout.tsx new file mode 100644 index 0000000..d169ec2 --- /dev/null +++ b/app/(routes)/(auth)/layout.tsx @@ -0,0 +1,9 @@ +import '@/app/globals.css'; + +export default function AuthLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return
{children}
; +} diff --git a/app/(routes)/(auth)/login/_components/login-form.tsx b/app/(routes)/(auth)/login/_components/login-form.tsx new file mode 100644 index 0000000..50db37c --- /dev/null +++ b/app/(routes)/(auth)/login/_components/login-form.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; +import { signIn } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import ArrowLeft from '@/public/images/arrow-left.svg'; +import StarBoy from '@/public/images/star-boy.svg'; +import Button from '@/components/button'; +import { CustomInput } from '@/components/input'; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + const res = await signIn('credentials', { + email: email.trim().toLowerCase(), + password: password.trim(), + redirect: false, + }); + + if (res?.ok && !res.error) { + router.push('/main'); // 성공 시 홈으로 이동 + } else { + setError('로그인 실패: 이메일 또는 비밀번호를 확인해주세요.'); + } + + setLoading(false); + }; + + return ( +
+
router.push('/')} + > + +
+
+ +

ISAID

+

I Save And Invest Daily

+
+ +
+ + + +
+ +
+ + +
+

인증 번호

+ + {isCodeSent && + formData.verificationCode.length === 3 && + formData.verificationCode !== sentCode && ( +

+ 인증번호가 일치하지 않습니다. +

+ )} + {isCodeSent && formData.verificationCode.length < 3 && ( +

+ SMS로 받은 인증번호를 입력해주세요. +

+ )} +
+ + +
+

주소를 입력해주세요.

+

+ 본인 확인을 위해 필요해요. +

+
+
setShowAddressModal(true)}> + +
+
+ +
+ + 자택 번호를 입력해주세요. (선택) + + +
+
+ +
+

+ 본인 인증을 완료해주세요. +

+

+ 마지막 단계에요! 거의 다 왔어요. +

+
+
+ + +
+
+ + + +

+ 영문+숫자+특수 문자, 8자 이상으로 조합해서 만들어 주세요 +

+
+ +
+ + + +

+ 비밀번호를 한번 더 입력해주세요. +

+
+
+ +
setShowSecurePinModal(true)}> + {}} + /> +
+ +

+ 숫자 6자리를 조합해서 만들어 주세요. +

+
+
+
+ + + + + + {submitError && ( +

{submitError}

+ )} + + {showAddressModal && ( + handleAddressSelect(addr)} + onCloseAction={() => setShowAddressModal(false)} + openState={showAddressModal} + /> + )} + {showSecurePinModal && ( + setShowSecurePinModal(false)} + onSubmit={async (pin) => { + setFormData((prev) => ({ ...prev, pinCode: pin })); + setShowSecurePinModal(false); + const isValid = validateField('pinCode', pin, formData); + setShowPinError(!isValid); + return true; + }} + /> + )} + + ); +} diff --git a/app/(routes)/(auth)/register/page.tsx b/app/(routes)/(auth)/register/page.tsx new file mode 100644 index 0000000..8155da5 --- /dev/null +++ b/app/(routes)/(auth)/register/page.tsx @@ -0,0 +1,20 @@ +import { getServerSession } from 'next-auth/next'; +import { redirect } from 'next/navigation'; +import { authOptions } from '@/lib/auth-options'; +import RegisterContainer from './_components/register-form'; + +const RegisterPage = async () => { + const session = await getServerSession(authOptions); + + if (session) { + redirect('/main'); + } + + return ( +
+ +
+ ); +}; + +export default RegisterPage; diff --git a/app/(routes)/challenge/_components/challenge-page-container.tsx b/app/(routes)/challenge/_components/challenge-page-container.tsx new file mode 100644 index 0000000..6f4cbc0 --- /dev/null +++ b/app/(routes)/challenge/_components/challenge-page-container.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useEffect } from 'react'; +import { useHeader } from '@/context/header-context'; +import ChallengeMainCharaters from '@/public/images/challenge/challenge-main.svg'; +import { challengeList, iconList } from '../data/challenge-list'; +import { MissionItem } from './mission-item'; + +interface ChallengeProps { + id: string; + issueName: string; + title: string; + challengeDescription: string; + quantity: number; + status: string; +} + +export default function ChallengePageContainer({ + challenges, +}: { + challenges: ChallengeProps[]; +}) { + const { setHeader } = useHeader(); + + useEffect(() => { + setHeader('하나모아 챌린지', '소소한 미션이 ETF 한조각으로'); + }, []); + + return ( +
+ {/* 헤더 영역 */} +
+

하나모아 챌린지

+

+ 챌린지 달성하면{' '} + ETF 조각 증정! +

+
+
+ +
+ + {/* 미션 리스트 */} + {/*
+ {challengeList.map((item) => ( + + ))} +
*/} + + {/* 미션 리스트 */} +
+ {challenges?.map((item: any) => { + // iconList에서 id 매핑 + const iconEntry = iconList.find((i) => i.id === item.id); + const iconSrc = iconEntry + ? iconEntry.icon + : '/images/challenge/icon-quiz-checkin.svg'; + + return ( + + ); + })} +
+
+ ); +} diff --git a/app/(routes)/challenge/_components/mission-item.tsx b/app/(routes)/challenge/_components/mission-item.tsx new file mode 100644 index 0000000..e131d9b --- /dev/null +++ b/app/(routes)/challenge/_components/mission-item.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useState } from 'react'; +import toast from 'react-hot-toast'; +import Image from 'next/image'; + +interface MissionItemProps { + id: string; + title: string; + description: string; + reward: string; + status: 'completed' | 'available' | 'pending'; + icon: string; +} + +export function MissionItem({ + id, + title, + description, + reward, + status, // 초기 상태 + icon, +}: MissionItemProps) { + // 1) status를 로컬 상태로 복제 + const [currentStatus, setCurrentStatus] = + useState(status); + const [isLoading, setIsLoading] = useState(false); + + const statusLabel = { + completed: '받기 완료', + available: '받기', + pending: '미달성', + } as const; + + const statusStyle = { + completed: 'bg-subtitle text-white', + available: 'bg-primary text-white', + pending: 'bg-primary-2 text-subtitle', + } as const; + + const rewardMatch = reward.match(/^(.+?)(\s\d+[\.\d]*[주%].*)$/); + + const handleClaim = async () => { + if (currentStatus !== 'available' || isLoading) return; + + setIsLoading(true); + try { + const res = await fetch('/api/challenge/claim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challengeId: id }), + }); + + if (!res.ok) throw new Error('보상 요청에 실패했습니다.'); + + // 2) 성공 시 로컬 상태를 completed로 바꿔버리기 + toast.success('보상 수령이 완료되었습니다!'); + setCurrentStatus('completed'); + } catch (error) { + console.error(error); + toast.error('보상 수령에 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {title} +
+
{title}
+
{description}
+ + {rewardMatch ? ( +
+ + {rewardMatch[1]} + + {rewardMatch[2]}{' '} + 지급! +
+ ) : ( +
{reward}
+ )} +
+ + +
+ ); +} diff --git a/app/(routes)/challenge/data/challenge-list.tsx b/app/(routes)/challenge/data/challenge-list.tsx new file mode 100644 index 0000000..89bb100 --- /dev/null +++ b/app/(routes)/challenge/data/challenge-list.tsx @@ -0,0 +1,104 @@ +export type MissionStatus = 'completed' | 'available' | 'pending'; + +export interface Mission { + id: number; + title: string; + description: string; + reward: string; + status: MissionStatus; + icon: string; +} +export const iconList = [ + { id: '34', icon: '/images/challenge/icon-streak-checkin.svg' }, + { id: '39', icon: '/images/challenge/icon-quiz-checkin.svg' }, + { id: '40', icon: '/images/challenge/icon-first-risk-test.svg' }, + { id: '41', icon: '/images/challenge/icon-connect-isa.svg' }, + { id: '42', icon: '/images/challenge/icon-own-3-etfs.svg' }, + { id: '43', icon: '/images/challenge/icon-hold-isa-500days.svg' }, + { id: '47', icon: '/images/challenge/icon-view-ai-portfolio.svg' }, + { id: '45', icon: '/images/challenge/icon-investment-dna-test.svg' }, + { id: '48', icon: '/images/challenge/icon-annual-deposit-over-1m.svg' }, +]; + +export const challengeList: Mission[] = [ + { + id: 1, + title: '연속 출석', + description: '7일 연속으로 출석하면', + reward: '1Q 미국 S&P 500 0.1주 지급', + status: 'completed', + icon: '/images/challenge/icon-streak-checkin.svg', + }, + { + id: 2, + title: '출석체크 금융 퀴즈', + description: '오늘의 금융 퀴즈를 풀면', + reward: '1Q 미국 S&P 500 0.02주 지급', + status: 'completed', + icon: '/images/challenge/icon-quiz-checkin.svg', + }, + { + id: 3, + title: '첫 투자성향 테스트', + description: '처음으로 투자성향 테스트를 하면', + reward: '1Q 차이나백 0.065주 지급', + status: 'available', + icon: '/images/challenge/icon-first-risk-test.svg', + }, + { + id: 4, + title: '첫 ISA 계좌 연결', + description: '처음으로 ISA 계좌 연결하면', + reward: '1Q 코리아백 0.084주 지급', + status: 'available', + icon: '/images/challenge/icon-connect-isa.svg', + }, + { + id: 5, + title: '보유 ETF 3종목 이상', + description: '서로 다른 ETF 3종 이상 보유하면', + reward: '1Q 미국빅테크 30 0.115주 지급', + status: 'pending', + icon: '/images/challenge/icon-own-3-etfs.svg', + }, + { + id: 6, + title: '계좌 보유 기간 500일 달성', + description: 'ISA 계좌를 보유한지 500일을 달성하면', + reward: 'Q S&P 500 0.3주 지급', + status: 'available', + icon: '/images/challenge/icon-hold-isa-500days.svg', + }, + { + id: 7, + title: '하나 원큐 AI포트폴리오 조회', + description: '하나은행의 AI추천 신탁/일임형 포트폴리오 조회', + reward: '1Q 미국빅테크 30 0.27주 지급', + status: 'available', + icon: '/images/challenge/icon-view-ai-portfolio.svg', + }, + { + id: 8, + title: '하나 원큐 ‘투자 DNA’ 테스트', + description: '하나 원큐의 투자 dna 테스트를 하면', + reward: '1Q K200 0.09주 지급', + status: 'available', + icon: '/images/challenge/icon-investment-dna-test.svg', + }, + { + id: 9, + title: '유형 전환', + description: '하나은행 ISA 일임형/신탁형 유형으로 전환하면', + reward: 'Q 미국 S&P 500 0.3주 지급', + status: 'available', + icon: '/images/challenge/icon-switch-isa-type.svg', + }, + { + id: 10, + title: '연간 납입 한도 100만원 이상', + description: '연간 납입한도 100만원 이상 납입하면', + reward: '1Q 미국 S&P 500 0.27주 지급', + status: 'available', + icon: '/images/challenge/icon-annual-deposit-over-1m.svg', + }, +]; diff --git a/app/(routes)/challenge/page.tsx b/app/(routes)/challenge/page.tsx new file mode 100644 index 0000000..61df16c --- /dev/null +++ b/app/(routes)/challenge/page.tsx @@ -0,0 +1,21 @@ +import { getChallenges } from '@/app/actions/get-challenge'; +import checkIsaAccount from '@/utils/check-isa-account'; +import NoIsaModal from '../main/_components/no-account-modal'; +import ChallengePageContainer from './_components/challenge-page-container'; + +const ChallengePage = async () => { + const challenges = await getChallenges(); + const hasIsaAccount = await checkIsaAccount(); + + return ( +
+ {hasIsaAccount ? ( + + ) : ( + + )} +
+ ); +}; + +export default ChallengePage; diff --git a/app/(routes)/etf/_components/data/etf-category-data.tsx b/app/(routes)/etf/_components/data/etf-category-data.tsx new file mode 100644 index 0000000..fa1dd7c --- /dev/null +++ b/app/(routes)/etf/_components/data/etf-category-data.tsx @@ -0,0 +1,51 @@ +import { + SlideImg1, + SlideImg2, + SlideImg3, + SlideImg4, + SlideImg5, +} from '@/public/images/etf/etf-slide'; +import { SlideCardProps } from '@/types/components'; + +export const cards: SlideCardProps[] = [ + { + id: 1, + title: '시장 대표 ETF', + subtitle: '대표 지수에 투자하고 싶다면', + description: '가장 기본이 되는 지수 ETF 로 시장 흐름을 따라가요', + category: 'market-core', + children: , + }, + { + id: 2, + title: '업종별로 골라보는 ETF', + subtitle: '관심있는 산업에 바로 투자', + description: '건설부터 IT까지, 다양한 산업별 테마를 모았어요', + category: 'industry', + children: , + }, + { + id: 3, + title: '전략형 ETF', + subtitle: '성장? 배당? 당신의 전략은?', + description: '가치, 성장, 배당... 투자 성향에 따라 전략을 골라보세요.', + category: 'strategy', + children: , + }, + { + id: 4, + title: '규모 기반 ETF', + subtitle: '대형주? 중형주? 내가 고르는 사이즈', + description: '안정적인 대형주부터 잠재력 있는 중형주까지.', + category: 'market-cap', + children: , + }, + { + id: 5, + title: '혼합 자산 ETF', + subtitle: '주식도 채권도 놓치기 싫다면', + description: '리스크는 낮추고 수익은 챙기는 균형형 포트폴리오', + category: 'mixed-assets', + children: , + }, +]; diff --git a/app/(routes)/etf/_components/data/etf-category-url-map.ts b/app/(routes)/etf/_components/data/etf-category-url-map.ts new file mode 100644 index 0000000..8fa19cb --- /dev/null +++ b/app/(routes)/etf/_components/data/etf-category-url-map.ts @@ -0,0 +1,59 @@ +export const idToCategoryUrl: Record = { + 1: 'market-core', + 2: 'industry?sub=etc', + 3: 'industry?sub=건설', + 4: 'industry?sub=중공업', + 5: 'industry?sub=산업재', + 6: 'industry?sub=금융', + 7: 'industry?sub=에너지화학', + 8: 'industry?sub=경기소비재', + 9: 'industry?sub=생활소비재', + 10: 'industry?sub=헬스케어', + 11: 'industry?sub=정보기술', + 12: 'industry?sub=철강소재', + 13: 'industry?sub=업종테마', + 14: 'industry?sub=커뮤니케이션서비스', + 15: 'strategy?sub=가치', + 16: 'strategy?sub=성장', + 17: 'strategy?sub=배당', + 18: 'strategy?sub=변동성', + 19: 'strategy?sub=구조화', + 20: 'strategy?sub=기업그룹', + 21: 'strategy?sub=전략테마', + 22: 'strategy?sub=혼합/퀀트', + 23: 'market-cap?sub=대형주', + 24: 'market-cap?sub=중형주', + 25: 'mixed-assets?sub=etc', + 26: 'mixed-assets?sub=주식%2B채권', +}; + +/* +export const idToCategoryUrl: Record = { + 1: '/etf/category/market-core', + 2: '/etf/category/industry?sub=etc', + 3: '/etf/category/industry?sub=건설', + 4: '/etf/category/industry?sub=중공업', + 5: '/etf/category/industry?sub=산업재', + 6: '/etf/category/industry?sub=금융', + 7: '/etf/category/industry?sub=에너지화학', + 8: '/etf/category/industry?sub=경기소비재', + 9: '/etf/category/industry?sub=생활소비재', + 10: '/etf/category/industry?sub=헬스케어', + 11: '/etf/category/industry?sub=정보기술', + 12: '/etf/category/industry?sub=철강소재', + 13: '/etf/category/industry?sub=업종테마', + 14: '/etf/category/industry?sub=커뮤니케이션서비스', + 15: '/etf/category/strategy?sub=가치', + 16: '/etf/category/strategy?sub=성장', + 17: '/etf/category/strategy?sub=배당', + 18: '/etf/category/strategy?sub=변동성', + 19: '/etf/category/strategy?sub=구조화', + 20: '/etf/category/strategy?sub=기업그룹', + 21: '/etf/category/strategy?sub=전략테마', + 22: '/etf/category/strategy?sub=혼합/퀀트', + 23: '/etf/category/market-cap?sub=대형주', + 24: '/etf/category/market-cap?sub=중형주', + 25: '/etf/category/mixed-assets?sub=etc', + 26: '/etf/category/mixed-assets?sub=주식+채권', +}; +*/ diff --git a/app/(routes)/etf/_components/etf-page-container.tsx b/app/(routes)/etf/_components/etf-page-container.tsx new file mode 100644 index 0000000..d2b84dc --- /dev/null +++ b/app/(routes)/etf/_components/etf-page-container.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Session } from 'next-auth'; +import { useRouter } from 'next/navigation'; +import { RecommendSliderWrapper } from '@/app/(routes)/etf/_components/recommend-slider-wrapper'; +import { useHeader } from '@/context/header-context'; +import ArrowIcon from '@/public/images/arrow-icon'; +import StarBoyFinger from '@/public/images/star-boy-finger.svg'; +import { EtfCardProps } from '@/types/etf'; +import { fetchRecommend } from '@/lib/api/etf'; +import { SliderWrapper } from '../_components/slider-wrapper'; +import { cards } from './data/etf-category-data'; +import { idToCategoryUrl } from './data/etf-category-url-map'; +import RecommendModal from './recommend-modal'; + +interface Props { + session: Session | null; +} +const ETFPageContainer = ({ session }: Props) => { + const { setHeader } = useHeader(); + const router = useRouter(); + const [selectedETFId, setSelectedETFId] = useState(26); + const [recommendList, setRecommendList] = useState([]); + const [showModal, setShowModal] = useState(false); + const [investType, setInvestType] = useState(null); + const [preferredCategories, setPreferredCategories] = useState< + { id: string; fullPath: string }[] + >([]); + + const [isPreferredCategoriesLoaded, setIsPreferredCategoriesLoaded] = + useState(false); + const [isRecommendListLoaded, setIsRecommendListLoaded] = useState(false); + + useEffect(() => { + setHeader('ETF 맞춤 추천', '당신의 투자 성향에 맞는 테마'); + + // 투자 성향 및 선호 카테고리 정보 호출 + const fetchEtfTestInfo = async () => { + try { + const res = await fetch('/api/etf/mbti', { method: 'GET' }); + if (!res.ok) return; + const data = await res.json(); + setInvestType(data.investType); + setPreferredCategories(data.preferredCategories); + setIsPreferredCategoriesLoaded(true); + } catch (error) { + console.error('MBTI 정보 조회 실패:', error); + setIsPreferredCategoriesLoaded(true); + } + }; + const fetchRecommendEtf = async () => { + try { + const res = await fetchRecommend(); + setRecommendList(res.data.recommendations); + setIsRecommendListLoaded(true); + } catch (error) { + setIsRecommendListLoaded(true); + } + }; + + fetchRecommendEtf(); + fetchEtfTestInfo(); + }, []); + + const handleClick = (id: number) => { + const path = idToCategoryUrl[id]; + if (!path) { + console.warn(`id ${id}에 대한 경로가 정의되지 않았습니다.`); + return; + } + router.push(`/etf/category/${path}`); + }; + + const clickSelectedETF = () => { + router.push(`/etf/detail/${recommendList[selectedETFId].etfId}`); + }; + + const clickRecommendETF = (idx: number) => { + setSelectedETFId(idx); + setShowModal(true); + }; + + return ( +
+
+ {/* 테스트 카드 */} +
router.push('etf/test')} + > + +
+

ETF, 뭐부터 시작하지?

+ + {investType + ? '테스트를 다시 진행하고 성향을 새롭게 확인해보세요' + : '몇 가지 질문에 답하면, 당신에게 어울리는 테마를 추천해드릴게요'} + +
+
+
+ {/* 추천 종목 */} + {isPreferredCategoriesLoaded && + isRecommendListLoaded && + preferredCategories.length > 0 && + recommendList.length > 0 ? ( +
+

+ {session?.user.name}님을 위한 추천 종목 +

+ +
+ ) : !isPreferredCategoriesLoaded || !isRecommendListLoaded ? ( +
+
+
+ ) : null} + {/* 추천 카테고리 */} + {isPreferredCategoriesLoaded && + isRecommendListLoaded && + preferredCategories.length > 0 && + recommendList.length > 0 ? ( +
+

선호 카테고리

+
+ {preferredCategories.map((sub) => ( +
handleClick(Number(sub.id))} + > + + {sub.fullPath} + + +
+ ))} +
+
+ ) : !isPreferredCategoriesLoaded || !isRecommendListLoaded ? ( +
+
+
+ ) : null} +
+ {/* 테마 슬라이더 */} +
+

ETF, 테마부터 시작해볼까요?

+ +
+
+ {showModal && ( + setShowModal(false)} + btnClick={() => clickSelectedETF()} + reasons={recommendList[selectedETFId].reasons} + issueName={recommendList[selectedETFId].issueName} + /> + )} +
+ ); +}; + +export default ETFPageContainer; diff --git a/app/(routes)/etf/_components/etf-recommend-card.tsx b/app/(routes)/etf/_components/etf-recommend-card.tsx new file mode 100644 index 0000000..b99fb47 --- /dev/null +++ b/app/(routes)/etf/_components/etf-recommend-card.tsx @@ -0,0 +1,53 @@ +interface EtfCardProps { + issueName: string; + riskGrade: number; + flucRate: number; + onClick: () => void; +} + +export default function EtfRecommendCard({ + issueName, + riskGrade, + onClick, + flucRate, +}: EtfCardProps) { + return ( +
+
+
+ {issueName} +
+
리스크 {riskGrade}
+
+
+

= 0 ? 'text-hana-red' : 'text-blue'}`} + > + {flucRate >= 0 ? `+${flucRate}` : flucRate} % +

+
+
+ ); +} + +function getRiskColor(riskGrade: number) { + const commonStyle = + 'rounded-2xl px-4 py-1 text-xs font-medium text-center min-w-[80px]'; + switch (riskGrade) { + case 1: + return `bg-green-100 text-green-800 ${commonStyle}`; + case 2: + return `bg-lime-100 text-lime-800 ${commonStyle}`; + case 3: + return `bg-yellow-100 text-yellow-800 ${commonStyle}`; + case 4: + return `bg-orange-100 text-orange-800 ${commonStyle}`; + case 5: + return `bg-red-100 text-red-800 ${commonStyle}`; + default: + return `bg-gray-100 text-gray-800 ${commonStyle}`; + } +} diff --git a/app/(routes)/etf/_components/recommend-modal.tsx b/app/(routes)/etf/_components/recommend-modal.tsx new file mode 100644 index 0000000..2e10789 --- /dev/null +++ b/app/(routes)/etf/_components/recommend-modal.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { ReasonProps } from '@/types/etf'; +import { CircleAlert, X } from 'lucide-react'; +import Button from '@/components/button'; + +export default function RecommendModal({ + onClose, + btnClick, + reasons, + issueName, +}: { + onClose: () => void; + btnClick: () => void; + reasons: ReasonProps[]; + issueName: string; +}) { + return ( +
+
+
e.stopPropagation()} + > +
+ +

이래서 추천드려요!

+

{issueName}

+
+
+
+
+ {reasons.map(({ title, description }, idx) => ( +
+ +
+

+ {title} +

+

{description}

+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/app/(routes)/etf/_components/recommend-slider-wrapper.tsx b/app/(routes)/etf/_components/recommend-slider-wrapper.tsx new file mode 100644 index 0000000..b49ff3a --- /dev/null +++ b/app/(routes)/etf/_components/recommend-slider-wrapper.tsx @@ -0,0 +1,44 @@ +import { Pagination } from 'swiper/modules'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import 'swiper/css'; +import 'swiper/css/pagination'; +import { EtfCardProps } from '@/types/etf'; +import EtfRecommendCard from './etf-recommend-card'; + +interface Props { + slides: EtfCardProps[]; + clickSlide: (idx: number) => void; +} + +export const RecommendSliderWrapper = ({ slides, clickSlide }: Props) => { + const handleClick = (idx: number) => { + clickSlide(idx); + }; + + return ( + { + return ``; + }, + }} + > + {slides.map((item, idx) => ( + + handleClick(idx)} + /> + + ))} + + ); +}; diff --git a/app/(routes)/etf/_components/slide-card.tsx b/app/(routes)/etf/_components/slide-card.tsx new file mode 100644 index 0000000..4601035 --- /dev/null +++ b/app/(routes)/etf/_components/slide-card.tsx @@ -0,0 +1,29 @@ +import { SlideCardProps } from '@/types/components'; + +export const SlideCard = ({ + title, + subtitle, + description, + children, + onClick, +}: SlideCardProps) => { + return ( +
+
+
+
+

{title}

+

{subtitle}

+
+

{description}

+
+
+
+ {children} +
+
+ ); +}; diff --git a/app/(routes)/etf/_components/slider-wrapper.tsx b/app/(routes)/etf/_components/slider-wrapper.tsx new file mode 100644 index 0000000..78778df --- /dev/null +++ b/app/(routes)/etf/_components/slider-wrapper.tsx @@ -0,0 +1,46 @@ +import { SlideCardProps } from '@/types/components'; +import { Pagination } from 'swiper/modules'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { SlideCard } from '../_components/slide-card'; +import 'swiper/css'; +import 'swiper/css/pagination'; +import { useRouter } from 'next/navigation'; + +interface SliderWrapperProps { + cards: SlideCardProps[]; +} + +export const SliderWrapper = ({ cards }: SliderWrapperProps) => { + const router = useRouter(); + return ( + <> + { + return ``; + }, + }} + > + {cards.map((card: SlideCardProps) => ( + + router.push(`/etf/category/${card.category}`)} + > + {card.children} + + + ))} + + + ); +}; diff --git a/app/(routes)/etf/category/[category-id]/_components/category-page-container.tsx b/app/(routes)/etf/category/[category-id]/_components/category-page-container.tsx new file mode 100644 index 0000000..b57d080 --- /dev/null +++ b/app/(routes)/etf/category/[category-id]/_components/category-page-container.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import EtfSection from '@/app/(routes)/etf/category/[category-id]/_components/etf-section'; +import { useHeader } from '@/context/header-context'; +import { useDebounce } from '@/hooks/useDebounce'; +import ArrowIcon from '@/public/images/arrow-icon'; +import { Category, Filter } from '@/types/etf'; +import { Loading } from '@/components/loading'; +import { fetchEtfCategory, fetchEtfItems } from '@/lib/api/etf'; +import { EtfItem, mapApiToRow } from '@/lib/utils'; + +const CategoryPageContainer = () => { + const { setHeader } = useHeader(); + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + + // 검색 + const [keyword, setKeyword] = useState(''); + const [filter, setFilter] = useState('name'); + const debounced = useDebounce(keyword, 400); + const [etfData, setEtfData] = useState([]); + + const rawCategoryId = params['category-id'] as string; + const subCategory = searchParams.get('sub') ?? ''; + + const [category, setCategory] = useState(null); + const [selectedSubId, setSelectedSubId] = useState(null); + const [loadingCategory, setLoadingCategory] = useState(true); + const [loadingItems, setLoadingItems] = useState(false); + + const [error, setError] = useState(''); + + const [tableName, setTableName] = useState(''); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [totalPages, setTotalPages] = useState(0); + const sentinelRef = useRef(null); + const SIZE = 10; + const shouldEmpty = loadingItems && page === 1; + + const onIntersect = useCallback( + (entries: IntersectionObserverEntry[]) => { + const entry = entries[0]; + if (entry.isIntersecting && hasMore && !loadingItems) { + setPage((p) => p + 1); + } + }, + [hasMore, loadingItems] + ); + + useEffect(() => { + const observer = new IntersectionObserver(onIntersect, { threshold: 0.1 }); + const el = sentinelRef.current; + if (el) observer.observe(el); + return () => observer.disconnect(); + }, [onIntersect]); + + useEffect(() => { + setPage(1); + setHasMore(true); + }, [debounced, filter, selectedSubId]); + + useEffect(() => { + setHeader('맞춤 테마 ETF', '당신의 투자 성향에 맞는 테마'); + }, []); + + useEffect(() => { + const fetchCategory = async () => { + try { + setLoadingCategory(true); + const data = await fetchEtfCategory(rawCategoryId); + setCategory({ + displayName: data.displayName, + categories: data.categories, + }); + } catch (e: any) { + setError(e.message || '카테고리 정보를 불러오는 데 실패했습니다.'); + } finally { + setLoadingCategory(false); + } + }; + fetchCategory(); + }, [rawCategoryId]); + + useEffect(() => { + if (!category) return; + + if (subCategory && selectedSubId === null) { + const targetSub = category.categories.find( + (cat) => cat.name === decodeURIComponent(subCategory) + ); + if (targetSub) { + setSelectedSubId(targetSub.id); + } + } + }, [category, subCategory, selectedSubId]); + + useEffect(() => { + if (!category) return; + + const targetId = + selectedSubId ?? + (category.categories.length ? category.categories[0].id : null); + if (!targetId) return; + + const needFetch = + category.categories.length === 1 || + (Boolean(subCategory) && selectedSubId !== null); + if (!needFetch) { + setEtfData([]); + return; + } + + const loadEtfData = async () => { + setLoadingItems(true); + try { + const res = await fetchEtfItems( + String(targetId), + debounced, + filter, + page, + SIZE + ); + setTotalPages(res.total); + setTableName(res.etfCategoryFullPath); + + setEtfData((prev) => + page === 1 + ? res.data.map(mapApiToRow) + : [...prev, ...res.data.map(mapApiToRow)] + ); + + setHasMore(res.data.length === SIZE); + } catch (e: any) { + setError(e.message || 'ETF 데이터를 불러오는 중 오류가 발생했습니다.'); + } finally { + setLoadingItems(false); + } + }; + + loadEtfData(); + }, [ + category, + subCategory, + debounced, + filter, + rawCategoryId, + selectedSubId, + page, + ]); + + useEffect(() => { + if (!subCategory) setSelectedSubId(null); + }, [subCategory]); + + const cleanUp = () => { + setTableName(''); + }; + + if (loadingCategory) return ; + + if (error) return
{error}
; + + if (!category) return
존재하지 않는 카테고리입니다.
; + + if (category.categories.length === 1) { + return ( + <> + + + {loadingItems && page > 1 && ( +
로딩 중…
+ )} + +
+ + ); + } + + const handleClick = (id: number, sub: string, fullName: string) => { + setSelectedSubId(id); + const encoded = encodeURIComponent(sub); + router.push(`/etf/category/${rawCategoryId}?sub=${encoded}`); + }; + + if (!subCategory) { + return ( +
+
+
+

+ {tableName || category.displayName} +

+

+ {category.categories.length} 종목 +

+
+
+
분류
+ {category.categories.map((sub) => ( +
handleClick(sub.id, sub.name, sub.fullname)} + > + {sub.name} + +
+ ))} +
+
+
+ ); + } + + return ( + <> + + + {loadingItems && page > 1 && ( +
로딩 중…
+ )} + +
+ + ); +}; +export default CategoryPageContainer; diff --git a/app/(routes)/etf/category/[category-id]/_components/custom-select.tsx b/app/(routes)/etf/category/[category-id]/_components/custom-select.tsx new file mode 100644 index 0000000..daebe46 --- /dev/null +++ b/app/(routes)/etf/category/[category-id]/_components/custom-select.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +type Option = { label: string; value: string }; +interface Props { + value: T; + options: Option[]; + onChangeAction: (v: T) => void; + className?: string; +} + +export default function CustomSelect({ + value, + options, + onChangeAction, + className = '', +}: Props) { + const [open, setOpen] = useState(false); + const boxRef = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (!boxRef.current?.contains(e.target as Node)) setOpen(false); + }; + window.addEventListener('click', handler); + return () => window.removeEventListener('click', handler); + }, []); + + return ( +
+ + + {open && ( +
    + {options.map((o) => ( +
  • { + onChangeAction(o.value as T); + setOpen(false); + }} + className={`px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 ${ + o.value === value ? 'font-semibold text-primary' : '' + }`} + > + {o.label} +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/(routes)/etf/category/[category-id]/_components/etf-section.tsx b/app/(routes)/etf/category/[category-id]/_components/etf-section.tsx new file mode 100644 index 0000000..a248425 --- /dev/null +++ b/app/(routes)/etf/category/[category-id]/_components/etf-section.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect } from 'react'; +import { Filter } from '@/types/etf'; +import { EtfItem } from '@/lib/utils'; +import EtfTable from './etf-table'; +import SearchBar from './search-bar'; + +interface Props { + title: string; + count: number; + keyword: string; + filter: Filter; + data: EtfItem[]; + onKeywordChangeAction: (v: string) => void; + onFilterChangeAction: (v: Filter) => void; + cleanUp?: () => void; + totalPages: number; +} + +export default function EtfSection({ + title, + count, + keyword, + filter, + data, + onKeywordChangeAction, + onFilterChangeAction, + cleanUp, + totalPages, +}: Props) { + useEffect(() => { + return () => { + cleanUp?.(); + }; + }, []); + return ( +
+
+

{title}

+

{totalPages} 종목

+
+ + + + +
+ ); +} diff --git a/app/(routes)/etf/category/[category-id]/_components/etf-table.tsx b/app/(routes)/etf/category/[category-id]/_components/etf-table.tsx new file mode 100644 index 0000000..c4e4fc2 --- /dev/null +++ b/app/(routes)/etf/category/[category-id]/_components/etf-table.tsx @@ -0,0 +1,42 @@ +import { useRouter } from 'next/navigation'; +import { EtfItem } from '@/lib/utils'; + +interface EtfTableProps { + data: EtfItem[]; +} + +export default function EtfTable({ data }: EtfTableProps) { + const router = useRouter(); + return ( +
+
+
종목
+
거래량
+
현재가
+
+ {data.map((item, index) => ( +
router.push(`/etf/detail/${item.etfId}`)} + > +
+
{item.name}
+
{item.code}
+
+ +
{item.volume}
+ +
+
{item.price}
+
0 ? 'text-hana-red' : 'text-blue'}`} + > + {item.changeRate} +
+
+
+ ))} +
+ ); +} diff --git a/app/(routes)/etf/category/[category-id]/_components/search-bar.tsx b/app/(routes)/etf/category/[category-id]/_components/search-bar.tsx new file mode 100644 index 0000000..4408cd0 --- /dev/null +++ b/app/(routes)/etf/category/[category-id]/_components/search-bar.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Filter } from '@/types/etf'; +import Input from '@/components/input'; +import CustomSelect from './custom-select'; + +interface Props { + keyword: string; + filter: Filter; + onKeywordChangeAction: (kw: string) => void; + onFilterChangeAction: (f: Filter) => void; +} + +export default function SearchBar({ + keyword, + filter, + onKeywordChangeAction, + onFilterChangeAction, +}: Props) { + const filterOptions = [ + { label: '종목명', value: 'name' }, + { label: '종목코드', value: 'code' }, + ]; + + return ( +
+ onFilterChangeAction(v)} + /> + + onKeywordChangeAction(v)} + name='search' + /> +
+ ); +} diff --git a/app/(routes)/etf/category/[category-id]/page.tsx b/app/(routes)/etf/category/[category-id]/page.tsx new file mode 100644 index 0000000..64a028a --- /dev/null +++ b/app/(routes)/etf/category/[category-id]/page.tsx @@ -0,0 +1,7 @@ +import CategoryPageContainer from './_components/category-page-container'; + +const CategoryPage = () => { + return ; +}; + +export default CategoryPage; diff --git a/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-chart.tsx b/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-chart.tsx new file mode 100644 index 0000000..8d8434a --- /dev/null +++ b/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-chart.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useEffect, useMemo, useRef } from 'react'; +import ApexCharts from 'apexcharts'; + +interface ChartRow { + date: string; + closePrice: number; +} + +interface Props { + chartRows: ChartRow[]; + selectedPeriod: number; + onReady?: () => void; +} + +export default function EftDetailChart({ + chartRows, + selectedPeriod, + onReady, +}: Props) { + const chartRef = useRef(null); + const chartInstance = useRef(null); + + const seriesData = useMemo<[number, number][]>( + () => + chartRows.map(({ date, closePrice }) => [ + new Date(date).getTime(), + closePrice, + ]), + [chartRows] + ); + + const firstTs = seriesData[0]?.[0] ?? 0; + const lastTs = seriesData[seriesData.length - 1]?.[0] ?? 0; + const availDays = Math.floor((lastTs - firstTs) / 86400000); + const needDays = [7, 30, 90, 365, 1095]; + + const getRange = (period: number): [number, number] => { + const need = needDays[period] ?? 1095; + const start = need > availDays ? firstTs : lastTs - need * 86400000; + return [start, lastTs]; + }; + + useEffect(() => { + if (!chartRef.current || seriesData.length === 0) { + onReady?.(); + return; + } + let alive = true; + + const chart = new ApexCharts(chartRef.current, { + chart: { + id: 'etf-area', + type: 'area', + height: 350, + toolbar: { show: false }, + zoom: { enabled: false }, + }, + dataLabels: { enabled: false }, + series: [{ name: '가격', data: seriesData }], + xaxis: { + type: 'datetime', + labels: { rotate: -30, style: { fontSize: '10px' } }, + tickAmount: 6, + }, + tooltip: { + enabled: true, + theme: 'light', + x: { format: 'MM/dd' }, + y: { + formatter: (value: number) => `${value.toFixed(2)}원`, + }, + }, + }); + chartInstance.current = chart; + + chart.render().then(() => { + if (!alive) return; + const [s, e] = getRange(selectedPeriod); + chart.zoomX(s, e); + onReady?.(); + }); + + return () => { + alive = false; + chart.destroy(); + chartInstance.current = null; + }; + }, [seriesData]); + + useEffect(() => { + if (!chartInstance.current) return; + const [s, e] = getRange(selectedPeriod); + chartInstance.current.zoomX(s, e); + }, [selectedPeriod]); + + return
; +} diff --git a/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-container.tsx b/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-container.tsx new file mode 100644 index 0000000..5e25ce3 --- /dev/null +++ b/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-container.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useHeader } from '@/context/header-context'; +import ArrowCross from '@/public/images/arrow-cross'; +import { EtfDetail, EtfDetailResponse, EtfIntro, RatioInfo } from '@/types/etf'; +import { Loading } from '@/components/loading'; +import Tab from '@/components/tab'; +import { fetchEtfDetails, fetchEtfRatio } from '@/lib/api/etf'; +import { EtfRatioData, formatComma, toEtfRatioData } from '@/lib/utils'; +import EftDetailChart from '../_components/etf-detail-chart'; +import EtfDetailRatioChart from '../_components/etf-detail-ratio-chart'; +import EtfDetailTable from '../_components/etf-detail-table'; +import Skeleton from '../_components/skeleton'; + +const emptyRatioData: EtfRatioData = { labels: [], series: [] }; +const emptyRatioInfo: RatioInfo[] = [ + { + compstIssueCu1Shares: '', + compstIssueName: '', + compstRatio: '', + }, +]; +interface EtfDetailContainerProps { + etfCode: string; + initialChart: { + date: string; + closePrice: number; + }[]; +} +export default function EtfDetailContainer({ + etfCode, + initialChart, +}: EtfDetailContainerProps) { + const [etfResponse, setEtfResponse] = useState(); + const [etfIntro, setEtfIntro] = useState(); + const [etfDetail, setEtfDetail] = useState(); + const [chartRows] = useState(initialChart); + const [showPie, setShowPie] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + const { setHeader } = useHeader(); + + const loadDetailAndRatio = async (id: string) => { + setIsLoading(true); + + const [detailRes, ratioRes] = await Promise.all([ + fetchEtfDetails(id), + fetchEtfRatio(id), + ]); + setShowPie(ratioRes.hasRecalculated); + setEtfResponse(detailRes); + setEtfIntro(detailRes.EtfIntro); + setEtfDetail(detailRes.EtfDetail); + + setChartData(toEtfRatioData(ratioRes)); + setRatioInfo(ratioRes.data); + setIsLoading(false); + }; + useEffect(() => { + if (etfIntro) { + setHeader(etfIntro.issueName, etfIntro.category); + } + }, [etfIntro]); + + const [chartData, setChartData] = useState(emptyRatioData); + const [ratioInfo, setRatioInfo] = useState(emptyRatioInfo); + + useEffect(() => { + // setHeader(etf.categoryId, '당신의 투자 성향에 맞는 테마'); + loadDetailAndRatio(etfCode); + }, []); + + const periodDays = [7, 30, 90, 365, 1095]; + const firstTs = new Date(chartRows[0].date).getTime(); + const lastTs = new Date(chartRows[chartRows.length - 1].date).getTime(); + const diffDays = Math.floor((lastTs - firstTs) / 86400000); + + const lastFitIdx = periodDays.findLastIndex((d) => d <= diffDays); + const allowedMaxIdx = Math.min(lastFitIdx + 1, 4); + const canUse = (idx: number) => idx <= allowedMaxIdx; + + const [selectedTab, setSelectedTab] = useState(0); + // const [selectedPeriod, setSelectedPeriod] = useState(() => { + // const firstTs = new Date(initialChart[0].date).getTime(); + // const lastTs = new Date( + // initialChart[initialChart.length - 1].date + // ).getTime(); + // const diffDays = Math.floor((lastTs - firstTs) / 86400000); + // + // const lastFitIdx = periodDays.findLastIndex((d) => d <= diffDays); + // const maxIdx = Math.min(lastFitIdx + 1, 4); + // return maxIdx; + // }); + + const [selectedPeriod, setSelectedPeriod] = useState(3); + + const [showSelected, setShowSelected] = useState(selectedPeriod); + const [chartReady, setChartReady] = useState(false); + + if (isLoading) { + return ; + } + if (!etfResponse || !etfIntro || !etfDetail) + return
해당 ETF가 존재하지 않습니다.
; + const periods = ['1주일', '1개월', '3개월', '1년', '3년']; + const tabs = ['기본정보', '구성 비중']; + + const clickTab = (idx: number) => { + if (canUse(idx)) { + setShowSelected(idx); + setSelectedPeriod(idx); + } else { + setShowSelected(idx); + } + }; + return ( + <> +
+
+
+

{etfIntro.category}

+
+ + {etfIntro.issueName} + + + {etfIntro.issueCode} + +
+
+
+ + {formatComma(etfIntro.todayClose)} + + {parseFloat(etfIntro.flucRate) !== 0 && ( + 0 ? 'up' : 'down'}`} + /> + )} + 0 ? 'text-hana-red' : parseFloat(etfIntro.flucRate) === 0 ? 'text-gray-500' : 'text-blue'}`} + > + {parseFloat(etfIntro.flucRate) > 0 + ? `+${parseFloat(etfIntro.flucRate)} %` + : `${parseFloat(etfIntro.flucRate)} %`} + +
+
+
+
+

iNAV

+

{formatComma(etfIntro.iNav)}

+
+
+
+

거래량

+

{formatComma(etfIntro.tradeVolume)}주

+
+
+
+
+ {periods.map((label, idx) => ( + { + clickTab(idx); + }} + /> + ))} +
+
+
+ {!chartReady && ( +
+ +
+ )} + setChartReady(true)} + /> +
+
+
+ {tabs.map((label, idx) => ( + setSelectedTab(idx)} + /> + ))} +
+ {selectedTab === 0 && } + {selectedTab === 1 && ( + + )} +
+ + ); +} diff --git a/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-ratio-chart.tsx b/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-ratio-chart.tsx new file mode 100644 index 0000000..3c6b80a --- /dev/null +++ b/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-ratio-chart.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { RatioInfo } from '@/types/etf'; +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; + +interface EtfDetailRatioChartProps { + labels: string[]; + series: number[]; + ratioInfoList: RatioInfo[]; + showPie: boolean; +} + +const COLORS = [ + '#5eead4', + '#2dd4bf', + '#14b8a6', + '#0f766e', + '#a7f3d0', + '#34d399', + '#059669', + '#047857', + '#064e3b', + '#083f2e', +]; + +export default function EtfDetailRatioChart({ + labels, + series, + ratioInfoList, + showPie, +}: EtfDetailRatioChartProps) { + const data = labels.map((name, idx) => ({ + name, + value: series[idx], + })); + + return ( +
+
+
+
구성종목명
+
주식수(계약수)
+
시가총액기준 구성비중
+
+ {ratioInfoList.map((ratioInfo, idx) => ( +
+
+ {ratioInfo.compstIssueName} +
+ +
+ {ratioInfo.compstIssueCu1Shares} +
+ +
+ {ratioInfo.compstRatio || '-'} +
+
+ ))} +
+ {showPie && ( + <> +
+ + + + {data.map((_, idx) => ( + + ))} + + [ + `${value.toFixed(2)}%`, + name, + ]} + /> + + + +
    + {data.map((item, idx) => ( +
  • +
    + + {item.name} +
    + + {item.value.toFixed(2)}% + +
  • + ))} +
+
+ + )} +
+ ); +} diff --git a/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-table.tsx b/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-table.tsx new file mode 100644 index 0000000..2e80e72 --- /dev/null +++ b/app/(routes)/etf/detail/[etf-code]/_components/etf-detail-table.tsx @@ -0,0 +1,43 @@ +import type { EtfDetail } from '@/types/etf'; +import { formatComma, formatDate } from '@/lib/utils'; + +interface EtfDetailTableProps { + etf: EtfDetail; +} + +export default function EtfDetailTable({ etf }: EtfDetailTableProps) { + return ( +
+

ETF 개요

+ +
+ 운용사 + {etf.comAbbrv} +
+
+ 상장일 + {formatDate(etf.listDate)} +
+
+ 기초지수 + {etf.etfObjIndexName} +
+
+ 기초자산분류 + {etf.idxMarketType} +
+
+ 총보수 + {etf.etfTotalFee}% +
+
+ 과세유형 + {etf.taxType} +
+
+ 순자산총액 + {formatComma(etf.marketCap)} +
+
+ ); +} diff --git a/app/(routes)/etf/detail/[etf-code]/_components/skeleton.tsx b/app/(routes)/etf/detail/[etf-code]/_components/skeleton.tsx new file mode 100644 index 0000000..3f41682 --- /dev/null +++ b/app/(routes)/etf/detail/[etf-code]/_components/skeleton.tsx @@ -0,0 +1,20 @@ +interface SkeletonProps { + width?: string; + height?: number; + className?: string; +} + +export default function Skeleton({ + width = '100%', + height = 20, + className = '', +}: SkeletonProps) { + return ( +
+
+
+ ); +} diff --git a/app/(routes)/etf/detail/[etf-code]/data/etf-detail-data.ts b/app/(routes)/etf/detail/[etf-code]/data/etf-detail-data.ts new file mode 100644 index 0000000..c75e004 --- /dev/null +++ b/app/(routes)/etf/detail/[etf-code]/data/etf-detail-data.ts @@ -0,0 +1,216 @@ +export type EtfDetail = { + name: string; + code: string; + price: number; + rate: number; + categoryId: string; // ex) '주식-업종섹터' + company: string; // 운용사 + listedDate: string; // 상장일 + index: string; // 기초지수 + marketCap: string; // 시가총액 + netAsset: string; // 순자산 + totalShares: string; // 상장주식수 + holdingsCount: string; // 구성종목수 + nav: string; // 전일 NAV + fundType: string; // 펀드형태 + taxType: string; // 과세유형 + replicationMethod: string; // 복제방법 + // 헤더에 있는 정보 + iNav: number; // iNAV 값 + iNavRate: number; // 그 옆에 등락율 + volume: number; // 거래량 +}; + +export const etfDetailMap = { + // TIGER + '123456': { + name: 'TIGER 성장 ETF', + code: '123456', + price: 9300, + rate: 5.68, + categoryId: '주식-전략', + company: '미래에셋', + listedDate: '2023.03.15', + index: 'KOSPI 성장지수', + marketCap: '1,200억원(320위)', + netAsset: '1,180억원(319위)', + totalShares: '10,000,000주', + holdingsCount: '50종목', + nav: '12,480.25', + fundType: '수익증권형', + taxType: '비과세', + replicationMethod: '실물(패시브)', + fee: '0.25%', + assetSize: '1,200억원', + iNav: 9417, + iNavRate: 5.71, + volume: 1_200_000, + }, + '123457': { + name: 'TIGER 미국테크 TOP10 ETF', + code: '123457', + price: 17_350, + rate: 0.67, + categoryId: '주식-해외', + company: '미래에셋', + listedDate: '2024.02.01', + index: '미국테크 TOP10 지수', + marketCap: '950억원', + netAsset: '930억원', + totalShares: '5,480,000주', + holdingsCount: '10종목', + nav: '17,310.45', + fundType: '수익증권형', + taxType: '비과세', + replicationMethod: '실물(패시브)', + fee: '0.30%', + assetSize: '950억원', + iNav: 17_420, + iNavRate: 0.4, + volume: 950_000, + }, + + // KODEX + '234567': { + name: 'KODEX 고배당 ETF', + code: '234567', + price: 12_300, + rate: -9.8, + categoryId: '주식-전략', + company: '삼성자산운용', + listedDate: '2022.11.10', + index: '고배당주지수', + marketCap: '900억원', + netAsset: '920억원', + totalShares: '9,200,000주', + holdingsCount: '35종목', + nav: '10,850.75', + fundType: '수익증권형', + taxType: '비과세', + replicationMethod: '실물(패시브)', + fee: '0.19%', + assetSize: '900억원', + iNav: 12_123, + iNavRate: -4.8, + volume: 890_000, + }, + '234568': { + name: 'KODEX 200 ETF', + code: '234568', + price: 33_410, + rate: 0.28, + categoryId: '주식-대표지수', + company: '삼성자산운용', + listedDate: '2002.01.30', + index: 'KOSPI200', + marketCap: '1조 4,500억원', + netAsset: '1조 4,320억원', + totalShares: '43,350,000주', + holdingsCount: '200종목', + nav: '33,390.90', + fundType: '수익증권형', + taxType: '비과세', + replicationMethod: '실물(패시브)', + fee: '0.15%', + assetSize: '1조 4,500억원', + iNav: 33_460, + iNavRate: 0.21, + volume: 1_450_000, + }, + + // ACE + '345678': { + name: 'ACE 2차전지 테마 ETF', + code: '345678', + price: 15_640, + rate: 2.05, + categoryId: '주식-테마', + company: '키움투자운용', + listedDate: '2023.08.25', + index: '국내 2차전지 테마지수', + marketCap: '1,050억원', + netAsset: '1,030억원', + totalShares: '6,580,000주', + holdingsCount: '30종목', + nav: '15,590.35', + fundType: '수익증권형', + taxType: '비과세', + replicationMethod: '실물(패시브)', + fee: '0.40%', + assetSize: '1,050억원', + iNav: 15_720, + iNavRate: 0.5, + volume: 1_050_000, + }, + '345679': { + name: 'ACE 미국리츠 ETF', + code: '345679', + price: 9_880, + rate: -0.37, + categoryId: '부동산-리츠', + company: '키움투자운용', + listedDate: '2021.05.12', + index: 'FTSE Nareit All REITs', + marketCap: '420억원', + netAsset: '415억원', + totalShares: '4,200,000주', + holdingsCount: '150종목', + nav: '9,870.20', + fundType: '수익증권형', + taxType: '비과세', + replicationMethod: '실물(패시브)', + fee: '0.45%', + assetSize: '420억원', + iNav: 9_860, + iNavRate: -0.2, + volume: 420_000, + }, + + // 1Q + '456789': { + name: '1Q 코스닥벤처 ETF', + code: '456789', + price: 7_420, + rate: -1.12, + categoryId: '주식-코스닥', + company: 'NH-Amundi', + listedDate: '2020.10.05', + index: 'KOSDAQ Venture 지수', + marketCap: '310억원', + netAsset: '305억원', + totalShares: '4,150,000주', + holdingsCount: '130종목', + nav: '7,415.10', + fundType: '수익증권형', + taxType: '비과세', + replicationMethod: '실물(패시브)', + fee: '0.50%', + assetSize: '310억원', + iNav: 7_400, + iNavRate: -0.27, + volume: 310_000, + }, + '456790': { + name: '1Q 항공우주 ETF', + code: '456790', + price: 8_760, + rate: 1.26, + categoryId: '주식-테마', + company: 'NH-Amundi', + listedDate: '2022.03.14', + index: '글로벌 항공우주·방산지수', + marketCap: '270억원', + netAsset: '268억원', + totalShares: '3,070,000주', + holdingsCount: '40종목', + nav: '8,740.80', + fundType: '수익증권형', + taxType: '비과세', + replicationMethod: '실물(패시브)', + fee: '0.55%', + assetSize: '270억원', + iNav: 8_800, + iNavRate: 0.45, + volume: 270_000, + }, +}; diff --git a/app/(routes)/etf/detail/[etf-code]/data/etf-detail-ratio-data.ts b/app/(routes)/etf/detail/[etf-code]/data/etf-detail-ratio-data.ts new file mode 100644 index 0000000..c365156 --- /dev/null +++ b/app/(routes)/etf/detail/[etf-code]/data/etf-detail-ratio-data.ts @@ -0,0 +1,20 @@ +export interface EtfRatioData { + labels: string[]; + series: number[]; +} + +export const etfRatioData: EtfRatioData = { + labels: [ + '삼성전자', + 'SK하이닉스', + 'LG에너지솔루션', + 'NAVER', + '카카오', + '현대차', + '기아', + '삼성바이오로직스', + 'POSCO홀딩스', + '셀트리온', + ], + series: [23.5, 15.2, 11.3, 9.1, 8.2, 7.6, 6.3, 5.1, 4.0, 9.7], +}; diff --git a/app/(routes)/etf/detail/[etf-code]/data/etf-price-data.ts b/app/(routes)/etf/detail/[etf-code]/data/etf-price-data.ts new file mode 100644 index 0000000..d67664e --- /dev/null +++ b/app/(routes)/etf/detail/[etf-code]/data/etf-price-data.ts @@ -0,0 +1,36 @@ +export interface EtfPriceSet { + categories: string[]; + data: number[]; +} + +export type EtfPeriod = '1주일' | '1개월' | '3개월' | '1년' | '3년'; + +function generatePriceSeries(days: number, startPrice = 9300): EtfPriceSet { + const categories: string[] = []; + const data: number[] = []; + + let currentPrice = startPrice; + const today = new Date(); + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(today); + date.setDate(today.getDate() - i); + + const iso = date.toISOString().split('T')[0]; + categories.push(iso); + + const fluctuation = (Math.random() - 0.5) * 2; + currentPrice = Math.round(currentPrice * (1 + fluctuation / 100)); // 정수화 + data.push(currentPrice); + } + + return { categories, data }; +} + +export const etfPriceData: Record = { + '1주일': generatePriceSeries(7), + '1개월': generatePriceSeries(30), + '3개월': generatePriceSeries(90), + '1년': generatePriceSeries(365), + '3년': generatePriceSeries(1095), +}; diff --git a/app/(routes)/etf/detail/[etf-code]/page.tsx b/app/(routes)/etf/detail/[etf-code]/page.tsx new file mode 100644 index 0000000..1b1f136 --- /dev/null +++ b/app/(routes)/etf/detail/[etf-code]/page.tsx @@ -0,0 +1,19 @@ +import { notFound } from 'next/navigation'; +import { getEtfDailyTrading3y } from '@/lib/db/etf'; +import { fillGapsBetweenSingleMonthData } from '@/lib/utils'; +import EtfDetailContainer from './_components/etf-detail-container'; + +export default async function EtfDetailPage({ params }: { params: any }) { + const raw = (params as { 'etf-code': string | string[] })['etf-code']; + const etfCode = Array.isArray(raw) ? raw[0] : raw; + const etfId = Number(etfCode); + + if (!Number.isInteger(etfId) || etfId <= 0) notFound(); + + const chartRows = await getEtfDailyTrading3y(etfId); + const filledChartRows = fillGapsBetweenSingleMonthData(chartRows); + + return ( + + ); +} diff --git a/app/(routes)/etf/page.tsx b/app/(routes)/etf/page.tsx new file mode 100644 index 0000000..1a55033 --- /dev/null +++ b/app/(routes)/etf/page.tsx @@ -0,0 +1,10 @@ +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth-options'; +import ETFPageContainer from './_components/etf-page-container'; + +const ETFPage = async () => { + const session = await getServerSession(authOptions); + return ; +}; + +export default ETFPage; diff --git a/app/(routes)/etf/test/_components/progress-bar.tsx b/app/(routes)/etf/test/_components/progress-bar.tsx new file mode 100644 index 0000000..e655592 --- /dev/null +++ b/app/(routes)/etf/test/_components/progress-bar.tsx @@ -0,0 +1,27 @@ +interface ProgressBarProps { + current: number; + total: number; + className?: string; +} + +export default function ProgressBar({ + current, + total, + className, +}: ProgressBarProps) { + const percentage = (current / total) * 100; + + return ( +
+
+
+ {current}/{total} +
+
+ ); +} diff --git a/app/(routes)/etf/test/_components/test-end-container.tsx b/app/(routes)/etf/test/_components/test-end-container.tsx new file mode 100644 index 0000000..e2973a4 --- /dev/null +++ b/app/(routes)/etf/test/_components/test-end-container.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; // useRouter 추가 +import StayBoyTest from '@/public/images/star-boy-test.svg'; +import Button from '@/components/button'; +import { getRecommendedTypesWithReasons, getRiskType } from '@/lib/test/utils'; + +interface TestEndContainerProps { + btnClick: () => void; + answers: (number | null)[]; +} + +export const TestEndContainer = ({ answers }: TestEndContainerProps) => { + const router = useRouter(); // useRouter 초기화 + const riskType = getRiskType(answers); + const recommended = getRecommendedTypesWithReasons(answers); // 상위 3개 분류체계 + + // ETF 페이지 이동 로직 + const handleBtnClick = () => { + router.push('/etf'); // '/etf' 경로로 이동 + }; + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + return ( +
+

+ ETF 투자 성향 테스트 결과 +

+ + 당신은 {riskType}{' '} + 투자 성향이에요! + + + + +
+ {recommended.map((item) => ( +
+ {/* 분류체계 이름 */} +
+ [{item.name}] +
+ + {/* 임팩트 문구 */} +
+ {item.impact} +
+ + {/* 줄바꿈 된 추천 이유 목록 */} +
+ {item.reason.map((line, idx) => ( +

• {line}

+ ))} +
+ + {/* 설명 */} +
+ {item.description} +
+ + {/* 해시태그 */} +
+ {item.hashtags.map((tag) => ( + + {tag} + + ))} +
+
+ ))} +
+ +
+ ); +}; diff --git a/app/(routes)/etf/test/_components/test-page-container.tsx b/app/(routes)/etf/test/_components/test-page-container.tsx new file mode 100644 index 0000000..e7e6e4a --- /dev/null +++ b/app/(routes)/etf/test/_components/test-page-container.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useHeader } from '@/context/header-context'; +import { questions } from '@/data/etf-test'; +import Button from '@/components/button'; +import QuestionOption from '@/components/question-option'; +import { submitEtfMbtiResult } from '@/lib/api/etf-test'; +import { convertToEnum, getEtfResult } from '@/lib/test/utils'; +import ProgressBar from './progress-bar'; +import { TestEndContainer } from './test-end-container'; +import { TestStartContainer } from './test-start-container'; + +export default function TestContainer() { + const FRONT_COUNT = 6; + const { setHeader } = useHeader(); + const [step, setStep] = useState<0 | 1 | 2 | 3>(0); + const [selectedOptions, setSelectedOptions] = useState<(number | null)[]>( + Array(questions.length).fill(null) + ); + const [focusedIdx, setFocusedIdx] = useState(null); + const questionRefs = useRef<(HTMLDivElement | null)[]>([]); + + useEffect(() => { + setHeader('ETF 투자 성향 테스트', '당신의 투자 성향을 알아보세요'); + }, []); + + const handleOptionClick = (idx: number, opt: number) => { + setFocusedIdx(null); + setSelectedOptions((prev) => { + const copy = [...prev]; + copy[idx] = opt; + return copy; + }); + }; + + const isGroupComplete = (start: number, count: number) => + questions + .slice(start, start + count) + .every((_, i) => selectedOptions[start + i] !== null); + + const scrollAndFocus = (idx: number) => { + const el = questionRefs.current[idx]; + if (!el) return; + el.scrollIntoView({ behavior: 'auto', block: 'center' }); + el.focus(); + setFocusedIdx(idx); + }; + + const handleNext = () => { + if (isGroupComplete(0, FRONT_COUNT)) { + setStep(2); + setTimeout(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, 0); + } else { + const missing = selectedOptions + .slice(0, FRONT_COUNT) + .findIndex((v) => v === null); + if (missing >= 0) scrollAndFocus(missing); + } + }; + + const handleSubmit = async () => { + const backCount = questions.length - FRONT_COUNT; + if (isGroupComplete(FRONT_COUNT, backCount)) { + const { riskType, topTypes } = getEtfResult(selectedOptions); + const investType = convertToEnum(riskType); + + try { + await submitEtfMbtiResult({ + investType, + preferredCategories: topTypes, + }); + setStep(3); + } catch (error: any) { + console.error('요청 실패:', error); + alert(error?.message || '알 수 없는 오류가 발생했습니다.'); + } + } else { + const missing = selectedOptions + .slice(FRONT_COUNT) + .findIndex((v) => v === null); + const idx = FRONT_COUNT + missing; + if (missing >= 0) scrollAndFocus(idx); + } + }; + + const renderGroup = (start: number, count: number) => + questions.slice(start, start + count).map((q, i) => { + const globalIdx = start + i; + return ( +
{ + questionRefs.current[globalIdx] = el; + }} + tabIndex={-1} + className={`flex flex-col border rounded-2xl w-full p-5 gap-5 transition-ring ${ + focusedIdx === globalIdx ? 'ring-2 ring-red-500' : 'border-gray-2' + }`} + > +
+ Q. {i + 1}/{count} +
+

{q.question}

+ {q.options.map((opt, oi) => ( + handleOptionClick(globalIdx, oi)} + /> + ))} +
+ ); + }); + + return ( +
+ {(step === 1 || step === 2) && ( +
+ v !== null).length + : selectedOptions.slice(FRONT_COUNT).filter((v) => v !== null) + .length + } + total={step === 1 ? FRONT_COUNT : questions.length - FRONT_COUNT} + /> +
+ )} + +
+
+ {step === 0 && setStep(1)} />} + {step === 1 && renderGroup(0, FRONT_COUNT)} + {step === 2 && + renderGroup(FRONT_COUNT, questions.length - FRONT_COUNT)} + {step === 3 && ( + setStep(0)} + answers={selectedOptions} + /> + )} +
+
+ + {(step === 1 || step === 2) && ( +
+
+ )} +
+ ); +} diff --git a/app/(routes)/etf/test/_components/test-start-container.tsx b/app/(routes)/etf/test/_components/test-start-container.tsx new file mode 100644 index 0000000..e1bc644 --- /dev/null +++ b/app/(routes)/etf/test/_components/test-start-container.tsx @@ -0,0 +1,28 @@ +import StayBoyTest from '@/public/images/star-boy-test.svg'; +import Button from '@/components/button'; + +interface StartProps { + btnClick: () => void; +} + +export const TestStartContainer = ({ btnClick }: StartProps) => { + return ( + <> +
+

ETF 투자 성향 테스트

+ + 간단한 테스트로 투자 성향을 파악하고, +
+ 맞춤형 ETF 테마를 추천받으세요 +
+
+ + +
+ +
+ {article.tags.map((tag, index) => ( +
+ #{tag} +
+ ))} +
+
+
+ + + ))} +
+ ); +} + +function getDifficultyColor(difficulty: string) { + const commonStyle = 'rounded-2xl px-4 py-1'; + switch (difficulty) { + case '초급': + return `bg-green-100 text-green-800 ${commonStyle}`; + case '중급': + return `bg-yellow-100 text-yellow-800 ${commonStyle}`; + case '고급': + return `bg-red-100 text-red-800 ${commonStyle}`; + default: + return `bg-gray-100 text-gray-800 ${commonStyle}`; + } +} diff --git a/app/(routes)/guide/category/[category-name]/page.tsx b/app/(routes)/guide/category/[category-name]/page.tsx new file mode 100644 index 0000000..c7854ba --- /dev/null +++ b/app/(routes)/guide/category/[category-name]/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from 'next/navigation'; +import { articles } from '../../data/category-data'; +import CategoryNameContainer from './_components/category-name-container'; + +type ArticleCategory = '투자 기초' | '절세 전략' | '상품 비교'; +type Params = Promise<{ 'category-name': string | string[] }>; + +export default async function Page({ params }: { params: Params }) { + const { 'category-name': raw } = await params; + const categoryName = Array.isArray(raw) ? raw[0] : raw; + const decoded = decodeURIComponent(categoryName) as ArticleCategory; + + if (!['투자 기초', '절세 전략', '상품 비교'].includes(decoded)) { + notFound(); + } + + const articleList = articles[decoded]; + + return ( + + ); +} diff --git a/app/(routes)/guide/data/article-data.ts b/app/(routes)/guide/data/article-data.ts new file mode 100644 index 0000000..bd72318 --- /dev/null +++ b/app/(routes)/guide/data/article-data.ts @@ -0,0 +1,804 @@ +interface ArticleDetail { + id: number; + author: string; + publishDate: string; + likes: number; + content: string; +} + +export const articleDetails: Record = { + 1: { + id: 1, + author: '금융전문가 김투자', + publishDate: '2024.12.20', + likes: 342, + content: `# 주식이란 무엇인가요? + +주식은 **회사의 소유권을 나타내는 증서**입니다. 쉽게 말해, 주식을 사면 그 회사의 일부를 소유하게 되는 거예요! + +## 주식의 기본 개념 + +### 1. 주식회사란? +주식회사는 여러 사람이 돈을 모아서 만든 회사입니다. 이때 돈을 낸 사람들에게 주는 증서가 바로 **주식**이에요. + +### 2. 주주가 되면? +- 회사의 이익을 나눠받을 권리 (배당금) +- 회사 경영에 참여할 권리 (의결권) +- 회사가 망하면 재산을 나눠받을 권리 + +## 주식으로 돈을 버는 방법 + +### 1. 시세차익 (Capital Gain) +주식을 싸게 사서 비싸게 파는 것! 가장 일반적인 수익 방법이에요. + +**예시:** 삼성전자 주식을 70,000원에 사서 80,000원에 팔면 10,000원의 수익! + +### 2. 배당금 (Dividend) +회사가 번 돈을 주주들에게 나눠주는 것입니다. + +**예시:** 1주당 1,000원씩 배당을 준다면, 100주를 가진 사람은 100,000원을 받아요! + +## 주식 투자 시 주의사항 + +### ⚠️ 리스크 관리가 중요해요! +- **분산투자**: 한 종목에만 몰빵하지 마세요 +- **장기투자**: 단기간에 큰 수익을 기대하지 마세요 +- **여유자금**: 당장 필요한 돈으로 투자하지 마세요 + +### 기본 분석 방법 +1. **재무제표 확인**: 회사가 돈을 잘 벌고 있는지 +2. **업종 전망**: 그 산업이 성장하고 있는지 +3. **경쟁사 비교**: 다른 회사보다 경쟁력이 있는지 + +## 초보자를 위한 실전 팁 + +### 1. 소액부터 시작하세요 +처음에는 10만원, 20만원 정도의 소액으로 시작해서 경험을 쌓아보세요. + +### 2. 유명한 대기업부터 +삼성전자, LG화학, 네이버 같은 안정적인 대기업 주식부터 시작하는 것을 추천해요. + +### 3. 정기적으로 투자하세요 +매월 일정 금액을 투자하는 **적립식 투자**를 해보세요. 리스크를 줄일 수 있어요! + +## 🤔 자주 묻는 질문 + +**Q: 주식은 언제 사고팔 수 있나요?** +A: 주식시장이 열리는 평일 오전 9시~오후 3시 30분에 거래할 수 있어요. + +**Q: 최소 투자금액이 있나요?** +A: 1주부터 살 수 있어요. 삼성전자 1주는 약 7만원 정도입니다. + +**Q: 세금은 얼마나 내야 하나요?** +A: 국내 상장 주식을 사고팔아 얻은 매매차익에는 세금이 없습니다! 다만, 배당금을 받을 경우에는 15.4%의 배당소득세가 원천징수돼요. + +--- + +## 💡 마무리 + +주식 투자는 **회사와 함께 성장하는 것**입니다. 단기간에 큰 돈을 벌려고 하지 말고, 좋은 회사를 찾아서 장기적으로 투자해보세요! +다음 글에서는 펀드처럼 분산 투자하면서 주식처럼 쉽게 거래할 수 있는 ETF에 대해 알아보겠습니다.`, + }, + 2: { + id: 2, + author: 'ETF 해설가 이지은', + publishDate: '2024.12.22', + likes: 278, + content: `# ETF, 한 번에 이해하기 + +## ETF란 무엇인가요? + +### "주식처럼 거래하는 펀드" + +ETF는 **Exchange Traded Fund**의 줄임말로, 상장지수펀드를 말해요. 특정 지수나 자산군의 움직임을 그대로 따라 움직이는(추종) 펀드를 주식처럼 거래할 수 있게 만든 상품이에요. + +## 왜 ETF에 투자해야 할까? + +- **적은 금액으로 분산투자**: ETF 1주에 투자하는 것은 지수를 구성하고 있는 개별 종목들에 투자하는 것과 같은 효과를 볼 수 있어요. 소액으로도 시장 자체에 투자하거나 채권, 원자재 등 직접 매매하기 어려운 자산에도 투자가 가능하다는 점! +- **높은 매매 편의성**: ETF는 기존 증권계좌를 가지고 주식처럼 시장에서 거래할 수 있어요. 거래소에 상장되어 있기 대문에 사고 팔기 편하고 실시간으로 매매를 할 수 있어 환금성도 뛰어납니다. +- **낮은 운용비용**: ETF는 기초지수를 따라 움직이므로, 운용 보수가 매니저 운용이 필요한 일반 펀드에 비해 비교적 저렴해요. +- **투명성 보장**: ETF는 일반 펀드와 달리, PDF(Portfolio Deposit File, 납입자산구성내역)를 매일 확인할 수 있어 투자자가 자산 상태를 명확히 파악할 수 있고, 신뢰할 수 있도록 돕습니다. 투명한 포트폴리오 제공은 초보 투자자들에게 정확한 정보를 얻을 수 있다는 측면에서 큰 장점으로 작용해요. + +## ETF 종류별 특징 + +| ETF 종류 | 설명 | +| ----------- | ----------------------------------------------- | +| 지수형 ETF | 특정 주식 시장 지수(S&P 500, 나스닥 등)를 추종하는 ETF | +| 섹터형 ETF | 특정 업종(자동차, IT, 반도체, 소비재 등)에 소속된 기업들로 지수를 구성해 추종하는 ETF | +| 테마형 ETF | 메타버스, AI, ESG 등 트렌디한 테마에 맞춰 구성 | +| 채권형 ETF | 국채와 우량 회사채 등의 채권지수를 추종하는 ETF | +| 원자재 ETF | 금, 원유, 구리 등 원자재에 투자하는 ETF | + +## ETF 투자 시 유의사항 + +- **운용보수 확인**: ETF마다 수수료가 달라요. +- **추적오차 확인**: 실제 수익률과 지수 수익률이 얼마나 차이 나는지 봐야 해요. +- **거래량 확인**: 너무 거래가 적으면 매매 시 손해볼 수 있어요. + +## ETF 초보자를 위한 추천 팁 + +1. **대형지수 ETF**부터 시작하세요. (예: KODEX 200, TIGER 미국S&P500) +2. **낮은 보수의 상품**을 고르세요. +3. **장기 투자 관점**으로 접근하세요. + +## 자주 묻는 질문 + +**Q: ETF는 주식과 뭐가 다른가요?** +A: 주식은 개별 기업에 투자하는 것이고, ETF는 개별 ETF 하나만으로도 지수나 여러 자산을 따라가면서 여러 종목에 한 번에 투자할 수 있어 위험을 줄일 수 있어요. + +**Q: 배당도 받을 수 있나요?** +A: 네! 배당을 주는 ETF도 있어요. 분기별로 입금됩니다. + +**Q: ETF는 어떻게 사나요?** +A: 증권사 앱에서 일반 주식처럼 검색하고 매수하면 됩니다. + +--- + +## 마무리 + +ETF는 분산투자, 실시간 거래, 저렴한 비용이라는 장점을 모두 갖춘 훌륭한 투자 도구입니다. 주식이 어렵게 느껴진다면 ETF부터 시작해보세요! + +다음 글에서는 **투자지표 ETF 용어**에 대해 알아보겠습니다.`, + }, + 3: { + id: 3, + author: '위험관리자 박현수', + publishDate: '2025.01.10', + likes: 198, + content: `# ETF 투자지표 용어 한눈에 정리하기 + +ETF에 투자할 때 자주 듣게 되는 용어들이 있어요. +NAV, iNAV, 괴리율, 추적오차, 유동성공급자(LP) 같은 용어들을 잘 이해하면 ETF를 훨씬 더 똑똑하게 매매할 수 있어요! + +--- + +## NAV란 무엇인가요? + +### 순자산가치(Net Asset Value) + +NAV는 **ETF가 보유한 자산의 가치에서 부채와 비용을 뺀 순수한 자산 가치**를 말해요. + +이걸 ETF **1주당 이론적인 가치**로 계산한 것이 NAV예요. + +- 매일 장 마감 후에 자산운용사가 계산해서 **다음 날 한국거래소를 통해 공시**돼요. +- **ETF의 ‘진짜 가치’가 얼마인지 판단할 수 있는 기준**이 되죠. + +💡 하지만 주식처럼 실시간 거래가 가능한 ETF에 대해 NAV는 **장 마감 후 계산되는 수치**라서 실시간 상황을 반영하지 못해요. + +--- + +## iNAV란 무엇인가요? + +### 실시간 추정 순자산가치(indicative NAV) + +iNAV는 **ETF의 실시간 이론 가치**예요. + +ETF는 주식처럼 장중에 거래되기 때문에, NAV 하나만으로는 부족해요. + +- iNAV는 **ETF가 들고 있는 기초자산의 실시간 시세**를 바탕으로 계산돼요. +- 그래서 실제 시장 가격이 너무 비싸거나 싼 건 아닌지 비교할 수 있죠. + +⚠️ iNAV는 어디까지나 ‘**추정치**’이기 때문에, NAV와 완벽히 일치하지는 않아요. + +--- + +## 괴리율이란? + +### ETF 시장가격과 기준가(NAV 또는 iNAV)의 차이 + +괴리율은 **ETF가 이론적으로 얼마여야 하는지(NAV 혹은 iNAV)**와 + +**실제로 얼마에 거래되고 있는지(시장가격)**의 차이를 **퍼센트로 나타낸 지표**예요. + +- 괴리율 = [(시장가격 – 기준가격) / 기준가격] × 100 +- 예: iNAV가 10,000원인데 시장에서 10,200원에 거래되면 → 괴리율 +2% (프리미엄) +- 반대로 9,800원에 거래되면 → 괴리율 -2% (디스카운트) + +🎯 괴리율이 **작을수록 적정한 가격**에 거래된다는 뜻! + +괴리율이 너무 크면 **비싸게 사거나 싸게 파는 실수**를 할 수도 있으니 주의가 필요해요. + +💡 국내 ETF는 ±1%, 해외 ETF는 ±2%를 넘으면 거래소에서 공시하도록 되어 있어요. + +--- + +## 추적오차란? + +### ETF가 지수를 얼마나 잘 따라가는지 보여주는 지표 + +ETF는 보통 **어떤 지수(기초지수)**를 따라가도록 설계돼요. + +그런데 실제로는 **수익률에 약간의 차이**가 생겨요. + +이 차이를 **추적오차**라고 불러요. + +- 예: 지수가 10% 올랐는데 ETF 수익률이 9.5%라면 → 추적오차 -0.5% +- 주로 **운용보수, 비용, 리밸런싱, 배당 지연** 등으로 오차가 발생해요. + +🎯 추적오차가 **작을수록 ETF가 기초지수를 잘 추종**하고 있다고 볼 수 있어요. + +--- + +## 유동성공급자(LP)란? + +### ETF가 원활히 거래되도록 돕는 조력자 + +ETF는 **주식처럼 실시간으로 사고팔 수 있지만**, 그 안에는 펀드처럼 **자산 가치가 내재**돼 있어요. + +그래서 시장가격과 iNAV가 너무 벌어지지 않도록, **유동성공급자(LP)**가 호가(매수·매도 가격)를 제시해요. + +- LP는 **증권사나 전문 기관**으로, 투자자들이 ETF를 적정한 가격에 사고팔 수 있게 도와줘요. +- ETF의 **괴리율을 낮추고, 거래 안정성을 높이는 중요한 역할**을 해요. + +⚠️ 단, LP도 항상 호가를 내는 건 아니에요. 아래 시간에는 예외가 있으니 주의! + +--- + +## 🤔 자주 묻는 질문 + +**Q: ETF NAV 뜻은 무엇인가요?** +A: 매일 저녁 장 마감 이후 1일 1회, 자산의 가치와 수량 변화를 통해 계산하는 ETF의 순자산가치로 ETF 1주가 보유한 본질적인 가격(가치)을 뜻합니다. + +**Q: ETF 괴리율 공시 기준은 어떻게 되나요?** +A: 한국거래소는 ETF의 괴리율이 일정 수준(국내투자형 ETF 1%, 해외투자형 ETF 2%)을 넘어서면 이를 의무적으로 공시하도록 하고 있습니다. 따라서 ETF의 괴리율이 크다면, 실제 거래 가격이 내재 가치와 다를 수 있으므로 주의가 필요합니다. + +**Q: ETF 추적오차는 무엇을 의미하나요?** +A: 추적오차는 ETF의 NAV(순자산가치)와 기초지수 수익률과의 차이로 ETF가 기초지수를 얼마나 잘 추종하는지를 나타내는 지표입니다. 보수 또는 비용, 현금배당, 지수, 구성 종목의 정기 또는 수시 변경 등으로 인해 발생할 수 있습니다. + +--- + +## 💡 마무리 + +ETF는 단순히 "싸게 사서 비싸게 판다"가 아니라, 그 가격이 적정한지 판단하는 기준이 필요해요. + +NAV, iNAV, 괴리율, 추적오차, LP까지! +이 다섯 가지 핵심 용어만 이해해도 ETF를 한층 더 똑똑하게 투자할 수 있어요. + +다음 글에서는 ETF 투자 시 꼭 알아야 할 세금 정보에 대해 알려드릴게요! `, + }, + 4: { + id: 4, + author: '투자전략가 최성훈', + publishDate: '2025.01.18', + likes: 156, + content: `# ETF 세금, 이것만은 꼭 알고 투자하자! + +ETF는 투자하기 쉽지만, **과세 방식은 ETF의 종류에 따라 다르게 적용**돼요. + +같은 ETF라도 국내/해외, 지수형/기타 유형에 따라 **세금 차이**가 생기니 꼭 알아두세요! + +--- + +## 국내 ETF, 매매차익에 세금 있을까? + +ETF가 **국내 주식형 지수추종 ETF**라면, + +매매차익에는 **세금이 부과되지 않아요! (비과세)** + +예: KODEX 200, TIGER KRX300 등 국내지수를 추종하는 ETF + +단, ETF에서 나오는 **분배금(배당금)**은 + +다른 금융상품처럼 **배당소득세 15.4%**가 원천징수돼요. + +--- + +## 그런데 레버리지/인버스/채권형 ETF는? + +국내에 상장됐더라도, 다음과 같은 ETF들은 **‘보유기간과세’**가 적용돼요: + +- 레버리지 ETF +- 인버스 ETF +- TR ETF, 액티브 ETF +- 해외주식, 채권, 원자재형 ETF + +이 경우 **매매차익에도 세금(15.4%)이 붙습니다.** + +세금은 아래 방식 중 **더 작은 금액**에 부과돼요: + +세금 = MIN(실제 매매차익, 과표기준가 증분) × 15.4% + +💡 과표기준가는 과세 기준이 되는 수치인데, + +**실제 매매차익보다 낮으면 더 적은 세금이 부과**돼요. + +--- + +## 해외 상장 ETF라면? + +미국 등 **해외 거래소에 직접 상장된 ETF**는 + +**주식처럼 양도소득세 22%**를 내야 해요. + +- 연 250만 원까지는 **비과세** +- 초과분에 대해 **22% 양도소득세** +- **다음 해 5월에 직접 신고**해야 하며, 미신고 시 가산세 부과 + + 예: 미국 상장 ETF (SPY, QQQ 등) + +--- + +## 분배금(배당금)에 대한 세금은? + +ETF에서 배당처럼 받는 **분배금**은 국내외 상장 여부와 관계없이 + +**15.4% 배당소득세**가 원천징수돼요. + +--- + +## 국내 vs 해외 ETF 세금 비교표 + +| 구분 | 국내 상장 지수형 ETF | 국내 상장 기타 ETF | 해외 상장 ETF | +| --- | --- | --- | --- | +| 매매차익 | 비과세 | 배당소득세 15.4% | 양도소득세 22% | +| 분배금(배당) | 배당소득세 15.4% | 배당소득세 15.4% | 배당소득세 15.4% | +| 종합과세 | 2천만 원 초과 시 적용 | 2천만 원 초과 시 적용 | 2천만 원 초과 시 적용 | +- 기타 ETF = 레버리지, 인버스, 액티브, 해외/채권/원자재형 등 + +--- + +## 연금계좌에서는 어떻게 될까? + +연금저축계좌나 IRP 계좌에서 ETF를 거래하면 + +세금이 **크게 줄어듭니다!** + +| 구분 | 국내지수형 ETF | 기타 ETF (레버리지 등) | +| --- | --- | --- | +| 매매차익/분배금 | 연금소득세 3.3~5.5%로 과세 | 연금소득세 3.3~5.5%로 과세 | +| 양도소득세 | 없음 | 없음 | +| 종합과세 | 연 1,200만 원 초과 시 대상 | 연 1,200만 원 초과 시 대상 | + +💡 일반계좌보다 훨씬 유리한 과세 구조! + +단, **연금 수령 시점까지 자금이 묶인다는 점**은 고려해야 해요. + +--- + +## ETF 세금, 이렇게 체크하세요 + +1. **ETF가 어디에 상장됐는지 확인** + + → 국내인지 해외인지에 따라 세금이 크게 달라요. + +2. **ETF가 어떤 자산에 투자하는지 확인** + + → 국내주식 지수형인지, 해외/채권/원자재인지 구분해보세요. + +3. **계좌 유형별 세금도 비교해보기** + + → 일반계좌 vs 연금계좌, 차이가 꽤 커요! + + +--- + +## 🤔 자주 묻는 질문 + +**Q: ETF 세금은 어떻게 계산하나요?** +A: ETF 세금은 상장 국가에 따라 다르게 과세됩니다. 국내 ETF의 매매차익은 비과세 대상이지만, 분배금에 대한 배당소득세는 15.4%를 내야 합니다. 국내 상장 해외주식형 ETF는 매매차익 및 분배금 모두 배당소득세 15.4%를 냅니다. 해외 상장 ETF는 매매차익에 대해서는 양도소득세 22%를 내고, 분배금에 대해서는 배당소득세 15.4%를 내면 됩니다. + +**Q: 해외 상장 ETF와 국내 상장 ETF의 차이점은 무엇인가요?** +A: 한국거래소에 상장된 ETF는 세법상 신탁형 펀드에 해당합니다. 따라서 ETF 매도 시에 증권거래세는 내지 않아도 되며, 매매차익에 대해서만 배당소득세를 내면 됩니다. 반면, 해외 상장 ETF는 주식과 동일하게 매도 수익에 대해 양도소득세를 내야 합니다. + +**Q: 국내 상장 해외 ETF의 종합소득세는 어떻게 되나요?** +A: 국내 상장 해외 ETF의 경우, 금융 소득이 연간 합산 2,000만원을 넘어가면 금융종합소득과세 대상이 됩니다. 2,000만원을 초과한 금융 소득은 다른 종합소득과 합산해 종합소득세율이 적용되므로, 누진세율로 인해 세 부담이 커질 수 있습니다. + +--- + +## 💡 마무리 + +ETF는 투자만큼 세금도 전략이에요. + +ETF의 **유형과 계좌에 따라 달라지는 과세 방식**을 잘 이해하면 쓸데없는 세금 줄이고 수익률은 높일 수 있어요.`, + }, + 5: { + id: 5, + author: '세무사 장세이', + publishDate: '2025.02.01', + likes: 402, + content: `## ISA 활용법 A to Z + +**ISA란?** + +ISA(개인종합자산관리계좌)는 일명 ‘만능통장’으로 불리며, 하나의 계좌로 다양한 금융 상품을 운용하면서 절세 혜택까지 받을 수 있는 상품이에요. 비과세, 분리과세 등 다양한 세제 혜택을 받으면서 투자할 수 있어요. 투자로 얻은 순이익 중 200만 원(서민형은 400만 원)까지는 세금을 매기지 않고, 그 이상의 수익은 분리과세를 적용해요. + +ISA는 중개형, 신탁형, 일임형 세 종류로 나뉘고, 각각 투자가능상품과 투자방법이 달라요. ISA에서 투자하여 발생한 순이익 중 비과세 한도까지는 세금을 매기지 않고, 초과하는 수익에서는 9.9% 세율로 분리과세를 적용하니, 일반적인 소득세 15.4%에 비해 낮죠. 또한, ISA 계좌 안에 있는 상품들끼리는 손실과 이익을 합쳐 계산하여 과세대상 금액을 줄일 수 있어요. + +**ISA 특징 요약** + +의무 가입 기간이 3년이고, 연장할 수 있어서 만기는 개인마다 다를 수 있어요. 1년에 2천만 원까지 넣을 수 있고, 최대 한도는 1억 원이에요. 모든 금융사 통틀어 1인 1계좌만 만들 수 있고, 국내 상장 주식을 하고 싶다면 중개형, 예적금을 하고 싶다면 신탁형을 선택하면 돼요. 만약 금융사 투자 전문가에게 위임하는 방식이 좋다면 일임형을 선택할 수 있어요. + +**만기까지 꾸준히 투자** + +의무 가입 기간 3년이 지나면 언제든 해지할 수 있어요. 연간 한도를 채우지 못하면 그 다음 해로 그 한도가 이월돼요. 중간에 돈을 빼고 싶다면 원금 범위 내에서만 뺄 수 있고, 해당 금액은 다시 납입할 수 없어요. ISA 계좌로 얻은 수익은 의무 가입 기간 끝나기 전까지 뺄 수 없답니다. + +**3년 후 해지** + +ISA 계좌 내에 있던 금융 상품을 모두 해지해요. 손익통산 후 200만 원까지 비과세(서민형은 400만 원) 혜택을 받아요. 그 이상 부분은 9.9%의 낮은 금리(일반과세 세율은 15.4%)로 분리과세 혜택을 받을 수 있습니다. + +※ 손익통산은 수익과 손실을 합쳐서 한꺼번에 정산하는 방식인데요. +펀드에서 600만 원 수익, 국내 주식에서 200만 원 손실일 경우, 일반 계좌에선 펀드 600만 원 수익에 대한 세금을 매기고, 국내 주식 200만 원 손실에 대한 세금은 안 매겨요. 둘을 합치면 사실상 400만 원 수익인데, 600만 원에 대한 세금을 매기니 뭔가 좀 이상하죠. ISA 계좌에서는 ‘손익통산’을 해주기 때문에 순소득 400만 원에 대한 세금을 매기게 됩니다. (서민형은 완전 비과세가 적용되는 거고요) **절세 효과가 꽤 커요.** + +**건강보험료 관련 혜택** + +ISA 수익은 건보료 부과 대상에서 일부 제외돼요. ISA 계좌 내에서 발생한 비과세 수익(200만 원 또는 400만 원)은 건강보험료 산정 시 소득으로 포함되지 않아요. 또한 분리과세 대상 수익(9.9% 과세분도 건보료 부과 기준 소득에서 제외되는 점이 포인트입니다. 즉, 일반 계좌에서 투자하여 수익이 나면 금융소득이 되어 건보료 부과 기준에 포함되지만, ISA 계좌는 수익이 발생해도 건보료 산정에 영향을 미치지 않는 구조입니다. ISA 수익은 '종합과세'가 아닌 '분리과세' 대상이에요. 건강보험 지역가입자의 보험료는 소득(특히 금융소득)에 따라 크게 달라질 수 있어요. ISA 계좌는 수익이 종합소득에 합산되지 않기 때문에, 연 소득 2천만 원 초과로 인한 건보료 폭탄을 막는 데에도 효과가 있어요. + +### 일반 계좌 vs. ISA 계좌 세금 비교 + +**일반 계좌 세금은?** + +- 수익금 600만원 × 15.4% = 92만 4천 원 + +**ISA 세금은?** + +- 일반형: 순소득 400만 원(수익 600만 원 - 손실 200만 원) - 비과세 혜택 200만 원= 200만 원 × 9.9% = 19만 8천 원 → 72만 6천 원 절세 효과 + +- 서민형/농어민: 순소득 400만 원(수익 600만 원 - 손실 200만 원) - 비과세 혜택 400만 원 = 0원 → 92만 4천 원 절세 효과 + +**재가입하고 다시 반복!** + +3년 만기 후 다시 ISA 가입해서, 납입 한도와 세제 혜택을 새롭게 받는 게 유리해요. 만기해지 시 중개형 ISA를 연금계좌로 이전할 경우 세액공제 대상금액 추가 한도가 적용돼요. (추가 납입액의 10%, 최대 300만원 한도) + +- 전환 가능 상품 : 개인연금 (만기 후 60일 이내 이전 시)`, + }, + 6: { + id: 6, + author: '연금 전문가 오지혜', + publishDate: '2025.02.05', + likes: 312, + content: `## ISA·연금저축·IRP '절세계좌 3종’ 혜택 하나씩 비교해보자면? + +투자자들에게 절세는 단순히 세금 절감 이상의 의미를 갖고 있다는 사실, 알고 계셨나요? ISA(개인종합자산관리계좌·Individual Savings Account)와 연금저축계좌, IRP(개인형 퇴직연금·Individual Retirement Pension) 계좌와 같은 절세계좌는 세금 부담을 줄이면서도 자산을 효과적으로 관리할 수 있는 강력한 도구인데요. 절세계좌를 잘 활용하면 세금 부담을 줄이면서도 세금으로 나갈 돈까지 투자에 활용할 수 있어 투자 목표를 달성하는 데 큰 도움이 될 수 있습니다. 절세의 중요성을 알고, 계좌 개설부터 실천하는 것이 초보 투자자의 필수 전략일 텐데요! ISA, 연금저축계좌, IRP계좌까지 절세계좌 3종을 함께 비교해 보면서 어떻게 활용해야 할지 살펴보겠습니다. + +### 절세계좌 3종이란? + +| 항목 | 중개형 ISA | 연금저축계좌 | IRP 계좌 | +|----------------|-------------------------------------------|--------------------------------------------------|------------------------------------------------------| +| 목적 | 목적 마련 | 노후 자금 마련 | 노후 자금 마련 | +| 가입 요건 | 19세 이상 누구나(15~19세 미만 소득 있으면 가능)※ 금융소득종합과세자 가입 불가 | 제한 없음※ 연금 수령 요건: 만 55세 이후 수령 가능 | 근로소득자, 자영업자, 프리랜서 등 소득이 있는 근로자 누구나 | +| 가입 기관 | 은행, 증권사, 보험사※ ISA 유형별로 가입기관 다름 | 증권사, 보험사 | 은행, 증권사, 보험사 | +| 의무 가입 기간 | 3년(최대 5년) | 5년 이상(연금 수령 최소 기간 10년) | 5년 이상(연금 수령 최소 기간 10년) | +| 납입 한도 | 연 2,000만 원, 최대 1억 원(5년 납입 기준, 납입한도 이월 가능) | 연금저축 + IRP 합산 연 1,800만 원 | 연금저축 + IRP 합산 연 1,800만 원 | +| 중도 인출 | 세액공제 받지 않은 금액(원금)은 자유롭게 인출 가능 | 불가※ 법적 예외 조건 충족 시에만 가능 | 불가※ 법적 예외 조건 충족 시에만 가능 | +| 투자 가능 상품 | 국내 주식, ETF/ETN, 펀드, ELS/DLS, 채권, 리츠, RP 등※ 해외주식 투자 불가 | 펀드, ETF, 리츠※ 레버리지, 인버스 ETF 투자 불가 | 1) 원금보장 상품: 예금, RP, ELB, 국고채 등2) 원금비보장 상품: 펀드, ETF, REITs, ELS, 회사채 등※ 레버리지, 인버스 ETF 투자 불가 | +| 투자 제한 | 제한 없음 | 제한 없음 (위험자산 100% 투자 가능) | 위험자산 비중 70% 제한 | + +1. ISA(개인종합자산관리계좌) + +ISA는 다양한 금융상품을 하나의 계좌에서 운용할 수 있는 '만능통장'이라고 할 수 있는데요. 국내주식, 채권, 펀드, ETF 등의 다양한 금융상품 중 투자자가 원하는 상품을 간편하게 사고팔 수 있습니다. + +ISA는 19세 이상이면 금융소득종합과세자를 제외하고 누구나 가입할 수 있는데요, 연 2000만원씩 5년간 최대 총 1억원까지 돈을 넣을 수 있고, 연간 납입한도를 채우지 못했다면 다음해로 이월해서 입금할 수 있는데요. 의무가입기간인 3년을 채우면 아래에서 알아볼 다양한 세제 혜택을 누릴 수 있습니다! +* 금융소득종합과세자 – 개인의 종합과세 대상 금융소득이 연간 기준금액(2천만 원)을 초과하는 경우에 해당 + +2. 연금저축계좌 + +연금저축계좌는 안정적인 노후를 위해 개인이 따로 가입하는 장기저축상품인데요. 나이와 소득에 상관없이 누구나 가입이 가능해서 소득이 없는 미성년자나 대학생, 주부도 원한다면 언제든 계좌를 만들 수 있습니다. + +1년에 최대 1800만원(단, IRP 납입액과 합산한 금액) 한도 내에서 입금할 수 있는데요. 펀드, 국내 상장 ETF, 리츠(REITs·부동산간접투자회사) 등 실적배당형 상품에 투자할 수 있고, 가입 기간이 5년을 넘었다면 만 55세 이후부터 연금으로 인출할 수 있습니다. + +또한, 계좌를 해지하지 않아도 중간에 필요한 만큼 돈을 뺄 수 있는데요. 다만, 연금 이외의 형태로 중간에 인출하거나 중도에 해지한다면 기타소득세 15.6%를 납부해야 합니다. 그래서 가급적이면 연금저축계좌에 넣은 자금은 연금으로 수령하기 전까지 건드리지 않는 것이 좋겠죠? + +3. IRP(개인형퇴직연금) 계좌 + +IRP는 퇴직 또는 이직 시 받는 퇴직금을 적립해 노후 자금으로 활용할 수 있게 하는 제도인데요. 근로소득자, 개인사업자, 프리랜서 등 소득이 있는 모든 형태의 근로자가 가입할 수 있습니다. + +IRP계좌에는 퇴직금과 별도로 개인이 연금저축계좌 납입액과 합산해 연간 1800만원 한도 안에서 자유롭게 입금할 수 있는데요. 예금이나 국고채 같은 원리금 보장형 상품과 펀드, ETF, 리츠 등 원리금 비보장 상품에 투자할 수 있습니다. 다만, 원리금이 보장되지 않는 위험자산의 투자 비중이 70%로 제한되기 때문의 주의할 필요가 있습니다. + +IRP계좌도 가입한지 5년이 지나면 55세부터 연금으로 받을 수 있는데요. 연금저축과 달리 중도 인출이 불가하고 돈을 빼려면 계좌를 아예 전액 해지해야 합니다. 다만, 6개월 이상의 요양 의료비, 천재지변, 무주택자 주택 구입·전세 보증금 등 법적으로 정해진 사유에는 중도에 인출할 수 있습니다. + +## 계좌별 절세 혜택 차이! + +결국 ISA, 연금저축계좌, IRP계좌는 각각 노후자금 준비, 목돈마련 등 다양한 목적에 따라 개인이 자금을 모을 수 있도록 도와주고 있는 있는데요. 절세계좌라는 명칭에 맞게 각각 어떤 절세 혜택이 있는지 살펴볼 필요가 있습니다. 용어는 어렵지만 쉽게 설명해드릴 테니 같이 살펴볼까요? + +1. ISA는 비과세, 분리과세, 손익통산! + +| 구분 | 일반형 | 서민형 | 농어민 | +|--------------------|------------------------------------|------------------------------------|------------------------------------| +| 비과세한도 | 200만 원 | 400만 원 | 400만 원 | +| 비과세한도 초과 시 | 9.9% 분리과세 적용 (지방소득세 포함) | 9.9% 분리과세 적용 (지방소득세 포함) | 9.9% 분리과세 적용 (지방소득세 포함) | +| 과세 방식 | 계좌 만기 시 손익통산 후 과세 | 계좌 만기 시 손익통산 후 과세 | 계좌 만기 시 손익통산 후 과세 | + + + +ISA를 통해 받을 수 있는 절세 혜택은 비과세, 분리과세, 손익통산으로 정리할 수 있습니다. 우선, ISA계좌에서 발생한 수익은 일반형 계좌는 200만 원까지, 서민형과 농어민형 계좌는 400만 원까지 세금을 내지 않는 비과세 혜택이 적용됩니다. 또한, 비과세 혜택을 받은 수익금을 제외한 추가 수익금에 대해서는 9.9% 저율로 분리과세 혜택이 적용됩니다. + +투자를 하다 보면 수익이 나기도 하고 손실이 나기도 하는데요. 일반 계좌에서는 ETF에 투자하는 경우 투자상품 하나하나의 수익을 따져 15.4%의 배당소득세를 내야 합니다. 반면, ISA 계좌 안에서는 모든 상품의 수익과 손실을 합산해서 최종적인 수익으로 계산하는 손익통산 혜택을 누릴 수 있는데요. 세율로 보나 수익 계산 방식으로 보나 ISA를 통한 투자가 훨씬 이득이지요. + +추가로, ISA 만기 자금을 연금저축계좌나 IRP계좌로 옮기면 연금계좌의 세액공제 금액에 더해 이체하는 금액의 10%(최대 300만 원)까지 추가로 세액공제를 얻을 수 있으니 참고하시길 바랍니다! + +2. 연금저축계좌는 세액공제, 과세이연, 저율과세! + +| 구분 | 연금저축계좌 | +|-----------------------------|----------------------------------------------------------------| +| 세액공제 한도 | 연 600만 원 | +| 세액공제율 | 연간 총 급여 5,500만 원(종합소득 4,500만 원) 이하: 16.5% / 초과: 13.2% | +| 과세 방식 | 배당소득세를 당장 납부하지 않아도 되며, 연금 수령 시 연금소득세만 과세 | +| 연금 소득세 | 수령 시기(나이)에 따라 3.3% / 4.4% / 5.5% 부과 | +| 연금소득 분리과세 기준 초과 시 | 분리과세(16.5%) 또는 종합과세(6.6~49.5%) 중 선택 | + +연금저축계좌는 돈을 계좌에 납입할 때, 운용하는 동안, 연금으로 받을 때 절세 혜택을 얻을 수 있는데요! 납입단계에서는 '세액공제' 혜택이 기다리고 있습니다. 연금저축계좌는 연간 600만원까지의 납입액에 대해 13.2%(연간 총 급여 5500만 원 초과, 종합소득 4500만 원 초과 시) 또는 16.5%(연간 총 급여가 5500만 원 이하, 종합소득 4500만 원 이하 시) 세금이 공제되어 연말정산 때 돌려받을 수 있습니다. + +운용단계에서는 '과세이연' 효과를 기대할 수 있습니다. 일반 계좌에서 투자하는 경우, 분배금 또는 매도 시 발생하는 수익에 대해 배당 소득세 15.4%를 바로 내야 하는데요. 연금저축계좌는 세금을 내야 하는 시기가 나중으로 미뤄지는 과세이연 효과가 적용됩니다. 그래서 세금으로 나갈 돈까지 다시 투자할 수 있기 때문에 복리 효과를 통한 수익 극대화가 가능하단 얘기지요. + +연금저축계좌는 그동안 납입했던 금액에 대해 연금으로 받을 때 세제 혜택을 받은 금액과 운용 수익에 대해 연금소득세를 내야 하는데요. 연금으로 수령하는 나이에 따라 55세 이상 70세 미만은 5.5%, 70세 이상 80세 미만은 4.4%, 80세 이상은 3.3%로 저율 과세 혜택을 얻을 수 있습니다. + +도중에 납입액을 인출하기에는 어렵지만, 연금저축계좌를 활용하면 상당한 금액을 절세할 수 있으니 반드시 활용하는 것이 좋겠지요?! + +3. IRP계좌도 연금저축계좌와 마찬가지로 세액공제, 과세이연, 저율과세! + +IRP계좌도 연금저축계좌와 마찬가지로 '세액공제', '과세이연', '저율과세'의 절세 혜택을 똑같이 얻을 수 있습니다. 다만, IRP계좌는 세액공제 대상 금액이 연간 900만원으로 더 많다는 점인데요. 이 금액은 연금저축계좌에서 세액공제 받을 수 있는 600만원을 포함한 것이므로 연금저축계좌와 IRP계좌 모두 합쳐 매해 900만원까지 세액공제를 받을 수 있습니다. + +또한 퇴직금을 IRP계좌에 넣어놓고 연금으로 받는다면 퇴직소득세를 바로 내지 않아도 됩니다. 그래서 과세이연 효과로 세금으로 나갈 돈까지 다시 투자에 활용할 수 있습니다. 그리고 먼 미래에 연금으로 수령할 때 퇴직소득세의 30%(연금 수령 11년째부터는 40%)를 감면 받을 수도 있습니다. + +한편 연금저축과 IRP를 통해 받는 연금 수령액은 연간 1500만원까지 저율의 연금소득세가 적용되고요. 1,500만원이 넘는 수령액에 대해선 종합과세 또는 분리과세 16.5%(지방소득세 포함)를 선택해서 세금을 내면 됩니다. + +앞서 잠깐 언급한 것처럼, ISA, 연금저축계좌, IRP계좌 모두 공통적으로 주의할 점은 의무가입기간을 채우지 않고 계좌를 해지하거나 중도에 인출하면 절세 혜택을 잃는다는 건데요. 이 경우 세제 혜택을 받은 금액과 운용 수익에 대해 원래 내야 하는 세금(ISA는 배당소득세 15.4%, 연금저축-IRP은 기타소득세 16.5%)을 고스란히 물어야 하니 주의할 필요가 있습니다. + +지금까지 3가지 절세 계좌의 특징, 세제 혜택을 알아보았습니다. 시작이 반이라는 말처럼 투자의 시작 역시 계좌 개설이라고 할 수 있는데요. 절세계좌를 통해 더욱 현명한 투자를 하실 수 있길 바랍니다!`, + }, + 7: { + id: 7, + author: '세무사 김지훈', + publishDate: '2025.02.12', + likes: 267, + content: `# 세금 아끼는 똑똑한 방법 +## 놓치면 후회하는 세액공제 9가지 + +🚨 잠깐! 세액공제를 모르고 지나갔다면? 내가 낸 돈이 그냥 하늘로 날아간 거예요. + +세금이 너무 아깝다고 생각해본 적 있나요? 매달 월급에서 빠져나가는 세금을 보면서 '이거 좀 줄일 수 없나?' 싶으셨죠? + +좋은 소식이 있어요. 세액공제라는 합법적인 세금 절약 방법이 있거든요. 산출된 세금에서 일정 금액을 직접 빼주는 거예요. 소득공제처럼 복잡한 계산 없이 말이죠. + +더 좋은 건, 올해 세금이 없어서 공제 혜택을 못 받았다고 해도 괜찮아요. 대부분 내년으로 이월이 가능하거든요. 그러니까 포기하지 말고 끝까지 확인해보세요! + +--- + +## 근로소득 세액공제 + +일하는 사람이라면 누구나 받을 수 있는 기본적인 혜택이에요. 근로소득이 있다면 자동으로 받는 공제라서 별도 신청이 필요하지 않아요. 저소득자를 중심으로 공제해주는 게 특징이죠. + +계산법은 생각보다 간단해요. 산출세액이 130만원 이하라면 세액의 55%를 공제해줍니다. 산출세액이 130만원을 넘는다면 71만 5천원에 초과분의 30%를 더한 금액을 공제해주죠. + +예를 들어볼게요. 산출세액이 100만원이라면 55만원을 공제받아서 실제로는 45만원만 내면 되는 거예요. 거의 반값이죠! 산출세액이 200만원이라면 92만 5천원을 공제받아서 107만 5천원만 내면 돼요. 생각보다 큰 혜택이죠? + +다만 총 급여별로 공제 한도가 정해져 있어서, 모든 사람이 같은 혜택을 받는 건 아니에요. 그래도 일하는 사람이라면 누구나 받을 수 있는 기본 혜택이니까 꼭 확인해보세요. + +## 자녀 세액공제 + +아이 키우는 게 이렇게 보람찬 줄 몰랐네요. 7세 이상에서 만 20세 이하 자녀가 있다면 세액공제를 받을 수 있어요. 입양아나 위탁아동도 포함되니까 걱정하지 마세요. + +공제 금액은 자녀 수에 따라 달라져요. 첫째와 둘째는 각각 15만원씩 공제받고, 셋째부터는 각각 30만원씩 공제받아요. 그러니까 자녀가 1명이면 15만원, 2명이면 30만원, 3명이면 60만원, 4명이면 90만원을 세액에서 빼주는 거죠. + +특히 작년에 출산을 했다면 새로 적용되니까 연말정산이나 종합소득세 신고할 때 까먹지 마세요. 자녀가 많을수록 혜택이 커지니까 다자녀 가정에게는 정말 도움이 되는 제도예요. + +## 연금계좌 세액공제 + +노후준비하면서 세금도 아끼는 일석이조의 효과를 누릴 수 있어요. 연금저축계좌나 퇴직연금 IRP 계좌에 돈을 넣었다면 세액공제를 받을 수 있거든요. ISA 계좌도 일부 공제 혜택이 적용돼요. + +공제율은 소득 수준에 따라 달라져요. 총 급여가 5,500만원 이하라면 16.5%를 공제받고, 그 이상이라면 13.2%를 공제받아요. 종합소득이 있는 분들은 4,500만원이 기준이에요. + +납입 한도는 연말정산 기준으로 연금저축 400만원, IRP 300만원으로 합계 700만원까지 가능해요. 만약 연 700만원을 납입하고 급여가 5,500만원 이하라면 115만 5천원을 세액공제받을 수 있어요. 꽤 큰 금액이죠? + +노후준비도 하면서 당장 세금 혜택도 받을 수 있으니까 젊을 때부터 시작하는 게 좋아요. 연금은 장기적으로 보면 복리 효과도 있고, 세액공제까지 받으면 일석이조예요. + +## 보험료 세액공제 + +이미 내고 있는 보험료로 세금까지 아낄 수 있다니 좋은 소식이죠? 생명보험, 상해보험, 자동차보험 등 보장성 보험료는 세액공제 대상이에요. 다만 저축성 보험은 해당되지 않으니까 주의하세요. + +일반 보장성 보험은 납입액의 12%를 공제받을 수 있고, 한도는 100만원이에요. 장애인 전용 보험은 조금 더 혜택이 좋아서 15%를 공제받을 수 있고, 역시 한도는 100만원이에요. + +예를 들어 연 보험료를 200만원 냈다면 한도인 100만원에 12%를 곱해서 12만원을 세액공제받을 수 있어요. 이미 보험에 가입해 있는 분들이라면 그냥 넘어가지 말고 꼭 챙겨보세요. 보험료 납입 영수증만 있으면 되거든요. + +## 의료비 세액공제 + +아프면 돈도 나가고 마음도 아픈데, 그래도 세금은 줄여주니까 위안이 되네요. 본인과 65세 이상 가족, 장애인, 기본공제대상자를 위해 지출한 의료비가 공제 대상이에요. + +계산법은 조금 복잡해요. 의료비에서 총급여의 3%를 뺀 금액의 15%를 공제받아요. 예를 들어 총급여가 5,000만원이고 의료비를 200만원 썼다면, 200만원에서 150만원(총급여의 3%)을 뺀 50만원의 15%인 7만 5천원을 공제받는 거죠. + +특히 본인 의료비, 65세 이상 가족 의료비, 장애인 의료비, 난임시술비, 미숙아나 선천성이상아 의료비는 한도가 없어요. 그러니까 큰 병원비가 나갔다면 꼭 세액공제를 받으세요. 병원비 영수증을 잘 챙겨두는 게 중요해요. + +## 교육비 세액공제 + +교육열은 세금 절약으로도 이어지네요. 본인이나 부양가족의 교육비를 지출했다면 15%를 세액공제받을 수 있어요. 등록금이 비싸다고 한숨만 쉬지 말고 세액공제라도 챙겨보세요. + +한도는 교육 단계에 따라 달라져요. 유치원부터 고등학교까지는 1인당 연 300만원까지, 대학생은 1인당 연 900만원까지 공제받을 수 있어요. 본인의 교육비와 장애인 특수교육비는 한도가 없어서 전액 공제 대상이에요. + +예를 들어 대학생 자녀 등록금으로 800만원을 냈다면 120만원을 세액공제받을 수 있어요. 등록금이 비싸서 부담스럽긴 하지만, 이런 혜택이라도 있으니까 놓치지 마세요. 직장인이 자기계발을 위해 학원비나 어학원비를 냈다면 그것도 공제 대상이에요. + +## 기부금 세액공제 + +착한 일 하면서 세금도 절약할 수 있다니 좋은 제도네요. 기부를 했다면 세액공제를 받을 수 있어요. 다만 기부금 종류에 따라 공제율이 달라지니까 주의하세요. + +기부금은 크게 지정기부금, 정치자금, 법정기부금, 우리사주조합 기부금으로 나뉘어요. 각 유형마다 공제율과 한도가 다르니까 자세한 건 국세청 홈택스에서 확인해보세요. 기부할 때 받은 영수증을 잘 보관해두는 게 중요해요. + +특히 종교단체 기부금도 지정기부금에 해당하니까 교회나 절에 헌금하신 분들도 놓치지 마세요. 소액이라도 모이면 꽤 큰 세액공제를 받을 수 있어요. + +## 월세 세액공제 + +집 없는 게 서러웠는데, 이런 혜택이라도 있어서 다행이네요. 무주택 세대주라면 월세 세액공제를 받을 수 있어요. 일정 조건 하에서는 세대원도 가능해요. + +조건은 총급여 7,000만원 이하, 전용면적 85㎡ 이하 또는 기준시가 3억원 이하 주택에 살아야 해요. 공제율은 총급여 5,500만원 이하면 15%, 그 이상이면 12%예요. + +연간 월세 한도는 750만원이에요. 월세를 60만원씩 1년 동안 냈다면 720만원이고, 총급여가 4,000만원이라면 108만원을 세액공제받을 수 있어요. 월세가 부담스럽긴 하지만, 이런 혜택이라도 있으니까 꼭 챙기세요. + +월세 계약서와 계좌이체 내역 등을 잘 보관해두셔야 해요. 현금으로 냈다면 증빙이 어려우니까 가급적 계좌이체로 월세를 내는 게 좋아요. + +## 표준 세액공제 + +아무것도 안 해도 13만원을 공제받을 수 있어요. 특별세액공제인 보험료, 의료비, 교육비, 기부금 세액공제나 월세 세액공제를 받지 않는 근로자에게 13만원을 일괄 공제해주는 거예요. + +주로 부양가족 없는 1인 근로자가 해당돼요. 나라에서 해주는 마지막 배려 같은 거죠. 다른 공제를 받을 게 없다면 최소한 이 13만원이라도 받으세요. 별도 신청 없이 자동으로 적용돼요. + +--- + +## 놓치지 말아야 할 핵심 포인트 + +타이밍이 정말 중요해요. 회사 다니는 분들은 연말정산 때, 사업자나 프리랜서는 5월 종합소득세 신고 기간에 챙겨야 해요. 이 시기를 놓치면 1년을 더 기다려야 하거든요. + +산출세액이 0원이어서 올해 공제 혜택을 못 받았다고 해도 포기하지 마세요. 대부분 내년으로 이월이 가능해요. 특히 세액공제는 소득공제와 달리 세금에서 직접 빼주는 거라서 이월해두면 나중에 큰 도움이 돼요. + +가장 중요한 건 내가 직접 챙겨야 한다는 거예요. 세액공제는 내가 신청해야 받을 수 있어요. 국세청에서 알아서 해주지 않거든요. 홈택스에 로그인해서 확인하거나, 회사 인사팀에 문의하거나, 세무사와 상담하는 것도 좋은 방법이에요. + +## 마지막 한마디 + +세금이 아깝다고 생각만 하지 말고, 이제 행동해보세요. 위에서 본 세액공제 중에 내가 받을 수 있는 게 하나라도 있다면, 그것만으로도 수십만원을 아낄 수 있어요. + +특히 직장인이라면 연말정산 때 간소화 서비스만 믿지 말고, 내가 직접 하나하나 챙겨보세요. 생각보다 놓친 공제가 많을 거예요. 영수증 챙기는 습관부터 시작해서, 각종 공제 요건을 미리 알아두는 것도 중요해요. + +다음 글에서는 **ETF와 펀드와 주식의 차이**를 알아볼게요. `, + }, + 8: { + id: 8, + author: '투자비교 분석가 이나연', + publishDate: '2025.03.01', + likes: 298, + content: `# ETF vs 펀드 vs 주식, 뭐가 다를까? + +투자를 시작하려는 사람이라면 한 번쯤 들어봤을 ETF와 펀드. 비슷해 보이지만 실제로는 여러 차이가 있어요. + +## 기본 개념 비교 + +### ETF란? +- **Exchange Traded Fund**의 줄임말 +- 말 그대로 **거래소에서 주식처럼 사고파는 펀드** +- 특정 지수의 움직임에 따라 수익률이 결정되는 펀드를 주식처럼 거래할 수 있게 만든 상품! +- 상품은 펀드처럼 구성, 주식처럼 쉽게 사고팔 수 있어요 +- 따라서 펀드, 주식의 장점을 모두 갖고 있어요 + +### 펀드란? +- 펀드 매니저가 투자자들의 투자금을 주식, 채권, 부동산 등으로 운용하는 금융 상품이에요 +- 일반적으로 펀드의 판매사인 증권사나 은행 등을 통해서만 거래할 수 있어요 +- 매매는 하루 1회, 실시간 거래 불가 + +### 주식이란? +- 회사의 일부 소유권을 갖고 있다는 증표에요! +- 주가가 오르면 자신의 주식을 팔아, 주가가 오른 만큼 이익을 얻을 수 있어요 + +--- + + +## ETF와 주식의 차이점은? +ETF와 주식은 주식 시장에서 직접 사고팔 수 있는 공통점이 있어요. 하지만 투자 대상과 분산 투자 효과에서 차이가 있어요. +- **투자 대상** : 주식은 한 기업의 소유권을 사고파는 반면, ETF는 여러 종목으로 구성된 지수와 자산을 따라가는 펀드라서, 하나의 ETF를 사면 여러 기업이나 자산에 동시에 투자하는 효과를 얻을 수 있어요. +- **분산 투자 효과** : 한 회사에 투자하는 주식은 회사의 실적이나 시장 상황에 따라 가격이 크게 변동해요. 반면 ETF는 하나의 상품으로 여러 종목에 투자해 리스크를 분산하는 효과가 있어요. + +--- + +## ETF와 펀드의 차이점은? +ETF와 펀드는 여러 종목에 분산 투자한다는 공통점이 있어요. 하지만 거래 방식과 운용 수수료에서 차이가 있어요. +- **거래 방식** : ETF는 주식 시장에서 실시간으로 자유롭게 사고팔 수 있어요. 반면 일반 펀드는 증권사나 은행을 통해 거래할 수 있고, 매수 매도 과정이 며칠씩 걸려요. 또 현재 가격이 아닌 환매 시점의 가격이 적용돼요! +- **운용 수수료** : 대부분의 ETF는 특정 지수를 자동으로 추종하기 때문에 운용 수수료가 비교적 낮아요. 반면 펀드는 펀드매니저라는 사람이 직접 관리하기 때문에 보수, 수수료 등 내야 하는 비용이 비교적 많이 들어요. + +--- + +## ETF VS 주식 VS 펀드 +위에서 살펴본 ETF, 주식, 펀드의 차이점을 표로 비교해 볼게요. + +| 구분 | 주식 | 펀드 | ETF | +|------------|------------|------------|-------------| +| 거래 방식 | 직접 투자 | 간접 투자 | 직접 & 간접 | +| 시장 거래 | 가능 | 불가능 | 가능 | + + + + +## 주식 대신 ETF 투자를 고려한는 이유는? +- 첫째, 한정된 자금으로 다양한 투자 기회를 가질 수 있어요! +- 둘째, 저절로 분산 투자를 하게 되어 위험 관리에 유리해요! +- 셋째, 거래 비용이 상대적으로 저렴해요. 주식을 개별적으로 매수하는 것보다 ETF는 운용 보수가 낮아 효율적이에요! +- 넷째, ETF는 주식처럼 실시간으로 매수와 매도가 가능해 시장 상황에 즉각적으로 대응할 수 있어요! +- 다섯째, 투명성이 높은 편이에요. ETF는 편입 종목이 공개되어 있어 투자자가 어떤 자산에 투자하는지 쉽게 확인할 수 있어요! + + +## 어떤 걸 선택해야 할까? + +### ETF를 추천하는 경우 +- 스스로 투자 판단이 가능하거나 직접 관리하고 싶은 경우 +- 낮은 수수료를 선호하는 경우 +- 실시간 매매가 필요한 경우 + +### 펀드를 추천하는 경우 +- 투자 공부가 부담스러운 사람 +- 전문가에게 자산 관리를 맡기고 싶은 사람 + +--- + +## Q: ETF는 소액으로 투자할 수 있나요? +A: 네! ETF는 한 주 단위로 거래가 가능하기 때문에, 몇 천 원~몇 만 원 수준의 소액으로도 투자가 가능합니다. + +## Q: 지수란 무엇인가요? +A: 주식, 채권 등 특정 시장의 전반적인 가격이 과거에 비해 얼마나 오르고 내렸는지 보여주는 지표예요. 예를 들어 주가지수는 주식시장에 상장된 모든 기업의 주가를 종합적으로 계산한 값이에요. + +--- + +## 마무리 + +ETF와 펀드는 **각자 장단점이 분명한 투자 상품**이에요. 자신의 투자 성향과 관리 방식에 따라 현명한 선택을 해보세요! + +다음 글에서는 **적금과 투자**에 대해 비교해보아요! `, + }, + 9: { + id: 9, + author: '재무설계사 문수빈', + publishDate: '2025.03.05', + likes: 221, + content: `# 적금 vs 투자, 어떤 선택이 좋을까요? + +돈을 모으고 불리는 방법에는 적금과 투자라는 두 가지 대표적인 길이 있어요. 어떤 게 나에게 맞을까요? + +## 적금의 장점과 단점 + +### 장점 +- **원금 보장**: 예금자 보호법으로 5천만 원까지 보장돼요! +- **예측 가능성**: 만기 시 받을 금액이 정해져 있어요! +- **금융 습관 형성**에 유리해요 + +### 단점 +- **금리가 낮음**: 물가 상승률을 따라가기 어려워요 +- **실질 수익률 하락** 가능성이 있어요 + +**예시:** 연 3% 금리의 적금은 세후 수익이 낮고, 물가 상승률이 4%라면 실질 구매력은 오히려 줄어들 수 있어요. + +## 투자의 장점과 단점 + +### 장점 +- **높은 수익 가능성** +- 다양한 금융 상품에 **자산 배분** 가능해요 +- **복리 효과**로 장기적 자산 성장 기대 + +### 단점 +- **원금 손실 가능** +- 시장 변동에 따른 **심리적 스트레스** +- 분석과 학습이 필요해요 + +--- + +## 나에게 맞는 방법은? + +### 적금이 적합한 경우 +- 목표가 명확하고 짧은 기간 내 사용 예정 (ex. 1년 뒤 여행) +- 원금 손실을 **절대 원치 않는 경우** +- 당장 큰 돈은 없지만 꾸준히 소액을 저축할 정도의 소득이 유지되는 경우 + +### 투자가 적합한 경우 +- 여유 자금으로 자산을 **장기적으로 불리고 싶은 경우** +- **위험 감수**가 가능하고, 공부할 의지가 있는 경우 + +--- + +## Q: 적금과 투자를 병행해도 되나요? +A: 물론입니다! 생활비나 단기 자금은 적금으로, 여유 자금은 투자로 운용하는 것이 이상적이에요. + +## Q: 적금은 절세 혜택이 있나요? +A: 청년 우대형, 비과세 세금우대 적금 등 일부 상품은 세제 혜택이 있습니다. + +--- + +## 마무리 + +적금과 투자는 **서로 대체재가 아니라 보완재**입니다. 자신의 재정 상황과 목표에 따라 두 방법을 **조화롭게 활용**하는 전략이 중요해요! + +다음 글에서는 **ISA 계좌를 활용한 절세 전략**을 소개할게요!`, + }, +}; diff --git a/app/(routes)/guide/data/category-data.ts b/app/(routes)/guide/data/category-data.ts new file mode 100644 index 0000000..32ed6a3 --- /dev/null +++ b/app/(routes)/guide/data/category-data.ts @@ -0,0 +1,102 @@ +type ArticleCategory = '투자 기초' | '절세 전략' | '상품 비교'; + +export interface Article { + id: number; + title: string; + summary: string; + views: string; + difficulty: '초급' | '중급' | '고급'; + tags: string[]; + image: string; +} +const imagePath = '/images/quiz/Star_Pro_Quiz.svg'; + +export const articles: Record = { + '투자 기초': [ + { + id: 1, + title: '주식이란 무엇인가요?', + summary: '주식의 기본 개념부터 투자 방법까지 쉽게 설명드려요', + views: '12.5K', + difficulty: '초급', + tags: ['주식', '기초', '투자'], + image: imagePath, + }, + { + id: 2, + title: 'ETF, 한 번에 이해하기', + summary: 'ETF 입문자를 위한 기초 가이드! 종류, 장점, 투자 팁까지 한눈에', + views: '9.8K', + difficulty: '중급', + tags: ['ETF', '펀드', '분산투자'], + image: imagePath, + }, + { + id: 3, + title: 'ETF 투자지표 용어 한눈에 정리하기', + summary: 'NAV, iNAV, 괴리율… 어렵게 느껴졌다면 이 글로 정리 끝!', + views: '7.2K', + difficulty: '고급', + tags: ['ETF 용어', '기초지수', '투자지표'], + image: imagePath, + }, + { + id: 4, + title: 'ETF 세금, 이것만은 꼭 알고 투자하자!', + summary: '국내·해외 ETF 세금 차이부터 절세 방법까지!', + views: '5.9K', + difficulty: '중급', + tags: ['ETF 세금', '양도소득세', '금융소득'], + image: imagePath, + }, + ], + '절세 전략': [ + { + id: 5, + title: 'ISA 활용법 A to Z', + summary: 'ISA 계좌가 뭔가요?', + views: '15.3K', + difficulty: '초급', + tags: ['ISA', '절세', '세금'], + image: imagePath, + }, + { + id: 6, + title: '절세계좌 3종 혜택 하나씩 비교해보자면?', + summary: '어떤 상품이 나에게 유리할까요?', + views: '11.7K', + difficulty: '중급', + tags: ['연금저축', 'IRP', '노후준비'], + image: imagePath, + }, + { + id: 7, + title: '세액공제 완벽 가이드', + summary: '놓치기 쉬운 세액공제 항목들', + views: '8.4K', + difficulty: '중급', + tags: ['세액공제', '소득공제', '절세'], + image: imagePath, + }, + ], + '상품 비교': [ + { + id: 8, + title: 'ETF vs 펀드, 뭐가 다를까?', + summary: '두 상품의 차이점과 장단점 비교', + views: '13.2K', + difficulty: '초급', + tags: ['ETF', '펀드', '비교'], + image: imagePath, + }, + { + id: 9, + title: '적금 vs 투자, 어떤 선택을 해야할까?', + summary: '안전한 적금과 투자, 나에게 맞는 것은?', + views: '10.1K', + difficulty: '초급', + tags: ['적금', '투자', '자산관리'], + image: imagePath, + }, + ], +}; diff --git a/app/(routes)/guide/data/video-data.ts b/app/(routes)/guide/data/video-data.ts new file mode 100644 index 0000000..5305297 --- /dev/null +++ b/app/(routes)/guide/data/video-data.ts @@ -0,0 +1,478 @@ +export interface VideoItem { + id: string; + title: string; + description: string; + duration: string; + views: string; + likes: number; + author: string; + videoUrl: string; + tags: string[]; + category: string; + investType?: string; +} +export const shortVideos: VideoItem[] = [ + { + id: '1', + title: '만기된 ISA를 100% 활용하는 방법', + description: 'IRP로 입금하고 연말정산 세액공제 혜택을 추가로 받아보자!', + duration: '0:45', + views: '10.1K', + likes: 162, + author: '하나TV[하나은행]', + videoUrl: 'https://www.youtube.com/shorts/N8aIuKDMEOE', + tags: ['하나연금닥터', '퇴직연금', 'ISA'], + category: 'hana', + }, + { + id: '2', + title: '성공적인 연금자산 운용방법 3가지', + description: '미래를 위해 준비한 연금자산, 어떻게 성공적으로 운용할까요?', + duration: '0:56', + views: '2.5K', + likes: 41, + author: '하나TV[하나은행]', + videoUrl: 'https://www.youtube.com/shorts/6X2ywA0YQ_g', + tags: ['하나연금닥터', '연금', '퇴직연금'], + category: 'hana', + }, + { + id: '3', + title: '미국 관세 영향, 1분만에 정리해줌', + description: '미국 관세 영향, 1분만에 정리해줌 ', + duration: '1:26', + views: '2.3K', + likes: 152, + author: '하나TV[하나은행]', + videoUrl: 'https://www.youtube.com/shorts/kzvzNHuQCUs', + tags: ['미국주식', '아이폰관세', '알잘딱깔센'], + category: 'hana', + }, + { + id: '4', + title: '주식은 내가 아는것과 다른 것! 쇼츠한 돈으로 만드는 쇼츠!', + description: '편견을 버린 투자 바로알기', + duration: '0:35', + views: '2.6K', + likes: 527, + author: '하나TV[하나은행]', + videoUrl: 'https://www.youtube.com/shorts/wBT2YroUACA', + tags: ['미국주식', '아이폰관세', '알잘딱깔센'], + category: 'hana', + }, + { + id: '5', + title: '캐서린 D. 우드 [별프로의 경제 인물사전 5화]', + description: '캐서린 D. 우드 [별프로의 경제 인물사전 5화]', + duration: '0:58', + views: '0.5K', + likes: 111, + author: '하나TV[하나은행]', + videoUrl: 'https://www.youtube.com/shorts/quwiZ4YibOk', + tags: ['비트코인', '아크인베스트', '돈나무'], + category: 'hana', + }, + { + id: '6', + title: + '예금은 이자받고, 주식은 뭘 받지? ...증권사 리포트도, 주식투자도 이제 쉽게 주며들자~', + description: 'DPS와 배당성향을 알아보자!', + duration: '0:28', + views: '0.2K', + likes: 6, + author: '하나TV[하나은행]', + videoUrl: 'https://www.youtube.com/shorts/gaFcUd_40-8', + tags: ['주린이', '주식사전', '투자사전'], + category: 'hana', + }, + + { + id: '7', + title: '예금 특판 말고, 더 주는 ‘이거‘해보세요', + description: '예금 특판 말고, 더 주는 ‘이거‘해보세요', + duration: '0:21', + views: '207.6K', + likes: 1412, + author: 'moneyhi', + videoUrl: 'https://www.youtube.com/shorts/Kx1Tj1FRiro?si=tv4yIVIShjJSAhYi', + tags: ['장외채권', '예금', '재테크'], + category: 'recommend', + investType: 'CONSERVATIVE', + }, + { + id: '8', + title: '채권이 뭔지 알려줄게', + description: '채권이 뭔지 알려줄게', + duration: '0:46', + views: '46.2K', + likes: 462, + author: 'oneminuteeconomy', + videoUrl: 'https://www.youtube.com/shorts/NAE1ppaY7cA', + tags: ['경제', '경제학', '경제공부'], + category: 'recommend', + investType: 'CONSERVATIVE', + }, + { + id: '9', + title: '채권금리가 올라가면 채권 가격이 하락하는 이유?.. 아.. 망할..ㅠ', + description: + '한국 주식 상한가, 거래량 천만주, 거래대금 천억 이상 관련주 리포트@@', + duration: '1:00', + views: '26.4K', + likes: 437, + author: '_1minte', + videoUrl: 'https://www.youtube.com/shorts/eny2_ptCP7k', + tags: ['주식', '재태크', '부동산'], + category: 'recommend', + investType: 'CONSERVATIVE', + }, + { + id: '10', + title: '2가지만 기억해! S&P500과 미국국채!', + description: + '워렌버핏 할아버지도 말씀하셨지, S&P500과 미국국채에 분산투자하라고!', + duration: '0:47', + views: '0.6K', + likes: 8, + author: '하나TV[하나자산운용]', + videoUrl: 'https://www.youtube.com/shorts/gN-XYKfYrdc', + tags: ['하나연금닥터', '퇴직연금', '연말정산세액공제'], + category: 'recommend', + investType: 'CONSERVATIVE', + }, + { + id: '11', + title: '절세 만능 ISA 계좌 100% 활용법 1분 요약', + description: + '걱정 NO! 저희가 계좌 개설부터 절세 혜택까지 자세하게 정리해 놓았답니다', + duration: '1:00', + views: '181.7K', + likes: 1984, + author: '삼성자산운용', + videoUrl: 'https://www.youtube.com/shorts/Uuc0jiZCsn0', + tags: ['ISA', 'ISA계좌', '재테크'], + category: 'recommend', + investType: 'CONSERVATIVE', + }, + { + id: '12', + title: 'ISA, 연금계좌에서 SCHD 활용하는 법?', + description: + 'SCHD를 좀 더 효율적으로 투자할 수 있는 곳! 바로바로~~ ✨연금계좌✨입니다', + duration: '1:00', + views: '100.6K', + likes: 840, + author: 'KODEX ETF', + videoUrl: 'https://www.youtube.com/shorts/Js-FE8ScBbA', + tags: ['미국투자', '미국주식', '미국채권'], + category: 'recommend', + investType: 'MODERATE', + }, + { + id: '13', + title: '난리난 해외 배당 ETF...이제 절세계좌보다 미국 직투일까?', + description: + '소리 소문 없이 개정된 외국납부세액 공제 방식 변경으로 역대급 난리난 ETF 시장', + duration: '1:00', + views: '23.1K', + likes: 121, + author: '서울경제 뉴스', + videoUrl: 'https://www.youtube.com/shorts/xq5E1TMX0Ik', + tags: ['연금계좌', 'ISA', 'IRP'], + category: 'recommend', + investType: 'MODERATE', + }, + { + id: '14', + title: '커버드콜 ETF 1분만에 이해하기', + description: '커버드콜 ETF 1분만에 이해하기', + duration: '0:59', + views: '38K', + likes: 367, + author: '주식부엉', + videoUrl: 'https://www.youtube.com/shorts/apcBFYjrhpA', + tags: ['커버드콜', '투자전략', '횡보장'], + category: 'recommend', + investType: 'MODERATE', + }, + { + id: '15', + title: '절세대장 RISE 200 위클리커버드콜', + description: '절세 계좌, 이제 세금 낸다고?! (feat. 멘붕)', + duration: '1:07', + views: '4.7K', + likes: 42, + author: 'KB자산운용', + videoUrl: 'https://www.youtube.com/shorts/oylAfIpaIGY', + tags: ['절세', '커버드콜', 'ISA'], + category: 'recommend', + investType: 'MODERATE', + }, + { + id: '16', + title: + '재테크 고수 시니어들은 다 아는 월배당 ETF, Kodex 배당맛집 시니어투자자 인터뷰', + description: '현금 흐름이 중요한 시니어의 월배당 포트폴리오', + duration: '1:03', + views: '38.9K', + likes: 117, + author: 'KODEX ETF', + videoUrl: 'https://www.youtube.com/shorts/MLC-3gTGwF4', + tags: ['etf', 'etf투자', '미국배당'], + category: 'recommend', + investType: 'MODERATE', + }, + { + id: '17', + title: + "뭐야~? Kodex 배당맛집이 열렸다고? '보름배당' Kodex 미국배당다우존스 ETF", + description: + '삼성 Kodex 배당맛집이 열렸어요! 삼성 Kodex 미국배당다우존스 ETF!', + duration: '0:27', + views: '47.3K', + likes: 83, + author: 'KODEX ETF', + videoUrl: 'https://www.youtube.com/shorts/uX18FVFmAv0', + tags: ['etf', 'etf투자', '월배당etf'], + category: 'recommend', + investType: 'MODERATE', + }, + { + id: '18', + title: '국내상장 해외ETF에 대한 세금은?', + description: '국내상장 해외ETF에 대한 세금은?', + duration: '0:42', + views: '7.7K', + likes: 40, + author: '돈워리', + videoUrl: 'https://www.youtube.com/shorts/76enNxZoDsM', + tags: ['미국주식', '배당소득세', '양도세'], + category: 'recommend', + investType: 'NEUTRAL', + }, + { + id: '19', + title: '해외 주식 양도소득세 250만원 한도까지 절세하는 꿀팁', + description: + '그 해에 주가가 오른 주식과 내린 주식이 있을때 양도세를 줄이기 좋은 방법입니다.', + duration: '0:58', + views: '400.3K', + likes: 5912, + author: '타민더마켓', + videoUrl: 'https://www.youtube.com/shorts/rUxXEs6BxZE', + tags: ['가치투자', '해외주식양도세', '절세'], + category: 'recommend', + investType: 'NEUTRAL', + }, + { + id: '20', + title: '국내상장 해외 ETF 도대체 뭘 사야 하는 걸까?', + description: '국내상장 해외 ETF', + duration: '0:48', + views: '40.6K', + likes: 558, + author: '지식왕 마코', + videoUrl: 'https://www.youtube.com/shorts/8UEep9K4VAY', + tags: ['etf', '국내상장', 'TR'], + category: 'recommend', + investType: 'NEUTRAL', + }, + { + id: '21', + title: '미국 주식이랑 한국 주식이랑 세금이 달라?', + description: '미국 주식이랑 한국 주식이랑 세금이 달라?', + duration: '1:44', + views: '4.8K', + likes: 31, + author: '더달란트 서울: 쉽고 재밌는 투자 교육', + videoUrl: 'https://www.youtube.com/shorts/M713RIGrFpA', + tags: ['더달란트서울', '주식세금', '차익거래'], + category: 'recommend', + investType: 'NEUTRAL', + }, + { + id: '22', + title: '배당주의 숨겨진 위험', + description: '배당주의 숨겨진 위험', + duration: '0:43', + views: '253.7K', + likes: 3053, + author: '머니치트키', + videoUrl: 'https://www.youtube.com/shorts/I4cXO7-yJ8w', + tags: ['배당주', '배당수익률', '기업가치'], + category: 'recommend', + investType: 'NEUTRAL', + }, + { + id: '23', + title: '1부국내상장 미국ETF 미국ETF 세금 비교쇼츠', + description: '1부국내상장 미국ETF 미국ETF 세금 비교쇼츠', + duration: '0:40', + views: '12.9K', + likes: 78, + author: '알아두면 좋은 정보', + videoUrl: 'https://www.youtube.com/shorts/acoX_oCzB_o', + tags: ['미국주식', '국내상장', '세금비교'], + category: 'recommend', + investType: 'NEUTRAL', + }, + { + id: '24', + title: '채권가격은 어떻게 계산할까요?', + description: '채권가격은 어떻게 계산할까요?', + duration: '0:46', + views: '3.2K', + likes: 59, + author: '10분 경제이야기', + videoUrl: 'https://www.youtube.com/shorts/gwm3YoSRSMg', + tags: ['채권투자', '채권가격계산', '채권'], + category: 'recommend', + investType: 'NEUTRAL', + }, + + { + id: '25', + title: '국내 ETF 시장 280배 성장했다고요??|ETF 쇼츠 |Kodex |코덱스', + description: + '삼성자산운용과 Kodex는 지난 20년이 넘는 시간 동안 국내 ETF시장과 함께 성장해 나아갔습니다.', + duration: '0:58', + views: '10.K', + likes: 162, + author: 'KODEX ETF', + videoUrl: 'https://youtube.com/shorts/qbjc7kK1-Yk?si=c6HMH-tIOQkrVvRG', + tags: ['삼성자산운용', '투자초보', 'ETF투자'], + category: 'recommend', + investType: 'ACTIVE', + }, + { + id: '26', + title: '전문가가 알려주는 좋은 ETF 고르는 꿀팁 3가지', + description: '전문가가 알려주는 좋은 ETF 고르는 꿀팁 3가지', + duration: '0:54', + views: '10.K', + likes: 162, + author: 'KODEX ETF', + videoUrl: 'https://youtube.com/shorts/O9f8uKOLJGY?si=ZrBaxrGMXtCll5bq', + tags: ['삼성자산운용', '코덱스', 'Kodex'], + category: 'recommend', + investType: 'ACTIVE', + }, + { + id: '27', + title: '연준 발표 속 숨겨진 메세지 ‘스태그플레이션’ 시그널', + description: '연준 발표 속 숨겨진 메세지 ‘스태그플레이션’ 시그널', + duration: '1:17', + views: '10.K', + likes: 162, + author: '머니치트키', + videoUrl: 'https://youtube.com/shorts/CUfHmSOZoDU?si=6ilmhJNUy4fYEBP3', + tags: ['스태그플레이션', '금리', '주가'], + category: 'recommend', + investType: 'ACTIVE', + }, + { + id: '28', + title: '통화량 사상 최고, 지금 무엇을 사야 할까', + description: '통화량 사상 최고, 지금 무엇을 사야 할까', + duration: '1:18', + views: '10.K', + likes: 162, + author: '머니치트키', + videoUrl: 'https://youtube.com/shorts/pR9ky0l2ejk?si=-gJw2ejueifIysc8', + tags: ['통화량', '자산', '배당주'], + category: 'recommend', + investType: 'ACTIVE', + }, + { + id: '29', + title: '코스피 상승의 진정한 이유 (효라클, 한국주식5차파동)', + description: '코스피 3000 시대를 넘어서 5000 까지 갈 수 있을까요?', + duration: '0:52', + views: '10.K', + likes: 162, + author: '잇콘TV', + videoUrl: 'https://youtube.com/shorts/EDTN-S6PrIM?si=OBLHc3PhERAAdBmz', + tags: ['한국주식', '재테크', '코스피3000'], + category: 'recommend', + investType: 'ACTIVE', + }, + { + id: '30', + title: '1분만에 완벽 정리 채권 가격과 금리의 상관관계 | 오답노트 EP.03', + description: + '1분만에 완벽 정리 채권 가격과 금리의 상관관계 | 오답노트 EP.03', + duration: '0:59', + views: '10.K', + likes: 162, + author: '매경 자이엔트', + videoUrl: 'https://youtube.com/shorts/370CsT8VKlc?si=lkZZYeIj2GTSwM4u', + tags: ['채권', '미국채', '금리'], + category: 'recommend', + investType: 'ACTIVE', + }, + { + id: '31', + title: 'ETF는 주식 같은? 펀드입니다', + description: 'ETF는 주식 같은? 펀드입니다', + duration: '0:57', + views: '10.K', + likes: 162, + author: '박곰희TV', + videoUrl: 'https://youtube.com/shorts/dyc_OTltC5c?si=GM-g1sF_9cBslEbm', + tags: ['ETF', '채권', '주식'], + category: 'recommend', + investType: 'ACTIVE', + }, + { + id: '32', + title: '해외투자절세꿀팁 배우자증여', + description: ' ', + duration: '0:59', + views: '10.K', + likes: 162, + author: '박곰희TV', + videoUrl: 'https://youtube.com/shorts/h3CIqfjRqjY?si=Xc9TbzTRfMaJMX47', + tags: ['해외투자절세', '꿀팁', '배우자증여'], + category: 'recommend', + investType: 'AGGRESSIVE', + }, + { + id: '33', + title: '바이오 주식은 왜? 변동성이 큰 걸까?', + description: '바이오 주식은 왜? 변동성이 큰 걸까?', + duration: '0:47', + views: '10.K', + likes: 162, + author: '이데일리 TV', + videoUrl: 'https://youtube.com/shorts/xuQfZrhZ4iY?si=JpB8ofZDHK0xtzNT', + tags: ['제약바이오', '주식투자', 'AI전략'], + category: 'recommend', + investType: 'AGGRESSIVE', + }, + { + id: '34', + title: '주식 투자 꼭 해야할까?', + description: '주식 투자 꼭 해야할까?', + duration: '0:36', + views: '10.K', + likes: 162, + author: '토스', + videoUrl: 'https://youtube.com/shorts/GW8YKMo1gcQ?si=SO_Clj3H4cEEVpct', + tags: ['주식', '미국주식', '금리'], + category: 'recommend', + investType: 'AGGRESSIVE', + }, + { + id: '35', + title: '[주식썰] 귀 얇은 초보 투자자는 특특고고고위험군 투자유형이다', + description: '[주식썰] 귀 얇은 초보 투자자는 특특고고고위험군 투자유형이다', + duration: '0:48', + views: '10.K', + likes: 162, + author: '삼성증권', + videoUrl: 'https://youtube.com/shorts/M159_alvr-w?si=QgdxFkbIr9BONw_v', + tags: ['투자', '투자위험성향', '고위험투자성향'], + category: 'recommend', + investType: 'AGGRESSIVE', + }, +]; diff --git a/app/(routes)/guide/page.tsx b/app/(routes)/guide/page.tsx new file mode 100644 index 0000000..b26718e --- /dev/null +++ b/app/(routes)/guide/page.tsx @@ -0,0 +1,20 @@ +import { getServerSession } from 'next-auth/next'; +import { redirect } from 'next/navigation'; +import { authOptions } from '@/lib/auth-options'; +import GuidePageContainer from './_components/guide-page-container'; + +const GuidePage = async () => { + const session = await getServerSession(authOptions); + + if (!session) { + redirect('/login'); + } + + return ( +
+ +
+ ); +}; + +export default GuidePage; diff --git a/app/(routes)/guide/shorts-viewer/[category]/[id]/_components/share-sheet.tsx b/app/(routes)/guide/shorts-viewer/[category]/[id]/_components/share-sheet.tsx new file mode 100644 index 0000000..2b047e7 --- /dev/null +++ b/app/(routes)/guide/shorts-viewer/[category]/[id]/_components/share-sheet.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { AnimatePresence, motion } from 'framer-motion'; +import { X } from 'lucide-react'; +import Button from '@/components/button'; +import { Input } from '@/components/ui/input'; + +interface Props { + visible: boolean; + onClose: () => void; + videoUrl: string; +} + +const backdrop = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, +}; + +const slideUp = { + hidden: { y: '100%' }, + visible: { y: 0 }, +}; + +const ShareSheet = ({ visible, onClose, videoUrl }: Props) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(videoUrl); + toast.success('링크가 복사되었습니다.'); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('복사에 실패했습니다.'); + } + }; + + return ( + + {visible && ( + <> + +
+ +
+ +
+
+

공유하기

+
+ +
+
+
+
+
+
+ + )} +
+ ); +}; + +export default ShareSheet; diff --git a/app/(routes)/guide/shorts-viewer/[category]/[id]/_components/shorts-viewer.tsx b/app/(routes)/guide/shorts-viewer/[category]/[id]/_components/shorts-viewer.tsx new file mode 100644 index 0000000..672ca8c --- /dev/null +++ b/app/(routes)/guide/shorts-viewer/[category]/[id]/_components/shorts-viewer.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useHeader } from '@/context/header-context'; +import { Share, Volume2, VolumeX } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import ShareSheet from './share-sheet'; + +interface VideoData { + id: string; + title: string; + description: string; + duration: string; + views: string; + likes: number; + author: string; + videoUrl: string; + tags: string[]; +} + +interface ShortsViewerProps { + video: VideoData; +} + +export default function ShortsViewer({ video }: ShortsViewerProps) { + const iframeRef = useRef(null); + const [isMuted, setIsMuted] = useState(true); + const [liked, setLiked] = useState(false); + const [shareVisible, setShareVisible] = useState(false); + const { setHeader } = useHeader(); + useEffect(() => { + if (video) { + setHeader('숏츠 가이드', video.title); + } + }, [video]); + + const getYoutubeId = (url: string): string | null => { + try { + const parsed = new URL(url); + + // youtube.com 도메인 처리 + if (parsed.hostname.includes('youtube.com')) { + if (parsed.pathname.startsWith('/shorts/')) { + return parsed.pathname.split('/shorts/')[1]; + } + if (parsed.pathname.startsWith('/embed/')) { + return parsed.pathname.split('/embed/')[1]; + } + if (parsed.searchParams.get('v')) { + return parsed.searchParams.get('v'); + } + } + + // youtu.be 도메인 처리 + if (parsed.hostname === 'youtu.be') { + return parsed.pathname.slice(1); + } + + return null; + } catch { + return null; + } + }; + const videoId = getYoutubeId(video.videoUrl); + useEffect(() => { + if (!iframeRef.current || !videoId) return; + + const message = JSON.stringify({ + event: 'command', + func: isMuted ? 'mute' : 'unMute', + args: [], + }); + + iframeRef.current.contentWindow?.postMessage(message, '*'); + }, [isMuted, videoId]); + + if (!videoId) { + return

유효하지 않은 영상 링크입니다.

; + } + + return ( +
+ {/* 영상 */} +
+
+
+