From 18bc2cd696ce212df5338e10115a773e638b92eb Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sat, 9 May 2026 16:53:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20=EB=AF=BC=EA=B0=90=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20git=20=EC=B6=94=EC=A0=81=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EB=B0=8F=20gitignore=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20#159?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 16 ++++- src/main/resources/application-local.yml | 74 ----------------------- src/main/resources/application-prod.yml | 75 ------------------------ 3 files changed, 15 insertions(+), 150 deletions(-) delete mode 100644 src/main/resources/application-local.yml delete mode 100644 src/main/resources/application-prod.yml diff --git a/.gitignore b/.gitignore index d2ca175b..1f21adb2 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,18 @@ build.gradle src/main/resources/static/payment-test.html src/main/resources/static/payment-success.html -.env \ No newline at end of file +.env +.env.* + +# 민감한 설정 파일 (DB 비밀번호, API Key 등 포함) +src/main/resources/application-local.yml +src/main/resources/application-prod.yml +*.secret +*.key +*-credentials.yml +*-credentials.yaml + +# Claude Code 개인 설정 +.claude/settings.local.json + +/CLAUDE.local.md \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml deleted file mode 100644 index 0c0f8f64..00000000 --- a/src/main/resources/application-local.yml +++ /dev/null @@ -1,74 +0,0 @@ -server: - port: 8080 - profile: local - -spring: - config: - activate: - on-profile: local - datasource: - url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul&zeroDateTimeBehavior=convertToNull - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver - data: - redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT} - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true - security: - oauth2: - client: - registration: - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - scope: - - email - - profile - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - authorization-grant-type: authorization_code - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} - scope: - - profile_nickname - - profile_image - - account_email - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - authorization-grant-type: authorization_code - client-name: Kakao - provider: kakao - provider: - google: - authorization-uri: https://accounts.google.com/o/oauth2/v2/auth - token-uri: https://oauth2.googleapis.com/token - user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - -payment: - toss: - widget-secret-key: ${TOSS_WIDGET_SECRET_KEY} - -cloud: - aws: - region: ${AWS_REGION} - s3: - bucket: ${AWS_S3_BUCKET} - base-url: ${AWS_S3_BASE_URL} - -api: - service-key: ${BIZ_API_KEY} - -jwt: - secret: ${SECRET_KEY} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml deleted file mode 100644 index d6abe23f..00000000 --- a/src/main/resources/application-prod.yml +++ /dev/null @@ -1,75 +0,0 @@ -server: - forward-headers-strategy: native - profile: prod - -spring: - config: - activate: - on-profile: prod - datasource: - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver - data: - redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT:6379} - security: - oauth2: - client: - registration: - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - scope: - - email - - profile - redirect-uri: "https://eatsfine.co.kr/login/oauth2/code/google" - authorization-grant-type: authorization_code - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} - scope: - - profile_nickname - - profile_image - - account_email - redirect-uri: "https://eatsfine.co.kr/login/oauth2/code/kakao" - authorization-grant-type: authorization_code - client-authentication-method: client_secret_post - client-name: Kakao - provider: kakao - provider: - google: - authorization-uri: https://accounts.google.com/o/oauth2/auth - token-uri: https://oauth2.googleapis.com/token - user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true - -payment: - toss: - widget-secret-key: ${TOSS_WIDGET_SECRET_KEY} - -cloud: - aws: - region: ${AWS_REGION} - s3: - bucket: ${AWS_S3_BUCKET} - base-url: ${AWS_S3_BASE_URL} - -api: - service-key: ${BIZ_API_KEY} - -jwt: - secret: ${SECRET_KEY} From dd336c7364337b092c284d6c01aabb4c0246468c Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Sat, 9 May 2026 16:53:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20Claude=20Code=20=EA=B0=9C=EB=B0=9C?= =?UTF-8?q?=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=9E=90=EB=8F=99=ED=99=94=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20#159?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/commands/issue-create.md | 278 ++++++++++++++++++++ .claude/commands/pr-create.md | 93 +++++++ .claude/commands/review.md | 78 ++++++ .claude/commands/start.md | 52 ++++ .claude/hooks/pre-push-test.sh | 40 +++ .claude/rules/api-design.md | 202 +++++++++++++++ .claude/rules/git.md | 87 +++++++ .claude/rules/java.md | 190 ++++++++++++++ .claude/rules/testing.md | 365 ++++++++++++++++++++++++++ .claude/settings.json | 27 ++ CLAUDE.local.example.md | 299 ++++++++++++++++++++++ CLAUDE.md | 427 +++++++++++++++++++++++++++++++ 12 files changed, 2138 insertions(+) create mode 100644 .claude/commands/issue-create.md create mode 100644 .claude/commands/pr-create.md create mode 100644 .claude/commands/review.md create mode 100644 .claude/commands/start.md create mode 100755 .claude/hooks/pre-push-test.sh create mode 100644 .claude/rules/api-design.md create mode 100644 .claude/rules/git.md create mode 100644 .claude/rules/java.md create mode 100644 .claude/rules/testing.md create mode 100644 .claude/settings.json create mode 100644 CLAUDE.local.example.md create mode 100644 CLAUDE.md diff --git a/.claude/commands/issue-create.md b/.claude/commands/issue-create.md new file mode 100644 index 00000000..87d56c3a --- /dev/null +++ b/.claude/commands/issue-create.md @@ -0,0 +1,278 @@ +# GitHub 이슈 생성 가이드 + +**모든 개발 작업은 반드시 GitHub 이슈를 먼저 생성하고 시작합니다!** + +--- + +## 📋 이슈 템플릿 선택 + +Eatsfine 프로젝트는 3가지 이슈 템플릿을 제공합니다: + +### 1. Feature Request (기능 추가) +**파일**: `.github/ISSUE_TEMPLATE/feature_request.yml` + +**사용 시점**: +- 새로운 기능 개발 +- 기존 기능 확장 + +**예시**: +- 예약 취소 기능 추가 +- OAuth 소셜 로그인 추가 +- 식당 검색 필터 추가 + +--- + +### 2. Bug Report (버그 수정) +**파일**: `.github/ISSUE_TEMPLATE/bug_report.yml` + +**사용 시점**: +- 기능 오류 수정 +- 예외 처리 누락 +- 데이터 정합성 문제 + +**예시**: +- 예약 내역 조회 시 500 에러 발생 +- 결제 취소 시 환불 금액 계산 오류 +- JWT 토큰 만료 시 무한 리다이렉트 + +--- + +### 3. Refactor Template (리팩토링) +**파일**: `.github/ISSUE_TEMPLATE/refactor_template.yml` + +**사용 시점**: +- 코드 구조 개선 +- 성능 최적화 +- 아키텍처 패턴 적용 + +**예시**: +- StoreService CQRS 패턴 적용 +- N+1 쿼리 문제 해결 +- 중복 코드 제거 + +--- + +## 📝 Feature 이슈 작성 예시 + +### Title +``` +[FEAT]: 예약 취소 및 환불 기능 구현 +``` + +### 📄 설명 +```markdown +사용자가 예약을 취소하고 환불받을 수 있는 기능을 구현합니다. + +**요구사항**: +- 예약 24시간 전까지 취소 가능 +- 취소 사유 필수 입력 +- 결제 금액 자동 환불 (Toss Payments) +- 본인만 취소 가능 (권한 검증) +- 취소된 예약은 상태가 CANCELED로 변경 +``` + +### ✅ 작업할 내용 +```markdown +- [ ] `CancelBookingRequest` DTO 작성 + - cancelReason: String (필수) +- [ ] `BookingCommandService.cancelBooking()` 메서드 구현 + - 취소 권한 검증 (본인 확인) + - 취소 가능 시간 검증 (24시간 전) + - 예약 상태 CANCELED로 변경 +- [ ] `TossPaymentService.cancelPayment()` 연동 + - 결제 환불 로직 + - 환불 실패 시 롤백 처리 +- [ ] `BookingController.cancelBooking()` API 구현 + - PATCH `/api/v1/bookings/{bookingId}/cancel` + - @CurrentUser 인증 필수 +- [ ] 단위 테스트 작성 + - 정상 취소 케이스 + - 권한 없는 사용자 케이스 + - 취소 불가 시간 케이스 + - 결제 환불 실패 케이스 +- [ ] Swagger 문서화 + - @Operation, @ApiResponses 추가 +``` + +### 🙋🏻 참고 자료 +```markdown +- Toss Payments 결제 취소 API: https://docs.tosspayments.com/reference#결제-취소 +- 관련 프론트 이슈: #120 +``` + +--- + +## 🐛 Bug Report 이슈 작성 예시 + +### Title +``` +[BUG]: 예약 내역 조회 시 500 에러 발생 +``` + +### 🐛 버그 설명 +```markdown +사용자가 마이페이지에서 예약 내역을 조회할 때 500 Internal Server Error가 발생합니다. +``` + +### 🔄 재현 방법 +```markdown +1. 로그인 후 마이페이지 접속 +2. "내 예약" 메뉴 클릭 +3. 500 에러 발생 (예약 목록이 표시되지 않음) +``` + +### ✅ 예상 동작 +```markdown +사용자의 예약 내역 목록이 정상적으로 표시되어야 합니다. +``` + +### ❌ 실제 동작 +```markdown +500 서버 에러가 발생하고 예약 내역을 불러올 수 없습니다. +``` + +### 📸 스크린샷 / 로그 +```markdown +**에러 로그**: +``` +org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.eatsfine.domain.booking.entity.Booking.bookingTables +``` + +**스택 트레이스**: +at com.eatsfine.domain.booking.service.BookingQueryServiceImpl.getBookingList(BookingQueryServiceImpl.java:45) +``` +``` + +### 🔍 원인 분석 +```markdown +`BookingQueryServiceImpl.getBookingList()` 메서드에 `@Transactional(readOnly = true)` 어노테이션이 누락되어 Lazy Loading 시 LazyInitializationException이 발생합니다. +``` + +### 🙋🏻 참고 자료 +```markdown +- 관련 커밋: ba3ece4 +- 프론트엔드 이슈: #155 +``` + +--- + +## 🔧 Refactor 이슈 작성 예시 + +### Title +``` +[REFACTOR]: StoreService CQRS 패턴 적용 +``` + +### 🔍 리팩토링 사유 +```markdown +현재 `StoreService`가 조회와 명령 로직을 모두 포함하고 있어 책임이 과중합니다. CQRS 패턴을 적용하여 조회(Query)와 명령(Command) 로직을 분리하면 다음과 같은 이점이 있습니다: + +- 책임 분리로 코드 가독성 향상 +- 조회 로직에 `@Transactional(readOnly = true)` 적용 가능 +- 성능 최적화 가능 (조회와 명령 독립적으로) +- 테스트 작성 용이 +``` + +### 📄 리팩토링 상세 내용 +```markdown +**변경 전**: +```java +StoreService + - createStore() + - updateStore() + - deleteStore() + - findById() + - search() +``` + +**변경 후**: +```java +StoreCommandService (인터페이스) + └── StoreCommandServiceImpl + - createStore() + - updateStore() + - deleteStore() + +StoreQueryService (인터페이스) + └── StoreQueryServiceImpl + - findById() + - search() + - getStoreDetail() +``` + +**작업 내용**: +- [ ] `StoreCommandService` 인터페이스 및 구현체 생성 +- [ ] `StoreQueryService` 인터페이스 및 구현체 생성 +- [ ] 기존 `StoreService` 로직 이동 +- [ ] Controller에서 서비스 주입 변경 +- [ ] 기존 테스트 수정 +- [ ] 새로운 테스트 추가 +``` + +--- + +## 🚀 이슈 생성 후 + +### 1. 이슈 번호 확인 +이슈를 생성하면 `#123`과 같은 번호를 받습니다. + +### 2. 브랜치 생성 +```bash +git checkout -b feat/#123-cancel-booking +``` + +### 3. 작업 시작 +```bash +/start +``` + +--- + +## 💡 이슈 작성 팁 + +### 체크리스트 세분화 +- 큰 작업을 작은 단위로 나누기 +- 각 체크리스트는 1시간 이내에 완료 가능하도록 +- 테스트와 문서화도 체크리스트에 포함 + +### 구체적인 설명 +- "예약 기능 구현" ❌ +- "사용자가 식당 테이블을 예약하고, 선입금을 결제하며, 예약 내역을 조회할 수 있는 기능 구현" ✅ + +### 참고 자료 첨부 +- API 문서 링크 +- 관련 이슈/PR 링크 +- 스크린샷 +- 에러 로그 + +--- + +## 📌 GitHub CLI로 이슈 생성 + +```bash +# 이슈 목록 보기 +gh issue list + +# Feature 이슈 생성 +gh issue create \ + --title "[FEAT]: 예약 취소 기능 구현" \ + --body "이슈 내용" \ + --label "feature" + +# Bug 이슈 생성 +gh issue create \ + --title "[BUG]: 예약 내역 조회 에러" \ + --body "이슈 내용" \ + --label "bug" + +# 이슈에 나 할당 +gh issue create \ + --title "[FEAT]: 예약 취소 기능" \ + --assignee "@me" +``` + +--- + +## ✅ 다음 단계 + +이슈를 생성했다면 `/start` 커맨드로 작업을 시작하세요! \ No newline at end of file diff --git a/.claude/commands/pr-create.md b/.claude/commands/pr-create.md new file mode 100644 index 00000000..f4f7f485 --- /dev/null +++ b/.claude/commands/pr-create.md @@ -0,0 +1,93 @@ +# PR 생성 + +현재 브랜치의 변경사항을 분석하고 PR을 자동으로 생성합니다. + +--- + +## 실행 단계 + +### Step 1: 현재 상태 확인 + +아래 명령어를 실행해 브랜치명과 미커밋 변경사항을 파악합니다: + +```bash +git branch --show-current +git status --short +``` + +브랜치명 `{타입}/#${이슈번호}-{설명}` 에서 타입과 이슈번호를 추출합니다. +브랜치명이 이 형식이 아니면 사용자에게 이슈번호와 타입을 직접 물어봅니다. + +### Step 2: 테스트 실행 + +```bash +./gradlew test +``` + +- **실패 시**: 즉시 중단합니다. 실패한 테스트 목록을 사용자에게 알리고 수정을 요청합니다. PR 생성을 재개하려면 다시 `/pr-create`를 실행하면 됩니다. +- **통과 시**: 다음 단계로 진행합니다. + +### Step 3: 미커밋 변경사항 처리 + +`git status`에 변경사항이 있으면: +1. 변경된 파일 목록을 보여줍니다 +2. 사용자에게 커밋 메시지를 물어봅니다 (형식: `{타입}: {설명} #{이슈번호}`) +3. 아래 명령어로 커밋합니다: + +```bash +git add {변경된 파일들} +git commit -m "{타입}: {설명} #{이슈번호}" +``` + +변경사항이 없으면 이 단계를 건너뜁니다. + +### Step 4: 변경 내용 분석 + +PR 본문 작성을 위해 변경 내용을 분석합니다: + +```bash +git diff origin/develop...HEAD --stat +git log origin/develop...HEAD --oneline +``` + +### Step 5: 브랜치 Push + +```bash +git push origin {현재_브랜치명} +``` + +### Step 6: PR 생성 + +분석한 내용을 바탕으로 아래 형식으로 PR을 생성합니다. + +- **PR 제목**: `[{타입}] {한 줄 요약}` (50자 이하) +- **본문**: `.github/pull_request_template.md` 기반으로 작성 + - `### 💡 작업 개요`: git log를 분석해 작업 내용 요약 + - `### ✅ 작업 내용`: 변경된 파일과 커밋을 기반으로 체크리스트 작성 + - `### 🧪 테스트 내용`: 통과한 테스트 요약 + - `### 📝 기타 참고 사항`: 반드시 `Closes #${이슈번호}` 포함 + +```bash +gh pr create \ + --base develop \ + --title "[{타입}] {요약}" \ + --body "$(cat <<'EOF' +### 💡 작업 개요 +{작업 요약} + +### ✅ 작업 내용 +{체크리스트} + +### 🧪 테스트 내용 +- `./gradlew test` 전체 통과 + +### 📝 기타 참고 사항 +- Closes #{이슈번호} +EOF +)" +``` + +### Step 7: 완료 안내 + +생성된 PR URL을 사용자에게 알립니다. +최소 2명의 팀원에게 리뷰를 요청하도록 안내합니다. diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 00000000..561bff64 --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,78 @@ +# 코드 리뷰 (백엔드) + +변경사항을 백엔드 팀 규칙 기준으로 리뷰합니다. + +## 리뷰 절차 + +### 1. 변경사항 확인 + +```bash +git diff origin/develop...HEAD +``` + +### 2. 레이어별 체크리스트 + +#### 🔴 머지 블로킹 (반드시 수정) + +**공통** +- [ ] `System.out.println` 사용 +- [ ] 민감 정보(비밀번호, API Key) 하드코딩 +- [ ] `application-local.yml` / `.env` 파일 커밋 +- [ ] 테스트 실패 + +**Controller** +- [ ] 비즈니스 로직이 Controller에 직접 작성됨 (Service로 위임 필수) +- [ ] Entity를 직접 반환 (반드시 DTO로 변환) +- [ ] 요청 DTO에 `@Valid` 없음 +- [ ] ApiResponse 사용 안 함 (통일된 응답 형식) +- [ ] Swagger 문서화 없음 (`@Operation`, `@ApiResponses`) + +**Service** +- [ ] Repository를 2개 이상 호출하는 로직에 `@Transactional` 없음 +- [ ] 조회 전용 메서드에 `@Transactional(readOnly = true)` 없음 +- [ ] 예외를 삼키는 빈 catch 블록 +- [ ] `RuntimeException` 직접 throw (GeneralException + ErrorStatus 사용 필수) + +**Repository** +- [ ] Service / 비즈니스 로직이 Repository에 작성됨 (Repository는 DB 접근만) + +**Entity** +- [ ] Entity 변경 시 팀 사전 공유 안 함 (DB 스키마 영향) + +#### 🟡 권장 수정 (리뷰어 판단) + +- [ ] 새 API에 Swagger `@Operation` 없음 +- [ ] HTTP 상태 코드가 규칙과 다름 (예: 생성인데 SuccessStatus._OK 반환) +- [ ] SuccessStatus 대신 도메인별 SuccessStatus 사용 권장 (예: StoreSuccessStatus) +- [ ] ErrorStatus 대신 도메인별 ErrorStatus 사용 권장 (예: BookingErrorStatus) +- [ ] 필드 주입(`@Autowired`) 사용 → 생성자 주입(`@RequiredArgsConstructor`) 권장 +- [ ] Converter 사용 안 함 (DTO ↔ Entity 변환 로직이 Service에 있음) +- [ ] 복잡한 Service에 Command/Query 분리 안 함 +- [ ] QueryDSL 사용 가능한데 일반 JPA 쿼리 사용 +- [ ] 테스트 given/when/then 구분 없음 +- [ ] 단위 테스트에서 `@SpringBootTest` 사용 (너무 무거움) + +#### 🔵 Nit (사소한 의견, 머지 블로킹 아님) + +- 네이밍 개선 제안 +- 더 간결한 코드 작성 방법 + +### 3. 리뷰 코멘트 작성 방식 + +``` +nit: 변수명을 좀 더 직관적으로 지으면 어떨까요? ex) result → savedUser +[질문] 여기서 @Transactional(readOnly = true) 를 안 쓴 이유가 있나요? +[제안] 이 로직은 UserValidator 로 분리하면 테스트하기 더 편할 것 같아요. +``` + +- 한국어로, 팀원에게 말하듯 부드럽게 +- 문제점만 지적하지 말고 개선 방향도 함께 제시 + +### 4. 리뷰 요약 + +``` +✅ 전반적으로 깔끔합니다. +🔴 블로킹: N건 +🟡 권장 수정: N건 +🔵 Nit: N건 +``` diff --git a/.claude/commands/start.md b/.claude/commands/start.md new file mode 100644 index 00000000..7945aed4 --- /dev/null +++ b/.claude/commands/start.md @@ -0,0 +1,52 @@ +# 작업 시작 + +인자 형식: `/start {이슈번호} {타입} {설명}` +예시: `/start 123 feat cancel-booking` + +$ARGUMENTS: $ARGUMENTS + +--- + +## 실행 단계 + +### Step 0: 정보 수집 + +`$ARGUMENTS`를 파싱합니다: +- 첫 번째 토큰 = 이슈번호 (숫자만, `#` 없이) +- 두 번째 토큰 = 타입 (`feat` / `fix` / `refactor` / `chore` / `docs`) +- 세 번째 이후 = 설명 (영문 kebab-case) + +`$ARGUMENTS`가 비어있거나 정보가 부족하면 사용자에게 아래 3가지를 물어봅니다: +1. **이슈 번호** (예: `123`) +2. **타입** (`feat` / `fix` / `refactor` / `chore` / `docs`) +3. **간단한 설명** (영문 kebab-case, 예: `cancel-booking`) + +### Step 1: develop 브랜치 최신화 + +아래 명령어를 순서대로 실행합니다: + +```bash +git checkout develop +git pull origin develop +``` + +### Step 2: 작업 브랜치 생성 + +브랜치명 규칙: `{타입}/#${이슈번호}-{설명}` + +수집한 정보로 브랜치를 생성합니다: + +```bash +git checkout -b {타입}/#${이슈번호}-{설명} +``` + +### Step 3: 완료 확인 + +```bash +git branch --show-current +``` + +현재 브랜치명을 사용자에게 알리고 아래 내용을 안내합니다: +- GitHub 이슈 `#${이슈번호}` 체크리스트를 확인하며 개발 진행 +- 커밋 메시지 형식: `{타입}: {설명} #{이슈번호}` (예: `feat: 예약 취소 서비스 구현 #123`) +- 구현 완료 후 `/pr-create` 로 PR 자동 생성 diff --git a/.claude/hooks/pre-push-test.sh b/.claude/hooks/pre-push-test.sh new file mode 100755 index 00000000..941b1961 --- /dev/null +++ b/.claude/hooks/pre-push-test.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Claude Code PreToolUse 훅: git push 전 테스트 자동 실행 +# stdin으로 Bash 도구의 입력 JSON을 받아 push 명령인지 확인 후 테스트를 실행합니다. + +INPUT=$(cat) + +# Bash 도구 입력에서 실행 명령어 추출 +BASH_CMD=$(python3 -c " +import sys, json +try: + data = json.loads(sys.stdin.read()) + print(data.get('command', '')) +except Exception: + print('') +" <<< "$INPUT") + +# git push 명령이 아니면 즉시 통과 +if ! echo "$BASH_CMD" | grep -qE '(^|&&|\|\|)\s*git push'; then + exit 0 +fi + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " git push 감지 — 테스트를 먼저 실행합니다" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +cd "$PROJECT_ROOT" + +if ./gradlew test 2>&1; then + echo "" + echo "✅ 테스트 통과 — push를 진행합니다." + exit 0 +else + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " ❌ 테스트 실패 — push가 차단되었습니다" + echo " 실패한 테스트를 수정한 후 다시 시도하세요." + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 2 +fi diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md new file mode 100644 index 00000000..a739c48a --- /dev/null +++ b/.claude/rules/api-design.md @@ -0,0 +1,202 @@ +# API 설계 규칙 + +## REST 엔드포인트 설계 + +- URL은 **소문자 kebab-case**, 명사 복수형 사용 +- 동사 사용 금지 (행위는 HTTP 메서드로 표현) +- 프로젝트 기본 경로: `/api/v1/` + +``` +✅ GET /api/v1/stores +✅ GET /api/v1/stores/{storeId} +✅ POST /api/v1/stores +✅ PATCH /api/v1/stores/{storeId} +✅ DELETE /api/v1/stores/{storeId} + +✅ POST /api/v1/bookings +✅ GET /api/v1/bookings/{bookingId} +✅ PATCH /api/v1/bookings/{bookingId}/cancel + +✅ POST /api/v1/payments/confirm +✅ POST /api/v1/payments/webhook + +❌ GET /api/v1/getStore +❌ POST /api/v1/deleteStore +❌ POST /api/v1/cancelBooking (동사 사용 금지) +``` + +### URL 설계 팁 +- 리소스 ID는 경로 변수로: `/stores/{storeId}` +- 하위 리소스: `/stores/{storeId}/menus` +- 특정 액션이 필요한 경우: `/bookings/{bookingId}/cancel` +- 검색/필터: 쿼리 파라미터 사용 `/stores?category=KOREAN®ion=서울` + +## HTTP 상태 코드 + +| 상황 | 코드 | +|------|------| +| 조회 성공 | 200 OK | +| 생성 성공 | 201 Created | +| 삭제 성공 (응답 없음) | 204 No Content | +| 잘못된 요청 (유효성 실패) | 400 Bad Request | +| 인증 실패 | 401 Unauthorized | +| 권한 없음 | 403 Forbidden | +| 리소스 없음 | 404 Not Found | +| 중복 리소스 | 409 Conflict | +| 서버 오류 | 500 Internal Server Error | + +## 공통 응답 형식 + +프로젝트 공통 응답 래퍼: `global/apiPayload/ApiResponse.java` + +모든 API는 **ApiResponse**로 래핑하여 반환합니다. + +```json +// 성공 응답 +{ + "isSuccess": true, + "code": "STORE_1", + "message": "식당 생성에 성공했습니다", + "result": { + "id": 1, + "storeName": "맛있는 한식당", + "address": "서울시 강남구", + "category": "KOREAN", + "rating": 4.5 + } +} + +// 실패 응답 +{ + "isSuccess": false, + "code": "STORE404", + "message": "존재하지 않는 식당입니다", + "result": null +} +``` + +### ApiResponse 사용법 + +```java +// ✅ 성공 응답 +@PostMapping +public ApiResponse createStore(@RequestBody @Valid CreateStoreRequest request) { + StoreResponse response = storeCommandService.createStore(request); + return ApiResponse.onSuccess(SuccessStatus._CREATED, response); +} + +// ✅ 성공 응답 (커스텀 메시지) +return ApiResponse.onSuccess(StoreSuccessStatus.STORE_CREATED, response); + +// ✅ 실패는 예외로 처리 (GeneralException) +throw new GeneralException(ErrorStatus.STORE_NOT_FOUND); +// GeneralExceptionAdvice에서 자동으로 ApiResponse로 변환 +``` + +### 상태 코드 정의 + +- **SuccessStatus**: 공통 성공 상태 (`_OK`, `_CREATED`) +- **도메인별 SuccessStatus**: `StoreSuccessStatus`, `BookingSuccessStatus` +- **ErrorStatus**: 공통 에러 상태 (`BAD_REQUEST`, `UNAUTHORIZED`, `NOT_FOUND`) +- **도메인별 ErrorStatus**: `StoreErrorStatus`, `BookingErrorStatus` + +## Swagger 문서화 + +**Springdoc OpenAPI 2.8.1** 사용 + +모든 API에 아래 어노테이션 필수: + +```java +@Operation( + summary = "식당 단건 조회", + description = "식당 ID로 상세 정보를 조회합니다." +) +@ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "식당을 찾을 수 없습니다", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) +}) +@GetMapping("/{storeId}") +public ApiResponse getStore(@PathVariable Long storeId) { + return ApiResponse.onSuccess(storeQueryService.getStoreDetail(storeId)); +} +``` + +### Swagger UI 접근 +- **로컬**: http://localhost:8080/swagger-ui/index.html +- **프로덕션**: https://eatsfine.co.kr/swagger-ui/index.html + +### 인증 필요 API +JWT 토큰이 필요한 API는 Swagger UI에서 **Authorize** 버튼으로 토큰 설정 +``` +Bearer {access_token} +``` + +## 버전 관리 + +- 현재 API 버전: `/api/v1/` +- 하위 호환이 깨지는 변경 시 버전 업: `/api/v2/` +- 버전 변경은 팀 사전 협의 필수 +- 기존 버전은 최소 3개월 유지 (deprecation 공지 후) + +## CORS 설정 + +CORS 설정은 `global/config/SecurityConfig.java`에서 관리합니다. + +### 현재 설정 +```java +// SecurityConfig.java +CorsConfiguration config = new CorsConfiguration(); +config.setAllowedOrigins(Arrays.asList( + "https://www.eatsfine.co.kr", + "http://localhost:3000", + "http://localhost:5173" +)); +config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); +config.setAllowedHeaders(Collections.singletonList("*")); +config.setAllowCredentials(true); +``` + +### 주의사항 +- 프론트엔드 도메인 추가 시 **팀에 공유** 후 SecurityConfig 수정 +- `localhost` 포트는 개발 환경에만 허용 +- 프로덕션에서는 실제 도메인만 허용 + +## 페이지네이션 + +검색/목록 조회 API는 **Spring Data의 Page** 사용 + +```java +@GetMapping +public ApiResponse> searchStores( + @RequestParam(required = false) String category, + @RequestParam(required = false) String region, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable +) { + Page stores = storeQueryService.searchStores(category, region, pageable); + return ApiResponse.onSuccess(stores); +} +``` + +**응답 형식**: +```json +{ + "isSuccess": true, + "code": "COMMON200", + "message": "성공", + "result": { + "content": [ /* 데이터 배열 */ ], + "pageable": { "pageNumber": 0, "pageSize": 20 }, + "totalElements": 150, + "totalPages": 8, + "last": false, + "first": true + } +} +``` diff --git a/.claude/rules/git.md b/.claude/rules/git.md new file mode 100644 index 00000000..1be9a106 --- /dev/null +++ b/.claude/rules/git.md @@ -0,0 +1,87 @@ +# Git / PR 규칙 + +## 브랜치 전략 + +``` +main ← 배포 브랜치 (직접 push 금지) +develop ← 통합 브랜치 +feat/기능명 ← 새 기능 개발 +fix/버그명 ← 버그 수정 +chore/작업명 ← 의존성, 설정 변경 +refactor/내용 ← 리팩토링 +``` + +## 커밋 메시지 규칙 (Conventional Commits) + +``` +<타입>: <한 줄 요약> (50자 이하) + +[선택] 본문 — 무엇을, 왜 변경했는지 + +[선택] Closes #이슈번호 +``` + +### 타입 목록 + +| 타입 | 사용 상황 | +|------|-----------| +| `feat` | 새로운 기능 추가 | +| `fix` | 버그 수정 | +| `docs` | 주석, README 수정 | +| `refactor` | 기능 변경 없는 리팩토링 | +| `test` | 테스트 추가/수정 | +| `chore` | 빌드, 의존성, 설정 변경 | + +### 예시 + +``` +feat: 회원가입 이메일 중복 검증 추가 + +이메일 중복 여부를 DB 조회로 검증하고, +중복 시 409 Conflict를 반환합니다. + +Closes #23 +``` + +## PR 규칙 + +### PR 생성 전 체크 +- [ ] base 브랜치: `develop` (main 직접 PR 금지) +- [ ] `./gradlew test` 전체 통과 +- [ ] 셀프 리뷰 완료 (diff 직접 확인) +- [ ] Entity 변경 시 팀 사전 공유 완료 + +### PR 제목 형식 + +``` +[feat] 회원가입 API 구현 +[fix] 토큰 만료 시 401 응답 누락 수정 +[refactor] UserService 트랜잭션 범위 개선 +``` + +### PR 본문 템플릿 + +```markdown +## 변경 사항 +- + +## API 변경 (해당 시) +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | /api/v1/users | 회원가입 | + +## 테스트 방법 +1. + +## 관련 이슈 +Closes # +``` + +### 리뷰 규칙 + +- 최소 **2명 승인** 후 머지 +- `nit:` — 사소한 의견 (머지 블로킹 아님) +- `[질문]` — 로직 이해를 위한 질문 +- DB 스키마 변경 PR은 **2명 이상 승인** 필수 +- Entity 변경 시 **Slack에 사전 공유** 필수 +- 커뮤니케이션: **Slack** 채널 활용 diff --git a/.claude/rules/java.md b/.claude/rules/java.md new file mode 100644 index 00000000..7b6a0724 --- /dev/null +++ b/.claude/rules/java.md @@ -0,0 +1,190 @@ +# Java 코딩 규칙 + +## 네이밍 규칙 + +| 대상 | 규칙 | 예시 | +|------|------|------| +| 클래스 | PascalCase | `UserService`, `StoreController`, `BookingService` | +| 메서드 / 변수 | camelCase | `findUserById`, `userId`, `createStore` | +| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT`, `DEFAULT_BOOKING_INTERVAL` | +| 패키지 | 소문자 | `com.eatsfine.domain.user`, `com.eatsfine.global.config` | +| DTO (요청) | `~Request` | `CreateStoreRequest`, `UpdateBookingRequest` | +| DTO (응답) | `~Response` | `UserResponse`, `StoreDetailResponse` | +| Converter | `~Converter` | `StoreConverter`, `BookingConverter` | +| 예외 클래스 | 커스텀 예외 | `GeneralException` (프로젝트 공통 예외) | +| 상태 코드 | `~Status` | `SuccessStatus`, `ErrorStatus`, `StoreErrorStatus` | + +## 레이어 역할 분리 + +``` +Controller → 요청/응답 처리, 유효성 검증만 담당 +Service → 비즈니스 로직 담당 +Repository → DB 접근만 담당 (비즈니스 로직 금지) +``` + +```java +// ✅ Controller는 Service에 위임만 (ApiResponse 사용) +@PostMapping("/stores") +public ApiResponse createStore( + @CurrentUser User owner, + @RequestBody @Valid CreateStoreRequest request) { + StoreResponse response = storeCommandService.createStore(owner, request); + return ApiResponse.onSuccess(SuccessStatus._CREATED, response); +} + +// ❌ Controller에서 비즈니스 로직 처리 금지 +@PostMapping("/stores") +public ApiResponse createStore(...) { + Store store = storeRepository.findByName(request.getName()); // 금지 + if (store != null) { + throw new GeneralException(ErrorStatus.STORE_ALREADY_EXISTS); // 금지 + } + ... +} +``` + +### Command/Query 서비스 분리 (CQRS 패턴) +복잡한 도메인의 경우 Command(CUD)와 Query(R) 서비스를 분리합니다. + +```java +// ✅ Command 서비스 (생성, 수정, 삭제) +@Service +@RequiredArgsConstructor +public class StoreCommandServiceImpl implements StoreCommandService { + private final StoreRepository storeRepository; + + @Transactional + public StoreResponse createStore(User owner, CreateStoreRequest request) { + // 비즈니스 로직 + } +} + +// ✅ Query 서비스 (조회) +@Service +@RequiredArgsConstructor +public class StoreQueryServiceImpl implements StoreQueryService { + private final StoreRepository storeRepository; + + @Transactional(readOnly = true) + public Page searchStores(StoreSearchCondition condition) { + // QueryDSL 조회 로직 + } +} +``` + +## DTO 규칙 + +- Entity를 Controller에서 직접 반환 **금지** — 반드시 DTO로 변환 +- 요청 DTO에는 `@Valid` 검증 어노테이션 필수 +- DTO ↔ Entity 변환은 **Converter 클래스** 사용 (각 도메인의 `converter/` 패키지) + +```java +// ✅ DTO 예시 (요청) +public record CreateStoreRequest( + @NotBlank(message = "가게 이름은 필수입니다") + String storeName, + + @NotBlank(message = "주소는 필수입니다") + String address, + + @NotNull(message = "카테고리는 필수입니다") + Category category, + + @Min(value = 0, message = "예약 간격은 0분 이상이어야 합니다") + Integer bookingIntervalMinutes +) {} + +// ✅ DTO 예시 (응답) +public record StoreResponse( + Long id, + String storeName, + String address, + Category category, + BigDecimal rating +) {} + +// ✅ Converter 패턴 (변환 로직 분리) +@Component +public class StoreConverter { + public Store toEntity(CreateStoreRequest request, User owner, Region region) { + return Store.builder() + .owner(owner) + .region(region) + .storeName(request.storeName()) + .address(request.address()) + .category(request.category()) + .bookingIntervalMinutes(request.bookingIntervalMinutes()) + .build(); + } + + public StoreResponse toResponse(Store store) { + return new StoreResponse( + store.getId(), + store.getStoreName(), + store.getAddress(), + store.getCategory(), + store.getRating() + ); + } +} +``` + +## 예외 처리 + +- 프로젝트 공통 예외: `GeneralException` (global/apiPayload/exception/) +- 상태 코드: `ErrorStatus`, 도메인별 ErrorStatus (예: `StoreErrorStatus`, `BookingErrorStatus`) +- `try-catch`로 예외를 삼키지 않는다 (로깅 후 반드시 re-throw 또는 변환) +- 전역 예외 처리: `GeneralExceptionAdvice` (global/apiPayload/handler/) + +```java +// ✅ GeneralException + ErrorStatus 사용 +@Transactional(readOnly = true) +public Store findById(Long storeId) { + return storeRepository.findById(storeId) + .orElseThrow(() -> new GeneralException(ErrorStatus.STORE_NOT_FOUND)); +} + +// ✅ 도메인별 ErrorStatus 사용 +public void validateBooking(Booking booking) { + if (booking.getStatus() == BookingStatus.CANCELED) { + throw new GeneralException(BookingErrorStatus.ALREADY_CANCELED); + } +} + +// ❌ RuntimeException 직접 throw 금지 +throw new RuntimeException("Store not found"); // 금지 + +// ❌ 예외를 삼키는 빈 catch 블록 금지 +try { + // ... +} catch (Exception e) { + // 아무것도 하지 않음 - 금지 +} +``` + +### ErrorStatus 정의 예시 +```java +// global/apiPayload/code/status/ErrorStatus.java +public enum ErrorStatus implements BaseErrorCode { + // 공통 에러 + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "존재하지 않는 식당입니다."), + + // ... +} + +// domain/booking/status/BookingErrorStatus.java +public enum BookingErrorStatus implements BaseErrorCode { + ALREADY_CANCELED(HttpStatus.BAD_REQUEST, "BOOKING400_1", "이미 취소된 예약입니다."), + INVALID_BOOKING_TIME(HttpStatus.BAD_REQUEST, "BOOKING400_2", "유효하지 않은 예약 시간입니다."), + + // ... +} +``` + +## 기타 + +- `@Autowired` 필드 주입 금지 → 생성자 주입 사용 (Lombok `@RequiredArgsConstructor`) +- `System.out.println` 금지 → `log.info()` / `log.error()` 사용 +- 매직 넘버는 상수로 추출 diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 00000000..d5040e23 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,365 @@ +# 테스트 작성 규칙 + +## 테스트 레이어 구분 + +| 레이어 | 테스트 방식 | 도구 | +|--------|-------------|------| +| Service | 순수 단위 테스트 (Mock 사용) | JUnit 5 + Mockito | +| Controller | 슬라이스 테스트 | `@WebMvcTest` + MockMvc | +| Repository | 슬라이스 테스트 | `@DataJpaTest` | +| 통합 | 전체 컨텍스트 | `@SpringBootTest` (최소화) | + +## 테스트 메서드 네이밍 + +한글 네이밍 사용 (형식: 메서드명_상황_기대결과) + +```java +@Test +void findById_존재하는식당ID_식당정보반환() { ... } + +@Test +void findById_존재하지않는ID_GeneralException발생() { ... } + +@Test +void createStore_중복된가게이름_GeneralException발생() { ... } + +@Test +void createBooking_예약가능시간_예약성공() { ... } + +@Test +void cancelBooking_이미취소된예약_BookingErrorStatus예외발생() { ... } +``` + +## Service 단위 테스트 예시 + +### Query Service 테스트 + +```java +package com.eatsfine.domain.store.service; + +@ExtendWith(MockitoExtension.class) +class StoreQueryServiceImplTest { + + @Mock + private StoreRepository storeRepository; + + @Mock + private StoreConverter storeConverter; + + @InjectMocks + private StoreQueryServiceImpl storeQueryService; + + @Test + void findById_존재하는식당ID_식당정보반환() { + // given + Long storeId = 1L; + Store store = Store.builder() + .id(storeId) + .storeName("맛있는 한식당") + .address("서울시 강남구") + .category(Category.KOREAN) + .build(); + StoreResponse expectedResponse = new StoreResponse( + storeId, "맛있는 한식당", "서울시 강남구", Category.KOREAN, BigDecimal.valueOf(4.5) + ); + + given(storeRepository.findById(storeId)).willReturn(Optional.of(store)); + given(storeConverter.toResponse(store)).willReturn(expectedResponse); + + // when + StoreResponse result = storeQueryService.findById(storeId); + + // then + assertThat(result.storeName()).isEqualTo("맛있는 한식당"); + assertThat(result.category()).isEqualTo(Category.KOREAN); + verify(storeRepository, times(1)).findById(storeId); + } + + @Test + void findById_존재하지않는ID_GeneralException발생() { + // given + given(storeRepository.findById(anyLong())).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> storeQueryService.findById(999L)) + .isInstanceOf(GeneralException.class) + .hasMessageContaining("STORE_NOT_FOUND"); + } +} +``` + +### Command Service 테스트 + +```java +@ExtendWith(MockitoExtension.class) +class BookingCommandServiceImplTest { + + @Mock + private BookingRepository bookingRepository; + + @Mock + private StoreRepository storeRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private BookingCommandServiceImpl bookingCommandService; + + @Test + void createBooking_예약가능시간_예약성공() { + // given + Long userId = 1L; + Long storeId = 2L; + CreateBookingRequest request = new CreateBookingRequest( + storeId, + LocalDate.now().plusDays(1), + LocalTime.of(18, 0), + 4, + true + ); + + User user = User.builder().id(userId).name("홍길동").build(); + Store store = Store.builder().id(storeId).storeName("맛집").build(); + Booking booking = Booking.builder() + .id(1L) + .user(user) + .store(store) + .bookingDate(request.bookingDate()) + .bookingTime(request.bookingTime()) + .partySize(request.partySize()) + .status(BookingStatus.PENDING) + .build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(storeRepository.findById(storeId)).willReturn(Optional.of(store)); + given(bookingRepository.save(any(Booking.class))).willReturn(booking); + + // when + BookingResponse result = bookingCommandService.createBooking(userId, request); + + // then + assertThat(result.status()).isEqualTo(BookingStatus.PENDING); + assertThat(result.partySize()).isEqualTo(4); + verify(bookingRepository, times(1)).save(any(Booking.class)); + } + + @Test + void cancelBooking_이미취소된예약_GeneralException발생() { + // given + Long bookingId = 1L; + Booking canceledBooking = Booking.builder() + .id(bookingId) + .status(BookingStatus.CANCELED) + .build(); + + given(bookingRepository.findById(bookingId)).willReturn(Optional.of(canceledBooking)); + + // when & then + assertThatThrownBy(() -> bookingCommandService.cancelBooking(bookingId)) + .isInstanceOf(GeneralException.class); + } +} +``` + +## Controller 슬라이스 테스트 예시 + +```java +package com.eatsfine.domain.store.controller; + +@WebMvcTest(StoreController.class) +class StoreControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private StoreQueryService storeQueryService; + + @MockBean + private StoreCommandService storeCommandService; + + @Test + void getStore_존재하는ID_조회성공() throws Exception { + // given + Long storeId = 1L; + StoreDetailResponse response = new StoreDetailResponse( + storeId, + "맛있는 한식당", + "서울시 강남구", + Category.KOREAN, + BigDecimal.valueOf(4.5), + "02-1234-5678" + ); + given(storeQueryService.getStoreDetail(storeId)).willReturn(response); + + // when & then + mockMvc.perform(get("/api/v1/stores/{storeId}", storeId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.storeName").value("맛있는 한식당")) + .andExpect(jsonPath("$.result.category").value("KOREAN")); + } + + @Test + void createStore_유효한요청_생성성공() throws Exception { + // given + CreateStoreRequest request = new CreateStoreRequest( + "새로운 맛집", + "서울시 서초구", + Category.KOREAN, + 30 + ); + StoreResponse response = new StoreResponse(1L, "새로운 맛집", "서울시 서초구", Category.KOREAN, null); + + given(storeCommandService.createStore(any(User.class), any(CreateStoreRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/v1/stores") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.storeName").value("새로운 맛집")); + } + + @Test + void createStore_필수값누락_검증실패() throws Exception { + // given + CreateStoreRequest invalidRequest = new CreateStoreRequest( + "", // 빈 문자열 (유효성 실패) + "서울시 서초구", + null, // null (유효성 실패) + 30 + ); + + // when & then + mockMvc.perform(post("/api/v1/stores") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } +} +``` + +## Repository 슬라이스 테스트 예시 + +QueryDSL을 사용하는 복잡한 쿼리의 경우 Repository 테스트 작성 + +```java +package com.eatsfine.domain.store.repository; + +@DataJpaTest +@Import(QueryDslConfig.class) // QueryDSL 설정 임포트 +class StoreRepositoryTest { + + @Autowired + private StoreRepository storeRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + void findByCategory_카테고리로조회_성공() { + // given + Store koreanStore = Store.builder() + .storeName("한식당") + .category(Category.KOREAN) + .build(); + entityManager.persist(koreanStore); + entityManager.flush(); + + // when + List result = storeRepository.findByCategory(Category.KOREAN); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getStoreName()).isEqualTo("한식당"); + } +} +``` + +## 통합 테스트 예시 + +전체 플로우 검증이 필요한 경우만 `@SpringBootTest` 사용 (최소화) + +```java +@SpringBootTest +@Transactional +class BookingIntegrationTest { + + @Autowired + private BookingCommandService bookingCommandService; + + @Autowired + private BookingQueryService bookingQueryService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StoreRepository storeRepository; + + @Test + void 예약생성부터조회까지_전체플로우_성공() { + // given: 사용자와 식당 생성 + User user = userRepository.save(User.builder().name("홍길동").build()); + Store store = storeRepository.save(Store.builder().storeName("맛집").build()); + + CreateBookingRequest request = new CreateBookingRequest( + store.getId(), + LocalDate.now().plusDays(1), + LocalTime.of(18, 0), + 4, + true + ); + + // when: 예약 생성 + BookingResponse created = bookingCommandService.createBooking(user.getId(), request); + + // then: 예약 조회 확인 + BookingResponse found = bookingQueryService.findById(created.id()); + assertThat(found.status()).isEqualTo(BookingStatus.PENDING); + } +} +``` + +## 테스트 작성 기준 + +### 필수 테스트 +- [ ] 새로운 **Service 메서드**: 단위 테스트 필수 +- [ ] 새로운 **API 엔드포인트**: Controller 테스트 필수 +- [ ] **복잡한 QueryDSL 쿼리**: Repository 테스트 권장 +- [ ] **결제, 예약 등 중요 비즈니스 로직**: 통합 테스트 권장 + +### 테스트 작성 가이드 +- **given / when / then** 주석으로 구분하여 가독성 확보 +- `@SpringBootTest`는 꼭 필요한 경우만 (느림, 전체 컨텍스트 로드) +- Mock 객체는 `@Mock`, `@MockBean` 사용 +- 테스트용 픽스처는 `src/test/java/.../fixture/` 에 별도 관리 +- 테스트 환경 설정: `application-test.yml` 사용 (H2 DB) + +### 테스트 커버리지 기준 +- **목표: 80% 이상** +- 필수 테스트 영역: + - 결제 로직 (`payment/` 도메인): **80% 이상 필수** + - 예약 로직 (`booking/` 도메인): **80% 이상 필수** + - 인증/인가 (`user/service/auth`, `global/auth`): **80% 이상 필수** +- 권장 테스트 영역: + - 모든 Service 메서드: 단위 테스트 + - 모든 API 엔드포인트: Controller 테스트 + - 복잡한 QueryDSL 쿼리: Repository 테스트 + +### 테스트 실행 명령어 +```bash +# 전체 테스트 실행 +./gradlew test + +# 특정 테스트 클래스만 실행 +./gradlew test --tests "com.eatsfine.domain.store.service.StoreQueryServiceImplTest" + +# 특정 패키지만 실행 +./gradlew test --tests "com.eatsfine.domain.booking.*" +``` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..303ef7c1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,27 @@ +{ + "permissions": { + "deny": [ + "Read(src/main/resources/application-local.yml)", + "Read(src/main/resources/application-prod.yml)", + "Read(.env)", + "Read(.env.*)", + "Read(**/*.secret)", + "Read(**/*.key)", + "Read(**/*-credentials.yml)", + "Read(**/*-credentials.yaml)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/pre-push-test.sh" + } + ] + } + ] + } +} diff --git a/CLAUDE.local.example.md b/CLAUDE.local.example.md new file mode 100644 index 00000000..f159292d --- /dev/null +++ b/CLAUDE.local.example.md @@ -0,0 +1,299 @@ +# 개인 설정 파일 (CLAUDE.local.md) + +**이 파일을 복사하여 `CLAUDE.local.md`로 이름을 변경하고 사용하세요.** + +```bash +cp CLAUDE.local.example.md CLAUDE.local.md +``` + +**참고**: `CLAUDE.local.md`는 `.gitignore`에 포함되어 있어 Git에 커밋되지 않습니다. + +--- + +## 👤 담당자 정보 + +- **이름**: [이름을 입력하세요] +- **담당 도메인**: [담당 도메인을 나열하세요] + +--- + +## 📂 담당 도메인 및 수정 가능 범위 + +### ✅ 내가 담당하는 도메인 (수정 가능) + +``` +src/main/java/com/eatsfine/domain/ +├── booking/ # 예약 관리 (담당) +│ ├── controller/ +│ ├── service/ +│ ├── repository/ +│ ├── entity/ +│ ├── dto/ +│ ├── converter/ +│ └── ... +│ +└── payment/ # 결제 관리 (담당) + ├── controller/ + ├── service/ + ├── repository/ + ├── entity/ + ├── dto/ + └── ... +``` + +**설명**: +- 위에 나열된 도메인만 수정할 수 있습니다 +- 각 도메인의 모든 하위 패키지 (controller, service, repository, dto 등) 수정 가능 +- 테스트 파일도 포함됩니다 + +--- + +## 🚫 절대 수정 금지 도메인 + +**다른 팀원이 담당하는 도메인입니다. 절대 수정하지 마세요!** + +``` +src/main/java/com/eatsfine/domain/ +├── store/ # 식당 관리 (팀원 A 담당) +├── user/ # 회원 관리 (팀원 B 담당) +├── menu/ # 메뉴 관리 (팀원 C 담당) +├── businesshours/ # 영업시간 (팀원 D 담당) +├── storetable/ # 테이블 관리 (팀원 E 담당) +├── tablelayout/ # 테이블 배치도 (팀원 F 담당) +├── tableimage/ # 식당 이미지 (팀원 G 담당) +├── tableblock/ # 테이블 블록 (팀원 H 담당) +├── inquiry/ # 1:1 문의 (팀원 I 담당) +├── region/ # 지역 정보 (공통) +├── term/ # 약관 (공통) +├── businessnumber/ # 사업자번호 검증 (공통) +└── image/ # 이미지 공통 (공통) +``` + +**만약 다른 도메인을 수정해야 하는 경우**: +1. 담당 팀원에게 Slack으로 먼저 요청 +2. 담당 팀원의 승인을 받은 후 수정 +3. PR에 해당 내용 명시 + +--- + +## ⚠️ 공통 모듈 수정 시 주의사항 + +### 수정 가능 (단, 신중히) +``` +src/main/java/com/eatsfine/domain/[내 담당 도메인]/ +├── exception/ # 도메인별 예외 +├── status/ # 도메인별 상태 코드 +└── validator/ # 도메인별 검증 로직 +``` + +### 수정 금지 (팀 전체 논의 필요) +``` +src/main/java/com/eatsfine/global/ +├── apiPayload/ # 공통 응답 & 예외 처리 +├── auth/ # 보안 & 인증 +├── config/ # 전역 설정 +│ ├── SecurityConfig.java +│ ├── SwaggerConfig.java +│ ├── JpaAuditConfig.java +│ └── jwt/ +├── common/ # BaseEntity 등 +└── s3/ # AWS S3 서비스 +``` + +**global 패키지 수정이 필요한 경우**: +1. Slack에 사전 공유 필수 +2. 팀 회의에서 논의 +3. PR에 최소 2명 이상 승인 필요 + +--- + +## 📝 Entity 수정 시 주의사항 + +### 내 담당 도메인 Entity 수정 +```java +// 담당 도메인의 Entity는 수정 가능 +Booking.java # 예약 (담당) +Payment.java # 결제 (담당) +``` + +**단, 다음 경우에는 Slack 사전 공유 필수**: +- 테이블 구조 변경 (컬럼 추가/삭제/타입 변경) +- 연관관계 변경 (ManyToOne, OneToMany 등) +- 제약조건 변경 (unique, nullable 등) + +### 다른 도메인 Entity 수정 절대 금지 +```java +// 절대 수정 금지! +Store.java # 식당 (팀원 A 담당) +User.java # 회원 (팀원 B 담당) +Menu.java # 메뉴 (팀원 C 담당) +``` + +--- + +## 🔗 도메인 간 연관관계 처리 + +### 다른 도메인 Entity를 사용해야 하는 경우 + +**✅ 가능 (읽기 전용)**: +```java +// Service에서 다른 도메인의 Repository 조회 +@Service +@RequiredArgsConstructor +public class BookingCommandServiceImpl { + private final BookingRepository bookingRepository; + private final StoreRepository storeRepository; // 다른 도메인 (읽기만) + private final UserRepository userRepository; // 다른 도메인 (읽기만) + + public BookingResponse createBooking(...) { + Store store = storeRepository.findById(storeId) // ✅ 조회만 + .orElseThrow(...); + // store 정보 사용 (읽기만) + } +} +``` + +**❌ 불가능 (수정)**: +```java +// 다른 도메인 Entity를 직접 수정하지 마세요! +public BookingResponse createBooking(...) { + Store store = storeRepository.findById(storeId) + .orElseThrow(...); + + store.setStoreName("새 이름"); // ❌ 절대 금지! + storeRepository.save(store); // ❌ 절대 금지! +} +``` + +**올바른 방법**: +- 다른 도메인의 데이터를 수정해야 한다면 담당 팀원에게 요청 +- 또는 담당 팀원이 제공하는 Service 메서드 호출 + +--- + +## 🧪 테스트 파일 수정 범위 + +### ✅ 수정 가능 +``` +src/test/java/com/eatsfine/domain/ +├── booking/ # 내 담당 도메인 테스트 +│ ├── service/ +│ ├── controller/ +│ └── repository/ +│ +└── payment/ # 내 담당 도메인 테스트 + └── ... +``` + +### 🚫 수정 금지 +``` +src/test/java/com/eatsfine/domain/ +├── store/ # 다른 팀원 담당 +├── user/ # 다른 팀원 담당 +└── ... +``` + +--- + +## 🎯 내 작업 우선순위 + +1. **High**: 내 담당 도메인의 핵심 기능 구현 +2. **Medium**: 내 담당 도메인의 테스트 작성 +3. **Low**: 코드 리팩토링 및 최적화 + +--- + +## 📌 추가 개인 규칙 + +여기에 개인적으로 지키고 싶은 규칙을 추가하세요. + +예시: +- 매일 오전 9시에 develop 브랜치 최신화 +- 커밋 전에 항상 `./gradlew test` 실행 +- 하루에 최소 1개 이상의 이슈 완료 +- 주석은 한글로 작성 + +--- + +## 🚨 Claude Code에게 전달할 지시사항 + +**Claude Code는 이 파일을 읽고 다음 규칙을 반드시 준수해야 합니다**: + +### 1. 도메인 수정 제한 +- **절대 수정 금지**: 위에 나열된 "절대 수정 금지 도메인"의 모든 파일 +- **수정 가능**: "내가 담당하는 도메인"의 파일만 수정 + +### 2. 파일 수정 전 확인 +모든 파일 수정 전에 다음을 확인: +``` +이 파일이 내 담당 도메인에 속하는가? +├─ Yes → 수정 가능 +└─ No → 절대 수정 금지, 사용자에게 알림 +``` + +### 3. 다른 도메인 수정 요청 시 대응 +사용자가 다른 팀원의 도메인을 수정하려고 할 때: +``` +⚠️ 경고: [도메인명]은(는) 담당 도메인이 아닙니다. +이 도메인은 [담당자명] 팀원이 담당하고 있습니다. + +수정이 필요한 경우: +1. Slack에서 담당 팀원에게 요청 +2. 담당 팀원의 승인을 받은 후 진행 +3. PR에 해당 내용 명시 + +계속 진행하시겠습니까? +``` + +### 4. global 패키지 수정 시도 시 대응 +``` +⚠️ 경고: global 패키지는 팀 전체가 공유하는 공통 모듈입니다. + +수정 전 필수 단계: +1. Slack에 사전 공유 +2. 팀 회의에서 논의 +3. PR에 최소 2명 이상 승인 필요 + +정말로 수정하시겠습니까? +``` + +--- + +## 📖 사용 예시 + +### 시나리오 1: 담당 도메인 작업 +``` +사용자: "Booking 도메인에 취소 기능을 추가해줘" +Claude: ✅ Booking은 담당 도메인입니다. 취소 기능을 구현하겠습니다. +``` + +### 시나리오 2: 다른 팀원 도메인 +``` +사용자: "Store 도메인에 새로운 필드를 추가해줘" +Claude: ⚠️ Store 도메인은 팀원 A가 담당하고 있습니다. + Slack에서 팀원 A에게 먼저 요청해주세요. +``` + +### 시나리오 3: 읽기 전용 사용 +``` +사용자: "Booking 생성 시 Store 정보를 조회해서 사용해줘" +Claude: ✅ Store 정보를 조회(읽기)만 하는 것은 가능합니다. + StoreRepository.findById()를 사용하겠습니다. +``` + +--- + +## 💡 팁 + +1. **담당 도메인을 명확히 작성**하세요 + - 예: `booking`, `payment`, `user` 등 + +2. **자주 변경되는 도메인 목록**을 업데이트하세요 + +3. **팀원과 Slack**으로 소통하세요 + +4. **충돌 시 develop 브랜치**를 자주 pull 하세요 + +--- + +이 파일을 수정하여 사용하세요! diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..688f73c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,427 @@ +# Eatsfine 백엔드 프로젝트 Claude Code 설정 + +## 프로젝트 개요 + +- **프로젝트명**: Eatsfine (식당 예약 시스템) +- **패키지명**: com.eatsfine +- **언어**: Java 21 +- **프레임워크**: Spring Boot 3.4.1 +- **서비스 URL**: https://www.eatsfine.co.kr +- **API 문서**: https://eatsfine.co.kr/swagger-ui/index.html + +--- + +## 협업 방식 (Issue-Based Workflow) + +### 📌 필수 워크플로우 + +**모든 개발 작업은 반드시 GitHub 이슈를 먼저 생성하고 시작합니다!** + +```mermaid +graph LR + A[1. 이슈 생성] --> B[2. 이슈 번호 획득] + B --> C[3. 브랜치 생성] + C --> D[4. 기능 구현] + D --> E[5. 검토 요청] + E --> F[6. PR 생성] + F --> G[7. 코드 리뷰] + G --> H[8. 머지] +``` + +### 1단계: GitHub 이슈 생성 +- **템플릿 종류**: + - `[FEAT]`: 새로운 기능 추가 (`.github/ISSUE_TEMPLATE/feature_request.yml`) + - `[BUG]`: 버그 수정 (`.github/ISSUE_TEMPLATE/bug_report.yml`) + - `[REFACTOR]`: 리팩토링 (`.github/ISSUE_TEMPLATE/refactor_template.yml`) + +- **필수 작성 항목**: + - 📄 설명: 작업 내용 상세 기술 + - ✅ 작업할 내용: 체크리스트 형태로 세분화 + - 🙋🏻 참고 자료: 관련 문서나 링크 + +### 2단계: 브랜치 생성 +- **브랜치명 규칙**: `타입/#이슈번호-간단한설명` +- 예시: + ```bash + git checkout -b feat/#123-cancel-booking + git checkout -b fix/#45-payment-error + git checkout -b refactor/#67-service-cqrs + ``` + +### 3단계: 커밋 메시지에 이슈 번호 포함 +```bash +git commit -m "feat: 예약 취소 서비스 메서드 구현 #123" +git commit -m "test: 예약 취소 단위 테스트 작성 #123" +``` + +### 4단계: 검토 요청 (필수) +구현 완료 후 **반드시 사용자에게 검토를 받습니다**. + +### 5단계: PR 생성 +- PR 템플릿 (`.github/pull_request_template.md`) 사용 +- **이슈 번호 연결**: `Closes #123` 또는 `Resolves #123` +- 최소 **2명 승인** 후 머지 + +### 커뮤니케이션 +- **Slack**: Entity 변경, 중요 로직 수정 시 사전 공유 +- **GitHub**: 모든 코드 리뷰 및 토론 + +--- + +## 기술 스택 + +### 핵심 프레임워크 +- **Framework**: Spring Boot 3.4.1 +- **Build**: Gradle +- **Database**: MySQL, Redis +- **ORM**: Spring Data JPA + Hibernate + QueryDSL 5.1.0 + +### 보안 & 인증 +- **Spring Security**: JWT 기반 인증 +- **JWT**: JJWT 0.11.5 +- **OAuth2**: Google, Naver, Kakao 소셜 로그인 + +### API & 문서화 +- **Springdoc OpenAPI**: 2.8.1 (Swagger UI) +- **Bean Validation**: 요청 DTO 검증 + +### 클라우드 & 외부 서비스 +- **AWS S3**: 이미지 업로드 +- **Toss Payments**: 결제 시스템 +- **WebFlux**: 비동기 HTTP 통신 + +### 테스트 +- **JUnit 5**: 단위 테스트 +- **Mockito**: Mock 객체 +- **H2**: 테스트용 In-Memory DB + +--- + +## 개발 명령어 + +```bash +# 빌드 +./gradlew build + +# 테스트 실행 +./gradlew test + +# 특정 테스트만 실행 +./gradlew test --tests "com.eatsfine.domain.user.service.UserServiceTest" + +# 로컬 실행 +./gradlew bootRun + +# QueryDSL Q클래스 생성 +./gradlew compileJava + +# 빌드 파일 정리 +./gradlew clean +``` + +--- + +## 로컬 환경 + +- **서버 포트**: `8080` +- **API 문서**: `http://localhost:8080/swagger-ui/index.html` +- **환경변수**: `src/main/resources/application-local.yml` 참고 (Git 제외) + +--- + +## 프로젝트 구조 + +``` +src/ +├── main/ +│ ├── java/com/eatsfine/ +│ │ ├── domain/ # 도메인 계층 (14개 도메인) +│ │ │ ├── user/ # 회원 관리 +│ │ │ │ ├── entity/ # User.java +│ │ │ │ ├── repository/ +│ │ │ │ ├── service/ +│ │ │ │ │ ├── auth/ # 인증 관련 +│ │ │ │ │ ├── oauth/ # OAuth2 관련 +│ │ │ │ │ └── user/ # 사용자 정보 +│ │ │ │ ├── controller/ +│ │ │ │ ├── dto/ +│ │ │ │ ├── converter/ +│ │ │ │ ├── enums/ # Role, SocialType +│ │ │ │ ├── exception/ +│ │ │ │ └── status/ +│ │ │ ├── store/ # 식당 정보 (핵심 도메인) +│ │ │ │ ├── entity/ # Store.java +│ │ │ │ ├── repository/ +│ │ │ │ ├── service/ # Command/Query 분리 +│ │ │ │ │ ├── StoreCommandService.java +│ │ │ │ │ └── StoreQueryService.java +│ │ │ │ ├── controller/ +│ │ │ │ ├── dto/ +│ │ │ │ ├── converter/ +│ │ │ │ ├── condition/ # StoreSearchCondition +│ │ │ │ ├── enums/ # Category, DepositRate +│ │ │ │ ├── validator/ +│ │ │ │ ├── exception/ +│ │ │ │ └── status/ +│ │ │ ├── booking/ # 예약 관리 +│ │ │ │ ├── entity/ # Booking, BookingTable, BookingMenu +│ │ │ │ ├── repository/ +│ │ │ │ ├── service/ +│ │ │ │ ├── controller/ +│ │ │ │ ├── dto/ +│ │ │ │ ├── converter/ +│ │ │ │ ├── enums/ # BookingStatus +│ │ │ │ ├── exception/ +│ │ │ │ └── status/ +│ │ │ ├── payment/ # 결제 (Toss Payments) +│ │ │ │ ├── entity/ # Payment.java +│ │ │ │ ├── repository/ +│ │ │ │ ├── service/ # PaymentService, TossPaymentService +│ │ │ │ ├── controller/ # 일반 + Webhook +│ │ │ │ ├── dto/ +│ │ │ │ ├── enums/ # PaymentStatus, PaymentMethod, PaymentProvider +│ │ │ │ ├── exception/ +│ │ │ │ └── status/ +│ │ │ ├── menu/ # 메뉴 관리 +│ │ │ ├── businesshours/ # 영업시간 +│ │ │ ├── storetable/ # 테이블 관리 +│ │ │ ├── tablelayout/ # 테이블 배치도 +│ │ │ ├── tableimage/ # 식당 이미지 +│ │ │ ├── tableblock/ # 테이블 예약 불가 관리 +│ │ │ ├── inquiry/ # 1:1 문의 +│ │ │ ├── region/ # 지역 정보 +│ │ │ ├── term/ # 약관 +│ │ │ ├── businessnumber/ # 사업자번호 검증 +│ │ │ └── image/ # 이미지 공통 +│ │ │ +│ │ └── global/ # 전역 설정 계층 +│ │ ├── annotation/ # @CurrentUser 등 커스텀 어노테이션 +│ │ ├── apiPayload/ # 공통 응답 & 예외 +│ │ │ ├── ApiResponse.java # 통일된 응답 형식 +│ │ │ ├── code/ # BaseCode, BaseErrorCode +│ │ │ │ └── status/ # SuccessStatus, ErrorStatus +│ │ │ ├── exception/ # GeneralException +│ │ │ └── handler/ # GeneralExceptionAdvice +│ │ ├── auth/ # 보안 & 인증 +│ │ │ ├── AuthCookieProvider.java +│ │ │ ├── CustomAccessDeniedHandler.java +│ │ │ ├── CustomAuthenticationEntryPoint.java +│ │ │ └── UserDetailsServiceImpl.java +│ │ ├── common/ # BaseEntity (JPA Auditing) +│ │ ├── config/ # 설정 +│ │ │ ├── SecurityConfig.java # Spring Security, OAuth2, CORS +│ │ │ ├── SwaggerConfig.java # Springdoc OpenAPI +│ │ │ ├── JpaAuditConfig.java +│ │ │ ├── QueryDslConfig.java +│ │ │ ├── S3Config.java +│ │ │ ├── TossPaymentConfig.java +│ │ │ └── jwt/ # JWT 설정 +│ │ │ ├── JwtTokenProvider.java +│ │ │ └── JwtAuthenticationFilter.java +│ │ ├── controller/ # HealthController +│ │ ├── s3/ # S3Service +│ │ └── validator/ # 커스텀 검증 +│ │ +│ └── resources/ +│ ├── application.yml +│ ├── application-local.yml # 로컬 전용 (Git 제외) +│ ├── application-prod.yml # 프로덕션 (민감정보) +│ └── application-test.yml # 테스트 +│ +└── test/ + └── java/com/eatsfine/ + ├── EatsfineApplicationTests.java + ├── controller/ + │ └── HealthControllerTest.java + └── domain/inquiry/controller/ + └── InquiryControllerTest.java +``` + +--- + +## 핵심 도메인 및 엔티티 + +### 주요 엔티티 관계 +``` +User (회원) + ├── OneToOne: Term (약관 동의) + └── role: ROLE_USER / ROLE_OWNER + +Store (식당) - 핵심 엔티티 + ├── ManyToOne: User (owner) + ├── ManyToOne: Region + ├── OneToMany: BusinessHours (영업시간) + ├── OneToMany: Menu + ├── OneToMany: TableImage + └── OneToMany: TableLayout + +Booking (예약) + ├── ManyToOne: User + ├── ManyToOne: Store + ├── OneToMany: BookingTable + ├── OneToMany: BookingMenu + └── OneToMany: Payment + +Payment (결제) + ├── ManyToOne: Booking + └── Provider: TOSS_PAYMENTS +``` + +--- + +## 아키텍처 패턴 + +### 계층 구조 (DDD + CQRS 패턴) +``` +Controller Layer (요청/응답 처리) + ↓ +Service Layer (비즈니스 로직) + ├── CommandService (생성, 수정, 삭제) + └── QueryService (조회) + ↓ +Repository Layer (DB 접근) + ├── JpaRepository (기본 CRUD) + └── Custom Repository (QueryDSL 복잡 쿼리) + ↓ +Entity Layer (도메인 모델) +``` + +### API 응답 형식 (ApiResponse) +```json +// 성공 +{ + "isSuccess": true, + "code": "STORE_1", + "message": "식당 생성에 성공했습니다", + "result": { ... } +} + +// 실패 +{ + "isSuccess": false, + "code": "BAD_REQUEST", + "message": "잘못된 요청입니다", + "result": null +} +``` + +--- + +## 코드 품질 기준 + +### PR 전 필수 체크리스트 +- [ ] `./gradlew test` 전체 통과 +- [ ] 새 비즈니스 로직에 단위 테스트 작성 +- [ ] **테스트 커버리지 80% 이상** 권장 (중요 로직 필수) +- [ ] Swagger 문서 정상 반영 확인 (`@Operation`, `@ApiResponses`) +- [ ] `application-local.yml` 커밋 금지 +- [ ] 민감 정보(비밀번호, API Key) 하드코딩 금지 +- [ ] Entity 변경 시 **Slack에 사전 공유** (DB 스키마 영향) + +### PR 승인 규칙 +- 일반 PR: **최소 2명 승인** 후 머지 +- Entity 변경, global 패키지, 보안 설정 변경: **2명 이상 승인** 필수 +- 커뮤니케이션: **Slack** 활용 + +--- + +## 개인별 담당 도메인 설정 (CLAUDE.local.md) + +### 📌 개인 설정 파일 사용 + +팀원마다 담당 도메인이 다르므로, **개인별 설정 파일**을 사용합니다. + +**설정 방법**: +```bash +# 예시 파일을 복사하여 개인 설정 파일 생성 +cp CLAUDE.local.example.md CLAUDE.local.md + +# CLAUDE.local.md 파일을 열어 담당 도메인 설정 +vim CLAUDE.local.md +``` + +**CLAUDE.local.md 파일 내용**: +- ✅ **내가 담당하는 도메인**: 자유롭게 수정 가능 +- 🚫 **다른 팀원 담당 도메인**: 절대 수정 금지 +- ⚠️ **공통 모듈** (`global/`): 팀 논의 필수 + +**중요**: +- `CLAUDE.local.md`는 `.gitignore`에 포함되어 있어 Git에 커밋되지 않습니다 +- 각 팀원이 자신만의 설정을 관리합니다 +- Claude Code는 이 파일을 읽고 도메인 수정 권한을 자동으로 제한합니다 + +--- + +## Claude Code 사용 시 주의사항 + +### 🚫 절대 수정 금지 +- `application-prod.yml`, `application-test.yml` 수정 금지 +- `*.secret`, `*.key`, `.env` 파일 수정 금지 +- 프론트엔드 코드 (`/frontend`, `/client` 등) 절대 수정 금지 +- **다른 팀원이 담당하는 도메인** (CLAUDE.local.md에 명시) + +### ⚠️ 신중히 수정 (팀 리뷰 필수) +- **Entity 수정** (DB 스키마 변경): **Slack**에 반드시 사전 공유 +- **공통 모듈** (`global/` 패키지): 팀 리뷰 필수, **2명 승인** 필요 +- **보안 설정** (`SecurityConfig`, `JwtTokenProvider`): 신중히 수정, **2명 승인** 필요 + +### ✅ 자유롭게 수정 가능 +- **내가 담당하는 도메인** (CLAUDE.local.md에 명시) + - 도메인 내 비즈니스 로직 (`service/`, `controller/`) + - DTO, Converter, Validator + - 도메인별 예외 및 상태 코드 + - 테스트 코드 + +--- + +## 세부 규칙 파일 + +- [Java 코딩 규칙](.claude/rules/java.md) +- [Git/PR 규칙](.claude/rules/git.md) +- [API 설계 규칙](.claude/rules/api-design.md) +- [테스트 작성 규칙](.claude/rules/testing.md) + +--- + +## Claude Code 커맨드 사용법 + +프로젝트에서 사용할 수 있는 커스텀 커맨드입니다: + +### `/issue-create` - 이슈 생성 가이드 +새로운 작업을 시작하기 전에 GitHub 이슈를 생성하는 방법을 안내합니다. +- Feature, Bug, Refactor 템플릿 가이드 +- 체크리스트 작성 예시 +- GitHub CLI 사용법 + +### `/start` - 작업 시작 +GitHub 이슈를 생성한 후 개발을 시작할 때 사용합니다. +- 이슈 번호 기반 브랜치 생성 +- 커밋 메시지 규칙 +- 작업 컨텍스트 공유 + +### `/review` - 코드 리뷰 +변경사항을 팀 규칙 기준으로 리뷰합니다. +- 레이어별 체크리스트 +- 머지 블로킹 항목 확인 +- 개선 제안 + +### `/pr-create` - PR 생성 +작업 완료 후 Pull Request를 생성합니다. +- PR 템플릿 작성 가이드 +- 이슈 번호 연결 +- 테스트 및 검증 확인 + +**사용 예시**: +```bash +# 1. 이슈 생성 가이드 확인 +/issue-create + +# 2. 작업 시작 +/start + +# 3. 코드 리뷰 요청 +/review + +# 4. PR 생성 +/pr-create +```