diff --git a/backend/src/me/me.controller.ts b/backend/src/me/me.controller.ts
index fa0d31f..4d0961d 100644
--- a/backend/src/me/me.controller.ts
+++ b/backend/src/me/me.controller.ts
@@ -23,7 +23,7 @@ import {
Max,
Min,
} from 'class-validator';
-import { MathUnit, Persona, ReviewIntensity } from '@prisma/client';
+import { MathUnit, Persona, ReviewIntensity, SubscriptionTier } from '@prisma/client';
import { extname, join } from 'path';
import { mkdirSync } from 'fs';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -84,6 +84,11 @@ class OnboardingDto {
onboarded?: boolean;
}
+class SwitchTierDto {
+ @IsEnum(SubscriptionTier)
+ tier: SubscriptionTier;
+}
+
class ProfilePatchDto {
@IsOptional()
@IsString()
@@ -187,6 +192,11 @@ export class MeController {
return this.me.updateAvatar(user.id, file);
}
+ @Patch('subscription-tier')
+ async switchTier(@CurrentUser() user: AuthUser, @Body() dto: SwitchTierDto) {
+ return this.me.switchTier(user.id, dto.tier);
+ }
+
@Delete('avatar')
removeAvatar(@CurrentUser() user: AuthUser) {
return this.me.removeAvatar(user.id);
diff --git a/backend/src/me/me.service.ts b/backend/src/me/me.service.ts
index abe7470..ec2e24b 100644
--- a/backend/src/me/me.service.ts
+++ b/backend/src/me/me.service.ts
@@ -2,7 +2,7 @@ import {
BadRequestException,
Injectable,
} from '@nestjs/common';
-import { MathUnit, Persona, Prisma, ReviewIntensity } from '@prisma/client';
+import { MathUnit, Persona, Prisma, ReviewIntensity, SubscriptionTier } from '@prisma/client';
import { unlink } from 'fs/promises';
import { join } from 'path';
import { PrismaService } from '../prisma/prisma.service';
@@ -173,6 +173,14 @@ export class MeService {
}
}
+ async switchTier(userId: number, tier: SubscriptionTier) {
+ const user = await this.prisma.user.update({
+ where: { id: userId },
+ data: { subscriptionTier: tier },
+ });
+ return this.toView(user);
+ }
+
async updateAvatar(
userId: number,
file?: {
diff --git a/backend/src/reviews/reviews.service.ts b/backend/src/reviews/reviews.service.ts
index b8de401..6c52e74 100644
--- a/backend/src/reviews/reviews.service.ts
+++ b/backend/src/reviews/reviews.service.ts
@@ -48,7 +48,11 @@ export class ReviewsService {
take: 100,
});
- return { items, rescheduledCount };
+ // Free 플랜: 복습 큐 10개 제한
+ const queueUser = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { subscriptionTier: true } });
+ const limitedItems = queueUser.subscriptionTier === 'free' ? items.slice(0, 10) : items;
+
+ return { items: limitedItems, rescheduledCount };
}
/**
diff --git a/backend/src/stats/stats.service.ts b/backend/src/stats/stats.service.ts
index c9aab6e..8fa04e6 100644
--- a/backend/src/stats/stats.service.ts
+++ b/backend/src/stats/stats.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PersonaForgetService } from '../forget/persona-forget.service';
@@ -71,6 +71,12 @@ export class StatsService {
* tag becomes mine"** instead of only the decay curve.
*/
async masteryPath(userId: number, tagId: number, targetP = 0.9) {
+ // Free 플랜: 마스터리 경로 차단
+ const tierUser = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { subscriptionTier: true } });
+ if (tierUser.subscriptionTier === 'free') {
+ throw new ForbiddenException('마스터리 경로는 Pro 플랜부터 이용할 수 있습니다.');
+ }
+
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
diff --git a/backend/src/subjects/subjects.service.ts b/backend/src/subjects/subjects.service.ts
index 7cdd80a..e0d7363 100644
--- a/backend/src/subjects/subjects.service.ts
+++ b/backend/src/subjects/subjects.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { findPresetUnits } from './curriculum-presets';
@@ -24,6 +24,15 @@ export class SubjectsService {
}
async create(userId: number, name: string, color: string) {
+ // Free 플랜: 과목 3개 제한
+ const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { subscriptionTier: true } });
+ if (user.subscriptionTier === 'free') {
+ const count = await this.prisma.subject.count({ where: { userId } });
+ if (count >= 3) {
+ throw new ForbiddenException('Free 플랜은 과목 3개까지만 등록할 수 있습니다. Pro로 업그레이드하세요.');
+ }
+ }
+
const subject = await this.prisma.subject.create({
data: { userId, name, color },
});
diff --git a/frontend/src/app/pricing/page.tsx b/frontend/src/app/pricing/page.tsx
index af4127b..24d5a62 100644
--- a/frontend/src/app/pricing/page.tsx
+++ b/frontend/src/app/pricing/page.tsx
@@ -50,10 +50,12 @@ const tiers: Tier[] = [
note: { monthly: '카드 등록 없이 바로 시작', yearly: '언제든 Pro로 전환 가능' },
featuresLabel: '바로 사용할 수 있는 기능',
features: [
- '학습 주제 3개 등록',
+ '학습 과목 3개까지 등록',
'학습 기록 무제한 저장',
- '매일 50개 적응형 복습 큐',
- '기본 망각 곡선 적용',
+ '일일 복습 큐 10개',
+ 'PDF 문제집 월 1회 업로드',
+ '샘플 문제집 열람',
+ '기본 복습 캘린더',
'학습 페르소나 4종 선택',
],
},
@@ -63,41 +65,41 @@ const tiers: Tier[] = [
summary: '시험 준비와 장기 학습을 본격화하는 추천 플랜',
description: '문제 풀이부터 복습 분석까지, 꾸준한 성장을 위한 기능을 모두 엽니다.',
cta: 'Pro 시작하기',
- price: { monthly: '7,900', yearly: '79,000' },
+ price: { monthly: '2,900', yearly: '24,900' },
period: { monthly: '/ 월', yearly: '/ 연' },
note: {
monthly: '월 단위로 유연하게 사용',
- yearly: '연간 결제 시 2개월 무료 혜택',
+ yearly: '연간 결제 시 약 30% 할인',
},
highlight: true,
featuresLabel: 'Free의 모든 기능 포함, 추가로',
features: [
- '학습 주제 무제한 등록',
- '기출 문제집 연동',
- '마스터리 경로 뷰와 예상 정답률 예측',
- 'PDF 문제/자료 이미지 업로드',
+ '학습 과목 무제한 등록',
+ '복습 큐 무제한',
+ '기출 문제집 전체 연동',
+ 'PDF 문제집 무제한 업로드',
+ '마스터리 경로 뷰 + 예상 정답률',
'단원별 상세 분석 리포트',
- '데이터 내보내기와 알림 위젯',
],
},
{
name: 'School',
- summary: '학원, 스터디, 클래스 운영을 위한 플랜',
- description: '학습자 관리, 단체 분석, 보고서 생성까지 한 번에 운영할 수 있습니다.',
+ summary: '학원, 학교, 교습소 운영을 위한 플랜',
+ description: '학습자 관리, 과제 배정, 리포트까지 한 번에 운영할 수 있습니다.',
cta: 'School 시작하기',
- price: { monthly: '20,000', yearly: '200,000' },
- period: { monthly: '/ 월', yearly: '/ 연' },
+ price: { monthly: '2,900', yearly: '24,900' },
+ period: { monthly: '/ 학생·월', yearly: '/ 학생·연' },
note: {
- monthly: '기본 30명 시트 포함',
- yearly: '연간 운영 예산에 맞춘 단체 플랜',
+ monthly: '학생 수에 따라 유연하게',
+ yearly: '연간 결제 시 약 30% 할인',
},
featuresLabel: 'Pro의 모든 기능 포함, 추가로',
features: [
- '기본 학습자 30명 시트 제공',
- '운영자 대시보드',
- '그룹 통계 및 진도 비교',
- '학습자별 페르소나/주기 일괄 조정',
- '월간 통합 리포트',
+ '교사 대시보드',
+ '과제 관리 (일괄 + 개인 지정)',
+ '학생별 풀이 상세 확인',
+ '학급 리포트 + 성적 비교',
+ '선생님 필기/해설 공유',
],
},
];
@@ -130,7 +132,7 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [
title: '학습',
rows: [
{
- feature: '등록 가능 학습 주제 수',
+ feature: '등록 가능 과목 수',
free: { type: 'text', value: '3개' },
pro: { type: 'text', value: '무제한' },
school: { type: 'text', value: '무제한' },
@@ -142,11 +144,23 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [
school: { type: 'text', value: '무제한' },
},
{
- feature: '일일 적응형 복습 큐',
- free: { type: 'text', value: '50개' },
+ feature: '일일 복습 큐',
+ free: { type: 'text', value: '10개' },
pro: { type: 'text', value: '무제한' },
school: { type: 'text', value: '무제한' },
},
+ {
+ feature: 'PDF 문제집 업로드',
+ free: { type: 'text', value: '월 1회' },
+ pro: { type: 'text', value: '무제한' },
+ school: { type: 'text', value: '무제한' },
+ },
+ {
+ feature: '기출 문제집 연동',
+ free: { type: 'text', value: '샘플만' },
+ pro: { type: 'check' },
+ school: { type: 'check' },
+ },
{
feature: '학습 페르소나 선택',
free: { type: 'text', value: '4종' },
@@ -158,15 +172,9 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [
{
title: '분석',
rows: [
- {
- feature: '망각 곡선 모델',
- free: { type: 'text', value: '기본' },
- pro: { type: 'text', value: '페르소나 맞춤형' },
- school: { type: 'text', value: '페르소나 맞춤형' },
- },
{
feature: '마스터리 경로 뷰',
- free: { type: 'dash' },
+ free: { type: 'text', value: '미리보기' },
pro: { type: 'check' },
school: { type: 'check' },
},
@@ -182,46 +190,34 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [
pro: { type: 'dash' },
school: { type: 'check' },
},
- {
- feature: '주간 요약과 알림',
- free: { type: 'dash' },
- pro: { type: 'check' },
- school: { type: 'check' },
- },
],
},
{
- title: '기타',
+ title: '학교/학원',
rows: [
{
- feature: '기출/문제집 연동',
- free: { type: 'dash' },
- pro: { type: 'check' },
- school: { type: 'check' },
- },
- {
- feature: 'PDF 문제 업로드',
- free: { type: 'dash' },
- pro: { type: 'check' },
- school: { type: 'check' },
- },
- {
- feature: '데이터 내보내기',
- free: { type: 'dash' },
- pro: { type: 'check' },
- school: { type: 'check' },
- },
- {
- feature: '선생님 관리자 대시보드',
+ feature: '교사 대시보드',
free: { type: 'dash' },
pro: { type: 'dash' },
school: { type: 'check' },
},
{
- feature: '우선 지원',
+ feature: '과제 관리 (일괄 + 개인)',
free: { type: 'dash' },
- pro: { type: 'text', value: '일반' },
- school: { type: 'text', value: '1영업일 내' },
+ pro: { type: 'dash' },
+ school: { type: 'check' },
+ },
+ {
+ feature: '학생 풀이 상세 확인',
+ free: { type: 'dash' },
+ pro: { type: 'dash' },
+ school: { type: 'check' },
+ },
+ {
+ feature: '선생님 해설 공유',
+ free: { type: 'dash' },
+ pro: { type: 'dash' },
+ school: { type: 'check' },
},
],
},
@@ -231,7 +227,7 @@ const faqItems = [
{
question: '연간 결제로 바꾸면 언제 할인 적용이 되나요?',
answer:
- '즉시 적용됩니다. 연간 플랜으로 전환하는 순간 2개월 무료가 반영된 가격으로 청구되고, 남아 있는 월간 사용 기간은 일할 계산해 차감합니다.',
+ '즉시 적용됩니다. 연간 플랜으로 전환하는 순간 약 30% 할인이 반영된 가격으로 청구되고, 남아 있는 월간 사용 기간은 일할 계산해 차감합니다.',
},
{
question: 'Free에서 Pro로 올리면 기존 데이터가 유지되나요?',
@@ -376,8 +372,8 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent
나에게 맞는 플랜을 선택하세요
- Free로 가볍게 시작하고, 필요할 때 Pro와 School로 확장할 수 있습니다. 시험
- 준비, 언어 학습, 취미 공부까지 반복의 핵심은 모든 플랜에 동일하게 포함됩니다.
+ Free로 가볍게 시작하고, 필요할 때 Pro와 School로 확장할 수 있습니다. 월
+ 2,900원, 편의점 음료 2개 값으로 무제한 복습과 AI 분석을 시작하세요.
@@ -397,7 +393,7 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent
>
연간
- 2개월 무료
+ 약 30% 할인
@@ -408,8 +404,7 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent
Pricing
세 가지 플랜, 하나의 복습 철학
- 월간은 유연하게, 연간은 더 경제적으로. 연간 결제 시 Pro와 School은
- 2개월 무료 혜택이 반영됩니다.
+ 월간은 유연하게, 연간은 더 경제적으로. 연간 결제 시 약 30% 할인이 적용됩니다.
diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx
index b266e10..45c98cf 100644
--- a/frontend/src/app/profile/page.tsx
+++ b/frontend/src/app/profile/page.tsx
@@ -373,6 +373,22 @@ function ProfileBody() {
}
}
+ async function handleTierSwitch(tier: 'free' | 'pro' | 'school') {
+ if (!userRef.current || userRef.current.subscriptionTier === tier) return;
+ setSavingKey('tier');
+ try {
+ const response = await api.patch('/me/subscription-tier', { tier });
+ const nextUser: MeUser = { ...response.data, focusUnits: response.data.focusUnits ?? [] };
+ userRef.current = nextUser;
+ setUser(nextUser);
+ showToast({ message: `${planLabel(tier)}으로 전환했어요`, variant: 'success' });
+ } catch {
+ showToast({ message: '플랜 전환에 실패했습니다', variant: 'danger' });
+ } finally {
+ setSavingKey((current) => (current === 'tier' ? null : current));
+ }
+ }
+
function handleLogout() {
clearToken();
router.replace('/login');
@@ -649,17 +665,39 @@ function ProfileBody() {
플랜 관리
- 현재 플랜을 확인하고 변경하세요
+ 프로토타입 모드 — 플랜 자유 전환
- 현재 이용 중인 요금제와 업그레이드 옵션을 `/pricing`에서 바로 확인할 수
- 있습니다.
+ 프로토타입이므로 결제 없이 Free / Pro / School을 자유롭게 전환할 수 있습니다.
- {planLabel(user.subscriptionTier)}
-
+
+ void handleTierSwitch('free')}
+ disabled={savingKey === 'tier'}
+ >
+ Free
+
+ void handleTierSwitch('pro')}
+ disabled={savingKey === 'tier'}
+ >
+ Pro
+
+ void handleTierSwitch('school')}
+ disabled={savingKey === 'tier'}
+ >
+ School
+
+
@@ -1336,3 +1374,37 @@ const AlgoDesc = styled.p`
font-weight: 600;
}
`;
+
+const TierSwitcher = styled.div`
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+`;
+
+const TierSwitchBtn = styled.button<{ $active: boolean; $accent?: boolean }>`
+ padding: 10px 18px;
+ border-radius: 999px;
+ border: 1px solid ${({ $active, $accent }) =>
+ $active
+ ? $accent ? 'rgba(99, 102, 241, 0.6)' : 'rgba(255, 255, 255, 0.3)'
+ : theme.color.borderSoftAlpha};
+ background: ${({ $active, $accent }) =>
+ $active
+ ? $accent ? 'rgba(79, 70, 229, 0.25)' : 'rgba(255, 255, 255, 0.1)'
+ : 'rgba(255, 255, 255, 0.04)'};
+ color: ${({ $active }) => $active ? theme.color.textBright : theme.color.textSub};
+ font-size: 14px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ &:hover:not(:disabled) {
+ background: rgba(79, 70, 229, 0.15);
+ color: ${theme.color.textBright};
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+`;