diff --git a/CLAUDE.md b/CLAUDE.md index cc0b29eef..5e213e41f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,29 +55,7 @@ CI 파이프라인: lint → typecheck → prettier → build → build-storyboo ## 도메인 혼동 주의: 멘토링 vs 멘토스터디 -### 멘토링 (1:1 개인 상담) -- **URL**: `/mentoring`, `/mentoring/[id]`, `/mentoring/[id]/apply`, `/mentoring/become-mentor` -- **피처**: `src/features/mentoring/` -- **API 훅**: `useMentorDirectoryQuery`, `useMentorDetail`, `useMentoringApplyController` 등 -- **백엔드 엔드포인트**: `/api/v1/mentors` -- **성격**: 전문 멘토와 학습자의 1:1 상담. 별도 신청·수락 흐름. 과제·멤버 관리 없음. - -### 멘토스터디 (그룹 스터디의 프리미엄 유형) -- **URL**: `/premium-study`, `/premium-study/[id]` -- **컴포넌트**: `src/components/pages/premium-study-*.tsx`, `src/app/(service)/premium-study/` -- **API 훅**: `useGetGroupStudyDetail`, `useGetGroupStudyList` (GroupStudy 훅 공용) -- **백엔드 엔드포인트**: `/api/v1/group-studies` (쿼리 파라미터로 MENTOR_STUDY 구분) -- **성격**: 그룹 스터디의 특수 유형(MentorStudy extends GroupStudy). 멤버 관리·과제·평가 포함. - -### 핵심 차이 요약 - -| | 멘토링 | 멘토스터디 | -|---|---|---| -| 참여 형태 | 1:1 | 1:N 그룹 | -| 프론트 URL | `/mentoring/*` | `/premium-study/*` | -| API 경로 | `/api/v1/mentors` | `/api/v1/group-studies` | -| 엔티티 | `Mentor`, `MentoringApplication` | `MentorStudy extends GroupStudy` | -| 과제·평가 | 없음 | 있음 | +@.claude/rules/domain-entities.md --- @@ -113,96 +91,7 @@ yarn generate:api #### TanStack Query 훅 작성 패턴 -**useQuery (조회):** - -```typescript -export const useGetMissions = ({ - groupStudyId, - page = 1, -}: GetMissionsParams) => { - return useQuery({ - queryKey: ['missions', groupStudyId, page], // 리소스명 + 파라미터 - queryFn: async () => { - const { data } = await missionApi.getMissions(groupStudyId, page); - return data.content; // content 추출 - }, - enabled: !!groupStudyId, // 조건부 실행 (선택) - }); -}; -``` - -**useMutation (생성/수정/삭제):** - -```typescript -export const useCreateMission = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ groupStudyId, request }: CreateMissionParams) => { - const { data } = await missionApi.createMission(groupStudyId, request); - return data.content; - }, - onSuccess: async (_, variables) => { - await queryClient.invalidateQueries({ - queryKey: ['missions', variables.groupStudyId], // 관련 쿼리 무효화 - }); - }, - }); -}; -``` - -**useMutation 콜백 패턴:** - -`onSettled`는 성공/실패 무관하게 항상 실행된다 (`finally` 블록과 동일). 성공 시에만 필요한 동작(페이지 이동, 성공 토스트)은 반드시 `onSuccess`에, 실패 처리는 `onError`에, UI 정리(모달 닫기, 상태 초기화)는 `onSettled`에 배치한다. - -```typescript -// 올바른 패턴 -mutate(params, { - onSuccess: () => { - showToast('완료되었습니다.'); - router.push('/list'); // 성공 시에만 이동 - }, - onError: () => { - showToast('실패하였습니다.', 'error'); - }, - onSettled: () => { - setConfirmAction(null); // 항상 UI 초기화 - }, -}); -``` - -**queryKey 컨벤션:** - -- 단일 리소스: `['mission', missionId]` -- 목록 리소스: `['missions', groupStudyId, page, size]` -- 무효화 시 상위 키 사용: `queryKey: ['missions']` (해당 리소스 전체 무효화) -- mutation이 여러 리소스에 영향을 줄 경우 관련 queryKey를 모두 무효화: - -```typescript -onSuccess: async (_, variables) => { - // 신청자 상태 변경 → 멤버 목록 + 신청자 목록 모두 갱신 - await queryClient.invalidateQueries({ queryKey: ['groupStudyMemberList', variables.groupStudyId] }); - await queryClient.invalidateQueries({ queryKey: ['entryList', variables.groupStudyId] }); -}, -``` - -#### 레거시 방식 (features 내부 API) - -`src/features/<도메인>/api/` 디렉토리에 직접 axios 함수 작성: - -```typescript -import { axiosInstance } from '@/api/client/axios'; - -export const getArchive = async (params: GetArchiveParams) => { - const { data } = await axiosInstance.get<{ content: ArchiveResponse }>( - '/archive', - { params }, - ); - return data.content; -}; -``` - -레거시 방식은 기존 코드 유지보수용. 신규 API는 OpenAPI 방식 권장. +@.claude/rules/api-patterns.md ### 상태 관리 @@ -220,59 +109,7 @@ export const getArchive = async (params: GetArchiveParams) => { ### 백엔드 데이터 처리 안전 패턴 -빈 배열 안전성은 상위 컴포넌트의 `if (!arr?.length) return null` 가드로 이미 보장되므로 `Math.max` 호출 전 별도 방어 코드는 불필요하다. - -#### optional 필드를 React key와 핸들러에서 안전하게 사용하기 - -백엔드에서 optional(`?`)로 내려오는 ID 필드를 React `key` prop에 직접 사용하면 여러 항목이 `undefined`로 중복되어 React가 잘못된 DOM 재사용을 할 수 있다. `??` 연산자로 `index` 폴백을 둔다. - -```typescript -// 잘못된 패턴 — missionId가 undefined이면 모든 항목이 key="undefined"로 중복 -{items.map((item) =>
...
)} - -// 올바른 패턴 — optional 필드 ?? index -{items.map((item, index) =>
...
)} -``` - -optional 필드를 이벤트 핸들러 내에서 사용할 때도 가드가 필요하다: - -```typescript -// 잘못된 패턴 — missionId가 undefined이면 ?missionId=undefined 라우팅 -const handleClick = (id: number) => router.push(`...?missionId=${id}`); - -// 올바른 패턴 — 복구 가능한 실패는 Toast로 안내 -const handleClick = (id: number | undefined) => { - if (!id) { - showToast('정보를 불러올 수 없습니다.', 'error'); - return; - } - router.push(`...?missionId=${id}`); -}; -``` - -#### enum-like 문자열 타입 단언 안전 가드 - -백엔드에서 프론트 타입 정의에 없는 값이 올 수 있다. `as StudyType` 같은 단순 타입 단언 대신 `in` 가드 + 폴백을 사용한다. TypeScript `as`는 런타임을 보호하지 않는다. - -```typescript -// 잘못된 패턴 — 알 수 없는 값 수신 시 undefined 렌더링 또는 런타임 오류 -const studyType = type as StudyType; -{STUDY_TYPE_LABELS[studyType]} - -// 올바른 패턴 — in 가드 후 폴백 처리 -const studyType = - type && type in STUDY_TYPE_LABELS ? (type as StudyType) : undefined; -{studyType ? STUDY_TYPE_LABELS[studyType] : '스터디'} - -// 목록 순회 시 -{experienceLevels?.map((level) => ( - - {level in EXPERIENCE_LEVEL_LABELS - ? EXPERIENCE_LEVEL_LABELS[level as ExperienceLevel] - : level} - -))} -``` +@.claude/rules/backend-data-safety.md ### 스타일링 @@ -291,119 +128,7 @@ const studyType = ### 에러 핸들링 -에러 처리는 `src/utils/error-handler.ts`를 중심으로 중앙 집중식 관리한다. `src/utils/error.ts`는 `extractErrorCode()` 하위 호환성용 deprecated 래퍼다. - -#### 핵심 파일 - -- `src/utils/error-handler.ts` — `analyzeError()`, `logError()`, `ErrorType`, `ErrorInfo`. 에러 코드-메시지 매핑(~40개), 한국어 fallback, Sentry 보고를 모두 담당 -- `src/config/query-client.ts` — `MutationCache` 글로벌 에러 핸들러. `onError`가 없는 mutation 실패 시 자동으로 에러 toast + Sentry 보고 -- `src/app/(service)/error.tsx`, `(landing)/error.tsx`, `(admin)/error.tsx` — route segment 에러 경계 -- `src/app/global-error.tsx` — root 에러 경계 (Sentry 자동 캡처) -- `src/app/not-found.tsx` 및 각 route group의 `not-found.tsx` - -#### 에러 분류 체계 - -`analyzeError()`는 3가지 에러 타입을 순서대로 분류한다: - -1. **AxiosError** — `isAxiosError()` 통과. HTTP 상태 코드 + API 에러 응답 추출 -2. **ApiError** — axios 인터셉터가 변환한 커스텀 에러. `isApiError()` 타입 가드로 `errorCode`, `statusCode` 보존 -3. **일반 Error / unknown** — fallback 처리 - -``` -AxiosError → isAxiosError() ✅ → HTTP 상태/에러 코드 추출 -ApiError → isApiError() ✅ → errorCode/statusCode 보존 (인터셉터 변환 에러) -Error → instanceof Error → UNKNOWN 타입 -unknown → String(error) → 기본 메시지 -``` - -#### 에러 코드-메시지 매핑 - -`error-handler.ts`의 `codeMessages` 객체에서 중앙 관리. 에러 코드 prefix별 분류: - -| Prefix | 도메인 | 예시 | -|--------|--------|------| -| AUTH | 인증 | AUTH001(토큰 만료), AUTH002(권한 없음) | -| CMM | 공통 | CMM001(입력값 오류), CMM006(접근 권한) | -| MEM | 회원 | MEM002(회원 미존재), MEM003(중복 가입) | -| GSM/GSA | 스터디 관리/신청 | GSM001(스터디 미존재), GSA003(정원 초과) | -| HWK/EVL | 과제/평가 | HWK003(제출 기간 만료), EVL002(중복 평가) | -| PAY 2xx | 결제 | PAY202(중복 승인), PAY207(금액 불일치) | -| PAY 3xx | 환불 | PAY302(중복 환불), PAY307(환불 불가) | -| FILE | 파일 | FILE001(업로드 실패), FILE002(형식 미지원) | - -매핑에 없는 코드는 백엔드 `message`가 한국어이면 그대로 사용(`/[가-힣]/` 정규식). 에러 코드를 사용자에게 직접 노출하지 않는다. - -#### Mutation 에러 글로벌 핸들러 - -`query-client.ts`의 `MutationCache.onError`가 안전망 역할: - -- `onError` 핸들러가 없는 mutation 실패 시 자동으로 에러 toast 표시 + Sentry 보고 -- 개별 `onError`가 있으면 글로벌 핸들러 스킵 (중복 방지) -- Query 에러는 글로벌 핸들러 미적용 (다중 동시 실패 시 toast 폭주 방지) - -#### 클라이언트 에러 처리 원칙 - -- 복구 가능한 실패 (`recoverable`): 사용자 흐름을 유지한다. Inline error를 우선하고, Toast를 보조적으로 사용한다. **브라우저 시스템 `alert()`는 사용하지 않는다** — Toast(`useToastStore`)를 사용한다 -- 사용자 판단이 필요한 실패 (`action required`): 다음 행동을 선택해야 할 때 Modal 또는 앱 내 확인 UI를 사용한다. 브라우저 시스템 `alert()`는 사용하지 않으며 기존의 디자인 시스템을 활용한다 -- 치명적 실패 (`fatal`, page-level): 특정 화면이 더 이상 정상 동작할 수 없을 때 route segment의 `error.tsx` 또는 client error boundary를 사용한다 -- 애플리케이션 전체 실패 (`critical`, app-level): hydration mismatch, 인증 컨텍스트 붕괴, 전역 provider 오류처럼 앱 전체에 영향을 주는 경우 `global-error.tsx`가 잡고 Sentry로 자동 보고 - -Toast 사용 패턴: - -```typescript -// 컴포넌트 내부 (React hook 사용) -const showToast = useToastStore((state) => state.showToast); -showToast('환불 요청이 접수되었습니다.', 'success'); - -// Hook / React 외부 (getState 사용) -useToastStore.getState().showToast(errorInfo.userMessage, 'error'); -``` - -``는 `(service)`, `(landing)`, `(admin)` 세 레이아웃 모두에 마운트되어 있다. - -#### 서버 에러 처리 원칙 - -- SSR/Server Component에서 필수 데이터 로딩에 실패해 페이지가 성립하지 않으면 예외를 다시 던져 `error.tsx`로 보낸다 -- 리소스가 존재하지 않는 케이스는 `notFound()`로 분기한다 -- `fetchQuery()` / `prefetchQuery()`의 `queryFn`은 `undefined`를 반환하면 안 된다. 404는 `notFound()`, 그 외는 반드시 `throw error` - -예: `src/api/endpoints/group-study/get-group-study-detail.server.ts`는 `GSM001`이면 `notFound()`, 나머지 에러는 `throw error` 한다 - -```typescript -export default async function Page() { - const data = await fetchData(); - return ; -} -``` - -불필요한 `try/catch`로 에러를 삼켜 `undefined`를 반환하지 않는다. - -#### 운영 보안 원칙 - -- Production에서는 `stack trace`, 원문 서버 메시지, 내부 경로, 민감한 백엔드 응답을 사용자 화면에 직접 노출하지 않는다 -- 3개 `error.tsx` 모두 `process.env.NODE_ENV === 'development'` 게이팅 적용: technicalMessage, error.message, error.stack은 개발 환경에서만 표시 -- 사용자 화면에는 일반화된 `userMessage`, 필요 시 `errorCode`, `statusCode`, `digest` 정도만 노출한다 -- `digest`는 서버 로그 또는 Sentry에서 원인을 찾기 위한 추적용 식별자로 사용한다 -- API route에서도 production 응답에는 상세 `details`를 그대로 넣지 않는 방향을 기본 원칙으로 삼는다 - -#### 성공 페이지 원칙 - -- 스터디 생성, 스터디 참여, 결제 완료 같은 주요 성공 이벤트는 별도 success page 또는 완료 화면으로 사용자의 다음 행동을 명확히 안내한다 -- 브랜딩 요소는 환영 문구, 운영팀 메시지, 후속 행동 CTA 중심으로 넣고, 정보량이 많은 경우에도 핵심 CTA를 먼저 보이게 한다 - -#### 모니터링 (Sentry) - -`@sentry/nextjs`가 통합되어 있다. 에러는 `logError()` → `Sentry.captureException()` 경로로 자동 보고된다. - -- **SDK 설정 파일**: `sentry.client.config.ts`, `sentry.server.config.ts`, `sentry.edge.config.ts` (프로젝트 루트) -- **Next.js instrumentation**: `src/instrumentation.ts` — 서버/엣지 런타임 초기화 + `onRequestError` 자동 캡처 -- **next.config.ts**: `withSentryConfig()` 래핑 — 소스맵 업로드, 트리셰이킹 -- **환경 변수**: `NEXT_PUBLIC_SENTRY_DSN` (런타임), `SENTRY_ORG` / `SENTRY_PROJECT` / `SENTRY_AUTH_TOKEN` (CI 소스맵 업로드) -- **환경 감지**: `NEXT_PUBLIC_API_BASE_URL` 기반으로 `production` / `staging` / `development` 자동 분류 -- **필터링**: AUTH001(토큰 만료)은 정상 플로우이므로 `beforeSend`에서 Sentry 보고 제외 -- **성능**: `tracesSampleRate: 0.1` (10%), Session Replay는 에러 시에만 기록 (`replaysOnErrorSampleRate: 1.0`) -- DSN이 없으면 Sentry가 초기화되지 않으므로, 로컬 개발에서는 환경 변수 없이도 정상 동작한다 -- 운영 단계에서는 Slack 즉시 알림을 연동할 수 있지만, 노이즈를 줄이기 위해 임계치와 대상 에러 범위를 먼저 정의한다 +@.claude/rules/error-handling.md ### 경로 별칭 diff --git a/src/app/(service)/payment/success/page.tsx b/src/app/(service)/payment/success/page.tsx index 63fb03ca2..6ff935d47 100644 --- a/src/app/(service)/payment/success/page.tsx +++ b/src/app/(service)/payment/success/page.tsx @@ -143,52 +143,64 @@ function PaymentSuccessContent() { } return ( -
-
+
+
{/* 상태 아이콘 영역 */} -
-
- +
+
+ 결제 완료
-

결제가 완료되었습니다

-

- 수강/학습 내역과 결제 내역은 -
- 마이페이지에서 확인하실 수 있습니다. +

+ 결제가 완료되었습니다. +

+

+ 수강/학습 내역과 결제 내역은 마이페이지에서 확인하실 수 있습니다.

{/* 결제 정보 */} -
- - - - -
+
+
+ + +

결제 정보

- {/* 안내 문구 */} -

- 스터디 수강 신청이 완료되었습니다. -

+ + +
+ + +
+
+
{/* 하단 버튼 */} -
+
} diff --git a/src/components/card/my-homework-status-card.tsx b/src/components/card/my-homework-status-card.tsx index b9ec5809a..417556108 100644 --- a/src/components/card/my-homework-status-card.tsx +++ b/src/components/card/my-homework-status-card.tsx @@ -33,6 +33,8 @@ export default function MyHomeworkStatusCard({ router.push(`?${params.toString()}`); }; + const isSubmissionOpen = mission?.status === 'IN_PROGRESS'; + // 미제출 상태 if (!myHomework || myHomework.homeworkStatus === 'NOT_SUBMITTED') { return ( @@ -42,9 +44,11 @@ export default function MyHomeworkStatusCard({
- 아직 과제를 제출하지 않았습니다. + {isSubmissionOpen + ? '아직 과제를 제출하지 않았습니다.' + : '제출 기간이 종료되었습니다.'} - + {isSubmissionOpen && }
); diff --git a/src/components/common/modals/create-mission-modal.tsx b/src/components/common/modals/create-mission-modal.tsx index 7c372d935..a6e83a608 100644 --- a/src/components/common/modals/create-mission-modal.tsx +++ b/src/components/common/modals/create-mission-modal.tsx @@ -1,4 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; +import { useQueryClient } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { Plus } from 'lucide-react'; import { FormEvent, useState } from 'react'; @@ -124,6 +125,7 @@ function CreateMissionForm({ existingMissions, onClose, }: CreateMissionFormProps) { + const queryClient = useQueryClient(); const methods = useForm({ resolver: zodResolver(CreateMissionFormSchema), mode: 'onChange', @@ -158,8 +160,11 @@ function CreateMissionForm({ }, }, { - onSuccess: () => { - showToast('미션이 성공적으로 생성되었습니다!'); + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['missions', groupStudyId], + }); + showToast('미션이 성공적으로 생성되었습니다.', 'success'); onClose(); }, onError: () => { diff --git a/src/components/contents/mission-detail-content.tsx b/src/components/contents/mission-detail-content.tsx index cb19f84ec..daa867d00 100644 --- a/src/components/contents/mission-detail-content.tsx +++ b/src/components/contents/mission-detail-content.tsx @@ -8,7 +8,7 @@ import Badge from '@/components/common/ui/badge'; import MarkdownContent from '@/components/common/ui/editor/markdown-content'; import Progress from '@/components/common/ui/progress'; import { useGetMission } from '@/hooks/queries/mission-api'; -import MyHomeworkStatus from '../card/my-homework-status-card'; +import MyHomeworkStatusCard from '../card/my-homework-status-card'; interface MissionDetailContentProps { missionId: number; @@ -68,7 +68,9 @@ export default function MissionDetailContent({
{/* 내 과제 현황 */} - {showMyHomework !== false && } + {showMyHomework !== false && ( + + )} {/* 제출 현황 */}
@@ -132,8 +134,10 @@ function HomeworkCard({ homework, onSelectHomework }: HomeworkCardProps) { }; return ( -
{statusConfig.label} )}
-
+ ); } diff --git a/yarn.lock b/yarn.lock index 08df60b01..96b0278a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8960,9 +8960,9 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor prosemirror-model "^1.21.0" prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.41.4: - version "1.41.7" - resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.7.tgz#61e6f44ac160795c913ead92a282247df9d468f6" - integrity sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w== + version "1.41.8" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.8.tgz#bfb48d9dc328f1aa2a0eea1600b0828818be03f1" + integrity sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA== dependencies: prosemirror-model "^1.20.0" prosemirror-state "^1.0.0"