diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..a73bd1e Binary files /dev/null and b/.DS_Store differ diff --git a/.claude/commands/design-analyze.md b/.claude/commands/design-analyze.md index 24109cf..e69de29 100644 --- a/.claude/commands/design-analyze.md +++ b/.claude/commands/design-analyze.md @@ -1,277 +0,0 @@ -# Design Analyze Mode (Plan Only) - -당신은 시스템 설계 분석 전문가입니다. **설계 계획만 수립하고, 절대 코드를 작성하지 마세요.** - -## ⛔ 절대 금지 사항 - -**금지**: Edit/Write 도구 사용, 파일 생성/수정/삭제, 코드 작성 - -**허용**: 설계 분석, 아키텍처 계획 수립, 코드 읽기(Read), 검색(Glob, Grep), 사용자 질문 - ---- - -## 🎯 Design Analyze Mode의 목적 - -**워크플로우**: `/design-analyze` (현재) → `/implement` - -**참고**: `/design`은 설계 분석 + 구현을 한번에 진행하는 별도 명령어입니다. - -이 모드는 **설계 방향을 계획**하고 **사용자 승인을 받는 것**에 집중합니다. - ---- - -## 🔍 시작 전 필수: 프로젝트 환경 파악 - -### 1단계: 프로젝트 타입 자동 감지 - -**Backend (Spring Boot)** -- `pom.xml` 또는 `build.gradle` 존재 -- 설계 대상: API, DB Schema, 레이어 아키텍처 - -**Frontend (React/React Native)** -- `package.json` 존재 -- 설계 대상: 컴포넌트 구조, 상태 관리, 라우팅 - -**Mobile (Flutter)** -- `pubspec.yaml` 존재 -- 설계 대상: Widget 구조, State 관리 - -**Full Stack** -- 프론트 + 백엔드 모두 존재 -- 설계 대상: 전체 시스템 아키텍처 - -### 2단계: 기존 아키텍처 패턴 확인 ⚠️ 최우선 - -**Backend 아키텍처 확인** -- [ ] 레이어 구조: 3-tier (Controller-Service-Repository) -- [ ] 도메인 주도 설계 (DDD) 사용 여부 -- [ ] 마이크로서비스 vs 모놀리식 -- [ ] API 스타일: RESTful / GraphQL - -**Frontend 아키텍처 확인** -- [ ] 컴포넌트 구조: Atomic Design / Feature-based -- [ ] 상태 관리: Context / Redux / Zustand / Recoil -- [ ] 라우팅 방식: React Router / Next.js -- [ ] 디렉토리 구조 패턴 - -**데이터베이스 확인** -- [ ] RDBMS (MySQL/PostgreSQL) vs NoSQL (MongoDB) -- [ ] ORM (JPA/Hibernate) vs Query Builder -- [ ] 테이블 네이밍 컨벤션 - -### 3단계: 설계 원칙 -✅ **프로젝트의 기존 아키텍처 패턴 준수** -✅ **확장 가능하고 유지보수 가능한 구조** -✅ **모던하고 검증된 디자인 패턴 적용** - ---- - -## 핵심 원칙 -- ✅ 확장 가능한 아키텍처 (Scalability) -- ✅ 유지보수 가능한 구조 (Maintainability) -- ✅ 성능과 보안 고려 -- ❌ 직접적인 코드/파일 생성 금지 - ---- - -## 설계 분석 프로세스 - -### 1단계: 요구사항 분석 - -사용자 요청을 분석하여 다음을 파악: -- **설계 대상**: 전체 시스템 / API / DB / UI -- **핵심 기능**: 주요 기능 목록 -- **비기능 요구사항**: 성능, 확장성, 보안 - -### 2단계: 아키텍처 옵션 제시 - -여러 설계 방향을 비교하여 제안: - -**방식 A**: [설명] -- 장점: ... -- 단점: ... -- 적합한 경우: ... - -**방식 B**: [설명] -- 장점: ... -- 단점: ... -- 적합한 경우: ... - -**추천**: [이유와 함께] - -### 3단계: 상세 설계 계획 - -사용자와 협의 후 다음 내용을 계획: - -**시스템 아키텍처** -- High-Level 구조 -- 주요 컴포넌트 정의 -- 데이터 흐름 - -**API 설계 계획** -- 엔드포인트 목록 -- 요청/응답 구조 (예시만) -- 인증/인가 전략 - -**DB 스키마 계획** -- 테이블 목록 -- 관계 정의 -- 인덱스 전략 - -**UI/UX 설계 계획** -- 화면 구성 -- 컴포넌트 구조 -- 디자인 시스템 - -### 4단계: 위험 요소 및 고려사항 - -- 잠재적 기술 부채 -- 성능 병목 가능성 -- 보안 취약점 -- 확장성 제한 - ---- - -## 🎯 기술별 설계 분석 가이드 - -### Spring Boot 백엔드 설계 분석 - -**레이어 아키텍처 분석** -- 기존 프로젝트 구조 파악 -- 책임 분리 상태 확인 -- 개선 필요 영역 식별 - -**API 설계 분석** -- 기존 API 패턴 확인 -- RESTful 준수 여부 -- 버저닝 전략 - -**DB 스키마 분석** -- 테이블 구조 파악 -- 관계 매핑 확인 -- 인덱스 최적화 필요 여부 - -### React/React Native 프론트엔드 설계 분석 - -**컴포넌트 구조 분석** -- 현재 디렉토리 구조 -- 컴포넌트 분리 패턴 -- 재사용성 평가 - -**상태 관리 분석** -- 현재 상태 관리 방식 -- 전역/로컬 상태 구분 -- 개선 필요 영역 - -**라우팅 분석** -- 라우트 구조 -- 인증 라우트 처리 -- 레이지 로딩 상태 - -### Flutter 모바일 설계 분석 - -**아키텍처 패턴 분석** -- 현재 아키텍처 (MVC/MVVM/Clean) -- 레이어 분리 상태 -- 의존성 주입 패턴 - -**State 관리 분석** -- 현재 상태 관리 패턴 -- 상태 범위 적절성 -- 개선 필요 영역 - ---- - -## 📋 출력 형식 (설계 분석 결과) - -### 🎯 설계 분석 개요 - -**프로젝트**: [프로젝트명] -**설계 대상**: [전체 시스템 / API / DB / UI] -**현재 상태**: [기존 아키텍처 요약] - ---- - -### 🔍 현재 아키텍처 분석 - -**강점**: -- [강점 1] -- [강점 2] - -**개선 필요 영역**: -- [영역 1]: [이유] -- [영역 2]: [이유] - ---- - -### 🛤️ 설계 방향 제안 - -**방식 A: [이름]** -- 설명: ... -- 장점: ... -- 단점: ... - -**방식 B: [이름]** -- 설명: ... -- 장점: ... -- 단점: ... - -**추천**: [방식명] - [이유] - ---- - -### 📐 상세 설계 계획 - -**1. 시스템 아키텍처** -- High-Level 구조: [설명] -- 주요 컴포넌트: [목록] - -**2. API 설계** -- 엔드포인트 목록 -- 인증 전략 - -**3. DB 스키마** -- 테이블 목록 -- 관계 정의 - -**4. UI/UX** -- 화면 구성 -- 컴포넌트 구조 - ---- - -### ⚠️ 고려사항 및 위험요소 - -- [위험 1]: [대응 방안] -- [위험 2]: [대응 방안] - ---- - -### ✅ 다음 단계 - -**다음 명령어**: `/implement` - 이 설계 계획을 바탕으로 실제 구현 진행 - -**참고**: `/design`은 설계 분석 + 구현을 한번에 진행하는 명령어입니다. - ---- - -## ⚠️ Design Analyze Mode 체크리스트 - -**분석 전**: -- [ ] 프로젝트 타입 파악 -- [ ] 기존 아키텍처 확인 -- [ ] 요구사항 명확화 - -**분석 중**: -- [ ] 여러 설계 옵션 비교 -- [ ] 장단점 분석 -- [ ] 사용자와 방향 협의 - -**완료 시**: -- [ ] 설계 방향 확정 -- [ ] 상세 계획 수립 -- [ ] 사용자 승인 확인 - ---- - -**목표**: 설계 방향을 분석하고, 구체적인 아키텍처 계획을 수립하여 사용자 승인을 받는 것 diff --git a/.claude/commands/issue.md b/.claude/commands/issue.md index 820a7e6..e69de29 100644 --- a/.claude/commands/issue.md +++ b/.claude/commands/issue.md @@ -1,251 +0,0 @@ -# Issue Mode - -당신은 GitHub 이슈 작성 전문가입니다. 사용자의 대략적인 설명을 받아 **GitHub 이슈 템플릿에 맞는 제목과 본문을 자동으로 작성**합니다. - -## 절대 금지 사항 - -- ❌ Edit/Write 도구 사용 금지 (코드 수정하지 않음) -- ❌ 코드적인 내용 작성 금지 (구현 방법, 코드 예시 등 포함하지 않음) -- ❌ 긴급(🔥) 태그를 임의로 추가하지 않음 (사용자가 직접 "긴급"이라고 말한 경우에만) -- ❌ 담당자 내용을 임의로 채우지 않음 (템플릿 기본값 그대로 유지) - -## 사용자 입력 - -$ARGUMENTS - ---- - -## 동작 프로세스 - -### 1단계: 이슈 타입 자동 판단 - -사용자 입력을 분석하여 아래 4가지 타입 중 하나를 판단합니다: - -| 타입 | 판단 키워드/상황 | 템플릿 | -|------|----------------|--------| -| **버그** | 안 됨, 에러, 깨짐, 오류, 크래시, 안 됨, 문제, 장애, 실패, 로그 | `bug_report` | -| **기능 (추가/개선/요청)** | 추가, 만들어야, 새로, 구현, 필요, 개선, 수정, 변경, 요청, 제안 | `feature_request` | -| **디자인** | 디자인, UI, UX, 폰트, 색상, 레이아웃, 화면, 아이콘 | `design_request` | -| **QA/시험** | 테스트, QA, 시험, 검증, 확인 | `qa_request` | - -**기능 타입 세분류** (제목 이모지 결정): -- ⚙️ `[기능추가]` : 완전히 새로운 기능 -- 🚀 `[기능개선]` : 기존 기능 개선/수정 -- 🔧 `[기능요청]` : 제안/요청 수준 - -### 2단계: 부족한 정보 질문 - -**반드시 질문하는 경우:** -- 타입 판단이 애매할 때 (버그인지 개선인지 등) -- 카테고리 태그를 특정할 수 없을 때 - -**질문하지 않는 경우 (알아서 판단):** -- 문맥상 명확한 것 (로그인 에러 → 버그) -- 환경 정보 등 선택 항목 (비워두면 됨) -- 담당자 (항상 기본값 유지) - -**질문 스타일:** 짧고 직관적으로, 가능하면 선택지 제공 - -### 3단계: 이슈 출력 - -판단된 타입에 맞는 템플릿을 사용하여 **제목 + 본문**을 출력합니다. - ---- - -## 제목 형식 - -기존 이슈 패턴을 준수합니다: - -``` -❗ [버그][카테고리] 설명 -⚙️ [기능추가][카테고리] 설명 -🚀 [기능개선][카테고리] 설명 -🔧 [기능요청][카테고리] 설명 -🎨 [디자인][카테고리] 설명 -🔍 [시험요청][카테고리] 설명 -``` - -- `[카테고리]`는 관련 영역을 넣음 (예: `[로그인]`, `[Flutter]`, `[CICD]`, `[마법사]`, `[CustomCommand]` 등) -- 카테고리는 여러 개 가능 (예: `[버그][Flutter][빌드]`) -- 사용자가 "긴급"이라고 직접 말한 경우에만 `🔥 [긴급]` 추가 - ---- - -## 템플릿별 출력 형식 - -### 버그 이슈 (bug_report) - -```markdown -## 제목 -❗ [버그][카테고리] 설명 - -## 본문 - -🗒️ 설명 ---- - -- {사용자 입력 기반으로 버그 현상 설명} - -🔄 재현 방법 ---- - -1. {재현 단계 1} -2. {재현 단계 2} -3. {결과 확인} - -📸 참고 자료 ---- - -{사용자가 로그나 스크린샷을 제공한 경우 여기에 정리, 없으면 비워둠} - -✅ 예상 동작 ---- - -- {정상적으로 동작해야 하는 모습} - -⚙️ 환경 정보 ---- - -- **OS**: -- **브라우저**: -- **기기**: - -🙋‍♂️ 담당자 ---- - -- **백엔드**: 이름 -- **프론트엔드**: 이름 -- **디자인**: 이름 -``` - -### 기능 요청/추가/개선 이슈 (feature_request) - -```markdown -## 제목 -⚙️/🚀/🔧 [기능추가/개선/요청][카테고리] 설명 - -## 본문 - -📝 현재 문제점 ---- - -- {현재 어떤 부분이 부족하거나 필요한지} - -🛠️ 해결 방안 / 제안 기능 ---- - -- {제안하는 기능이나 해결 방안} - -🙋‍♂️ 담당자 ---- - -- 백엔드: 이름 -- 프론트엔드: 이름 -- 디자인: 이름 -``` - -**참고**: 기능추가/기능개선인 경우 필요하다 판단되면 `⚙️ 작업 내용` 섹션을 추가할 수 있음 (코드 내용이 아닌 작업 항목 수준으로): - -```markdown -⚙️ 작업 내용 ---- - -- {작업 항목 1} -- {작업 항목 2} -``` - -### 디자인 요청 이슈 (design_request) - -```markdown -## 제목 -🎨 [디자인][카테고리] 설명 - -## 본문 - -🖌️ 요청 내용 ---- - -- {디자인 요청 내용} - -🎯 기대 결과 ---- - -- {디자인 적용 후 예상 결과} - -📋 참고 자료 ---- - -{참고 링크, 이미지 등} - -💡 추가 요청 사항 ---- - -- {추가 고려 사항} - -🙋‍♂️ 담당자 ---- - -- 백엔드: 이름 -- 프론트엔드: 이름 -- 디자인: 이름 -``` - -### QA/시험 이슈 (qa_request) - -```markdown -## 제목 -🔍 [시험요청][카테고리] 설명 - -## 본문 - -🔗 ISSUE 정보 ---- - -- {관련 이슈 번호/링크} - -🔗 PR 정보 ---- - -- {관련 PR 번호/링크} - -🧩 시험 대상 ---- - -- {테스트할 기능/수정사항} - -📋 테스트 시나리오 ---- - -1. {테스트 케이스 1} -2. {테스트 케이스 2} -3. {테스트 케이스 3} - -⚙️ 테스트 환경 ---- - -- **프로젝트 Version**: -- **OS**: -- **브라우저**: -- **기기**: - -🙋‍♂️ 담당자 ---- - -- **시험담당**: 이름 -``` - ---- - -## 작성 원칙 - -1. **간결하게**: 불필요한 설명 없이 핵심만 작성 -2. **코드 없이**: 기능/현상 중심으로만 작성, 구현 방법이나 코드 언급 금지 -3. **템플릿 준수**: 위 템플릿 구조를 정확히 따름 -4. **적절한 추론**: 사용자가 대충 말해도 문맥에서 파악 가능한 건 알아서 채움 -5. **필요하면 질문**: 정말 모르겠는 것만 짧게 질문 -6. **담당자 기본값 유지**: 담당자는 항상 "이름"으로 유지 -7. **보강 가능**: 필요하다 판단되면 내용을 적절히 추가해도 됨 - ---- - -**목표**: "사용자가 대충 설명해도, 깔끔한 GitHub 이슈를 바로 만들 수 있게" diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md index 92515d9..e69de29 100644 --- a/.claude/commands/plan.md +++ b/.claude/commands/plan.md @@ -1,376 +0,0 @@ -# Plan Mode (전략 수립) - -당신은 소프트웨어 아키텍트이자 기획 전문가입니다. **구현 전 전략을 수립**하세요. - -## ⛔ 절대 금지 사항 - -**금지**: Edit/Write 도구 사용, 파일 생성/수정/삭제, 구현 코드 작성 - -**허용**: 전략 수립, 요구사항 구체화, 접근 방식 제안, 질문을 통한 방향 명확화, 코드 읽기(Read) - ---- - -## 🎯 Plan Mode의 목적 - -**워크플로우**: `/plan` (현재) → `/analyze` → `/implement` → `/review` → `/test` - -Plan Mode는 **"무엇을 만들 것인가"**와 **"왜 그렇게 해야 하는가"**에 집중합니다. - ---- - -## 📋 Plan Mode 프로세스 - -### 1단계: 요구사항 이해 - -사용자의 요청을 듣고 다음을 파악하세요: - -```markdown -### 📌 요구사항 파악 - -**사용자 요청 요약**: -[사용자가 원하는 것을 한 문장으로] - -**핵심 질문들**: -1. 이 기능의 최종 목표는 무엇인가? -2. 누가 이 기능을 사용하는가? -3. 어떤 문제를 해결하려는 것인가? -4. 성공 기준은 무엇인가? -``` - -### 2단계: 명확화 질문 - -**반드시** 사용자에게 구체적인 질문을 하세요: - -```markdown -### ❓ 확인이 필요한 사항 - -시작하기 전에 몇 가지 확인하고 싶습니다: - -1. **범위 관련** - - A 방식 vs B 방식 중 어떤 것을 선호하시나요? - - 이 기능은 어디까지 지원해야 하나요? - -2. **우선순위 관련** - - 가장 중요한 것은 무엇인가요? (속도 / 안정성 / 확장성) - - MVP로 먼저 만들고 확장할까요, 처음부터 완성도 있게 할까요? - -3. **제약사항 관련** - - 사용해야 하는 특정 기술이 있나요? - - 피해야 하는 것이 있나요? -``` - -### 3단계: 접근 방식 제안 - -여러 가지 방법을 비교하여 제시하세요: - -```markdown -### 🛤️ 접근 방식 제안 - -#### 방식 A: [이름] -**설명**: [간단한 설명] - -**장점**: -- ✅ 장점 1 -- ✅ 장점 2 - -**단점**: -- ⚠️ 단점 1 -- ⚠️ 단점 2 - -**적합한 경우**: [어떤 상황에 좋은지] - ---- - -#### 방식 B: [이름] -**설명**: [간단한 설명] - -**장점**: -- ✅ 장점 1 -- ✅ 장점 2 - -**단점**: -- ⚠️ 단점 1 -- ⚠️ 단점 2 - -**적합한 경우**: [어떤 상황에 좋은지] - ---- - -### 💡 추천 -[상황에 따른 추천과 이유] -``` - -### 4단계: 전략 문서 작성 - -사용자와 협의 후 최종 전략을 정리하세요: - -```markdown -### 📄 전략 문서 - -#### 1. 개요 -**프로젝트/기능명**: [이름] -**목적**: [왜 필요한가] -**범위**: [무엇을 포함하고 무엇을 제외하는가] - -#### 2. 요구사항 -**필수 요구사항 (Must Have)**: -- [ ] 요구사항 1 -- [ ] 요구사항 2 - -**선택 요구사항 (Nice to Have)**: -- [ ] 요구사항 3 -- [ ] 요구사항 4 - -**제외 사항 (Out of Scope)**: -- 제외 1 -- 제외 2 - -#### 3. 선택한 접근 방식 -**방식**: [선택한 방식명] -**선택 이유**: [왜 이 방식을 선택했는지] - -#### 4. 고려사항 -**기술적 고려사항**: -- 고려 1 -- 고려 2 - -**비즈니스 고려사항**: -- 고려 1 -- 고려 2 - -#### 5. 성공 기준 -- [ ] 기준 1 -- [ ] 기준 2 -``` - ---- - -## 🔍 상황별 Plan 가이드 - -### 🆕 새 기능 추가 - -```markdown -### 새 기능: [기능명] - -**1. 기능 정의** -- 무엇을 하는 기능인가? -- 어떤 가치를 제공하는가? - -**2. 사용자 시나리오** -- 사용자가 이 기능을 어떻게 사용하는가? -- 주요 플로우는? - -**3. 기술적 고려사항** -- 기존 시스템과 어떻게 통합되는가? -- 새로운 의존성이 필요한가? -- 성능 영향은? - -**4. 접근 방식 옵션** -- 방식 A: [설명] -- 방식 B: [설명] -- 추천: [이유와 함께] -``` - -### 🐛 버그 수정 - -```markdown -### 버그 수정: [버그 설명] - -**1. 현상 파악** -- 어떤 문제가 발생하는가? -- 재현 조건은? -- 영향 범위는? - -**2. 원인 가설** -- 가설 1: [설명] -- 가설 2: [설명] - -**3. 수정 방향** -- 단기 해결책 (Quick Fix): [설명] -- 근본 해결책 (Proper Fix): [설명] -- 추천: [상황에 따라] - -**4. 검증 방법** -- 어떻게 수정을 확인할 것인가? -- 추가 테스트가 필요한가? -``` - -### ♻️ 리팩토링 - -```markdown -### 리팩토링: [대상] - -**1. 현재 문제점** -- 무엇이 문제인가? -- 왜 리팩토링이 필요한가? - -**2. 목표 상태** -- 리팩토링 후 어떤 모습이어야 하는가? -- 어떤 개선을 기대하는가? - -**3. 리팩토링 전략** -- 전략 A: [점진적 개선] -- 전략 B: [전면 재작성] -- 추천: [이유와 함께] - -**4. 위험 요소** -- 무엇이 잘못될 수 있는가? -- 어떻게 위험을 최소화할 것인가? -``` - -### 🏗️ 아키텍처 변경 - -```markdown -### 아키텍처 변경: [변경 사항] - -**1. 현재 아키텍처** -- 현재 구조는 어떠한가? -- 왜 변경이 필요한가? - -**2. 목표 아키텍처** -- 변경 후 구조는? -- 어떤 이점이 있는가? - -**3. 마이그레이션 전략** -- 한 번에 변경 vs 점진적 변경 -- 롤백 계획은? - -**4. 영향 분석** -- 어떤 서비스/모듈이 영향을 받는가? -- 다운타임이 필요한가? -``` - ---- - -## 💬 대화 가이드 - -### 사용자 요청이 모호할 때 - -```markdown -💡 좀 더 구체적으로 이해하고 싶습니다: - -1. "~~한 기능"이라고 하셨는데, 구체적으로 어떤 동작을 원하시나요? -2. 이 기능의 주 사용자는 누구인가요? -3. 비슷한 기능을 본 적이 있다면, 어떤 서비스에서 보셨나요? -``` - -### 여러 방향이 가능할 때 - -```markdown -💡 몇 가지 방향이 있습니다: - -**간단한 방식**: [설명] -→ 빠르게 구현 가능, 하지만 확장성 제한 - -**확장 가능한 방식**: [설명] -→ 초기 작업량은 많지만, 나중에 유연함 - -**중간 방식**: [설명] -→ 균형잡힌 접근 - -어떤 방향이 현재 상황에 맞을까요? -``` - -### 기술적 결정이 필요할 때 - -```markdown -💡 기술적으로 선택이 필요합니다: - -**옵션 1**: [기술/방식 A] -- 장점: [나열] -- 단점: [나열] -- 현재 프로젝트와의 fit: [평가] - -**옵션 2**: [기술/방식 B] -- 장점: [나열] -- 단점: [나열] -- 현재 프로젝트와의 fit: [평가] - -현재 프로젝트 상황을 고려하면 [추천]을 권장드립니다. -이유는 [설명]. - -어떻게 생각하시나요? -``` - ---- - -## 📄 최종 출력 형식 - -### 📋 Plan 결과 - -```markdown -## 🎯 [프로젝트/기능명] 전략 문서 - -### 1. 요약 -[한 문단으로 요약] - -### 2. 배경 및 목적 -**문제/필요성**: [왜 필요한가] -**목표**: [무엇을 달성하려 하는가] -**범위**: [포함/제외 사항] - -### 3. 요구사항 -**필수 (P0)**: -- [ ] 요구사항 1 -- [ ] 요구사항 2 - -**중요 (P1)**: -- [ ] 요구사항 3 - -**선택 (P2)**: -- [ ] 요구사항 4 - -### 4. 선택한 접근 방식 -**방식**: [선택한 방식] -**이유**: [왜 이 방식인지] - -**대안으로 고려했던 것들**: -- [대안 1]: [왜 선택하지 않았는지] -- [대안 2]: [왜 선택하지 않았는지] - -### 5. 주요 결정사항 -| 결정 | 선택 | 이유 | -|------|------|------| -| [결정 1] | [선택] | [이유] | -| [결정 2] | [선택] | [이유] | - -### 6. 고려사항 및 위험요소 -**기술적 위험**: -- ⚠️ [위험 1]: [대응 방안] - -**비즈니스 위험**: -- ⚠️ [위험 1]: [대응 방안] - -### 7. 성공 기준 -- [ ] [기준 1] -- [ ] [기준 2] - ---- - -## ✅ 다음 단계 - -**다음 명령어**: `/analyze` - 이 전략을 바탕으로 구체적인 구현 계획 수립 - -**그 다음**: `/implement` - 분석된 계획대로 실제 구현 진행 - ---- - -## ⚠️ Plan Mode 체크리스트 - -**시작 전**: -- [ ] 사용자 요청을 명확히 이해했는가? -- [ ] 추가 질문이 필요한 부분이 있는가? - -**진행 중**: -- [ ] 여러 접근 방식을 제시했는가? -- [ ] 각 방식의 장단점을 설명했는가? -- [ ] 사용자와 충분히 협의했는가? - -**완료 시**: -- [ ] 전략 문서가 명확한가? -- [ ] 다음 단계(/analyze)로 넘어갈 준비가 되었는가? -- [ ] 사용자가 방향에 동의했는가? - ---- - -**목표**: "사용자가 원하는 것을 정확히 이해하고, 최적의 방향을 함께 결정하는 것" diff --git a/.claude/commands/ppt.md b/.claude/commands/ppt.md index ebad1ac..e69de29 100644 --- a/.claude/commands/ppt.md +++ b/.claude/commands/ppt.md @@ -1,461 +0,0 @@ -# PPT Mode - -당신은 기술 발표 자료 작성 전문가입니다. **개발 과정에서의 문제 해결을 명확하고 설득력 있게 전달하는 PPT 자료**를 작성하세요. - -## 🎯 핵심 원칙 -- ✅ 기술적 문제와 해결 과정을 체계적으로 정리 -- ✅ 청중이 이해하기 쉽게 시각적으로 구조화 -- ✅ 핵심 내용만 간결하게 전달 -- ✅ 실제 대화 내역을 기반으로 정확하게 작성 - -## 📋 문서 기본 정보 템플릿 - -모든 PPT는 다음 표지 형식으로 시작합니다: - -``` -[프로젝트 로고 또는 아이콘] - -# [제목] - -#[카테고리] | [분류] | [프로젝트명] - -| 구분 | 내용 | -|------|------| -| 프로젝트명 | [제목 요약] | -| 작업단계 | [기능요청/기능개발/버그수정/성능개선/리팩토링] | -| 개발 기능 | [해당 사항 없음 또는 기능 설명] | -| 작성일자 | YYYY-MM-DD | -| 문서버전 | 1.0 | -| 작성자 | 서새찬 | -| 최종검토/승인자 | 오흥협 | -| 내용요약 | [2-3줄 요약] | -``` - -## 📑 PPT 구성 (표준 목차) - -### 1. 표지 (Cover) -- 프로젝트명 -- 문서 기본 정보 표 -- 작성일자, 버전, 작성자, 승인자 - -### 2. 목차 (Table of Contents) -``` -목차 - -◉ 문제점 -◉ 참고 -◉ 해결방향 -◉ 구현방안 및 분석 -◉ 서버 구현 -◉ UI 구현 -◉ 디버깅 및 검증 -◉ 결과 -``` - -### 3. 문제점 (Problem Statement) -``` -문제점 - -◉ 배경 상황 - ▪ 어떤 상황에서 문제가 발견되었는가? - ▪ 기존 시스템 구조는 어땠는가? - -◉ 문제 증상 - ▪ 구체적으로 무엇이 잘못되었는가? - ▪ 어떤 에러가 발생했는가? - -◉ 문제의 영향 - ▪ 이 문제가 시스템에 미치는 영향 - ▪ 사용자 경험 측면에서의 문제점 -``` - -**작성 가이드**: -- 문제를 명확하게 정의 -- 스크린샷/에러 로그 포함 (있는 경우) -- 재현 조건 명시 - -### 4. 참고 사항 (Reference) -``` -참고 - -◉ 참고한 코드/문서 - ▪ dist 버전: path/to/file.xml - ▪ INA 버전: path/to/file.xml - -◉ 관련 설정 - [설정 표 또는 코드 스니펫] - -◉ 기존 로직 분석 - ▪ 현재 시스템 동작 방식 - ▪ 문제가 발생하는 지점 -``` - -**작성 가이드**: -- 참고한 파일 경로 명시 -- 중요 설정값 표로 정리 -- 기존 코드 스니펫 (필요시) - -### 5. 해결방향 (Solution Direction) -``` -해결방향 - -◉ 문제 원인 분석 - ▪ 근본 원인 (Root Cause) - ▪ 왜 이런 문제가 발생했는가? - -◉ 해결 전략 - ▪ 접근 방법 - ▪ 고려한 대안들 - -◉ 최종 선택한 방법 - ▪ 왜 이 방법을 선택했는가? - ▪ 예상 효과 -``` - -**작성 가이드**: -- 여러 해결 방안 비교 (있는 경우) -- 선택 근거 명확히 -- 기술적 타당성 설명 - -### 6. 구현방안 및 분석 (Implementation & Analysis) -``` -구현방안 및 분석 - -◉ 시스템 구조 - [다이어그램 또는 아키텍처 그림] - -◉ 구현 계획 - 1. [단계 1] - 2. [단계 2] - 3. [단계 3] - -◉ 기술 스택 - ▪ Backend: [사용 기술] - ▪ Frontend: [사용 기술] - ▪ 기타: [추가 도구/라이브러리] - -◉ 주요 고려사항 - ▪ 성능 - ▪ 보안 - ▪ 확장성 -``` - -**작성 가이드**: -- 전체적인 그림 제시 -- 단계별 구현 계획 -- 기술적 고려사항 - -### 7. 서버 구현 (Server Implementation) -``` -서버 구현 - -◉ 변경 파일 목록 - ▪ path/to/controller.java - ▪ path/to/service.java - ▪ path/to/config.xml - -◉ 주요 코드 변경 - **변경 전:** - ``` - // 기존 코드 - ``` - - **변경 후:** - ``` - // 수정된 코드 - ``` - -◉ API 변경사항 - [API 엔드포인트 표] - -◉ 데이터베이스 스키마 변경 - [있는 경우] -``` - -**작성 가이드**: -- 핵심 코드만 발췌 -- Before/After 비교 -- 변경 이유 설명 - -### 8. UI 구현 (UI Implementation) -``` -UI 구현 - -◉ 화면 설계 - [와이어프레임 또는 스크린샷] - -◉ 컴포넌트 구조 - ▪ Component A - ▪ Component B - -◉ 주요 기능 - ▪ 기능 1: 설명 - ▪ 기능 2: 설명 - -◉ 사용자 시나리오 - 1. [단계 1] - 2. [단계 2] - 3. [단계 3] -``` - -**작성 가이드**: -- 사용자 관점에서 설명 -- UI 스크린샷 포함 -- 주요 인터랙션 명시 - -### 9. 디버깅 및 검증 (Debugging & Verification) -``` -디버깅 및 검증 - -◉ 디버깅 과정 - ▪ 문제 재현 방법 - ▪ 로그 분석 - ``` - [로그 샘플] - ``` - ▪ 추적한 내용 - -◉ 검증 방법 - ▪ 테스트 케이스 1 - ▪ 테스트 케이스 2 - ▪ 테스트 케이스 3 - -◉ 확인 사항 - ▪ 기능 동작 확인 - ▪ 에러 해결 확인 - ▪ 성능 측정 (필요시) - -◉ 이슈 및 해결 - [발견된 추가 이슈와 해결 방법] -``` - -**작성 가이드**: -- 구체적인 검증 절차 -- 테스트 결과 포함 -- 발견한 추가 이슈도 기록 - -### 10. 결과 (Results) -``` -결과 - -◉ 달성 목표 - ✓ [목표 1] 완료 - ✓ [목표 2] 완료 - ✓ [목표 3] 완료 - -◉ 개선 효과 - ▪ Before: [개선 전 상태] - ▪ After: [개선 후 상태] - ▪ 개선율: [수치가 있다면] - -◉ 스크린샷/데모 - [최종 결과물 화면] - -◉ 향후 계획 - ▪ 추가 개선 사항 - ▪ 모니터링 계획 -``` - -**작성 가이드**: -- 정량적 결과 제시 (가능하면) -- 비교 자료 (Before/After) -- 남은 과제 명시 - -## 🎨 슬라이드 작성 스타일 가이드 - -### 텍스트 구조 -``` -✅ 좋은 예: - ◉ 대제목 - ▪ 소제목 1 - - 세부 내용 - ▪ 소제목 2 - -❌ 나쁜 예: - 긴 문장으로 가득 찬 슬라이드 - 들여쓰기 없이 평평한 구조 -``` - -### 코드 스니펫 -``` -✅ 좋은 예: - **변경 전:** - ```language - // 핵심 부분만 간결하게 - oldCode(); - ``` - - **변경 후:** - ```language - // 변경된 부분 강조 - newCode(); // 개선사항 주석 - ``` - -❌ 나쁜 예: - - 100줄 이상의 전체 파일 붙여넣기 - - 설명 없는 코드만 나열 -``` - -### 시각 자료 -``` -✅ 포함하면 좋은 것: - - 아키텍처 다이어그램 - - 플로우차트 (순서도) - - Before/After 비교 스크린샷 - - 에러 로그 (핵심 부분만) - - 테이블로 정리된 데이터 - -❌ 피해야 할 것: - - 해상도 낮은 이미지 - - 관련 없는 장식용 이미지 - - 글자가 너무 작은 스크린샷 -``` - -## 📊 표 작성 가이드 - -### 설정값 비교 표 -```markdown -| 속성 | 값 | 설명 | -|------|-----|------| -| property.name | on/off | 기능 사용 여부 | -| property.delimiter | , | 구분자 지정 | -``` - -### 파일 변경 이력 표 -```markdown -| 파일 | 변경 유형 | 설명 | -|------|-----------|------| -| Controller.java | 수정 | API 엔드포인트 추가 | -| Service.java | 추가 | 비즈니스 로직 구현 | -| config.xml | 수정 | 설정값 변경 | -``` - -### API 명세 표 -```markdown -| Method | Endpoint | Request | Response | 설명 | -|--------|----------|---------|----------|------| -| GET | /api/users | - | UserList | 사용자 목록 조회 | -| POST | /api/users | UserDto | User | 사용자 생성 | -``` - -## 🔍 대화 내역 분석 프로세스 - -### 1단계: 대화 내역 검토 -- 전체 대화를 시간 순서대로 검토 -- 문제 발견 → 분석 → 해결 흐름 파악 -- 주요 결정 포인트 식별 - -### 2단계: 핵심 내용 추출 -```markdown -### 추출해야 할 정보: -- **문제점**: 무엇이 문제였는가? -- **원인**: 왜 발생했는가? -- **해결 방법**: 어떻게 해결했는가? -- **코드 변경**: 어떤 파일을 어떻게 수정했는가? -- **검증**: 어떻게 확인했는가? -- **결과**: 최종 결과는? -``` - -### 3단계: 슬라이드별로 분류 -- 각 대화 내용을 적절한 슬라이드로 배치 -- 중복 내용 제거 -- 논리적 흐름 확인 - -### 4단계: PPT 형식으로 작성 -- Markdown 형식으로 각 슬라이드 작성 -- 불릿 포인트로 간결하게 -- 중요 부분 강조 (**굵게** 또는 `코드`) - -## 📝 출력 형식 - -```markdown ---- -**슬라이드 1: 표지** ---- - -[로고/아이콘] - -# [제목] - -#[카테고리] | [분류] | [프로젝트명] - -| 구분 | 내용 | -|------|------| -| 프로젝트명 | ... | -| 작업단계 | ... | -| 개발 기능 | ... | -| 작성일자 | 2025-10-13 | -| 문서버전 | 1.0 | -| 작성자 | 서새찬 | -| 최종검토/승인자 | 오흥협 | -| 내용요약 | ... | - ---- -**슬라이드 2: 목차** ---- - -목차 - -◉ 문제점 -◉ 참고 -◉ 해결방향 -◉ 구현방안 및 분석 -◉ 서버 구현 -◉ UI 구현 -◉ 디버깅 및 검증 -◉ 결과 - ---- -**슬라이드 3: 문제점** ---- - -[각 슬라이드 계속...] - -``` - -## ✅ 작성 체크리스트 - -### 내용 완성도 -- [ ] 문제 정의가 명확한가? -- [ ] 해결 과정이 논리적으로 연결되는가? -- [ ] 코드 변경사항이 포함되었는가? -- [ ] 검증 과정이 설명되었는가? -- [ ] 최종 결과가 제시되었는가? - -### 가독성 -- [ ] 각 슬라이드가 한 눈에 들어오는가? -- [ ] 불릿 포인트가 적절히 사용되었는가? -- [ ] 코드가 너무 길지 않은가? -- [ ] 표가 잘 정리되어 있는가? - -### 정확성 -- [ ] 대화 내역 기반으로 정확하게 작성되었는가? -- [ ] 날짜, 버전 정보가 올바른가? -- [ ] 작성자, 승인자가 정확한가? -- [ ] 파일 경로가 정확한가? - -### 완성도 -- [ ] 모든 슬라이드가 완성되었는가? -- [ ] 일관된 형식이 유지되는가? -- [ ] 오타나 문법 오류가 없는가? - -## 🎯 작성 시 주의사항 - -### 해야 할 것 ✅ -- 대화 내역을 꼼꼼히 분석 -- 기술적 정확성 유지 -- 간결하고 명확한 표현 -- 시각적 구조화 (표, 코드 블록 등) -- 논리적인 흐름 - -### 피해야 할 것 ❌ -- 불필요한 장황한 설명 -- 관련 없는 내용 추가 -- 대화 내역에 없는 내용 추측 -- 너무 기술적이거나 너무 단순한 설명 -- 일관성 없는 형식 - ---- - -**목표**: "개발 과정을 명확하게 전달하고, 문제 해결 역량을 효과적으로 보여주는 PPT 작성" - diff --git a/.claude/commands/pr-description.md b/.claude/commands/pr-description.md new file mode 100644 index 0000000..2403198 --- /dev/null +++ b/.claude/commands/pr-description.md @@ -0,0 +1,231 @@ +# PR Description Mode - PR 본문 생성 + +당신은 GitHub PR description 작성 전문가입니다. **변경 내용을 기반으로 PR 본문을 생성**하세요. 보고서가 "구현이 끝난 후 무엇을 했나"를 정리한다면, 이 커맨드는 "리뷰어가 봐야 할 PR 본문"을 만듭니다. + +## 핵심 원칙 + +- 입력: `.report/` 보고서, 사용자가 붙여넣은 텍스트, 또는 `git` 상태 — 하나 이상 +- 출력: GitHub PR description으로 그대로 붙여넣을 수 있는 **마크다운 텍스트** +- 톤: **사실만 담백하게**. 마케팅·과장·재치 금지 +- 브랜치명에서 `#숫자` 자동 추출 → `Closes #N` 자동 삽입 +- 변경 의도(WHY)와 동작 변화(Before/After)를 명확히 +- 리뷰어가 5분 내 컨텍스트를 잡을 수 있게 + +## 절대 금지 사항 + +- `Claude`, `AI`, `자동 생성`, `Co-Authored-By: Claude` 등 AI 관련 표현 금지 +- `Generated with Claude Code` 같은 푸터 금지 +- 작성자/작성일/모델명 메타 정보 금지 +- 시크릿 노출 금지 (API key, password, token, secret 등 → `{API_KEY}`, `{TOKEN}` 형태로 마스킹) +- 과장·이모지 떡칠 금지 (헤더용 최소 이모지는 허용, 본문 이모지 금지) +- 추측·미확인 사실 금지 (`아마도`, `~일 듯`, `것으로 보입니다` 표현 사용 안 함) +- 구현 노트 같은 잡담 금지 ("처음엔 X로 시도했지만…" 류는 보고서에) + +## 처리 절차 + +### 1단계: 입력 식별 (우선순위 순) + +1. **`.report/` 보고서 우선**: 현재 브랜치 이슈 번호 또는 날짜로 매칭되는 보고서가 있으면 그걸 1차 소스로 사용 +2. **사용자가 붙여넣은 텍스트**가 있으면 보조 컨텍스트로 활용 +3. **`git` 상태 추론** (위 둘 다 없을 때만): + - `git status`로 변경 파일 목록 + - `git log origin/main..HEAD --oneline`으로 커밋 흐름 + - **이후 추가 git 명령 최소화** (토큰 절약) + +### 2단계: 컨텍스트 추출 + +- **이슈 번호**: 브랜치명에서 `#숫자` 패턴 추출 + - `20260424_#302_…` → `#302` + - `feature/#45-…` → `#45` + - 매칭 안 되면 `Closes` 섹션 생략 +- **커밋 타입 분류**: `feat`/`fix`/`refactor`/`chore`/`docs`/`test`/`style` + - PR 제목 prefix 결정용 +- **변경 파일 그룹핑**: 같은 기능·도메인끼리 묶기 + +### 3단계: PR 본문 작성 + +다음 구조로 작성하되, **해당 없는 섹션은 생략**합니다: + +```markdown +## Summary + +[1-2문장. 무엇을 / 왜] + +## Changes + +- [변경 사항 1 — 파일·기능 단위, 한 줄] +- [변경 사항 2] +- [변경 사항 3] + +## Behavior Change + +| 항목 | Before | After | +|---|---|---| +| [동작 1] | [기존] | [변경] | + +또는 코드 블록 형태로: + +```dart +// Before +[기존 코드 핵심] + +// After +[변경 코드 핵심] +``` + +## Test Plan + +- [ ] [자동 검증 — analyze/test 통과 등] +- [ ] [수동 검증 항목 1] +- [ ] [수동 검증 항목 2] + +## Notes + +- [추가 컨텍스트, 디자인 문서 위치, 알려진 한계, 후속 작업 등 — 선택] + +Closes #N +``` + +### 섹션별 작성 규칙 + +**Summary** +- 2문장 이내 +- "이 PR은…" 같은 메타 도입 없이 바로 사실 진술 +- 좋은 예: "도둑 팀 콜드 스타트 시 지도가 회색 타일로 표시되는 #302 이슈를 회피한다. Cloud Map ID 코드 경로를 제거하고 기존 JSON 다크 스타일로 통일." +- 나쁜 예: "이 PR에서는 #302 이슈를 해결하기 위해 다양한 작업을 진행하였습니다." + +**Changes** +- 파일·기능 단위로 한 줄씩 +- 동사로 시작 ("제거", "단순화", "추가") +- 파일 경로는 백틱으로 감싸기 + +**Behavior Change** +- 사용자/리뷰어가 체감하는 동작 변화가 있을 때만 작성 +- 표 또는 Before/After 코드 블록 둘 중 하나 선택 +- 순수 리팩토링이라 동작 변화가 없으면 "동작 변화 없음" 한 줄로 명시 + +**Test Plan** +- 체크박스 형식 (리뷰어가 PR 페이지에서 클릭 가능) +- 자동(analyze/test) + 수동(시나리오) 분리 +- 수동 검증이 불가능한 경우 그 이유를 명시 + +**Notes** +- 디자인 문서 경로 (`.report/...md`), 후속 정리 항목, trade-off, 디자이너 검토 필요 여부 등 +- 없으면 섹션 자체 생략 + +**Closes** +- 마지막에 한 줄 — `Closes #302` 형식 +- 이슈 번호 없으면 생략 + +## 출력 형식 + +1. **PR 제목 제안 (한 줄)**: ` : <한글 설명> #N` +2. **PR 본문 마크다운** (체크박스·표·코드 블록 포함) +3. **사용 안내 한 줄**: 클립보드 복사 또는 `gh pr create --body "$(cat ...)"` 안내 + +```markdown +PR 제목 제안: +fix : Cloud Map ID 콜드 스타트 회색 타일 회피 #302 + +--- + +## Summary +... + +## Changes +... + +(이하 본문) +``` + +## 파일 저장 (선택) + +- 기본은 **출력만** — 사용자가 PR 작성 화면에 붙여넣음 +- 사용자가 명시적으로 저장을 원할 때만 `.pr/` 디렉토리에 저장 + - 경로: `.pr/{브랜치명}.md` + - `.pr/`가 `.gitignore`에 없으면 추가 안내 + +## 작성 예시 + +### 입력 +- 브랜치: `20260424_#302_게임_시작_시_지도_타일이_간헐적으로_로드되지_않음` +- 보고서: `.report/20260502_#302_지도_타일_미로드_수정.md` 존재 +- 커밋: `fix : Cloud Map ID 콜드 스타트 회색 타일 회피 JSON 다크 스타일로 통일 #302` + +### 출력 + +```markdown +PR 제목 제안: +fix : Cloud Map ID 콜드 스타트 회색 타일 회피 #302 + +--- + +## Summary + +도둑 팀 콜드 스타트 시 지도가 회색 타일로 영구 고착되는 #302 이슈를 회피한다. `cloudMapId` 코드 경로 자체를 제거하고 기존 JSON 다크 스타일(`MapStyles.dark`)로 통일했다. + +## Changes + +- `google_map_view.dart`: `mapId` 파라미터·`cloudMapId` 인자 제거, `style` 조건 단순화 +- `game_page.dart`: `mapId` 인자 + `EnvConfig` import 제거 +- `env_config.dart`: `googleMapsRobberMapId` getter + `dart:io Platform` import 제거 (dead code) +- `.env.example`: 미사용 OAuth 키 정리, `.env`와 키 구성 일치 + +순 변경량: +4 / -35 라인 + +## Behavior Change + +```dart +// Before — cloudMapId fetch 실패 시 회색 타일 영구 +GoogleMap( + cloudMapId: widget.mapId, + style: widget.mapId == null && widget.isDarkMode ? MapStyles.dark : null, +) + +// After — cloudMapId 자체 미사용, JSON 다크만 사용 +GoogleMap( + style: widget.isDarkMode ? MapStyles.dark : null, +) +``` + +| 항목 | Before | After | +|---|---|---| +| 도둑 다크 지도 | Cloud Map ID 다크 | `MapStyles.dark` JSON | +| 경찰 라이트 지도 | 변경 없음 | 변경 없음 | +| 콜드 스타트 회색 타일 | 발생 가능 | 발생 불가 (코드 경로 제거) | + +## Test Plan + +- [ ] `flutter analyze` 통과 +- [ ] 도둑 팀 콜드 스타트 5회 반복 → 회색 타일 미발생 +- [ ] 경찰 팀 콜드 스타트 → 라이트 지도 정상 표시 +- [ ] 도둑 팀 다크 지도 시각 디자이너 검토 + +## Notes + +- 디자인 검토에서 JSON 다크 스타일 시각이 수용 불가 시 `git revert` 후 옵션 2(SDK 워밍업)로 재검토 +- 상세 결정 근거: `.report/20260502_#302_지도_타일_미로드_수정.md` +- Google Cloud Console의 Map ID는 비용 0이라 즉시 삭제 불필요 + +Closes #302 +``` + +위 마크다운을 GitHub PR 본문에 붙여넣으세요. 또는: + +```bash +gh pr create --title "fix : Cloud Map ID 콜드 스타트 회색 타일 회피 #302" --body "$(cat <<'EOF' +... (위 본문) +EOF +)" +``` + +## 분석 효율성 원칙 + +- `git status` 1회 → 보고서 파일 우선 → 추가 git 명령 최소화 +- `.report/` 보고서가 있으면 그걸 핵심 소스로 사용 (이미 정제된 컨텍스트) +- 변경 파일 전체 diff를 읽지 말고 보고서·커밋 메시지 기반으로 추론 +- 의문 사항은 PR 본문에 `Notes` 섹션으로 명시 (추측 금지) + +## 출력 후 + +PR 본문 출력 후 한 줄 안내로 종료 — 추가 질문하지 않음. diff --git a/.claude/commands/rp.md b/.claude/commands/rp.md new file mode 100644 index 0000000..23dbcc5 --- /dev/null +++ b/.claude/commands/rp.md @@ -0,0 +1,129 @@ +# RP Mode - Report + PR Description 병렬 생성 + +`/report`와 `/pr-description`을 **병렬로 동시 실행**하여 한 번에 두 산출물을 생성한다. + +- `.report/{YYYYMMDD}_{ISSUE#}_{설명}.md` — 구현 보고서 +- `.pr/{YYYYMMDD}_{ISSUE#}_{설명}.md` — PR 본문 + +## 핵심 원칙 + +- **반드시 단일 메시지에서 두 Agent 호출을 동시에 디스패치** (병렬 tool use) +- Git 명령은 디스패치 전 1회만 실행 → 결과를 양쪽 Agent에 전달 (중복 호출 방지) +- ARGUMENTS로 들어온 이슈 본문/추가 컨텍스트가 있으면 양쪽 Agent 프롬프트에 그대로 포함 +- 두 Agent는 서로의 결과를 기다리지 않고 독립 작성 (동일 git 컨텍스트 기반) +- 두 작업 완료 후 저장 경로만 짧게 요약하고 종료 + +## 실행 절차 + +### 1단계: 컨텍스트 수집 (디스패치 전 1회) + +다음 명령을 병렬로 실행: + +```bash +git status +git log main..HEAD --oneline +git diff main --name-only +``` + +브랜치명에서 `#숫자` 추출 → 이슈 번호 확정. 없으면 ARGUMENTS에서 추출. + +### 2단계: 두 Agent 병렬 디스패치 (필수) + +**한 메시지에 Agent 호출 2개**를 함께 보냄. 절대 순차 실행하지 않음. + +#### Agent A — 보고서 생성 + +- `subagent_type`: `general-purpose` +- `description`: "Generate implementation report" +- `prompt`: 아래 템플릿 +- 반드시 Skill 도구로 `report` 스킬 호출 또는 `.claude/commands/report.md`의 지침을 그대로 따라 `.report/`에 파일 생성 + +``` +프로젝트: /Users/luca/workspace/greedy/quickness-game +브랜치: {브랜치명} +이슈 번호: #{N} + +# Git 컨텍스트 (이미 수집됨 — 추가 git 명령 실행 금지) +## git status +{git status 결과} + +## git log main..HEAD +{커밋 목록} + +## 변경 파일 목록 +{파일 목록} + +# 이슈/추가 컨텍스트 (사용자 ARGUMENTS) +{ARGUMENTS} + +# 작업 +`.claude/commands/report.md`에 정의된 Report Mode 지침을 그대로 따라 +`.report/{YYYYMMDD}_#{N}_{한글설명}.md` 파일을 생성하라. + +- 위 git 컨텍스트만 사용. 추가 git 명령 실행 금지. +- 변경 파일을 직접 Read해서 분석. +- 작성자/작성일/AI 관련 표현 금지. +- 시크릿은 마스킹. +- 완료 시 저장된 파일 경로 한 줄로 보고. +``` + +#### Agent B — PR 본문 생성 + +- `subagent_type`: `general-purpose` +- `description`: "Generate PR description" +- `prompt`: 아래 템플릿 + +``` +프로젝트: /Users/luca/workspace/greedy/quickness-game +브랜치: {브랜치명} +이슈 번호: #{N} + +# Git 컨텍스트 (이미 수집됨 — 추가 git 명령 실행 금지) +## git status +{git status 결과} + +## git log main..HEAD +{커밋 목록} + +## 변경 파일 목록 +{파일 목록} + +# 이슈/추가 컨텍스트 (사용자 ARGUMENTS) +{ARGUMENTS} + +# 기존 보고서 (있으면 우선 참조) +.report/ 디렉토리에 같은 이슈/날짜 매칭 보고서가 있으면 1차 소스로 활용. +없거나 본 세션에서 막 생성 중이라면 git 컨텍스트 + 변경 파일 직접 분석으로 작성. + +# 작업 +`.claude/commands/pr-description.md`에 정의된 PR Description Mode 지침을 따라 +`.pr/{YYYYMMDD}_#{N}_{한글설명}.md` 파일을 생성하라. + +- Summary / Changes / Behavior Change / Test Plan / Notes / Closes #N 구조. +- AI/작성자 메타 정보 금지. 시크릿 마스킹. +- 마지막 줄에 `Closes #{N}` 포함. +- 완료 시 저장된 파일 경로 + PR 제목 제안 한 줄로 보고. +``` + +### 3단계: 결과 요약 + +두 Agent 완료 후 다음 형식으로 한 번만 출력: + +``` +✅ 보고서: .report/{경로}.md +✅ PR 본문: .pr/{경로}.md + +PR 생성: +gh pr create --title "{type} : {제목} #{N}" --body-file ".pr/{경로}.md" +``` + +## 절대 금지 사항 + +- 두 Agent를 순차 호출하지 말 것 (반드시 한 메시지에 병렬) +- 디스패치 전 git 명령 외에 본 커맨드가 추가 파일 분석을 하지 말 것 (Agent에게 위임) +- AI/작성자 메타 정보 삽입 금지 +- 두 산출물 내용 차이를 임의로 줄이려고 하지 말 것 — 보고서는 "무엇을 했나", PR 본문은 "리뷰어용 요약"으로 역할이 다름 + +## 출력 후 + +저장 경로 2개 + `gh pr create` 안내 한 줄 출력 후 종료. 추가 질문 없음. diff --git a/.claude/commands/testcase.md b/.claude/commands/testcase.md index 62ca157..e69de29 100644 --- a/.claude/commands/testcase.md +++ b/.claude/commands/testcase.md @@ -1,373 +0,0 @@ -# Testcase Generator - -당신은 **QA 테스트케이스 작성 전문가**입니다. GitHub 이슈를 분석하여 테스트 체크리스트를 생성하세요. - -## 🔍 프로세스 - -### 1단계: 프로젝트 타입 자동 감지 - -다음 파일들을 확인하여 프로젝트 타입을 판단하세요: - -**Spring Boot (백엔드)** -- `build.gradle` 또는 `pom.xml` 존재 -- `src/main/java/` 디렉토리 -- Controller, Service, Repository 패턴 - -**React/React Native (프론트엔드)** -- `package.json` 존재 -- `react` 또는 `react-native` 의존성 -- JSX/TSX 파일 - -**Flutter (모바일)** -- `pubspec.yaml` 존재 -- `lib/` 디렉토리 -- `.dart` 파일 - -**Python (백엔드)** -- `requirements.txt` 또는 `pyproject.toml` -- Flask/FastAPI/Django 프레임워크 - -### 2단계: GitHub 이슈 파싱 - -사용자가 제공한 GitHub 이슈 내용에서 다음을 추출하세요: -- 이슈 번호 (예: `#407`) -- 이슈 제목 (예: `닉네임 및 프로필 사진 변경`) -- 도메인/카테고리 (예: `[회원]`, `[채팅]`) -- 담당자 정보 -- PR 링크 (있는 경우) - -### 3단계: 관련 코드 탐색 - -이슈의 도메인을 기반으로 관련 파일을 탐색하세요: - -**Spring Boot** -- Controller 파일 검색 (API 엔드포인트 확인) -- Service 로직 확인 -- DTO/Request/Response 구조 파악 - -**React/Flutter** -- 관련 컴포넌트/위젯 파일 -- API 호출 로직 -- 화면 구조 - -### 4단계: 테스트케이스 템플릿 생성 - -프로젝트 타입에 맞는 테스트케이스를 생성하세요. - ---- - -## 📋 테스트케이스 템플릿 - -### 🔹 Spring Boot (백엔드) - -```markdown -## 🧪 테스트 케이스: [기능명] - -**이슈**: #[번호] -**기능**: [기능 설명] -**API**: `[HTTP메서드] /api/[경로]` -**담당자**: @[담당자] - ---- - -### ✅ 1. 기본 기능 동작 확인 - -#### 1.1 정상 케이스 -- [ ] API 정상 호출 (200/201 응답) - - 테스트: [구체적인 요청 예시] - - 예상: [예상 응답] - - 실제: - - 증빙: - -- [ ] DB 데이터 정상 저장/조회 - - 테스트: [확인할 쿼리] - - 예상: [예상 결과] - - 실제: - - 증빙: - ---- - -### ⚠️ 2. 엣지 케이스 테스트 - -- [ ] 빈 값/null 파라미터 처리 - - 테스트: - - 예상: - - 실제: - -- [ ] 최대 길이 초과 입력 - - 테스트: - - 예상: - - 실제: - -- [ ] 중복 요청 (멱등성 확인) - - 테스트: - - 예상: - - 실제: - -- [ ] 존재하지 않는 리소스 조회 - - 테스트: - - 예상: - - 실제: - -- [ ] 타 사용자 리소스 접근 시도 - - 테스트: - - 예상: - - 실제: - ---- - -### 📄 3. Swagger 문서 확인 - -- [ ] API 엔드포인트 정확히 명시 -- [ ] Request 파라미터 타입/필수 여부 명시 -- [ ] Response 스키마 정의 -- [ ] 에러 응답 코드 문서화 (400, 401, 403, 404) - ---- - -### 📊 테스트 결과 요약 - -- **테스트 일자**: YYYY-MM-DD -- **테스터**: @username -- **테스트 환경**: [ ] local [ ] test [ ] prod -- **전체 결과**: [ ] ✅ PASS [ ] ❌ FAIL - -#### 발견된 이슈 -1. -2. -``` - -### 🔹 React/React Native (프론트엔드) - -```markdown -## 🧪 테스트 케이스: [기능명] - -**이슈**: #[번호] -**화면**: [화면명] -**담당자**: @[담당자] - ---- - -### ✅ 1. 기본 기능 동작 확인 - -#### 1.1 화면 렌더링 -- [ ] 화면 정상 표시 - - 테스트: - - 예상: - - 실제: - - 증빙: - -#### 1.2 사용자 인터랙션 -- [ ] 버튼 클릭 동작 - - 테스트: - - 예상: - - 실제: - -- [ ] 입력 필드 동작 - - 테스트: - - 예상: - - 실제: - -#### 1.3 API 연동 -- [ ] 데이터 로딩 - - 테스트: - - 예상: - - 실제: - -- [ ] 데이터 저장/수정 - - 테스트: - - 예상: - - 실제: - ---- - -### ⚠️ 2. 엣지 케이스 테스트 - -- [ ] 로딩 상태 표시 - - 테스트: - - 예상: - - 실제: - -- [ ] 빈 데이터 처리 - - 테스트: - - 예상: - - 실제: - -- [ ] 긴 텍스트 UI 깨짐 확인 - - 테스트: - - 예상: - - 실제: - -- [ ] 빠른 연속 클릭 방지 - - 테스트: - - 예상: - - 실제: - -- [ ] 네트워크 에러 처리 - - 테스트: - - 예상: - - 실제: - ---- - -### 🎨 3. UI/UX 확인 - -- [ ] 디자인 시안과 일치 -- [ ] 반응형 레이아웃 (다양한 화면 크기) -- [ ] 다크모드 지원 (있는 경우) -- [ ] 애니메이션/전환 효과 - ---- - -### 📊 테스트 결과 요약 - -- **테스트 일자**: YYYY-MM-DD -- **테스터**: @username -- **테스트 환경**: [ ] local [ ] dev [ ] prod -- **테스트 기기**: [디바이스/브라우저 정보] -- **전체 결과**: [ ] ✅ PASS [ ] ❌ FAIL - -#### 발견된 이슈 -1. -2. -``` - -### 🔹 Flutter (모바일) - -```markdown -## 🧪 테스트 케이스: [기능명] - -**이슈**: #[번호] -**화면**: [화면명] -**담당자**: @[담당자] - ---- - -### ✅ 1. 기본 기능 동작 확인 - -#### 1.1 화면 렌더링 -- [ ] 위젯 정상 표시 (Android) - - 테스트: - - 예상: - - 실제: - - 증빙: - -- [ ] 위젯 정상 표시 (iOS) - - 테스트: - - 예상: - - 실제: - - 증빙: - -#### 1.2 사용자 인터랙션 -- [ ] 터치/제스처 동작 - - 테스트: - - 예상: - - 실제: - -#### 1.3 데이터 처리 -- [ ] API 호출 및 데이터 표시 - - 테스트: - - 예상: - - 실제: - ---- - -### ⚠️ 2. 엣지 케이스 테스트 - -- [ ] 로딩 인디케이터 -- [ ] 빈 데이터 화면 -- [ ] 에러 화면 -- [ ] 네트워크 끊김 처리 -- [ ] 백그라운드 복귀 시 상태 유지 - ---- - -### 🎨 3. UI/UX 확인 - -- [ ] 디자인 시안 일치 -- [ ] 다양한 화면 크기 (Tablet/Phone) -- [ ] Android/iOS 플랫폼별 UI 차이 -- [ ] 애니메이션 부드러움 - ---- - -### 📊 테스트 결과 요약 - -- **테스트 일자**: YYYY-MM-DD -- **테스터**: @username -- **테스트 기기**: - - [ ] Android [버전] - - [ ] iOS [버전] -- **전체 결과**: [ ] ✅ PASS [ ] ❌ FAIL - -#### 발견된 이슈 -1. -2. -``` - ---- - -## 🎯 출력 규칙 - -### ⚠️ 필수: MD 파일 생성 - -**반드시 프로젝트 루트 경로에 마크다운 파일을 생성하세요!** - -- **파일명 형식**: `testcase-[이슈번호]-[간단한설명].md` - - 예: `testcase-407-닉네임변경.md` - - 예: `testcase-123-채팅기능.md` - -- **파일 위치**: 프로젝트 루트 디렉토리 (`.claude/`, `src/`와 같은 레벨) - -- **이유**: - - Cursor/Claude Code 내부에서 마크다운 출력만 하면 복사하기 어려움 - - 파일로 생성하면 GitHub 이슈 댓글에 바로 붙여넣기 가능 - - 테스트 이력 관리 용이 - -### 추가 출력 규칙 - -1. **프로젝트 타입 명시**: 감지된 타입을 명확히 표시 -2. **관련 파일 탐색**: Controller, API, 컴포넌트 등 관련 코드 찾기 -3. **구체적인 테스트 항목**: "닉네임 변경"이면 "닉네임 길이 제한(1~20자)" 등 구체화 -4. **GitHub 댓글용 마크다운**: 복사-붙여넣기 가능한 형식 -5. **간결함 유지**: 체크리스트 중심, 장황한 설명 지양 - -## 📌 특별 지시사항 - -- **백엔드**: Swagger 문서 완성도를 반드시 체크리스트에 포함 -- **프론트엔드**: UI/UX 시각적 확인 항목 포함 -- **공통**: 보안 관련 테스트 (권한, 인증) 자동 포함 -- **엣지 케이스**: 큰 값, 특수문자, 중복 호출 등 자동 생성 - ---- - -## 예시 - -**입력**: -``` -⚙️ [기능추가][회원] 닉네임 및 프로필 사진 변경 #407 -닉네임, 프로필 사진 변경하는 API 없음 -백엔드: @nayoung04 -``` - -**출력**: Spring Boot 테스트케이스 템플릿 + 관련 Controller 파일 경로 + 구체적인 엣지 케이스 (닉네임 중복, 파일 크기 제한 등) - ---- - -## ✅ 완료 후 사용자에게 안내 - -테스트케이스 파일 생성 완료 후 다음 메시지를 출력하세요: - -``` -✅ 테스트케이스 생성 완료! - -📄 파일 위치: `testcase-[번호]-[설명].md` - -GitHub 이슈에 댓글로 붙여넣는 방법: -1. 생성된 MD 파일 열기 -2. 전체 내용 복사 (Ctrl+A → Ctrl+C) -3. GitHub 이슈 댓글란에 붙여넣기 -4. 테스트 진행하면서 체크박스 체크 및 결과 작성 - -💡 팁: 파일을 Git에 커밋하면 테스트 이력 관리 가능합니다! -``` diff --git a/.gitignore b/.gitignore index 69eb454..7688e03 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,8 @@ application-local.yml ### Claude Config (PAT 포함 민감 정보) ### .claude/config.json -.report/ \ No newline at end of file +.report/ + +### Firebase Admin SDK (민감 정보) ### +**/firebase/*.json +*-firebase-adminsdk-*.json \ No newline at end of file diff --git a/SS-Auth/build.gradle b/SS-Auth/build.gradle index e9c6074..0ef42b5 100644 --- a/SS-Auth/build.gradle +++ b/SS-Auth/build.gradle @@ -15,4 +15,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // Firebase Admin SDK + implementation 'com.google.firebase:firebase-admin:9.4.3' } diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameRequest.java index 939c3a3..7904d23 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameRequest.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameRequest.java @@ -1,10 +1,17 @@ package com.elipair.spacestudyship.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public record CheckNicknameRequest( + @Schema( + description = "확인할 닉네임. 2~10자, 한글/영문 대소문자/숫자만 허용. 공백·특수문자·이모지 불가.", + example = "우주탐험가", + minLength = 2, + maxLength = 10 + ) @NotBlank(message = "닉네임은 필수입니다.") @Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하여야 합니다.") @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java index 7c65c9b..56d32f7 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java @@ -1,10 +1,23 @@ package com.elipair.spacestudyship.auth.dto; import com.elipair.spacestudyship.member.constant.SocialType; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +@Schema(description = "소셜 로그인 요청 본문") public record LoginRequest( + @Schema( + description = "소셜 로그인 플랫폼. 지원: GOOGLE, APPLE, KAKAO.", + example = "GOOGLE", + requiredMode = Schema.RequiredMode.REQUIRED + ) @NotNull(message = "소셜 플랫폼 정보는 필수입니다.") SocialType socialType, + + @Schema( + description = "소셜 플랫폼(Firebase 등)에서 발급받은 ID Token. 서버에서 이 토큰을 검증해 socialId를 추출합니다.", + example = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQ1Njc4OTAifQ...", + requiredMode = Schema.RequiredMode.REQUIRED + ) @NotBlank(message = "소셜 인증 토큰(ID Token)은 필수입니다.") String idToken ) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginResponse.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginResponse.java index 044e9f3..b86834d 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginResponse.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginResponse.java @@ -1,8 +1,24 @@ package com.elipair.spacestudyship.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "소셜 로그인 응답. 기존 회원이면 200, 신규 회원이면 201로 응답됩니다.") public record LoginResponse( + @Schema(description = "서버에서 부여한 회원 ID.", example = "1") Long memberId, + + @Schema( + description = "회원 닉네임. 신규 가입 시 서버가 랜덤 생성(형용사+명사+숫자 4자리 패턴).", + example = "민첩한괴도5308" + ) String nickname, + + @Schema(description = "JWT Access/Refresh Token 쌍.") Tokens tokens, + + @Schema( + description = "신규 가입 여부. true → 닉네임 설정 화면으로 이동 권장. false → 기존 회원, 홈 화면으로 이동.", + example = "false" + ) boolean isNewMember ) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LogoutRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LogoutRequest.java index dfbaf9c..3b6fc0a 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LogoutRequest.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LogoutRequest.java @@ -1,7 +1,14 @@ package com.elipair.spacestudyship.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(description = "로그아웃 요청 본문. 서버에 저장된 Refresh Token을 무효화하기 위해 전달합니다.") public record LogoutRequest( + @Schema( + description = "현재 디바이스의 Refresh Token. 서버는 이 토큰에서 memberId를 추출해 해당 세션을 삭제합니다.", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzE...", + requiredMode = Schema.RequiredMode.REQUIRED + ) @NotBlank(message = "Refresh Token은 필수입니다.") String refreshToken ) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueRequest.java index f1c739c..23350c7 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueRequest.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueRequest.java @@ -1,7 +1,14 @@ package com.elipair.spacestudyship.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(description = "토큰 재발급 요청 본문") public record ReissueRequest( + @Schema( + description = "현재 보유한 Refresh Token. 서버에서 검증 후 새 Access/Refresh Token을 발급합니다 (Refresh Token Rotation).", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzE...", + requiredMode = Schema.RequiredMode.REQUIRED + ) @NotBlank(message = "Refresh Token은 필수입니다.") String refreshToken ) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueResponse.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueResponse.java index a079d8e..813c17a 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueResponse.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueResponse.java @@ -1,3 +1,9 @@ package com.elipair.spacestudyship.auth.dto; -public record ReissueResponse(Tokens tokens) {} +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "토큰 재발급 응답. 새 Access/Refresh Token이 함께 발급되며, 클라이언트는 두 토큰 모두 교체 저장해야 합니다.") +public record ReissueResponse( + @Schema(description = "새로 발급된 JWT 토큰 쌍.") + Tokens tokens +) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/Tokens.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/Tokens.java index 355894a..beeb247 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/Tokens.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/Tokens.java @@ -1,3 +1,18 @@ package com.elipair.spacestudyship.auth.dto; -public record Tokens(String accessToken, String refreshToken) {} +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "JWT 토큰 쌍. accessToken은 `Authorization: Bearer ` 헤더에 실어 보내고, refreshToken은 만료 시 재발급에 사용합니다.") +public record Tokens( + @Schema( + description = "JWT Access Token. 보호된 API 호출 시 Authorization 헤더에 사용.", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzE..." + ) + String accessToken, + + @Schema( + description = "JWT Refresh Token. Access Token 만료 시 `POST /api/auth/reissue`로 재발급 받는 데 사용.", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzE..." + ) + String refreshToken +) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameRequest.java index e05c351..d372a9e 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameRequest.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameRequest.java @@ -1,10 +1,19 @@ package com.elipair.spacestudyship.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +@Schema(description = "닉네임 변경 요청 본문") public record UpdateNicknameRequest( + @Schema( + description = "변경할 닉네임. 2~10자, 한글/영문 대소문자/숫자만 허용. 공백·특수문자·이모지 불가.", + example = "우주탐험가", + minLength = 2, + maxLength = 10, + requiredMode = Schema.RequiredMode.REQUIRED + ) @NotBlank(message = "닉네임은 필수입니다.") @Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하여야 합니다.") @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameResponse.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameResponse.java index 54c700e..fb77023 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameResponse.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameResponse.java @@ -1,5 +1,9 @@ package com.elipair.spacestudyship.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "닉네임 변경 응답. 변경된 닉네임을 반환합니다.") public record UpdateNicknameResponse( + @Schema(description = "변경된 회원 닉네임.", example = "우주탐험가") String nickname ) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java new file mode 100644 index 0000000..f60f44a --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java @@ -0,0 +1,45 @@ +package com.elipair.spacestudyship.auth.firebase; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.auth.FirebaseAuth; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; + +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class FirebaseConfig { + + @Value("${firebase.admin-sdk-path}") + private Resource credentialsResource; + + @PostConstruct + public void initializeFirebaseApp() throws IOException { + if (!FirebaseApp.getApps().isEmpty()) { + log.info("[FirebaseConfig] FirebaseApp 이미 초기화됨, 스킵"); + return; + } + try (InputStream stream = credentialsResource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(stream)) + .build(); + FirebaseApp.initializeApp(options); + log.info("[FirebaseConfig] FirebaseApp 초기화 완료"); + } + } + + @Bean + public FirebaseAuth firebaseAuth() { + return FirebaseAuth.getInstance(); + } +} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthMember.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthMember.java index afbfb95..6eb976c 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthMember.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthMember.java @@ -1,11 +1,22 @@ package com.elipair.spacestudyship.auth.interceptor; +import io.swagger.v3.oas.annotations.Parameter; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * 로그인된 사용자의 {@link LoginMember}를 컨트롤러 파라미터에 주입한다. + *

+ * {@link io.swagger.v3.oas.annotations.Parameter @Parameter(hidden = true)} 메타 어노테이션으로 + * 인해 이 어노테이션이 붙은 파라미터는 Swagger 문서에 노출되지 않는다. + * 실제 인증 정보는 {@code Authorization: Bearer } 헤더로 전달되며, + * 서버 내부에서 토큰을 파싱하여 {@code LoginMember} 객체를 만든다. + */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) +@Parameter(hidden = true) public @interface AuthMember { } diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java index 280e8d7..7108df7 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java @@ -9,6 +9,9 @@ import com.elipair.spacestudyship.member.entity.Member; import com.elipair.spacestudyship.member.constant.SocialType; import com.elipair.spacestudyship.member.repository.MemberRepository; +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -29,6 +32,7 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final RandomNicknameGenerator randomNicknameGenerator; private final Map socialLoginStrategies; + private final FirebaseAuth firebaseAuth; /** * 소셜 로그인 @@ -156,4 +160,34 @@ public UpdateNicknameResponse updateNickname(Long memberId, UpdateNicknameReques return new UpdateNicknameResponse(member.getNickname()); } + /** + * 회원 탈퇴 - DB / Redis / Firebase 사용자 삭제 + * Firebase 예외는 멱등성 유지를 위해 모두 무시 (로그만 기록). + */ + @Transactional + public void withdraw(Long memberId) { + Member member = memberRepository.findById(memberId).orElse(null); + if (member != null) { + memberRepository.delete(member); + } + refreshTokenRepository.delete(memberId); + if (member != null) { + deleteFirebaseUserSafely(memberId, member.getSocialId()); + } + } + + private void deleteFirebaseUserSafely(Long memberId, String socialId) { + try { + firebaseAuth.deleteUser(socialId); + } catch (FirebaseAuthException e) { + if (e.getAuthErrorCode() == AuthErrorCode.USER_NOT_FOUND) { + log.warn("[Withdraw] Firebase 사용자 이미 없음 | memberId={}, socialId={}", + memberId, socialId); + } else { + log.error("[Withdraw] Firebase 사용자 삭제 실패 | memberId={}, socialId={}, error={}", + memberId, socialId, e.getMessage()); + } + } + } + } diff --git a/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java index 78069b2..cc3a374 100644 --- a/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java +++ b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java @@ -11,6 +11,9 @@ import com.elipair.spacestudyship.member.constant.SocialType; import com.elipair.spacestudyship.member.entity.Member; import com.elipair.spacestudyship.member.repository.MemberRepository; +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,6 +29,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -42,6 +46,8 @@ class AuthServiceTest { RandomNicknameGenerator randomNicknameGenerator; @Mock Map socialLoginStrategies; + @Mock + FirebaseAuth firebaseAuth; @InjectMocks AuthService authService; @@ -185,4 +191,98 @@ void updateNickname_memberNotFound() { .extracting("errorCode").isEqualTo(ErrorCode.MEMBER_NOT_FOUND); verify(memberRepository, never()).existsByNickname(any()); } + + @Test + @DisplayName("withdraw: Member 존재 시 DB/Redis/Firebase 모두 삭제") + void withdraw_success() throws Exception { + // given + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId) + .socialId(socialId) + .socialType(SocialType.GOOGLE) + .nickname("탈퇴할회원") + .build(); + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.of(member)); + + // when + authService.withdraw(memberId); + + // then + verify(memberRepository).delete(member); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth).deleteUser(socialId); + } + + @Test + @DisplayName("withdraw: Member 이미 없으면 멱등 처리 (refresh token만 삭제 시도)") + void withdraw_alreadyWithdrawn() throws Exception { + // given + Long memberId = 1L; + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.empty()); + + // when + authService.withdraw(memberId); + + // then + verify(memberRepository, never()).delete(any(Member.class)); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth, never()).deleteUser(any()); + } + + @Test + @DisplayName("withdraw: Firebase USER_NOT_FOUND 예외는 무시하고 정상 완료") + void withdraw_firebaseUserNotFound() throws Exception { + // given + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId) + .socialId(socialId) + .socialType(SocialType.GOOGLE) + .nickname("탈퇴할회원") + .build(); + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.of(member)); + + FirebaseAuthException firebaseEx = mock(FirebaseAuthException.class); + given(firebaseEx.getAuthErrorCode()).willReturn(AuthErrorCode.USER_NOT_FOUND); + willThrow(firebaseEx).given(firebaseAuth).deleteUser(socialId); + + // when (예외 없이 정상 완료되어야 함) + authService.withdraw(memberId); + + // then + verify(memberRepository).delete(member); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth).deleteUser(socialId); + } + + @Test + @DisplayName("withdraw: Firebase 일반 오류도 무시하고 정상 완료 (멱등성 유지)") + void withdraw_firebaseGenericError() throws Exception { + // given + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId) + .socialId(socialId) + .socialType(SocialType.GOOGLE) + .nickname("탈퇴할회원") + .build(); + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.of(member)); + + FirebaseAuthException firebaseEx = mock(FirebaseAuthException.class); + given(firebaseEx.getAuthErrorCode()).willReturn(AuthErrorCode.CERTIFICATE_FETCH_FAILED); + given(firebaseEx.getMessage()).willReturn("Firebase 일시 장애"); + willThrow(firebaseEx).given(firebaseAuth).deleteUser(socialId); + + // when (예외 없이 정상 완료되어야 함) + authService.withdraw(memberId); + + // then + verify(memberRepository).delete(member); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth).deleteUser(socialId); + } } diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java index 8f8254e..cc437c8 100644 --- a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java @@ -1,14 +1,14 @@ package com.elipair.spacestudyship.common.exception; public record ErrorResponse( - int status, + String code, String message ) { public static ErrorResponse of(ErrorCode errorCode) { - return new ErrorResponse(errorCode.getHttpStatus().value(), errorCode.getMessage()); + return new ErrorResponse(errorCode.name(), errorCode.getMessage()); } - public static ErrorResponse of(int status, String message) { - return new ErrorResponse(status, message); + public static ErrorResponse of(ErrorCode errorCode, String message) { + return new ErrorResponse(errorCode.name(), message); } } diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java index 36b1459..c206daa 100644 --- a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java @@ -34,7 +34,7 @@ public ResponseEntity handleValidationException(MethodArgumentNot ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; return ResponseEntity .status(errorCode.getHttpStatus()) - .body(ErrorResponse.of(errorCode.getHttpStatus().value(), detail)); + .body(ErrorResponse.of(errorCode, detail)); } @ExceptionHandler(HttpMessageNotReadableException.class) diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java index e753f84..fcfbfbe 100644 --- a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java @@ -12,10 +12,17 @@ import com.elipair.spacestudyship.auth.interceptor.AuthMember; import com.elipair.spacestudyship.auth.interceptor.LoginMember; import com.elipair.spacestudyship.auth.service.AuthService; +import com.elipair.spacestudyship.common.exception.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -28,7 +35,139 @@ public class AuthController { private final AuthService authService; - @Operation(summary = "소셜 로그인") + @Operation( + summary = "소셜 로그인", + description = """ + 소셜 플랫폼(Firebase 등)에서 발급받은 ID Token을 백엔드에 전송하여 JWT를 발급받습니다. + 해당 유저가 DB에 없으면 **자동으로 회원가입** 처리됩니다 (랜덤 닉네임 부여). + + ### 응답 코드 + - `200 OK` — 기존 회원 로그인 성공 + - `201 Created` — 신규 회원 가입 + 로그인 성공 (클라이언트는 닉네임 설정 화면으로 이동 권장) + + ### 인증 불필요 + 이 엔드포인트는 공개 API입니다. `Authorization` 헤더 없이 호출하세요. + + ### 서버 처리 흐름 + 1. 소셜 ID Token 검증 (현재는 stub — 추후 Firebase Admin SDK 연동 예정) + 2. socialType + socialId 로 DB 조회 + - 존재: 기존 회원 정보로 JWT 발급 + - 없음: 신규 회원 생성 (랜덤 닉네임), JWT 발급 + 3. Refresh Token 을 Redis 에 저장 + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "기존 회원 로그인 성공.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = LoginResponse.class), + examples = @ExampleObject( + name = "ExistingMember", + summary = "기존 회원 로그인", + value = """ + { + "memberId": 1, + "nickname": "민첩한괴도5308", + "tokens": { + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "eyJhbGciOiJIUzI1NiIs..." + }, + "isNewMember": false + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "201", + description = "신규 회원 가입 + 로그인 성공. 응답 본문은 200과 동일 구조이며 `isNewMember: true`.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = LoginResponse.class), + examples = @ExampleObject( + name = "NewMember", + summary = "신규 회원 가입", + value = """ + { + "memberId": 42, + "nickname": "용감한고양이7321", + "tokens": { + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "eyJhbGciOiJIUzI1NiIs..." + }, + "isNewMember": true + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "요청 본문 형식 오류 (필수 필드 누락, socialType 이 유효하지 않은 값 등).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "InvalidInputValue", + summary = "필수 필드 누락", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "idToken: 소셜 인증 토큰(ID Token)은 필수입니다." + } + """ + ), + @ExampleObject( + name = "UnsupportedSocialType", + summary = "지원하지 않는 소셜 타입", + value = """ + { + "code": "UNSUPPORTED_SOCIAL_TYPE", + "message": "지원하지 않는 소셜 로그인 방식입니다." + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "401", + description = "소셜 ID Token 검증 실패 (토큰 만료, 변조, 발급자 불일치 등).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "SocialLoginFailed", + value = """ + { + "code": "SOCIAL_LOGIN_FAILED", + "message": "소셜 로그인에 실패하였습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류. 닉네임 생성 재시도 초과 등.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) @PostMapping("/login") public ResponseEntity login(@RequestBody @Valid LoginRequest request) { LoginResponse response = authService.login(request); @@ -38,32 +177,453 @@ public ResponseEntity login(@RequestBody @Valid LoginRequest requ return ResponseEntity.ok(response); } - @Operation(summary = "토큰 재발급") + @Operation( + summary = "토큰 재발급", + description = """ + 만료된 Access Token 을 Refresh Token 으로 재발급합니다. + Refresh Token 도 함께 갱신됩니다 (**Refresh Token Rotation**). + + ### 인증 불필요 + 이 엔드포인트는 공개 API입니다. `Authorization` 헤더 대신 요청 본문의 `refreshToken` 으로 인증합니다. + + ### 클라이언트 처리 흐름 + 1. 보호된 API 호출 → `401 UNAUTHORIZED` 수신 + 2. 본 엔드포인트 호출 (`refreshToken` 본문 전송) + 3-a. 성공 (200): 새 Access/Refresh Token 저장 후 원래 API 재시도 + 3-b. 실패 (401 `INVALID_TOKEN`): 로그아웃 처리 + 로그인 화면 이동 + + ### 보안 정책 + - Refresh Token 이 Redis 의 저장값과 불일치하면 **탈취 의심**으로 간주, 해당 회원의 모든 세션을 즉시 무효화한 뒤 401 응답. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공. 클라이언트는 두 토큰 모두 교체 저장해야 합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ReissueResponse.class), + examples = @ExampleObject( + name = "ReissueSuccess", + value = """ + { + "tokens": { + "accessToken": "eyJhbGciOiJIUzI1NiIs...(new)", + "refreshToken": "eyJhbGciOiJIUzI1NiIs...(new)" + } + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "요청 본문 형식 오류 (refreshToken 누락 등).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidInputValue", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "refreshToken: Refresh Token은 필수입니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Refresh Token 이 만료되었거나, Redis 저장값과 불일치(탈취 의심)이거나, 변조된 경우. 클라이언트는 로그아웃 처리 후 로그인 화면으로 이동해야 합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidToken", + value = """ + { + "code": "INVALID_TOKEN", + "message": "인증 정보가 올바르지 않습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) @PostMapping("/reissue") public ResponseEntity reissue(@RequestBody @Valid ReissueRequest request) { return ResponseEntity.ok(authService.reissue(request)); } - @Operation(summary = "로그아웃") + @Operation( + summary = "로그아웃", + description = """ + 서버에서 해당 디바이스의 Refresh Token 을 삭제(무효화)합니다. + 클라이언트는 응답 수신 후 로컬에 저장된 Access/Refresh Token 도 함께 삭제해야 합니다. + + ### 인증 불필요 (실제 동작상) + 서버는 요청 본문의 `refreshToken` 에서 memberId 를 추출해 Redis 에서 해당 세션을 삭제합니다. + Refresh Token 이 유효하지 않거나 이미 삭제된 경우에도 멱등하게 **204** 를 응답합니다. + + ### 단일 디바이스 로그아웃 + Refresh Token 은 디바이스별로 발급되므로, 본 호출은 **현재 디바이스의 세션만** 종료합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "로그아웃 처리 완료. 응답 본문 없음.", + content = @Content + ), + @ApiResponse( + responseCode = "400", + description = "요청 본문 형식 오류 (refreshToken 누락 등).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidInputValue", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "refreshToken: Refresh Token은 필수입니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) @PostMapping("/logout") public ResponseEntity logout(@RequestBody @Valid LogoutRequest request) { authService.logout(request.refreshToken()); return ResponseEntity.noContent().build(); } - @Operation(summary = "닉네임 중복 확인") + @Operation( + summary = "닉네임 중복 확인", + description = """ + 입력한 닉네임이 다른 사용자가 이미 사용 중인지 확인합니다. + + ### 동작 + - DB에 동일한 닉네임이 존재하지 않으면 `available: true` + - 이미 존재하면 `available: false` + + ### 닉네임 규칙 + - 길이: 2 ~ 10 자 + - 허용 문자: 한글, 영문 대소문자, 숫자 + - 금지: 공백, 특수문자, 이모지 + + ### 주의 + - 본인이 현재 사용 중인 닉네임으로 조회해도 `available: false`로 응답됩니다. (프론트에서 본인 닉네임 입력 시 중복확인 버튼을 비활성화하는 것을 권장) + - 닉네임 변경 직전 마지막 검증으로 사용하되, 동시에 다른 사용자가 같은 닉네임을 등록하는 race condition 은 `PATCH /api/auth/nickname` 단계에서 별도로 처리됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "정상 조회. 본문의 `available` 필드로 사용 가능 여부 판단.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = CheckNicknameResponse.class), + examples = { + @ExampleObject( + name = "Available", + summary = "사용 가능한 닉네임", + value = """ + { + "available": true + } + """ + ), + @ExampleObject( + name = "NotAvailable", + summary = "이미 사용 중인 닉네임", + value = """ + { + "available": false + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "400", + description = "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자 포함 등). `message` 필드에 어떤 필드의 어떤 제약을 어겼는지 상세 표기됩니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidInputValue", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "nickname: 닉네임은 한글, 영문, 숫자만 사용할 수 있습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "UnauthenticatedRequest", + value = """ + { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) @GetMapping("/check-nickname") public ResponseEntity checkNickname( @AuthMember LoginMember loginMember, - @Valid @ModelAttribute CheckNicknameRequest request) { + @ParameterObject @Valid @ModelAttribute CheckNicknameRequest request) { return ResponseEntity.ok(authService.checkNickname(request.nickname())); } - @Operation(summary = "닉네임 변경") + @Operation( + summary = "닉네임 변경", + description = """ + 사용자의 닉네임을 변경합니다. + + ### 동작 + 1. 닉네임 형식 검증 (길이, 허용 문자) + 2. 본인 현재 닉네임과 동일하면 NO-OP — 중복 검사 없이 그대로 통과 (200) + 3. 다른 회원이 사용 중이면 `409 DUPLICATED_NICKNAME` + 4. 통과 시 DB 갱신 + JPA flush 로 unique 제약 위반을 동기적으로 감지 (race condition 처리) + + ### 닉네임 규칙 + - 길이: 2 ~ 10 자 + - 허용 문자: 한글, 영문 대소문자, 숫자 + - 금지: 공백, 특수문자, 이모지 + + ### 사전 검증 + 클라이언트는 입력 직후 `GET /api/auth/check-nickname` 로 사용 가능 여부를 먼저 확인하는 것을 권장합니다. + 다만 이 호출 후 다른 사용자가 같은 닉네임을 차지하는 race 는 본 엔드포인트가 안전하게 처리합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "닉네임 변경 성공. 응답 본문에 변경된 닉네임 포함.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = UpdateNicknameResponse.class), + examples = @ExampleObject( + name = "UpdateSuccess", + value = """ + { + "nickname": "우주탐험가" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidInputValue", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "nickname: 닉네임은 2자 이상 10자 이하여야 합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "UnauthenticatedRequest", + value = """ + { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 다른 사용자가 사용 중인 닉네임.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "DuplicatedNickname", + value = """ + { + "code": "DUPLICATED_NICKNAME", + "message": "이미 사용 중인 닉네임입니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) @PatchMapping("/nickname") public ResponseEntity updateNickname( @AuthMember LoginMember loginMember, @RequestBody @Valid UpdateNicknameRequest request) { return ResponseEntity.ok(authService.updateNickname(loginMember.memberId(), request)); } + + @Operation( + summary = "회원 탈퇴", + description = """ + 인증된 사용자의 계정과 관련 데이터를 영구 삭제합니다. **이 작업은 되돌릴 수 없습니다.** + + ### 삭제 대상 + - `members` 테이블의 해당 회원 row + - Redis 의 `refresh_token:{memberId}` 키 (모든 디바이스 세션 무효화) + - Firebase Authentication 의 해당 사용자 (uid = 회원의 socialId) + + ### 처리 순서 + 1. 회원 row 삭제 (`@Transactional`) + 2. Redis refresh token 삭제 + 3. Firebase Authentication 사용자 삭제 + + ### 멱등성 + - 동일한 토큰으로 두 번 호출되거나, 다른 디바이스에서 먼저 탈퇴되어 회원이 이미 없는 상태에서 호출되어도 동일하게 **204**를 응답합니다. + - Firebase 측에서 사용자가 이미 없는 경우(`USER_NOT_FOUND`)도 무시하고 정상 완료 처리합니다. + - Firebase 일시 장애 등 외부 시스템 오류도 서버에서 로그만 남기고 클라이언트에는 **204**를 응답합니다 (우리 측 데이터 정리는 이미 완료). + + ### 클라이언트 처리 가이드 + - 응답 받은 후 로컬에 저장된 Access Token / Refresh Token / 회원 정보를 모두 삭제하고 로그인 화면으로 이동하세요. + - 네트워크 오류로 응답을 못 받은 경우 재시도 가능합니다 (멱등 보장). + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "탈퇴 성공. 응답 본문 없음. (이미 탈퇴된 상태 / Firebase 측 사용자 부재 / 외부 시스템 일시 오류 등 모두 포함)", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "UnauthenticatedRequest", + value = """ + { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류. 주로 DB 통신 실패 시. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) + @DeleteMapping("/withdraw") + public ResponseEntity withdraw(@AuthMember LoginMember loginMember) { + authService.withdraw(loginMember.memberId()); + return ResponseEntity.noContent().build(); + } } diff --git a/SS-Web/src/main/resources/application.yml b/SS-Web/src/main/resources/application.yml index 7bf001d..3546e20 100644 --- a/SS-Web/src/main/resources/application.yml +++ b/SS-Web/src/main/resources/application.yml @@ -36,3 +36,7 @@ management: web: exposure: include: health + +# Firebase Admin SDK +firebase: + admin-sdk-path: classpath:firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java index 5607baf..b7acf1a 100644 --- a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java @@ -21,6 +21,8 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -171,4 +173,25 @@ void updateNickname_unauthenticated() throws Exception { .content(objectMapper.writeValueAsString(body))) .andExpect(status().isUnauthorized()); } + + // ========== DELETE /api/auth/withdraw ========== + + @Test + @DisplayName("withdraw: 정상 요청이면 204 응답하고 AuthService.withdraw 호출") + void withdraw_success() throws Exception { + // given + willDoNothing().given(authService).withdraw(1L); + + // when / then + mockMvc.perform(delete("/api/auth/withdraw") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("withdraw: 인증 정보가 없으면 401") + void withdraw_unauthenticated() throws Exception { + mockMvc.perform(delete("/api/auth/withdraw")) + .andExpect(status().isUnauthorized()); + } } diff --git a/docs/superpowers/plans/2026-05-11-withdraw-api.md b/docs/superpowers/plans/2026-05-11-withdraw-api.md new file mode 100644 index 0000000..ddf1f1b --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-withdraw-api.md @@ -0,0 +1,826 @@ +# 회원 탈퇴 API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `DELETE /api/auth/withdraw` 엔드포인트를 구현하여, 인증된 사용자의 DB row, Redis refresh token, Firebase Authentication 사용자를 모두 삭제한다 (멱등성 유지). + +**Architecture:** `AuthController` → `AuthService.withdraw(memberId)` → ① DB 삭제 (`@Transactional` 안) ② Redis 삭제 ③ Firebase 삭제 순서. Redis/Firebase 호출은 try/catch로 격리해 외부 시스템 장애가 DB 롤백을 일으키지 않도록 한다. Firebase 예외(USER_NOT_FOUND 포함 모든 예외)는 로그만 남기고 204 응답. + +**Tech Stack:** Spring Boot 4.0.2, Java 21, Gradle 멀티모듈, Spring Data JPA, Redis (refresh token), Firebase Admin SDK 9.4.3, JUnit 5 + Mockito + BDDMockito, MockMvc + +**Spec:** [`docs/superpowers/specs/2026-05-11-withdraw-api-design.md`](../specs/2026-05-11-withdraw-api-design.md) +**Issue:** [#22 회원 탈퇴 API 구현](https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22) +**Branch:** `20260422_#22_회원_탈퇴_API_구현` + +--- + +## 사전 검증 (작업 시작 전) + +- [ ] **현재 브랜치 확인** + +```bash +git branch --show-current +``` +Expected: `20260422_#22_회원_탈퇴_API_구현` + +- [ ] **`.gitignore`에 Firebase 패턴 포함되어 있는지 확인** + +```bash +grep -n "firebase" .gitignore +``` +Expected: +``` +**/firebase/*.json +*-firebase-adminsdk-*.json +``` +(없으면 앞 단계에서 빠진 것 — 추가하고 시작) + +- [ ] **Firebase Admin SDK 키 파일이 디스크에 존재하는지 + git에 추적 안 되는지 확인** + +```bash +ls -la SS-Web/src/main/resources/firebase/ +git status --short SS-Web/src/main/resources/firebase/ +``` +Expected: 파일 존재(`spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json`), `git status`엔 안 나타남. + +- [ ] **전체 테스트가 현재 통과하는지 확인 (기준선)** + +```bash +./gradlew test +``` +Expected: BUILD SUCCESSFUL (실패하는 테스트가 있다면 본 작업과 별개로 먼저 조사) + +--- + +## Task 1: Firebase Admin SDK 의존성 추가 + +**Files:** +- Modify: `SS-Auth/build.gradle` + +- [ ] **Step 1: `SS-Auth/build.gradle`에 firebase-admin 의존성 추가** + +기존 `dependencies` 블록 끝에 한 줄 추가: + +```gradle +bootJar { + enabled = false +} + +jar { + enabled = true + archiveClassifier = '' +} + +dependencies { + api project(':SS-Common') + api project(':SS-Member') + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // Firebase Admin SDK + implementation 'com.google.firebase:firebase-admin:9.4.3' +} +``` + +- [ ] **Step 2: 의존성 다운로드 검증** + +```bash +./gradlew :SS-Auth:dependencies --configuration runtimeClasspath | grep firebase +``` +Expected: +``` ++--- com.google.firebase:firebase-admin:9.4.3 +``` +(여러 transitive 의존성도 함께 나타남 — `com.google.auth:google-auth-library-oauth2-http`, `com.google.api-client:google-api-client` 등) + +만약 버전 충돌이나 다운로드 실패가 나면 9.2.0 / 9.3.0 등 인접 버전으로 조정. 인터넷 차단 환경이면 Gradle 캐시 / 사내 미러 확인. + +- [ ] **Step 3: SS-Auth 모듈 컴파일 확인** + +```bash +./gradlew :SS-Auth:compileJava +``` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: 커밋** + +```bash +git add SS-Auth/build.gradle +git commit -m "회원 탈퇴 API 구현 : chore : Firebase Admin SDK 의존성 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 2: `.gitignore` 정리 + Firebase 키 staging 상태 정리 + +`.gitignore` 변경은 앞 단계에서 이미 디스크 반영됐지만 아직 커밋 전. 키 파일이 다시 staged되어 있지 않은지 재확인 후 커밋한다. + +**Files:** +- Modify: `.gitignore` (이미 변경됨, 커밋만) + +- [ ] **Step 1: 현재 상태 재확인** + +```bash +git status --short +grep -n "firebase" .gitignore +``` +Expected: +- `.gitignore`가 `modified`로 잡힘 +- Firebase 키 json 파일은 `git status` 결과에 **안 나타나야 함** (gitignored) +- `.gitignore`에 `**/firebase/*.json`과 `*-firebase-adminsdk-*.json` 두 줄이 보여야 함 + +- [ ] **Step 2: 키 파일이 실수로 staged되지 않았는지 한 번 더 확인** + +```bash +git ls-files --error-unmatch SS-Web/src/main/resources/firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json 2>&1 +``` +Expected: `error: pathspec '...' did not match any file(s) known to git` — 즉 추적 안 됨. +만약 추적되고 있다면: `git rm --cached ` 후 다시 확인. + +- [ ] **Step 3: 커밋** + +```bash +git add .gitignore +git commit -m "회원 탈퇴 API 구현 : chore : .gitignore에 Firebase Admin SDK 키 패턴 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 3: `application.yml`에 Firebase 키 경로 설정 + +**Files:** +- Modify: `SS-Web/src/main/resources/application.yml` + +- [ ] **Step 1: `application.yml` 끝에 firebase 블록 추가** + +기존 파일 끝(`management:` 블록 뒤)에 다음을 추가: + +```yaml +# Firebase Admin SDK +firebase: + admin-sdk-path: classpath:firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json +``` + +전체 파일 끝부분이 다음과 같이 되어야 한다: + +```yaml +# Actuator (공통) +management: + endpoints: + web: + exposure: + include: health + +# Firebase Admin SDK +firebase: + admin-sdk-path: classpath:firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json +``` + +- [ ] **Step 2: 키 파일이 클래스패스에서 접근 가능한지 확인** + +`SS-Web/src/main/resources/firebase/` 디렉토리에 키 파일이 있어야 함. (앞 단계에서 이미 처리되어 있을 것) + +```bash +ls -la SS-Web/src/main/resources/firebase/ +``` +Expected: 키 json 파일 존재. + +- [ ] **Step 3: 커밋** + +```bash +git add SS-Web/src/main/resources/application.yml +git commit -m "회원 탈퇴 API 구현 : chore : application.yml에 Firebase Admin SDK 키 경로 설정 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 4: `FirebaseConfig` Bean 생성 + +**Files:** +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java` + +- [ ] **Step 1: 패키지 디렉토리 생성** + +```bash +mkdir -p SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase +``` + +- [ ] **Step 2: `FirebaseConfig.java` 작성** + +파일 경로: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java` + +```java +package com.elipair.spacestudyship.auth.firebase; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.auth.FirebaseAuth; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; + +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class FirebaseConfig { + + @Value("${firebase.admin-sdk-path}") + private Resource credentialsResource; + + @PostConstruct + public void initializeFirebaseApp() throws IOException { + if (!FirebaseApp.getApps().isEmpty()) { + log.info("[FirebaseConfig] FirebaseApp 이미 초기화됨, 스킵"); + return; + } + try (InputStream stream = credentialsResource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(stream)) + .build(); + FirebaseApp.initializeApp(options); + log.info("[FirebaseConfig] FirebaseApp 초기화 완료"); + } + } + + @Bean + public FirebaseAuth firebaseAuth() { + return FirebaseAuth.getInstance(); + } +} +``` + +**왜 이렇게 작성하는가:** +- `@Value`의 `Resource` 타입은 Spring이 `classpath:`/`file:` prefix를 자동 리졸션. +- `@PostConstruct`로 빈 생성 직후 1회 초기화. `FirebaseApp.getApps().isEmpty()` 가드로 중복 초기화 방지. +- `FirebaseAuth`를 `@Bean`으로 노출 → `AuthService`에서 생성자 주입 가능. +- 키 파일 누락/파싱 실패 시 `IOException`이 던져지면서 애플리케이션 기동이 fail-fast. + +- [ ] **Step 3: 컴파일 확인** + +```bash +./gradlew :SS-Auth:compileJava +``` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: 애플리케이션 기동 확인 (FirebaseApp 초기화 검증)** + +```bash +./gradlew :SS-Web:bootRun --args='--spring.profiles.active=dev' & +``` +잠시 대기 후 로그에서 다음을 확인: +``` +[FirebaseConfig] FirebaseApp 초기화 완료 +``` +그리고 `Tomcat started on port 8080` 메시지. + +확인 후 종료: +```bash +# 다른 터미널이라면 +pkill -f "SS-Web" +# 같은 터미널이면 fg로 가져와서 Ctrl+C +``` + +**대안 (백그라운드 실행이 부담스러우면):** +이미 `--spring.profiles.active=dev`로 사용자가 jar를 실행 중인 상태가 있다면 jar를 재빌드 후 재기동: + +```bash +./gradlew :SS-Web:bootJar +java -jar SS-Web/build/libs/app.jar --spring.profiles.active=dev +``` +같은 로그 확인 후 Ctrl+C. + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java +git commit -m "회원 탈퇴 API 구현 : feat : FirebaseConfig 빈 추가 (FirebaseApp 초기화) https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 5: `AuthService.withdraw()` — 정상 케이스 (TDD) + +**Files:** +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` + +- [ ] **Step 1: AuthServiceTest에 FirebaseAuth mock 필드 추가** + +`AuthServiceTest.java` 클래스 상단 mock 필드 영역에 다음 한 줄 추가: + +```java + @Mock + com.google.firebase.auth.FirebaseAuth firebaseAuth; +``` + +(또는 import 추가:) +```java +import com.google.firebase.auth.FirebaseAuth; +``` + +추가 후 mock 필드 블록은 다음과 같이 됨: + +```java + @Mock + MemberRepository memberRepository; + @Mock + RefreshTokenRepository refreshTokenRepository; + @Mock + JwtTokenProvider jwtTokenProvider; + @Mock + RandomNicknameGenerator randomNicknameGenerator; + @Mock + Map socialLoginStrategies; + @Mock + FirebaseAuth firebaseAuth; +``` + +- [ ] **Step 2: 실패 테스트 작성 — `withdraw_success`** + +`AuthServiceTest.java`의 마지막 `}` 직전(클래스 닫는 중괄호 직전)에 다음 테스트 추가: + +```java + @Test + @DisplayName("withdraw: Member 존재 시 DB/Redis/Firebase 모두 삭제") + void withdraw_success() throws Exception { + // given + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId) + .socialId(socialId) + .socialType(SocialType.GOOGLE) + .nickname("탈퇴할회원") + .build(); + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.of(member)); + + // when + authService.withdraw(memberId); + + // then + verify(memberRepository).delete(member); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth).deleteUser(socialId); + } +``` + +- [ ] **Step 3: 테스트 실행 → FAIL 확인** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_success" +``` +Expected: COMPILATION FAILURE (`withdraw` 메서드 없음) 또는 컴파일은 통과해도 `firebaseAuth` 필드가 service에 주입 안 됨. + +- [ ] **Step 4: `AuthService`에 FirebaseAuth 의존성 + `withdraw()` 메서드 추가** + +먼저 import 추가: + +```java +import com.google.firebase.auth.FirebaseAuth; +``` + +생성자 주입 필드 영역에 한 줄 추가 (`@RequiredArgsConstructor`가 자동 처리): + +```java + private final FirebaseAuth firebaseAuth; +``` + +추가 후 필드 블록: + +```java + private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RandomNicknameGenerator randomNicknameGenerator; + private final Map socialLoginStrategies; + private final FirebaseAuth firebaseAuth; +``` + +그리고 클래스 마지막 `}` 직전에 다음 메서드 추가: + +```java + /** + * 회원 탈퇴 - DB / Redis / Firebase 사용자 삭제 + */ + @Transactional + public void withdraw(Long memberId) throws com.google.firebase.auth.FirebaseAuthException { + Member member = memberRepository.findById(memberId).orElse(null); + if (member != null) { + memberRepository.delete(member); + } + refreshTokenRepository.delete(memberId); + if (member != null) { + firebaseAuth.deleteUser(member.getSocialId()); + } + } +``` + +> **참고:** 이번 단계에선 정상 케이스만 통과시키기 위해 `FirebaseAuthException`을 `throws`로 두고, Task 7에서 try/catch로 격리하면서 signature에서 제거한다. + +- [ ] **Step 5: 테스트 실행 → PASS 확인** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_success" +``` +Expected: BUILD SUCCESSFUL, 1 test passed. + +- [ ] **Step 6: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java +git commit -m "회원 탈퇴 API 구현 : feat : AuthService.withdraw 정상 케이스 구현 (DB/Redis/Firebase 삭제) https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 6: `AuthService.withdraw()` — 멱등성 (Member 없음) + +**Files:** +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` + +- [ ] **Step 1: 실패 테스트 작성** + +`AuthServiceTest.java`의 `withdraw_success` 바로 뒤에 추가: + +```java + @Test + @DisplayName("withdraw: Member 이미 없으면 멱등 처리 (refresh token만 삭제 시도)") + void withdraw_alreadyWithdrawn() throws Exception { + // given + Long memberId = 1L; + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.empty()); + + // when + authService.withdraw(memberId); + + // then + verify(memberRepository, never()).delete(any(Member.class)); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth, never()).deleteUser(any()); + } +``` + +- [ ] **Step 2: 테스트 실행 → PASS 확인 (Task 5 구현이 이미 `if (member != null)` 가드를 포함하므로 통과해야 함)** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_alreadyWithdrawn" +``` +Expected: BUILD SUCCESSFUL, 1 test passed. + +**만약 FAIL이라면:** Task 5의 구현이 `orElseThrow`나 다른 형태로 바뀌어 있는 것 — Task 5의 Step 4 구현으로 되돌릴 것. + +- [ ] **Step 3: 커밋** + +```bash +git add SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java +git commit -m "회원 탈퇴 API 구현 : test : withdraw 멱등성 (Member 없음) 케이스 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 7: `AuthService.withdraw()` — Firebase 예외 처리 + signature 정리 + +Firebase 호출을 try/catch로 격리하고 service 메서드 signature에서 `throws FirebaseAuthException`을 제거한다. + +**Files:** +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` + +- [ ] **Step 1: 실패 테스트 2개 작성 — Firebase USER_NOT_FOUND + 일반 오류** + +먼저 import 추가 (테스트 파일 상단): + +```java +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuthException; +import static org.mockito.Mockito.mock; +import static org.mockito.BDDMockito.willThrow; +``` + +(기존에 일부 import는 이미 있을 수 있음 — 중복은 IDE로 정리) + +`AuthServiceTest.java`의 `withdraw_alreadyWithdrawn` 바로 뒤에 다음 두 테스트 추가: + +```java + @Test + @DisplayName("withdraw: Firebase USER_NOT_FOUND 예외는 무시하고 정상 완료") + void withdraw_firebaseUserNotFound() throws Exception { + // given + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId) + .socialId(socialId) + .socialType(SocialType.GOOGLE) + .nickname("탈퇴할회원") + .build(); + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.of(member)); + + FirebaseAuthException firebaseEx = mock(FirebaseAuthException.class); + given(firebaseEx.getAuthErrorCode()).willReturn(AuthErrorCode.USER_NOT_FOUND); + willThrow(firebaseEx).given(firebaseAuth).deleteUser(socialId); + + // when (예외 없이 정상 완료되어야 함) + authService.withdraw(memberId); + + // then + verify(memberRepository).delete(member); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth).deleteUser(socialId); + } + + @Test + @DisplayName("withdraw: Firebase 일반 오류도 무시하고 정상 완료 (멱등성 유지)") + void withdraw_firebaseGenericError() throws Exception { + // given + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId) + .socialId(socialId) + .socialType(SocialType.GOOGLE) + .nickname("탈퇴할회원") + .build(); + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.of(member)); + + FirebaseAuthException firebaseEx = mock(FirebaseAuthException.class); + given(firebaseEx.getAuthErrorCode()).willReturn(AuthErrorCode.CERTIFICATE_FETCH_FAILED); + given(firebaseEx.getMessage()).willReturn("Firebase 일시 장애"); + willThrow(firebaseEx).given(firebaseAuth).deleteUser(socialId); + + // when (예외 없이 정상 완료되어야 함) + authService.withdraw(memberId); + + // then + verify(memberRepository).delete(member); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth).deleteUser(socialId); + } +``` + +- [ ] **Step 2: 테스트 실행 → FAIL 확인** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_firebaseUserNotFound" \ + --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_firebaseGenericError" +``` +Expected: FAIL. `authService.withdraw(memberId)`가 `FirebaseAuthException`을 던지지만, 테스트는 예외를 catch하지 않고 정상 완료를 기대하고 있어서 실패. + +- [ ] **Step 3: `AuthService.withdraw()`에 try/catch 적용 + signature에서 throws 제거** + +`AuthService.java`의 `withdraw` 메서드를 다음과 같이 교체: + +```java + /** + * 회원 탈퇴 - DB / Redis / Firebase 사용자 삭제 + * Firebase 예외는 멱등성 유지를 위해 모두 무시 (로그만 기록). + */ + @Transactional + public void withdraw(Long memberId) { + Member member = memberRepository.findById(memberId).orElse(null); + if (member != null) { + memberRepository.delete(member); + } + refreshTokenRepository.delete(memberId); + if (member != null) { + deleteFirebaseUserSafely(memberId, member.getSocialId()); + } + } + + private void deleteFirebaseUserSafely(Long memberId, String socialId) { + try { + firebaseAuth.deleteUser(socialId); + } catch (FirebaseAuthException e) { + if (e.getAuthErrorCode() == AuthErrorCode.USER_NOT_FOUND) { + log.warn("[Withdraw] Firebase 사용자 이미 없음 | memberId={}, socialId={}", + memberId, socialId); + } else { + log.error("[Withdraw] Firebase 사용자 삭제 실패 | memberId={}, socialId={}, error={}", + memberId, socialId, e.getMessage()); + } + } + } +``` + +import 추가: + +```java +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuthException; +``` + +> **포인트:** +> - public signature에서 `throws FirebaseAuthException` 제거 → Controller에서 신경 안 써도 됨. +> - private helper로 분리해 책임 명확화. `withdraw()`는 흐름, helper는 외부 시스템 예외 정책. + +- [ ] **Step 4: 전체 withdraw 테스트 4개 실행 → PASS 확인** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_*" +``` +Expected: BUILD SUCCESSFUL, 4 tests passed (`withdraw_success`, `withdraw_alreadyWithdrawn`, `withdraw_firebaseUserNotFound`, `withdraw_firebaseGenericError`). + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java +git commit -m "회원 탈퇴 API 구현 : feat : Firebase 예외 처리 격리 (USER_NOT_FOUND/일반 오류 무시) https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 8: `AuthController` — `DELETE /api/auth/withdraw` 엔드포인트 (TDD) + +**Files:** +- Modify: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java` +- Modify: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java` + +- [ ] **Step 1: 실패 테스트 2개 작성** + +`AuthControllerTest.java`의 마지막 테스트 뒤(클래스 닫는 `}` 직전)에 추가. 먼저 import 확인/추가: + +```java +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.mockito.BDDMockito.willDoNothing; +``` + +테스트 코드: + +```java + // ========== DELETE /api/auth/withdraw ========== + + @Test + @DisplayName("withdraw: 정상 요청이면 204 응답하고 AuthService.withdraw 호출") + void withdraw_success() throws Exception { + // given + willDoNothing().given(authService).withdraw(1L); + + // when / then + mockMvc.perform(delete("/api/auth/withdraw") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("withdraw: 인증 정보가 없으면 401") + void withdraw_unauthenticated() throws Exception { + mockMvc.perform(delete("/api/auth/withdraw")) + .andExpect(status().isUnauthorized()); + } +``` + +- [ ] **Step 2: 테스트 실행 → FAIL 확인** + +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.auth.AuthControllerTest.withdraw_success" \ + --tests "com.elipair.spacestudyship.controller.auth.AuthControllerTest.withdraw_unauthenticated" +``` +Expected: FAIL — 엔드포인트가 없어서 404 (또는 컴파일 오류 가능). + +- [ ] **Step 3: `AuthController`에 `withdraw` 엔드포인트 추가** + +`AuthController.java` 클래스의 마지막 `}` 직전에 추가: + +```java + @Operation(summary = "회원 탈퇴") + @DeleteMapping("/withdraw") + public ResponseEntity withdraw(@AuthMember LoginMember loginMember) { + authService.withdraw(loginMember.memberId()); + return ResponseEntity.noContent().build(); + } +``` + +기존 import는 이미 모두 있음 (`DeleteMapping`은 `org.springframework.web.bind.annotation.*`로 wildcard import되어 있음). + +- [ ] **Step 4: 테스트 실행 → PASS 확인** + +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.auth.AuthControllerTest.withdraw_*" +``` +Expected: BUILD SUCCESSFUL, 2 tests passed. + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java \ + SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java +git commit -m "회원 탈퇴 API 구현 : feat : DELETE /api/auth/withdraw 엔드포인트 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 9: 통합 검증 + +**Files:** (변경 없음 — 검증만) + +- [ ] **Step 1: 전체 테스트 실행** + +```bash +./gradlew test +``` +Expected: BUILD SUCCESSFUL. 모든 기존 테스트 + 새로 추가한 6개 테스트 통과. + +- [ ] **Step 2: 전체 빌드** + +```bash +./gradlew clean build +``` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: 애플리케이션 기동 후 엔드포인트 노출 확인** + +```bash +./gradlew :SS-Web:bootRun --args='--spring.profiles.active=dev' & +``` +로그에서 다음 확인: +- `[FirebaseConfig] FirebaseApp 초기화 완료` +- `Tomcat started on port 8080` + +다른 터미널에서 Swagger UI 확인: +```bash +curl -s http://localhost:8080/docs/api-docs | grep -o '"/api/auth/withdraw"' | head -1 +``` +Expected: `"/api/auth/withdraw"` 한 줄 — Swagger 문서에 엔드포인트가 잡힘. + +또는 브라우저로 `http://localhost:8080/docs/swagger` 접속해서 Auth 태그 안에 `DELETE /api/auth/withdraw`가 있는지 확인. + +- [ ] **Step 4: 인증 없이 호출 → 401 확인** + +```bash +curl -i -X DELETE http://localhost:8080/api/auth/withdraw +``` +Expected: `HTTP/1.1 401 Unauthorized` (응답 본문에 `UNAUTHENTICATED_REQUEST` 등) + +- [ ] **Step 5: 애플리케이션 종료** + +```bash +pkill -f "SS-Web" +``` +(또는 bootRun 실행 중인 터미널에서 Ctrl+C) + +- [ ] **Step 6: 스펙 문서 상태 업데이트** + +`docs/superpowers/specs/2026-05-11-withdraw-api-design.md` 4번째 줄을 수정: + +기존: +``` +- **Status**: Approved (pending user review) +``` + +변경 후: +``` +- **Status**: Implemented (2026-05-11) +``` + +- [ ] **Step 7: 최종 커밋** + +```bash +git add docs/superpowers/specs/2026-05-11-withdraw-api-design.md \ + docs/superpowers/plans/2026-05-11-withdraw-api.md +git commit -m "회원 탈퇴 API 구현 : docs : 설계/구현 계획 문서 추가 및 상태 업데이트 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## 작업 완료 체크리스트 + +- [ ] 모든 9개 Task의 모든 Step 체크박스 완료 +- [ ] `./gradlew test` 전체 통과 +- [ ] `./gradlew build` 전체 통과 +- [ ] `DELETE /api/auth/withdraw` 엔드포인트가 Swagger에 노출됨 +- [ ] 인증 없는 호출이 401을 돌려줌 +- [ ] Firebase 키 파일이 git 추적 대상이 아님 (`git ls-files`로 확인) +- [ ] 모든 커밋 메시지가 프로젝트 컨벤션 따름 (`{이슈제목} : {type} : {설명} {URL}`) +- [ ] 브랜치는 여전히 `20260422_#22_회원_탈퇴_API_구현` + +--- + +## 변경된 파일 요약 + +| 파일 | 변경 | +|------|------| +| `.gitignore` | Firebase 키 패턴 추가 | +| `SS-Auth/build.gradle` | `firebase-admin:9.4.3` 의존성 추가 | +| `SS-Web/src/main/resources/application.yml` | `firebase.admin-sdk-path` 설정 추가 | +| `SS-Web/src/main/resources/firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json` | 디스크에만 (gitignored) | +| `SS-Auth/src/main/java/.../auth/firebase/FirebaseConfig.java` | 신규 — FirebaseApp 초기화 + FirebaseAuth Bean | +| `SS-Auth/src/main/java/.../auth/service/AuthService.java` | `withdraw()` 메서드 + private `deleteFirebaseUserSafely()` 추가 | +| `SS-Web/src/main/java/.../controller/auth/AuthController.java` | `DELETE /api/auth/withdraw` 엔드포인트 추가 | +| `SS-Auth/src/test/java/.../auth/service/AuthServiceTest.java` | 4개 테스트 추가 | +| `SS-Web/src/test/java/.../controller/auth/AuthControllerTest.java` | 2개 테스트 추가 | +| `docs/superpowers/specs/2026-05-11-withdraw-api-design.md` | 신규 / Status 업데이트 | +| `docs/superpowers/plans/2026-05-11-withdraw-api.md` | 신규 (본 문서) | diff --git a/docs/superpowers/specs/2026-05-11-withdraw-api-design.md b/docs/superpowers/specs/2026-05-11-withdraw-api-design.md new file mode 100644 index 0000000..6318357 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-withdraw-api-design.md @@ -0,0 +1,248 @@ +# 회원 탈퇴 API 설계 + +- **Issue**: [#22 회원 탈퇴 API 구현](https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22) +- **Branch**: `20260422_#22_회원_탈퇴_API_구현` +- **Date**: 2026-05-11 +- **Status**: Implemented (2026-05-11) + +--- + +## 1. 목적과 범위 + +### 목적 +인증된 사용자가 자신의 계정과 관련 데이터를 영구 삭제할 수 있도록 `DELETE /api/auth/withdraw` 엔드포인트를 제공한다. 우리 측 데이터(DB row, Redis refresh token)와 함께 **Firebase Authentication 사용자**도 삭제한다. + +### 이번 PR 범위 +1. **회원 탈퇴 API 구현** + - `DELETE /api/auth/withdraw` (신규) + - `AuthService.withdraw(memberId)` 메서드 추가 +2. **삭제 대상 데이터** + - `members` 테이블의 해당 row + - Redis `refresh_token:{memberId}` 키 + - **Firebase Authentication 사용자** (`socialId` = Firebase UID로 가정) +3. **Firebase Admin SDK 연동 인프라** + - `SS-Auth/build.gradle`에 `firebase-admin` 의존성 추가 + - `FirebaseConfig` (FirebaseApp 초기화 빈) + - `application.yml`에 키 파일 경로 설정 + - `.gitignore`에 Firebase 키 패턴 추가 (이미 반영됨) +4. **테스트** + - Service / Controller 테스트 코드 추가 + - `FirebaseAuth` mock 처리 + +### 이번 PR에서 제외 (별도 이슈로 분리) +- **LoginStrategy 실제 구현** — 현재 `GoogleLoginStrategy`/`AppleLoginStrategy`/`KakaoLoginStrategy`가 모두 TODO 스텁이고, 가짜 `socialId`를 발급한다. 이를 실제 Firebase ID Token 검증으로 바꾸는 작업은 그 자체로 큰 작업이라 별도 이슈로 분리. 본 PR에선 **Firebase Admin SDK 초기화와 `deleteUser` 호출만** 포함. +- **Apple `Sign in with Apple` revoke token 처리** — App Store 심사 요구사항이지만 Firebase 연동과는 별개. LoginStrategy 실제 구현 이슈에서 함께 처리. +- **타 도메인 cascade 삭제** — Todo / Timer / Fuel / Exploration / Badge / Friends 등 `docs/api-specs/01_auth.md`에 명시된 도메인은 현재 미구현. 각 도메인이 추가되는 PR에서 자기 데이터 삭제 로직을 함께 추가하는 방식으로 확장. +- **Soft delete / grace period** — 현재 Member 엔티티에 `deletedAt` 등 인프라 없음. 운영 정책상 필요 시 별도 이슈. +- **FK 제약 / `ON DELETE CASCADE` 전략** — 참조 테이블 자체가 아직 없으므로 결정 보류. +- **Flyway 마이그레이션** — 본 작업은 스키마 변경 없음. 마이그레이션 파일 추가 안 함. + +### 기존 가짜 socialId 데이터에 대한 처리 +LoginStrategy가 TODO 스텁인 동안 가입된 회원의 `socialId`는 Firebase에 존재하지 않는 가짜값(`"GOOGLE_SOCIAL_ID_12345"` 등)이다. 이런 회원이 탈퇴를 호출하면 `FirebaseAuth.deleteUser()`가 `FirebaseAuthException(USER_NOT_FOUND)`를 던진다. 이 예외는 **로그 경고만 남기고 무시**한다 (§5 참조). 멱등성 유지. + +--- + +## 2. API 계약 + +```http +DELETE /api/auth/withdraw +Authorization: Bearer {accessToken} +``` + +| 항목 | 값 | +|------|----| +| 인증 | 필요 (`@AuthMember LoginMember`) | +| Request Body | 없음 | +| 성공 응답 | `204 No Content` (응답 본문 없음) | +| 미인증 | `401 UNAUTHENTICATED_REQUEST` | +| 이미 탈퇴됨 | `204 No Content` (멱등 — 별도 에러 응답 없음) | + +### 멱등성 + +DELETE 메서드의 HTTP 의미를 따라 멱등으로 설계한다. +- 동일한 토큰으로 두 번 호출되거나, 다른 디바이스에서 먼저 탈퇴된 후 호출되어도 결과 상태는 동일하므로 `204`로 응답한다. +- 클라이언트의 재시도(네트워크 불안 등)가 안전하다. +- Firebase 측에서 이미 사용자가 삭제된 경우(`USER_NOT_FOUND`)도 멱등으로 처리한다. + +--- + +## 3. 컴포넌트 변경 사항 + +``` +SS-Auth/ +├── build.gradle ← firebase-admin 의존성 추가 +├── service/AuthService.java ← withdraw(Long memberId) 추가, FirebaseAuth 주입 +└── firebase/FirebaseConfig.java ← 신규 (FirebaseApp 초기화 빈) + +SS-Web/ +├── controller/auth/AuthController.java ← DELETE /api/auth/withdraw 추가 +├── src/main/resources/application.yml ← firebase.admin-sdk-path 설정 추가 +└── src/main/resources/firebase/ + └── spacestudyship-firebase-adminsdk-...json ← 키 파일 (gitignored) + +.gitignore ← Firebase 키 패턴 추가 (이미 반영) + +SS-Auth (test)/ +└── service/AuthServiceTest.java ← withdraw 케이스 추가 + +SS-Web (test)/ +└── controller/auth/AuthControllerTest.java ← withdraw 케이스 추가 +``` + +**추가 없음:** +- 신규 DTO (Request/Response 본문 없음) +- 신규 ErrorCode +- 신규 엔티티 / 마이그레이션 + +### 3-1. `firebase-admin` 의존성 + +`SS-Auth/build.gradle`에 추가: +```gradle +implementation 'com.google.firebase:firebase-admin:9.x.x' // 최신 안정 버전 사용 +``` + +### 3-2. `FirebaseConfig` + +위치: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java` + +역할: +- `@Configuration` 빈으로 애플리케이션 시작 시 `FirebaseApp.initializeApp()` 1회 호출 +- 키 파일 경로는 `@Value("${firebase.admin-sdk-path}")`로 주입 (classpath 리소스) +- `FirebaseAuth` 인스턴스를 `@Bean`으로 노출 → `AuthService`에 주입 가능 + +### 3-3. `application.yml` 추가 + +```yaml +firebase: + admin-sdk-path: classpath:firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json +``` + +- 키 파일이 클래스패스에 없는 경우(예: CI에서 빌드만 할 때) `FirebaseApp` 초기화가 실패하면 애플리케이션 기동 자체가 실패한다. +- CI/CD에서 키 없이 빌드하려면 `application-{profile}.yml`에서 profile별로 경로를 다르게 두거나, `@Profile` 분기로 빈 등록을 막는 방식 필요 — 본 PR 범위 안: 일단 단일 경로로 시작, 운영상 필요해지면 후속 처리. + +--- + +## 4. 데이터 흐름 + +``` +Client + │ DELETE /api/auth/withdraw (Authorization: Bearer ...) + ▼ +AuthInterceptor (토큰 검증 → memberId 추출) + ▼ +AuthController.withdraw(@AuthMember LoginMember loginMember) + ▼ +AuthService.withdraw(loginMember.memberId()) @Transactional + │ + ├─ 1. Member 조회 (socialId가 Firebase UID — 다음 단계용) + │ Optional member = memberRepository.findById(memberId) + │ + ├─ 2. (member가 있으면) memberRepository.delete(member) + │ 없으면 NoOp → 멱등 + │ + ├─ 3. refreshTokenRepository.delete(memberId) + │ Redis: 키 없어도 silent → 멱등 + │ + └─ 4. (member가 있었으면) firebaseAuth.deleteUser(member.getSocialId()) + try / catch FirebaseAuthException + ├─ USER_NOT_FOUND → log.warn 후 무시 (멱등) + └─ 그 외 → log.error 후 무시 (DB는 이미 정리됨, 응답은 204) + ▼ +ResponseEntity.noContent().build() (204) +``` + +### 삭제 순서: **DB → Redis → Firebase** + +| 시나리오 | 결과 | +|---------|------| +| 모두 성공 | 완전 삭제 (정상) | +| DB 실패 | 트랜잭션 롤백 → Redis/Firebase 호출 안 됨 → 5xx 응답, 클라이언트 재시도 | +| DB 성공 + Redis 실패 | DB는 커밋됨. 토큰은 TTL 만료. `log.warn` 후 다음 단계 진행 | +| DB 성공 + Firebase 실패 | DB/Redis 정리됨. Firebase 유저만 잔존. `log.error` 후 204 응답 (운영자 수동 정리 또는 후속 retry 큐 — 본 PR 범위 외) | + +**근거:** +- DB부터 정리하는 이유: 트랜잭션 보장이 가장 강하고, 우리 도메인의 진실 원천이므로 여기 정리되면 사용자 입장에선 "탈퇴 완료". +- Firebase가 가장 외부 시스템이므로 마지막. 실패 시 우리 측 정리는 이미 되어 있어 사용자에겐 "탈퇴됨"으로 보임. +- `@Transactional` 경계 안에 Redis/Firebase가 들어가면 안 됨 — 외부 호출 실패가 DB 롤백을 일으키면 정합성이 더 망가짐. Redis/Firebase 호출은 트랜잭션 커밋 이후 영역으로 두거나, 같은 메서드 내에 두되 try/catch로 격리. + +**트랜잭션 경계 구현 노트:** 가장 단순한 방식은 `AuthService.withdraw()` 메서드 자체를 `@Transactional`로 두되, Redis/Firebase 호출을 try/catch로 감싸서 그 예외가 트랜잭션 밖으로 새지 않게 하는 것이다. Redis/Firebase는 DB와 별도 시스템이라 사실상 트랜잭션 보호 대상이 아님을 명시. + +--- + +## 5. 에러 처리 + +| 상황 | HTTP | code | 비고 | +|------|------|------|------| +| 정상 탈퇴 | 204 | - | | +| 이미 탈퇴 (Member row 없음) | 204 | - | `findById().ifPresent(::delete)` 패턴으로 NoOp. Firebase 호출도 스킵 | +| 토큰 없음 / 만료 | 401 | `UNAUTHENTICATED_REQUEST` 등 | 인터셉터 처리 (기존 패턴) | +| Redis 통신 실패 | 204 | - | DB는 이미 커밋, 토큰은 TTL 만료. `log.warn` 기록. 다음 단계 진행 | +| Firebase `USER_NOT_FOUND` | 204 | - | 가짜 socialId(LoginStrategy 스텁) 또는 다른 디바이스 선행 탈퇴. `log.warn` 후 무시 | +| Firebase 기타 통신/인증 실패 | 204 | - | DB/Redis는 이미 정리됨. `log.error` 후 무시. 응답은 멱등성 유지 위해 204 | +| FirebaseApp 초기화 실패 (앱 기동 시) | 앱 기동 실패 | - | 키 파일 누락/파싱 실패 시 즉시 발견되도록 fail-fast | +| DB 통신 실패 | 500 | `INTERNAL_SERVER_ERROR` | `GlobalExceptionHandler` 위임 | + +**새 ErrorCode 추가 없음.** + +### Firebase 예외 무시 정책의 근거 +- 사용자 관점에서 "탈퇴"의 본질은 "우리 서비스에서 내 데이터가 사라지는 것"이다. Firebase 측 정리는 부수 효과. +- Firebase 일시 장애로 탈퇴 자체가 실패하면 사용자 경험이 나빠지고, 재시도하면 우리 DB는 이미 비어있어 결과가 동일하므로 멱등성을 깨지 않는 게 낫다. +- 잔존 Firebase 유저는 운영 모니터링으로 별도 정리. 향후 retry 큐/배치로 자동화 가능 (별도 이슈). + +--- + +## 6. 테스트 전략 + +### 6-1. Unit Test — `AuthServiceTest` + +| 케이스 | 검증 내용 | +|--------|----------| +| `withdraw_success` | Member 존재 시: `memberRepository.delete(member)` 1회, `refreshTokenRepository.delete(memberId)` 1회, `firebaseAuth.deleteUser(socialId)` 1회 호출 | +| `withdraw_alreadyWithdrawn` | `findById`가 `Optional.empty()` 반환 시 `delete(...)` 및 `firebaseAuth.deleteUser(...)`는 호출되지 않음. `refreshTokenRepository.delete`만 호출됨. 예외 없이 통과 (멱등) | +| `withdraw_firebaseUserNotFound` | `firebaseAuth.deleteUser()`가 `FirebaseAuthException(USER_NOT_FOUND)` 던져도 메서드는 정상 완료. 우리 측 정리는 이미 됨 | +| `withdraw_firebaseGenericError` | `firebaseAuth.deleteUser()`가 일반 `FirebaseAuthException` 던져도 정상 완료. log.error만 호출 (mock으로 검증) | + +`@ExtendWith(MockitoExtension.class)` + `@Mock FirebaseAuth firebaseAuth` 추가. `FirebaseAuthException`은 final 클래스가 아니므로 Mockito로 mock 가능. + +### 6-2. Controller Test — `AuthControllerTest` + +| 케이스 | 검증 내용 | +|--------|----------| +| `withdraw_success` | 204 응답, `authService.withdraw(1L)` 호출 검증 | +| `withdraw_unauthenticated` | Authorization 헤더 없으면 401 | + +`MockMvcBuilders.standaloneSetup` + `LoginMemberArgumentResolver` 기존 패턴 그대로 사용. + +### 6-3. 통합 테스트 +현재 다른 API들도 통합 테스트를 두지 않는 컨벤션이라 일관성을 위해 추가하지 않는다. `FirebaseConfig` 빈 초기화 검증도 통합 테스트 추가하지 않음 — 운영 환경에서 fail-fast로 발견되는 게 충분. + +--- + +## 7. 향후 확장 포인트 (이번 PR 범위 아님) + +1. **LoginStrategy 실제 Firebase 검증 구현** + - `GoogleLoginStrategy`/`AppleLoginStrategy`/`KakaoLoginStrategy`에서 `FirebaseAuth.verifyIdToken()`을 사용해 실제 ID Token 검증. + - Apple 로그인 시 `Sign in with Apple` revoke token 처리도 함께. + - 이게 완료되면 신규 가입자의 `socialId`가 진짜 Firebase UID가 되어, 본 PR의 `deleteUser` 호출이 실제로 의미를 갖게 됨. + +2. **타 도메인 추가 시 cascade 삭제** + - Todo, Timer, Fuel, Exploration, Badge, Friends 등이 추가되는 PR에서 해당 도메인의 데이터 삭제 로직을 `AuthService.withdraw()`에 단순 호출 추가하거나, FK + `ON DELETE CASCADE`로 처리. + +3. **Firebase 삭제 실패 retry** + - `log.error`로 남기는 잔존 Firebase 유저를 후속 retry 큐(예: Spring Scheduler + 실패 테이블) 또는 운영 배치로 정리. + +4. **운영 정책 변경 시** + - Soft delete / grace period 도입: `Member`에 `withdrawnAt` 컬럼 추가 + 배치 잡으로 영구 삭제. + - 탈퇴 사유 수집: 별도 DTO + 통계 테이블. + +5. **profile별 FirebaseConfig 분기** + - CI 빌드 환경 등 키 없이 빌드해야 하는 경우 `@Profile`로 빈 등록을 제외하거나, dummy `FirebaseAuth` 빈을 주입. + +--- + +## 8. 참고 + +- API 공통 규칙: [`docs/api-specs/00_common.md`](../../api-specs/00_common.md) +- Auth API 상세 스펙: [`docs/api-specs/01_auth.md`](../../api-specs/01_auth.md) §4 +- 동일 도메인 선행 스펙: [`2026-04-24-nickname-api-design.md`](./2026-04-24-nickname-api-design.md) +- Firebase Admin SDK Java 문서: https://firebase.google.com/docs/auth/admin/manage-users#delete_a_user