diff --git a/.gitignore b/.gitignore index 655fb8d..76debf5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,6 @@ build/ bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ -*spec.md init/ docker-compose-monitoring.yml lock-test.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 358ede3..2ef4fce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,3 +91,28 @@ Each domain package follows: `controller/ → service/ → repository/ → domai - **JWT**: jjwt 0.11.5 | **AWS SDK**: v2.27.21 (S3) | **API Docs**: Springdoc OpenAPI 2.7.0 - **Monitoring**: Spring Actuator + Micrometer Prometheus | **Cache**: Caffeine + +## 필수 참조 문서 + +코드 작성 전 반드시 아래 문서를 읽고 규칙을 따르십시오. + +| 문서 | 역할 | 필수 시점 | +|------|------|----------| +| `rule.md` | AI 그라운드 룰 (개발 프로세스, 아키텍처, 코드 스타일, 테스트 규칙) | **모든 작업 전** | +| `spec.md` | 기능 명세 인덱스 → `docs/spec/` 하위 문서 | 기능 구현·수정 시 | + +### 프로젝트 문서 구조 + +``` +rule.md ← AI 코딩 규칙 (개발 프로세스 0번 ~ Git 규칙 13번) +spec.md ← 기능 명세 인덱스 +docs/ + spec/ ← 기능 명세 (*-spec.md) + api-spec.md + erd-spec.md + business-rules-spec.md + domain-spec.md + reports/ ← 부하 테스트 결과 리포트 +k6/ ← k6 부하 테스트 스크립트 +perf.sh ← 부하 테스트 실행 래퍼 +``` diff --git a/README.md b/README.md index 659e088..8f99fa2 100644 --- a/README.md +++ b/README.md @@ -195,4 +195,4 @@ k6 run k6/reservation-constant-test.js | Spike (100 VU 피크) | 0→100 | 11,200건 | 706ms | 1,000ms | 0% | 0건 | | Constant (50 VU) | 50 | 8,152건 | 236ms | 567ms | 0% | 0건 | -상세 결과: [`docs/load-test-report.md`](docs/load-test-report.md), [`docs/constant-test-report.md`](docs/constant-test-report.md) +상세 결과: [`docs/reports/load-test-report.md`](docs/reports/load-test-report.md), [`docs/reports/constant-test-report.md`](docs/reports/constant-test-report.md) diff --git a/docs/constant-test-report.md b/docs/reports/constant-test-report.md similarity index 100% rename from docs/constant-test-report.md rename to docs/reports/constant-test-report.md diff --git a/docs/reports/load-test-guide.md b/docs/reports/load-test-guide.md new file mode 100644 index 0000000..89d017d --- /dev/null +++ b/docs/reports/load-test-guide.md @@ -0,0 +1,264 @@ +# 부하 테스트 가이드 + +k6를 활용한 부하 테스트 작성 및 실행 가이드입니다. + +--- + +## 디렉토리 구조 + +``` +k6/ + env/ + production.env ← 운영 환경 변수 + local.env ← 로컬 환경 변수 + sql/ + reset-reservations.sql ← 예약 데이터 초기화 SQL + common.js ← 범용 GET 엔드포인트 부하 테스터 + reservation-helpers.js ← 예약 테스트 공통 모듈 (메트릭, 헬퍼) + reservation-test.js ← 예약 Ramp-up/Spike 테스트 + reservation-constant-test.js ← 예약 Constant VU 테스트 + +perf.sh ← 부하 테스트 실행 래퍼 + +docs/reports/ ← 테스트 결과 리포트 + load-test-report.md + constant-test-report.md +``` + +--- + +## 환경 설정 + +환경별 설정은 `k6/env/` 디렉토리의 `.env` 파일로 관리합니다. + +### 환경 파일 + +| 파일 | BASE_URL | 용도 | +|------|----------|------| +| `production.env` | https://api.form-pass.life | 운영 서버 대상 테스트 | +| `local.env` | http://localhost:8080 | 로컬 개발 서버 테스트 | + +### 환경 파일 형식 + +```bash +BASE_URL=https://api.form-pass.life +SCHEDULE_ID=1 +DENYLIST=/api/auth/email/send,/api/auth/email/verify,/api/host/s3/presigned-url +``` + +### 규칙 +- **시크릿(TOKEN 등)은 env 파일에 넣지 않습니다.** 실행 시 `--token` 옵션으로 전달하십시오. +- 새 환경이 필요하면 `k6/env/{환경명}.env` 파일을 추가하십시오. +- CLI 옵션(`--base-url`, `--schedule-id` 등)은 env 파일보다 우선합니다. + +--- + +## 실행 방법 + +Docker 기반으로 k6를 실행합니다. `perf.sh` 래퍼를 사용하며, `--env`는 필수입니다. + +### 기본 사용법 + +```bash +# 예약 Spike 테스트 (운영, warm-up + 대시보드) +./perf.sh --env production --script reservation-test.js --warmup --dashboard + +# 예약 Constant 테스트 (운영) +./perf.sh --env production --script reservation-constant-test.js --warmup --dashboard + +# 로컬 서버 대상 테스트 +./perf.sh --env local --script reservation-test.js --dashboard + +# 특정 GET 엔드포인트 부하 테스트 +./perf.sh --env production --endpoints "/api/events,/health" --dashboard + +# 인증 필요 엔드포인트 +./perf.sh --env production --endpoints "/api/host/events" --token "$TOKEN" --dashboard + +# 특정 스케줄 ID 지정 (env 파일 값 오버라이드) +./perf.sh --env production --script reservation-test.js --schedule-id 3 --dashboard +``` + +### perf.sh 전체 옵션 + +| 옵션 | 설명 | 필수 | 기본값 | +|------|------|------|--------| +| `--env ` | 환경 파일 (production, local) | **필수** | - | +| `--script ` | k6 스크립트 파일명 | X | common.js | +| `--base-url ` | API 서버 URL (env보다 우선) | X | env 파일 값 | +| `--endpoints ` | 테스트할 GET 엔드포인트 (common.js 전용) | X | /health | +| `--denylist ` | 차단할 엔드포인트 (env보다 우선) | X | env 파일 값 | +| `--token ` | Bearer 토큰 | X | - | +| `--test-id ` | 테스트 ID | X | exp-YYYYMMDD-HHMM | +| `--schedule-id ` | 테스트 대상 스케줄 ID (env보다 우선) | X | env 파일 값 | +| `--warmup` | warm-up 후 main 실행 (2회) | X | false | +| `--dashboard` | 웹 대시보드 (http://localhost:5665) | X | false | + +### 대시보드 확인 + +`--dashboard` 옵션 사용 시 브라우저에서 확인: + +``` +http://localhost:5665 +``` + +--- + +## 테스트 전 준비 + +### DB 데이터 초기화 + +예약 테스트 실행 전 반드시 기존 데이터를 초기화하십시오: + +```bash +# 로컬 DB +mysql -u root -p reservation-local < k6/sql/reset-reservations.sql + +# 운영 DB (RDS 접속 후) +mysql -h {RDS_HOST} -u {USER} -p {DATABASE} < k6/sql/reset-reservations.sql +``` + +초기화 SQL 위치: `k6/sql/reset-reservations.sql` + +### 차단 목록 (Denylist) + +아래 엔드포인트는 외부 부작용이 있으므로 부하 테스트에서 차단합니다: + +| 엔드포인트 | 이유 | +|-----------|------| +| `/api/auth/email/send` | 실제 이메일 발송 | +| `/api/auth/email/verify` | 인증 상태 변경 | +| `/api/host/s3/presigned-url` | S3 URL 생성 비용 | + +--- + +## 스크립트 작성 규칙 + +### 파일 템플릿 + +모든 k6 스크립트는 아래 구조를 따르십시오: + +```javascript +/** + * =========================================== + * Form PASS {테스트 대상} 부하 테스트 ({시나리오 유형}) + * =========================================== + * + * [테스트 시나리오] + * {시나리오 설명} + * + * [테스트 시간] {총 소요 시간} + * + * [성공 기준] + * - p(95) 응답 시간 < {기준}ms + * - 에러율 < {기준}% + * + * [실행 방법] + * $ ./perf.sh --env {환경} --script {파일명} --warmup --dashboard + */ + +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +// ============================================ +// Test Configuration +// ============================================ +export const options = { + // stages 또는 vus + duration + thresholds: { ... }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)', 'count'], +}; + +// ============================================ +// Main +// ============================================ +export default function () { ... } +export function setup() { ... } +export function teardown() { ... } +``` + +### 환경변수 + +| 변수 | 설명 | 제공 방식 | +|------|------|----------| +| `BASE_URL` | API 서버 URL | env 파일 | +| `SCHEDULE_ID` | 테스트 대상 스케줄 ID | env 파일 | +| `DENYLIST` | 차단 엔드포인트 | env 파일 | +| `TEST_ID` | 테스트 식별자 | perf.sh 자동 생성 | +| `TOKEN` | Bearer 토큰 | CLI 옵션 (--token) | +| `WARMUP` | warm-up 모드 여부 | perf.sh 관리 | + +### 공통 헤더 + +모든 요청에 아래 헤더를 포함하십시오: + +```javascript +headers: { + 'X-Test-Id': TEST_ID, // 서버 로그 추적용 + 'X-Perf-Test': 'true', // 부하 테스트 식별 +} +``` + +### 공통 모듈 패턴 + +도메인별 테스트는 공통 모듈을 먼저 만들고, 테스트 스크립트에서 import합니다: + +``` +{도메인}-helpers.js ← 메트릭, 헬퍼 함수, 요청 로직 +{도메인}-test.js ← Spike/Ramp-up 시나리오 +{도메인}-constant-test.js ← Constant VU 시나리오 +``` + +```javascript +// reservation-test.js +import { createReservation } from './reservation-helpers.js'; + +export default function () { + createReservation(); +} +``` + +--- + +## 결과 리포트 작성 + +테스트 완료 후 결과를 `docs/reports/` 디렉토리에 리포트로 작성하십시오. + +### 파일명 규칙 + +`{테스트대상}-{시나리오}-report.md` + +예: `load-test-report.md`, `constant-test-report.md` + +### 리포트 필수 항목 + +1. **테스트 환경** — 서버, DB, 클라이언트, 사용한 env 파일 +2. **테스트 시나리오** — 부하 패턴, VU, 시간 +3. **성공 기준** — Thresholds +4. **응답 시간** — avg, med, p90, p95, p99, max +5. **처리량** — 총 요청 수, RPS, 성공률 +6. **병목 분석 및 결론** +7. **실행 명령어** — 재현 가능하도록 전체 명령어 기재 + +--- + +## 시나리오 유형 + +| 유형 | 목적 | VU 패턴 | 사용 시점 | +|------|------|---------|----------| +| **Constant** | 기준 성능(Baseline) 측정 | 고정 VU | 기능 구현 후 초기 검증 | +| **Ramp-up/Spike** | 부하 증가 시 내구성 확인 | 점진적 증가 → 피크 | 동시성 로직 검증 | +| **Stress** | 한계점 탐색 | 지속적 증가 | 최대 수용량 파악 | +| **Soak** | 장시간 안정성 | 고정 VU, 장시간 | 메모리 누수 등 확인 | + +--- + +## 전체 워크플로우 요약 + +``` +1. 환경 확인 → k6/env/{환경}.env 내용 확인 +2. DB 초기화 → k6/sql/reset-reservations.sql 실행 +3. 테스트 실행 → ./perf.sh --env {환경} --script {스크립트} --warmup --dashboard +4. 대시보드 확인 → http://localhost:5665 +5. 결과 리포트 → docs/reports/{테스트명}-report.md 작성 +``` diff --git a/docs/load-test-report.md b/docs/reports/load-test-report.md similarity index 100% rename from docs/load-test-report.md rename to docs/reports/load-test-report.md diff --git a/docs/spec/api-spec.md b/docs/spec/api-spec.md new file mode 100644 index 0000000..fd2de52 --- /dev/null +++ b/docs/spec/api-spec.md @@ -0,0 +1,578 @@ +# API 명세 + +Base URL: `https://api.form-pass.life` + +Swagger UI: `http://localhost:8080/swagger-ui/index.html` + +--- + +## 인증 + +- **방식**: JWT Bearer Token +- **헤더**: `Authorization: Bearer {accessToken}` +- **만료**: 30분 (1,800,000ms) +- **알고리즘**: HS256 + +| 접근 수준 | 경로 | 설명 | +|-----------|------|------| +| Public | `/api/auth/**` | 인증 불필요 | +| Public | `/api/events/**` | 인증 불필요 | +| Public | `/api/reservations/**` | 인증 불필요 | +| Public | `/actuator/**`, `/health` | 모니터링 | +| ROLE_HOST | `/api/host/**` | JWT 필수 | + +--- + +## 1. Auth API + +### POST `/api/auth/signup` + +호스트 회원가입 + +**Request Body:** +```json +{ + "email": "host@example.com", // @NotBlank @Email + "password": "password123", // @NotBlank + "name": "홍길동" // @NotBlank +} +``` + +**Response:** `201 Created` + +**에러:** +| 상태 | 조건 | +|------|------| +| 400 | 이미 존재하는 이메일 | +| 400 | 이메일 인증 미완료 | + +--- + +### POST `/api/auth/login` + +로그인 (JWT 발급) + +**Request Body:** +```json +{ + "email": "host@example.com", // @NotBlank @Email + "password": "password123" // @NotBlank +} +``` + +**Response:** `200 OK` +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "email": "host@example.com" +} +``` + +**에러:** +| 상태 | 조건 | +|------|------| +| 400 | 존재하지 않는 이메일 | +| 400 | 비밀번호 불일치 | + +--- + +### POST `/api/auth/email/send` + +이메일 인증코드 발송 (6자리) + +**Request Body:** +```json +{ + "email": "host@example.com" +} +``` + +**Response:** `200 OK` `"인증 코드가 전송되었습니다."` + +--- + +### POST `/api/auth/email/verify` + +인증코드 확인 + +**Request Body:** +```json +{ + "email": "host@example.com", + "authCode": "123456" +} +``` + +**Response:** `200 OK` `"이메일 인증이 완료되었습니다."` + +**에러:** +| 상태 | 조건 | +|------|------| +| 400 | 잘못된 인증 코드 | + +--- + +## 2. Event API (Guest - Public) + +### GET `/api/events` + +공개 이벤트 목록 조회 (isPublic = true) + +**Response:** `200 OK` +```json +[ + { + "id": 1, + "title": "2026 봄 페스티벌", + "location": "서울 올림픽공원", + "images": ["https://s3.../image1.jpg"], + "description": "봄 축제입니다", + "eventCode": "ABC1234567", + "isPublic": true, + "schedules": [ + { + "id": 1, + "startTime": "2026-04-01T10:00:00", + "endTime": "2026-04-01T18:00:00", + "maxCapacity": 100, + "reservedCount": 45 + } + ], + "questions": [ + { + "id": 1, + "questionText": "소속을 입력해주세요", + "questionType": "TEXT", + "isRequired": true + } + ] + } +] +``` + +--- + +### GET `/api/events/{eventCode}` + +이벤트 상세 조회 (이벤트 코드 기반) + +**Path Parameter:** `eventCode` (String, 10자리) + +**Response:** `200 OK` (EventResponse 단건) + +**접근 규칙:** +- 공개 이벤트: 누구나 조회 가능 +- 비공개 이벤트: 호스트 본인만 조회 가능 (JWT 필요) + +**에러:** +| 상태 | 조건 | +|------|------| +| 400 | 존재하지 않는 이벤트 코드 | +| 403 | 비공개 이벤트에 비소유자 접근 | + +--- + +## 3. Event API (Host - ROLE_HOST) + +### POST `/api/host/events` + +이벤트 생성 + +**Headers:** `Authorization: Bearer {token}` + +**Request Body:** +```json +{ + "title": "2026 봄 페스티벌", // @NotBlank + "location": "서울 올림픽공원", // @NotBlank + "images": ["https://s3.../img.jpg"], + "description": "봄 축제입니다", + "schedules": [ // @NotEmpty @Valid + { + "startTime": "2026-04-01T10:00:00", // @NotNull + "endTime": "2026-04-01T18:00:00", // @NotNull + "maxCapacity": 100 // @NotNull + } + ], + "questions": [ // @Valid + { + "questionText": "소속을 입력해주세요", // @NotBlank + "questionType": "TEXT", // @NotNull (TEXT|CHECKBOX|RADIO) + "isRequired": true // @NotNull + } + ] +} +``` + +**Response:** `201 Created` (EventResponse) + +--- + +### GET `/api/host/events` + +내 이벤트 목록 조회 + +**Response:** `200 OK` (List\) + +--- + +### GET `/api/host/events/{eventId}` + +내 이벤트 상세 조회 + +**Response:** `200 OK` (EventResponse) + +**에러:** +| 상태 | 조건 | +|------|------| +| 400 | 존재하지 않는 이벤트 | +| 403 | 본인 소유가 아닌 이벤트 | + +--- + +### PUT `/api/host/events/{eventId}` + +이벤트 수정 (전체 교체) + +**Request Body:** CreateEventRequest와 동일 구조 + +**Response:** `200 OK` (EventResponse) + +**에러:** +| 상태 | 조건 | +|------|------| +| 400 | 존재하지 않는 이벤트 | +| 403 | 본인 소유가 아닌 이벤트 | +| 400 | 이미 폼 응답이 존재하는 질문 수정 시도 | + +--- + +### PATCH `/api/host/events/{eventId}/visibility` + +공개/비공개 전환 + +**Request Body:** +```json +{ + "isPublic": true // @NotNull +} +``` + +**Response:** `200 OK` + +--- + +## 4. Reservation API (Guest - Public) + +### POST `/api/reservations` + +예약 생성 + +**Request Body:** +```json +{ + "scheduleId": 1, // @NotNull + "guestName": "김철수", // @NotBlank + "guestPhoneNumber": "01012345678", // @NotBlank @Pattern(^01[0-9]\d{7,8}$) + "ticketCount": 2, // @NotNull @Min(1) + "answers": [ + { + "questionId": 1, + "answerText": "한국대학교" + } + ] +} +``` + +**Response:** `201 Created` +```json +{ + "id": 1, + "qrToken": "550e8400-e29b-41d4-a716-446655440000", + "guestName": "김철수", + "guestPhoneNumber": "01012345678", + "ticketCount": 2, + "status": "CONFIRMED", + "isCheckedIn": false, + "eventTitle": "2026 봄 페스티벌", + "eventLocation": "서울 올림픽공원", + "schedule": { + "id": 1, + "startTime": "2026-04-01T10:00:00", + "endTime": "2026-04-01T18:00:00", + "maxCapacity": 100, + "reservedCount": 47 + }, + "answers": [ + { + "questionId": 1, + "questionText": "소속을 입력해주세요", + "answerText": "한국대학교" + } + ], + "createdAt": "2026-03-06T14:30:00" +} +``` + +**에러:** +| 상태 | 조건 | +|------|------| +| 400 | 존재하지 않는 스케줄 | +| 409 | 잔여 좌석 부족 (capacity 초과) | +| 409 | 동일 스케줄 + 전화번호 중복 예약 | +| 400 | 필수 질문 미응답 | + +--- + +### GET `/api/reservations/{id}` + +예약 상세 조회 + +**Response:** `200 OK` (ReservationResponse) + +--- + +### GET `/api/reservations/qr/{qrToken}` + +QR 토큰으로 예약 조회 + +**Response:** `200 OK` (ReservationResponse) + +--- + +### DELETE `/api/reservations/{id}` + +예약 취소 + +**Response:** `204 No Content` + +**에러:** +| 상태 | 조건 | +|------|------| +| 400 | 존재하지 않는 예약 | +| 409 | 이미 취소된 예약 | +| 409 | 이미 체크인된 예약 | + +--- + +### POST `/api/reservations/lookup` + +이름 + 전화번호로 내 예약 조회 + +**Request Body:** +```json +{ + "guestName": "김철수", + "guestPhoneNumber": "01012345678" +} +``` + +**Response:** `200 OK` +```json +[ + { + "id": 1, + "qrToken": "550e8400-...", + "eventTitle": "2026 봄 페스티벌", + "eventLocation": "서울 올림픽공원", + "guestName": "김철수", + "ticketCount": 2, + "scheduleStartTime": "2026-04-01T10:00:00", + "scheduleEndTime": "2026-04-01T18:00:00", + "isCheckedIn": false, + "createdAt": "2026-03-06T14:30:00" + } +] +``` + +--- + +## 5. Host Reservation API (ROLE_HOST) + +### GET `/api/host/events/{eventId}/dashboard` + +대시보드 통계 + +**Response:** `200 OK` +```json +{ + "totalSeats": 500, + "reservedCount": 234, + "reservationRate": 46.8, + "checkedInCount": 120, + "availableSeats": 266 +} +``` + +--- + +### GET `/api/host/events/{eventId}/reservations` + +예약 목록 (페이징 + 필터링 + 검색) + +**Query Parameters:** +| 파라미터 | 타입 | 필수 | 기본값 | 설명 | +|----------|------|------|--------|------| +| `scheduleId` | Long | X | - | 특정 스케줄 필터 | +| `searchKeyword` | String | X | - | 이름/전화번호 검색 | +| `page` | int | X | 0 | 페이지 번호 | +| `size` | int | X | 20 | 페이지 크기 | +| `sort` | String | X | createdAt,DESC | 정렬 | + +**Response:** `200 OK` (Page\) +```json +{ + "content": [ + { + "id": 1, + "guestName": "김철수", + "guestPhoneNumber": "01012345678", + "ticketCount": 2, + "status": "CONFIRMED", + "isCheckedIn": false, + "createdAt": "2026-03-06T14:30:00", + "scheduleName": "2026-04-01T10:00 ~ 2026-04-01T18:00" + } + ], + "totalElements": 234, + "totalPages": 12, + "size": 20, + "number": 0 +} +``` + +--- + +### GET `/api/host/reservations/{reservationId}` + +예약 상세 조회 (호스트) + +**Response:** `200 OK` (ReservationResponse) + +--- + +### PATCH `/api/host/reservations/{reservationId}/cancel` + +호스트의 예약 취소 + +**Response:** `204 No Content` + +--- + +### POST `/api/host/checkin` + +QR 체크인 + +**Request Body:** +```json +{ + "qrToken": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**Response:** `200 OK` +```json +{ + "message": "체크인이 완료되었습니다.", + "guestName": "김철수", + "ticketCount": 2 +} +``` + +**에러:** +| 상태 | 조건 | +|------|------| +| 400 | 존재하지 않는 QR 토큰 | +| 409 | 이미 체크인 완료 | +| 409 | 취소된 예약 | +| 403 | 본인 소유 이벤트가 아님 | + +--- + +### PATCH `/api/host/reservations/{reservationId}/checkin` + +수동 체크인 + +**Response:** `200 OK` (CheckinResponse) + +--- + +### GET `/api/host/events/{eventId}/schedules-status` + +스케줄별 현황 + +**Response:** `200 OK` +```json +[ + { + "scheduleId": 1, + "startTime": "2026-04-01T10:00", + "endTime": "2026-04-01T18:00", + "maxCapacity": 100, + "currentCount": 45, + "reservations": [ + { + "id": 1, + "guestName": "김철수", + "guestPhoneNumber": "01012345678", + "ticketCount": 2, + "isCheckedIn": false + } + ] + } +] +``` + +--- + +## 6. Image API (ROLE_HOST) + +### POST `/api/host/s3/presigned-url` + +S3 Presigned URL 발급 + +**Request Body:** +```json +{ + "fileName": "event-banner.jpg", + "contentType": "image/jpeg" +} +``` + +**Response:** `200 OK` +```json +{ + "presignedUrl": "https://form-pass-images.s3.ap-northeast-2.amazonaws.com/...", + "fileUrl": "https://form-pass-images.s3.ap-northeast-2.amazonaws.com/event-banner.jpg" +} +``` + +--- + +## 7. Monitoring (Public) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/health` | 서버 상태 (status, timestamp) | +| GET | `/actuator/health` | 상세 헬스체크 (DB, Disk) | +| GET | `/actuator/prometheus` | Prometheus 메트릭 | +| GET | `/actuator/metrics` | 전체 메트릭 목록 | + +--- + +## 공통 에러 응답 형식 + +```json +{ + "status": 400, + "message": "존재하지 않는 이벤트입니다" +} +``` + +| HTTP 상태 | 발생 조건 | +|-----------|----------| +| 400 Bad Request | 유효성 검증 실패, 존재하지 않는 리소스 | +| 401 Unauthorized | JWT 없음 또는 만료 | +| 403 Forbidden | 권한 없음 (소유권 불일치) | +| 409 Conflict | 중복 예약, 좌석 부족, 이미 취소/체크인 | +| 500 Internal Server Error | 서버 내부 오류 | diff --git a/docs/spec/business-rules-spec.md b/docs/spec/business-rules-spec.md new file mode 100644 index 0000000..eeb04c2 --- /dev/null +++ b/docs/spec/business-rules-spec.md @@ -0,0 +1,181 @@ +# 비즈니스 규칙 + +--- + +## 1. 회원가입 / 인증 + +### 이메일 인증 +- 6자리 인증코드를 이메일로 발송 +- 인증코드 유효시간: **5분** (Caffeine Cache TTL) +- 인증 완료 후 회원가입 허용시간: **1분** +- 최대 동시 저장 인증코드: 1,000건 + +### 회원가입 조건 +- 이메일 중복 불가 (`existsByEmail` 검증) +- 이메일 인증 완료 필수 (`isEmailVerified` 검증) +- 비밀번호는 BCrypt로 해시 저장 +- 가입 시 역할은 `ROLE_HOST`로 고정 + +### 로그인 +- 이메일 + 비밀번호 검증 +- 성공 시 JWT 토큰 발급 (30분 만료) +- JWT Claims: subject(email), role(HOST) + +--- + +## 2. 이벤트 관리 + +### 이벤트 생성 +- 호스트만 생성 가능 (ROLE_HOST) +- 10자리 고유 `eventCode` 자동 생성 (CodeGenerator) +- 중복 eventCode 발생 시 재생성 +- 최소 1개 이상의 스케줄 필수 (`@NotEmpty`) +- 질문(FormQuestion)은 선택사항 +- 이미지는 S3 URL 목록으로 전달 + +### 이벤트 수정 +- **소유권 검증**: `event.getHost().getEmail().equals(hostEmail)` 필수 +- 스케줄, 질문, 이미지는 전체 교체 (clear → re-add) +- **질문 수정 제한**: 해당 이벤트에 이미 폼 응답(FormAnswer)이 존재하면 질문 수정 불가 + - `formAnswerRepository.existsByEventId(eventId)` 검증 + +### 이벤트 조회 +- 공개 이벤트(`isPublic = true`): 누구나 조회 가능 +- 비공개 이벤트: 호스트 본인만 조회 가능 + - 비소유자 접근 시 `AccessDeniedException` (403) + +### 공개/비공개 전환 +- 소유권 검증 후 `isPublic` 값 변경 + +--- + +## 3. 예약 + +### 예약 생성 플로우 + +``` +1. scheduleId로 EventSchedule 조회 (PESSIMISTIC_WRITE Lock) +2. 전화번호 AES-256 암호화 +3. 중복 예약 검증 (같은 스케줄 + 같은 전화번호 + CONFIRMED 상태) +4. 잔여 좌석 검증 (reservedCount + ticketCount <= maxCapacity) +5. reservedCount 증가 +6. UUID 기반 qrToken 발급 +7. 폼 응답 처리 (FormAnswer 생성) +8. 필수 질문 응답 여부 검증 +9. 예약 저장 (status = CONFIRMED) +``` + +### 동시성 제어 + +``` +요청 A ──▶ ┌──────────────────────────────┐ + │ PESSIMISTIC_WRITE Lock 획득 │ + │ reservedCount 검증 → 증가 │ + │ 예약 생성 → Lock 해제 │ +요청 B ──▶ └──────────────────────────────┘ ──▶ Lock 대기 → 순차 처리 +``` + +- `EventScheduleRepository.findByIdWithLock()` → `@Lock(PESSIMISTIC_WRITE)` +- Row-level Lock으로 같은 스케줄에 대한 동시 예약을 순차 처리 +- `reservedCount >= maxCapacity` 시 `IllegalStateException` (409) + +### 중복 예약 방지 +- 동일 스케줄 + 동일 전화번호(암호화된 값) + CONFIRMED 상태 조합으로 검증 +- 중복 시 `IllegalStateException` (409) + +### 폼 응답 규칙 +- `questionId`로 FormQuestion 조회 후 FormAnswer 생성 +- 필수 질문(`isRequired = true`)에 대해 응답 여부 검증 +- 단, 빌트인 질문("이름", "전화번호", "name", "phone")은 검증에서 제외 + +### 예약 취소 조건 +- 이미 **취소된** 예약: 취소 불가 (409) +- 이미 **체크인된** 예약: 취소 불가 (409) +- 취소 시 `reservedCount` 감소 (ticketCount만큼) +- 상태를 `CANCELLED`로 변경 + +### 내 예약 조회 (lookup) +- 이름 + 전화번호로 조회 +- 전화번호를 암호화하여 DB 검색 +- CONFIRMED 상태인 예약만 반환 +- 생성일 역순 정렬 + +--- + +## 4. 체크인 + +### QR 체크인 +1. `qrToken`으로 예약 조회 +2. 소유권 검증 (예약의 이벤트 호스트 == 요청자) +3. 체크인 가능 여부 검증 +4. `isCheckedIn = true` 업데이트 + +### 수동 체크인 +1. `reservationId`로 예약 조회 +2. 소유권 검증 +3. 체크인 가능 여부 검증 +4. `isCheckedIn = true` 업데이트 + +### 체크인 불가 조건 +- 이미 체크인 완료된 예약 (409) +- 취소된 예약 (409) +- 본인 소유 이벤트가 아닌 경우 (403) + +--- + +## 5. 호스트 대시보드 + +### 통계 계산 +- `totalSeats`: 이벤트 전체 스케줄의 maxCapacity 합산 +- `reservedCount`: 전체 스케줄의 reservedCount 합산 +- `reservationRate`: (reservedCount / totalSeats) * 100 +- `checkedInCount`: 이벤트의 모든 CONFIRMED 예약 중 isCheckedIn = true 수 +- `availableSeats`: totalSeats - reservedCount + +### 예약 목록 조회 +- 페이징 지원 (기본: 20건, createdAt DESC) +- 스케줄 ID로 필터링 가능 +- 이름 또는 전화번호로 검색 가능 (LIKE 쿼리) +- 전화번호는 암호화된 값을 복호화하여 응답 + +### 스케줄별 현황 +- 이벤트의 모든 스케줄 목록 +- 각 스케줄의 capacity, 현재 예약 수 +- 각 스케줄의 CONFIRMED 예약 목록 포함 + +--- + +## 6. 이미지 업로드 + +### Presigned URL 플로우 +1. 호스트가 `fileName` + `contentType` 전달 +2. 서버가 S3 Presigned PUT URL 생성 (유효시간 제한) +3. 클라이언트가 Presigned URL로 S3에 직접 업로드 +4. 이벤트 생성/수정 시 S3 URL을 이미지 목록에 포함 + +--- + +## 7. 소유권 검증 (공통) + +모든 호스트 전용 API에서 공통으로 적용: + +```java +event.getHost().getEmail().equals(hostEmail) +``` + +- 불일치 시 `AccessDeniedException` (403) 또는 `ForbiddenException` (403) +- 검증 대상: 이벤트 CRUD, 예약 조회/취소, 체크인, 대시보드 + +--- + +## 8. 개인정보 보호 + +### 전화번호 암호화 +- 저장 시: 평문 → AES-256 암호화 → DB 저장 +- 조회 시: DB → AES-256 복호화 → 응답 +- `EncryptionUtils` 클래스에서 처리 +- 암호화 키: `application.yml`의 `encryption.secret-key` + +### 비밀번호 +- BCrypt 해시 저장 +- 로그인 시 `PasswordEncoder.matches()`로 검증 diff --git a/docs/spec/domain-spec.md b/docs/spec/domain-spec.md new file mode 100644 index 0000000..ad9aaf0 --- /dev/null +++ b/docs/spec/domain-spec.md @@ -0,0 +1,307 @@ +# 도메인 상세 + +--- + +## 1. 엔티티 + +### Host + +```java +@Entity @Table(name = "hosts") +public class Host extends BaseTimeEntity { + @Id @GeneratedValue(IDENTITY) Long id; + @Column(unique = true, nullable = false) String email; + @Column(nullable = false) String password; // BCrypt + @Column(nullable = false) String name; + @Enumerated(STRING) @Column(nullable = false) Role role; +} +``` + +**팩토리 메서드:** +```java +Host.create(String email, String password, String name, Role role) +``` + +--- + +### Event + +```java +@Entity @Table(name = "events") +public class Event extends BaseTimeEntity { + @Id @GeneratedValue(IDENTITY) Long id; + @Column(nullable = false) String title; + @Column(nullable = false) String location; + @Column(columnDefinition = "TEXT") String description; + @Column(unique = true, nullable = false, length = 10) String eventCode; + Boolean isPublic; + + @ManyToOne(LAZY) Host host; + + @OneToMany(mappedBy = "event", cascade = ALL, orphanRemoval = true) + List schedules; + + @OneToMany(mappedBy = "event", cascade = ALL, orphanRemoval = true) + List questions; + + @OneToMany(mappedBy = "event", cascade = ALL, orphanRemoval = true) + List images; +} +``` + +**팩토리 메서드:** +```java +Event.create(String title, String location, String description, String eventCode, Host host) +``` + +**주요 메서드:** +- `updateBasicInfo(title, location, description)` - 기본 정보 수정 +- `clearSchedules()` / `clearQuestions()` - 하위 엔티티 초기화 (전체 교체용) +- `addSchedule(EventSchedule)` / `addQuestion(FormQuestion)` / `addImage(EventImage)` +- `updateVisibility(Boolean isPublic)` + +--- + +### EventSchedule + +```java +@Entity @Table(name = "event_schedules") +public class EventSchedule extends BaseTimeEntity { + @Id @GeneratedValue(IDENTITY) Long id; + @Column(nullable = false) LocalDateTime startTime; + @Column(nullable = false) LocalDateTime endTime; + @Column(nullable = false) Integer maxCapacity; + @Column(nullable = false) Integer reservedCount = 0; + + @ManyToOne(LAZY) Event event; +} +``` + +**팩토리 메서드:** +```java +EventSchedule.create(LocalDateTime startTime, LocalDateTime endTime, Integer maxCapacity, Event event) +``` + +**주요 메서드:** +- `incrementReservedCount(int count)` - 예약 수 증가 (capacity 초과 시 IllegalStateException) +- `decrementReservedCount(int count)` - 예약 수 감소 +- `isFull()` → Boolean +- `getAvailableSeats()` → int + +--- + +### Reservation + +```java +@Entity @Table(name = "reservations") +public class Reservation extends BaseTimeEntity { + @Id @GeneratedValue(IDENTITY) Long id; + @ManyToOne(LAZY) EventSchedule eventSchedule; + String guestName; + String guestPhoneNumber; // AES-256 암호화 + @Column(nullable = false) Integer ticketCount = 1; + @Column(unique = true, nullable = false) String qrToken; // UUID + @Column(nullable = false) Boolean isCheckedIn = false; + @Enumerated(STRING) @Column(nullable = false) ReservationStatus status; + + @OneToMany(mappedBy = "reservation", cascade = ALL, orphanRemoval = true) + List formAnswers; +} +``` + +**팩토리 메서드:** +```java +Reservation.create(EventSchedule schedule, String guestName, String guestPhoneNumber, Integer ticketCount) +// qrToken = UUID.randomUUID(), status = CONFIRMED, isCheckedIn = false +``` + +**주요 메서드:** +- `cancel()` - 상태를 CANCELLED로 변경 +- `checkIn()` - isCheckedIn을 true로 변경 +- `validateCancellable()` - 취소 가능 여부 검증 (취소됨/체크인됨 시 예외) +- `validateCheckInPossible()` - 체크인 가능 여부 검증 +- `addFormAnswer(FormAnswer)` + +--- + +### FormQuestion + +```java +@Entity @Table(name = "form_questions") +public class FormQuestion extends BaseTimeEntity { + @Id @GeneratedValue(IDENTITY) Long id; + @Column(nullable = false) String questionText; + @Enumerated(STRING) @Column(nullable = false) QuestionType questionType; + @Column(nullable = false) Boolean isRequired; + + @ManyToOne(LAZY) Event event; +} +``` + +**팩토리 메서드:** +```java +FormQuestion.create(String questionText, QuestionType questionType, Boolean isRequired, Event event) +``` + +--- + +### FormAnswer + +```java +@Entity @Table(name = "form_answers") +public class FormAnswer { + @Id @GeneratedValue(IDENTITY) Long id; + @ManyToOne(LAZY) Reservation reservation; + @ManyToOne(LAZY) FormQuestion formQuestion; + @Column(nullable = false) String answerText; +} +``` + +**팩토리 메서드:** +```java +FormAnswer.create(Reservation reservation, FormQuestion formQuestion, String answerText) +``` + +--- + +### EventImage + +```java +@Entity @Table(name = "event_images") +public class EventImage { + @Id @GeneratedValue(IDENTITY) Long id; + @Column(nullable = false) String imageUrl; + @Column(nullable = false) Integer orderIndex; + + @ManyToOne(LAZY) Event event; +} +``` + +**팩토리 메서드:** +```java +EventImage.create(String imageUrl, Integer orderIndex, Event event) +``` + +--- + +### BaseTimeEntity (공통 상위 클래스) + +```java +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + @CreatedDate @Column(updatable = false) LocalDateTime createdAt; + @LastModifiedDate LocalDateTime updatedAt; +} +``` + +--- + +## 2. Enum + +| Enum | 값 | 위치 | +|------|----|------| +| `Role` | HOST | api.auth.domain | +| `ReservationStatus` | CONFIRMED, CANCELLED | api.reservation.domain | +| `QuestionType` | TEXT, CHECKBOX, RADIO | api.event.domain | + +--- + +## 3. DTO + +### Auth + +| 클래스 | 타입 | 필드 | +|--------|------|------| +| `SignupRequest` | record | email(@NotBlank @Email), password(@NotBlank), name(@NotBlank) | +| `LoginRequest` | record | email(@NotBlank @Email), password(@NotBlank) | +| `LoginResponse` | record | accessToken, email | +| `TokenResponse` | record | accessToken | +| `EmailSendRequest` | record | email | +| `EmailVerifyRequest` | record | email, authCode | + +### Event + +| 클래스 | 타입 | 필드 | +|--------|------|------| +| `CreateEventRequest` | class | title(@NotBlank), location(@NotBlank), images(List\), description, schedules(@NotEmpty @Valid List), questions(@Valid List) | +| `EventUpdateRequest` | class | CreateEventRequest와 동일 구조 | +| `EventVisibilityRequest` | record | isPublic(@NotNull) | +| `ScheduleRequest` | class | startTime(@NotNull), endTime(@NotNull), maxCapacity(@NotNull) | +| `QuestionRequest` | class | questionText(@NotBlank), questionType(@NotNull), isRequired(@NotNull) | +| `EventResponse` | class | id, title, location, images, description, eventCode, isPublic, schedules, questions | +| `ScheduleResponse` | class | id, startTime, endTime, maxCapacity, reservedCount | +| `QuestionResponse` | class | id, questionText, questionType, isRequired | + +### Reservation + +| 클래스 | 타입 | 필드 | +|--------|------|------| +| `ReservationRequest` | class | scheduleId(@NotNull), guestName(@NotBlank), guestPhoneNumber(@NotBlank @Pattern), ticketCount(@NotNull @Min(1)), answers(List) | +| `ReservationResponse` | class | id, qrToken, guestName, guestPhoneNumber(복호화), ticketCount, status, isCheckedIn, eventTitle, eventLocation, schedule, answers, createdAt | +| `ReservationListResponse` | class | id, guestName, guestPhoneNumber, ticketCount, status, isCheckedIn, createdAt, scheduleName | +| `ReservationLookupRequest` | class | guestName, guestPhoneNumber | +| `ReservationLookupResponse` | class | id, qrToken, eventTitle, eventLocation, guestName, ticketCount, scheduleStartTime, scheduleEndTime, isCheckedIn, createdAt | +| `FormAnswerRequest` | record | questionId, answerText | +| `FormAnswerResponse` | class | questionId, questionText, answerText | + +### Host + +| 클래스 | 타입 | 필드 | +|--------|------|------| +| `DashboardResponse` | record | totalSeats, reservedCount, reservationRate(Double), checkedInCount, availableSeats | +| `CheckinRequest` | record | qrToken | +| `CheckinResponse` | record | message, guestName, ticketCount | +| `ScheduleStatusResponse` | class | scheduleId, startTime, endTime, maxCapacity, currentCount, reservations(List\) | +| `SimpleReservationDto` | class | id, guestName, guestPhoneNumber, ticketCount, isCheckedIn | + +--- + +## 4. Repository 쿼리 메서드 + +### HostRepository + +| 메서드 | 반환 | 설명 | +|--------|------|------| +| `findByEmail(String)` | Optional\ | 이메일로 호스트 조회 | +| `existsByEmail(String)` | boolean | 이메일 중복 확인 | + +### EventRepository + +| 메서드 | 반환 | 설명 | +|--------|------|------| +| `findByHost(Host)` | List\ | 호스트의 이벤트 목록 | +| `findByIdWithDetails(Long)` | Optional\ | Fetch Join (schedules) | +| `findByEventCodeWithDetails(String)` | Optional\ | 이벤트 코드로 조회 + Fetch Join | +| `existsByEventCode(String)` | boolean | 이벤트 코드 중복 확인 | + +### EventScheduleRepository + +| 메서드 | 반환 | 설명 | +|--------|------|------| +| `findByIdWithLock(Long)` | Optional\ | **PESSIMISTIC_WRITE Lock** | + +### FormQuestionRepository + +| 메서드 | 반환 | 설명 | +|--------|------|------| +| `findByEventIdOrderById(Long)` | List\ | 이벤트의 질문 목록 (ID 순) | + +### ReservationRepository + +| 메서드 | 반환 | 설명 | +|--------|------|------| +| `findByQrToken(String)` | Optional\ | QR 토큰으로 조회 | +| `existsByEventScheduleIdAndGuestPhoneNumberAndStatus(...)` | boolean | 중복 예약 확인 | +| `findByEventScheduleIdAndStatus(Long, Status)` | List\ | 스케줄별 예약 목록 | +| `findByEventScheduleIdIn(List\, Pageable)` | Page\ | 다중 스케줄 페이징 | +| `findByIdWithDetails(Long)` | Optional\ | EntityGraph (schedule, answers) | +| `findByGuestNameAndGuestPhoneNumberAndStatus(...)` | List\ | 게스트 조회 + EntityGraph | +| `findByEventScheduleIdAndKeyword(Long, String, Pageable)` | Page\ | 스케줄 + 키워드 검색 | +| `findByEventScheduleIdInAndKeyword(List, String, Pageable)` | Page\ | 다중 스케줄 + 키워드 검색 | + +### FormAnswerRepository + +| 메서드 | 반환 | 설명 | +|--------|------|------| +| `existsByEventId(Long)` | boolean | 이벤트에 폼 응답 존재 여부 (질문 수정 가능 판단) | diff --git a/docs/spec/erd-spec.md b/docs/spec/erd-spec.md new file mode 100644 index 0000000..3fd6374 --- /dev/null +++ b/docs/spec/erd-spec.md @@ -0,0 +1,207 @@ +# ERD (Entity Relationship Diagram) + +--- + +## 관계도 + +``` +┌──────────┐ ┌──────────────┐ ┌─────────────────┐ +│ hosts │ │ events │ │ event_schedules │ +├──────────┤ ├──────────────┤ ├─────────────────┤ +│ id (PK) │──1:N─▶│ id (PK) │──1:N─▶│ id (PK) │ +│ email │ │ host_id (FK) │ │ event_id (FK) │ +│ password │ │ title │ │ startTime │ +│ name │ │ location │ │ endTime │ +│ role │ │ description │ │ maxCapacity │ +└──────────┘ │ eventCode │ │ reservedCount │ + │ isPublic │ └────────┬────────┘ + └──────┬───────┘ │ + │ │ 1:N + │ 1:N ▼ + ┌──────┴───────┐ ┌─────────────────┐ + │form_questions│ │ reservations │ + ├──────────────┤ ├─────────────────┤ + │ id (PK) │ │ id (PK) │ + │ event_id(FK) │ │ schedule_id(FK) │ + │ questionText │ │ guestName │ + │ questionType │ │ guestPhone(AES) │ + │ isRequired │ │ ticketCount │ + └──────┬───────┘ │ qrToken (UUID) │ + │ │ isCheckedIn │ + │ │ status │ + │ └────────┬────────┘ + │ │ 1:N + │ ┌────────┴────────┐ + │ │ form_answers │ + └──────────────▶├─────────────────┤ + N:1 │ id (PK) │ + │ reservation_id │ + │ question_id(FK) │ + │ answerText │ + └─────────────────┘ + +┌──────────────┐ +│ event_images │ +├──────────────┤ +│ id (PK) │ +│ event_id(FK) │ ◀── events 1:N +│ imageUrl │ +│ orderIndex │ +└──────────────┘ +``` + +--- + +## 테이블 스키마 + +### hosts + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|---------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| email | VARCHAR | NOT NULL, UNIQUE | 로그인 이메일 | +| password | VARCHAR | NOT NULL | BCrypt 해시 | +| name | VARCHAR | NOT NULL | 호스트 이름 | +| role | VARCHAR | NOT NULL | Enum(HOST) | +| created_at | DATETIME | NOT NULL, 수정불가 | JPA Auditing | +| updated_at | DATETIME | NOT NULL | JPA Auditing | + +--- + +### events + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|---------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| host_id | BIGINT | FK → hosts.id, NOT NULL | 소유 호스트 | +| title | VARCHAR | NOT NULL | 이벤트명 | +| location | VARCHAR | NOT NULL | 장소 | +| description | TEXT | | 상세 설명 | +| event_code | VARCHAR(10) | NOT NULL, UNIQUE | 10자리 고유 코드 | +| is_public | BOOLEAN | | 공개 여부 (기본 false) | +| created_at | DATETIME | NOT NULL | | +| updated_at | DATETIME | NOT NULL | | + +**Cascade:** schedules, questions, images → ALL + orphanRemoval + +--- + +### event_schedules + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|---------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| event_id | BIGINT | FK → events.id, NOT NULL | | +| start_time | DATETIME | NOT NULL | 시작 시간 | +| end_time | DATETIME | NOT NULL | 종료 시간 | +| max_capacity | INT | NOT NULL | 최대 수용 인원 | +| reserved_count | INT | NOT NULL, DEFAULT 0 | 현재 예약 수 | +| created_at | DATETIME | NOT NULL | | +| updated_at | DATETIME | NOT NULL | | + +**Lock:** `PESSIMISTIC_WRITE` (findByIdWithLock) + +--- + +### form_questions + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|---------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| event_id | BIGINT | FK → events.id, NOT NULL | | +| question_text | VARCHAR | NOT NULL | 질문 내용 | +| question_type | VARCHAR | NOT NULL | Enum(TEXT, CHECKBOX, RADIO) | +| is_required | BOOLEAN | NOT NULL | 필수 응답 여부 | +| created_at | DATETIME | NOT NULL | | +| updated_at | DATETIME | NOT NULL | | + +--- + +### reservations + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|---------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| event_schedule_id | BIGINT | FK → event_schedules.id, NOT NULL | | +| guest_name | VARCHAR | | 게스트 이름 | +| guest_phone_number | VARCHAR | | AES-256 암호화 저장 | +| ticket_count | INT | NOT NULL, DEFAULT 1 | 티켓 수량 | +| qr_token | VARCHAR | NOT NULL, UNIQUE | UUID 기반 체크인 토큰 | +| is_checked_in | BOOLEAN | NOT NULL, DEFAULT false | 체크인 여부 | +| status | VARCHAR | NOT NULL | Enum(CONFIRMED, CANCELLED) | +| created_at | DATETIME | NOT NULL | | +| updated_at | DATETIME | NOT NULL | | + +**Cascade:** formAnswers → ALL + orphanRemoval + +--- + +### form_answers + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|---------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| reservation_id | BIGINT | FK → reservations.id, NOT NULL | | +| form_question_id | BIGINT | FK → form_questions.id, NOT NULL | | +| answer_text | VARCHAR | NOT NULL | 응답 내용 | + +--- + +### event_images + +| 컬럼 | 타입 | 제약조건 | 설명 | +|------|------|---------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| event_id | BIGINT | FK → events.id, NOT NULL | | +| image_url | VARCHAR | NOT NULL | S3 이미지 URL | +| order_index | INT | NOT NULL | 정렬 순서 | + +--- + +## Enum 정의 + +### Role +| 값 | 설명 | +|-----|------| +| `HOST` | 이벤트 주최자 | + +### ReservationStatus +| 값 | 설명 | +|-----|------| +| `CONFIRMED` | 예약 확정 | +| `CANCELLED` | 예약 취소 | + +### QuestionType +| 값 | 설명 | +|-----|------| +| `TEXT` | 텍스트 입력 | +| `CHECKBOX` | 체크박스 | +| `RADIO` | 라디오 버튼 | + +--- + +## 관계 요약 + +| 부모 | 자식 | 관계 | Cascade | 설명 | +|------|------|------|---------|------| +| hosts | events | 1:N | - | 호스트가 이벤트 소유 | +| events | event_schedules | 1:N | ALL + orphanRemoval | 이벤트 삭제 시 스케줄 삭제 | +| events | form_questions | 1:N | ALL + orphanRemoval | 이벤트 삭제 시 질문 삭제 | +| events | event_images | 1:N | ALL + orphanRemoval | 이벤트 삭제 시 이미지 삭제 | +| event_schedules | reservations | 1:N | - | 스케줄별 예약 관리 | +| reservations | form_answers | 1:N | ALL + orphanRemoval | 예약 삭제 시 응답 삭제 | +| form_questions | form_answers | 1:N | - | 질문-응답 매핑 | + +--- + +## Fetch 전략 + +모든 `@ManyToOne`, `@OneToOne` 관계는 `FetchType.LAZY`를 사용합니다. + +**EntityGraph 사용처:** +- `ReservationRepository.findByIdWithDetails` → eventSchedule, formAnswers, formAnswers.formQuestion +- `ReservationRepository.findByGuestNameAndGuestPhoneNumberAndStatus` → eventSchedule, eventSchedule.event, formAnswers, formAnswers.formQuestion + +**Fetch Join 사용처:** +- `EventRepository.findByIdWithDetails` → LEFT JOIN FETCH schedules +- `EventRepository.findByEventCodeWithDetails` → LEFT JOIN FETCH schedules diff --git a/k6/env/local.env b/k6/env/local.env new file mode 100644 index 0000000..334ee27 --- /dev/null +++ b/k6/env/local.env @@ -0,0 +1,3 @@ +BASE_URL=http://localhost:8080 +SCHEDULE_ID=1 +DENYLIST=/api/auth/email/send,/api/auth/email/verify,/api/host/s3/presigned-url diff --git a/k6/env/production.env b/k6/env/production.env new file mode 100644 index 0000000..33d0d72 --- /dev/null +++ b/k6/env/production.env @@ -0,0 +1,3 @@ +BASE_URL=https://api.form-pass.life +SCHEDULE_ID=1 +DENYLIST=/api/auth/email/send,/api/auth/email/verify,/api/host/s3/presigned-url diff --git a/k6/sql/reset-reservations.sql b/k6/sql/reset-reservations.sql new file mode 100644 index 0000000..4f60f30 --- /dev/null +++ b/k6/sql/reset-reservations.sql @@ -0,0 +1,14 @@ +-- 예약 부하 테스트 전 초기화 스크립트 +-- 대상 스케줄의 예약 데이터를 삭제하고 reservedCount를 초기화합니다. +-- 사용법: mysql -u {user} -p {database} < k6/sql/reset-reservations.sql + +-- 1. 폼 응답 삭제 +DELETE FROM form_answers WHERE reservation_id IN ( + SELECT id FROM reservations WHERE event_schedule_id = 1 +); + +-- 2. 예약 삭제 +DELETE FROM reservations WHERE event_schedule_id = 1; + +-- 3. 예약 수 초기화 + capacity 확보 +UPDATE event_schedules SET reserved_count = 0, max_capacity = 100000 WHERE id = 1; diff --git a/perf.sh b/perf.sh index 2c91d90..b3702ab 100755 --- a/perf.sh +++ b/perf.sh @@ -14,19 +14,23 @@ set -euo pipefail # Defaults # ============================================ SCRIPT="common.js" -BASE_URL="https://api.form-pass.life" -DENYLIST="/api/auth/email/send,/api/auth/email/verify,/api/host/s3/presigned-url" +ENV_NAME="" +BASE_URL="" +DENYLIST="" ENDPOINTS="" TOKEN="" TEST_ID="" WARMUP=false DASHBOARD=false +SCHEDULE_ID="" # ============================================ # Parse Arguments # ============================================ while [[ $# -gt 0 ]]; do case "$1" in + --env) + ENV_NAME="$2"; shift 2 ;; --script) SCRIPT="$2"; shift 2 ;; --base-url) @@ -39,20 +43,24 @@ while [[ $# -gt 0 ]]; do TOKEN="$2"; shift 2 ;; --test-id) TEST_ID="$2"; shift 2 ;; + --schedule-id) + SCHEDULE_ID="$2"; shift 2 ;; --warmup) WARMUP=true; shift ;; --dashboard) DASHBOARD=true; shift ;; -h|--help) - echo "Usage: ./perf.sh [OPTIONS]" + echo "Usage: ./perf.sh --env [OPTIONS]" echo "" echo "Options:" + echo " --env 환경 파일 (production|local) [필수]" echo " --script k6 스크립트 파일명 (기본: common.js)" - echo " --base-url API 서버 URL (기본: https://api.form-pass.life)" + echo " --base-url API 서버 URL (env 파일보다 우선)" echo " --endpoints 테스트할 GET 엔드포인트 CSV (common.js 전용)" - echo " --denylist 차단할 엔드포인트 CSV" + echo " --denylist 차단할 엔드포인트 CSV (env 파일보다 우선)" echo " --token Bearer 토큰" echo " --test-id 테스트 ID (미지정 시 자동 생성)" + echo " --schedule-id 테스트 대상 스케줄 ID (env 파일보다 우선)" echo " --warmup warm-up 실행 후 main 실행 (2회 자동 실행)" echo " --dashboard 웹 대시보드 활성화 (http://localhost:5665)" echo " -h, --help 도움말 출력" @@ -63,6 +71,39 @@ while [[ $# -gt 0 ]]; do esac done +# ============================================ +# Load Environment File +# ============================================ +if [[ -z "$ENV_NAME" ]]; then + echo "❌ --env 옵션은 필수입니다. (production|local)" + echo " 예: ./perf.sh --env production --script reservation-test.js" + exit 1 +fi + +ENV_FILE="$(dirname "$0")/k6/env/${ENV_NAME}.env" +if [[ ! -f "$ENV_FILE" ]]; then + echo "❌ 환경 파일을 찾을 수 없습니다: ${ENV_FILE}" + echo " 사용 가능한 환경: $(ls k6/env/*.env 2>/dev/null | xargs -I{} basename {} .env | tr '\n' ', ')" + exit 1 +fi + +# env 파일에서 기본값 로드 (CLI 옵션이 우선) +while IFS='=' read -r key value; do + [[ -z "$key" || "$key" =~ ^# ]] && continue + value="${value%%#*}" # 인라인 주석 제거 + value="${value%"${value##*[! ]}"}" # 후행 공백 제거 + case "$key" in + BASE_URL) [[ -z "$BASE_URL" ]] && BASE_URL="$value" ;; + SCHEDULE_ID) [[ -z "$SCHEDULE_ID" ]] && SCHEDULE_ID="$value" ;; + DENYLIST) [[ -z "$DENYLIST" ]] && DENYLIST="$value" ;; + esac +done < "$ENV_FILE" + +# 최종 기본값 적용 +BASE_URL="${BASE_URL:-https://api.form-pass.life}" +DENYLIST="${DENYLIST:-/api/auth/email/send,/api/auth/email/verify,/api/host/s3/presigned-url}" +SCHEDULE_ID="${SCHEDULE_ID:-1}" + # ============================================ # Auto-generate Test ID # ============================================ @@ -83,6 +124,7 @@ build_docker_args() { "-e" "BASE_URL=${BASE_URL}" "-e" "TEST_ID=${current_test_id}" "-e" "DENYLIST=${DENYLIST}" + "-e" "SCHEDULE_ID=${SCHEDULE_ID}" ) if [[ -n "$ENDPOINTS" ]]; then @@ -119,8 +161,10 @@ build_docker_args() { echo "========================================" echo " Form PASS 부하테스트" echo "========================================" +echo " Env : ${ENV_NAME}" echo " Script : ${SCRIPT}" echo " Base URL : ${BASE_URL}" +echo " Schedule : ${SCHEDULE_ID}" echo " Test ID : ${TEST_ID}" echo " Warmup : ${WARMUP}" echo " Dashboard : ${DASHBOARD}" diff --git a/rule.md b/rule.md index fc3eee4..0af5cb4 100644 --- a/rule.md +++ b/rule.md @@ -1,28 +1,199 @@ -# AI Coding Agent Rules for Backend Project - -이 문서는 프로젝트의 백엔드 아키텍처, 코드 컨벤션, 비즈니스 제약 사항을 정의합니다. 코드를 작성하거나 수정하기 전에 반드시 아래 규칙을 숙지하고 엄격하게 준수하십시오. - -## 1. Tech Stack & Core Convention -- **Framework:** Spring Boot (Java/Kotlin) -- **Dependency Injection:** `@Autowired` 사용을 엄격히 금지합니다. 반드시 Lombok의 `@RequiredArgsConstructor`를 이용한 생성자 주입(Constructor Injection)을 사용하십시오. -- **DTO (Data Transfer Object):** Entity 클래스를 API 요청/응답 객체로 절대 직접 노출하지 마십시오. Controller와 Service 계층 간의 데이터 교환은 반드시 전용 DTO 또는 Record 클래스를 생성하여 매핑해야 합니다. -- **Exception Handling:** 개별 메서드나 컨트롤러에서 `try-catch`로 에러를 처리하지 마십시오. 전역 예외 처리기(`@RestControllerAdvice`)를 통해 Custom Exception을 던지고, 일관된 에러 응답 스펙을 유지하십시오. - -## 2. Database, JPA & Query Optimization -- **Lazy Loading 기본화:** 모든 JPA 연관관계 매핑(`@OneToMany`, `@ManyToOne` 등)은 반드시 `FetchType.LAZY`로 명시하십시오. `LazyInitializationException`이 발생하지 않도록 주의하십시오. -- **N+1 문제 방지:** 컬렉션이나 연관된 엔티티를 조회할 때는 단순 `findAll()`을 지양하고, 반드시 `Fetch Join` 또는 `IN` 쿼리를 적절히 혼합하여 N+1 문제를 원천 차단하십시오. -- **인덱스(Index) 고려:** DB 테이블이나 스키마 설계 시, 혹은 복잡한 조회 쿼리를 작성할 때는 클러스터링 인덱스 및 커버링 인덱스(Covering Index) 활용 가능성을 고려하여 쿼리를 최적화하십시오. -- **동시성 제어 (Concurrency):** 이벤트 생성, 예약(Reservation) 등 동시성 이슈가 발생할 수 있는 비즈니스 로직에서는 데이터베이스의 비관적 락(Pessimistic Locking)을 우선적으로 적용하여 데이터 정합성을 보장하십시오. - -## 3. Domain & Architecture Constraints -- **회원 통합 시스템 (Member Integration):** 시스템에는 `SUPER_ADMIN`, `AGENCY`, `VISITOR` 등의 다양한 권한(Role)이 존재하며, 이는 단일 `Member` 엔티티로 통합되어 관리됩니다. 권한 검증 로직은 하드코딩된 `if-else` 분기 대신, 시큐리티 컨텍스트나 명확한 권한 검증 계층을 통해 처리하십시오. -- **레거시 호환성 (Legacy Compatibility):** 기존 시스템과의 하위 호환성을 유지해야 합니다. 기존 데이터베이스 스키마나 공통 API 응답 규격을 임의로 변경하지 마십시오. -- **비동기 및 이벤트 처리:** `@Async` 또는 `@TransactionalEventListener`를 사용할 때는 트랜잭션의 커밋 시점을 명확히 인지하고, 메인 트랜잭션 롤백 시 발생할 수 있는 사이드 이펙트를 주석으로 명시하십시오. Session 관리 로직과 충돌하지 않도록 주의하십시오. - -## 4. DevOps & Infrastructure -- **모니터링 친화적 코드:** 향후 Prometheus, Grafana, Loki, APM 등의 도구와 k6 부하 테스트를 통한 모니터링이 용이하도록, 주요 비즈니스 로직에 적절한 로그 레벨(Info, Warn, Error)을 적용하여 명확한 추적(Trace)이 가능하게 작성하십시오. - -## 5. AI Behavior Rules -- **선 제안, 후 작성:** 복잡한 아키텍처 설계, 대규모 리팩토링, 또는 성능 최적화가 필요한 경우 코드를 바로 생성하지 마십시오. 구현 전략을 3줄 이내로 먼저 설명하고 사용자의 승인을 받은 후 작성하십시오. -- **주석 및 TODO 보존:** 기존 코드에 작성된 한국어 주석이나 `TODO`, `FIXME` 마커는 리팩토링 중에도 절대 삭제하거나 임의로 변경하지 마십시오. -- **최소 범위 수정:** 파일 전체를 재작성하지 말고, 목적을 달성할 수 있는 최소한의 라인과 메서드만 수정하십시오. \ No newline at end of file +# Role Objective +당신은 10년 차 이상의 시니어 백엔드(Spring Boot) 개발자이자 깐깐한 코드 리뷰어입니다. 가독성, 유지보수성, 성능, 테스트 용이성을 최우선으로 고려하며, 단순한 '동작'을 넘어 '운영 환경(Production)에서 안전한가?'를 항상 검증합니다. + +# 🤖 AI Coding Agent Master Rules for Backend Project + +이 문서는 본 프로젝트의 아키텍처, 코드 퀄리티 기준, 프레임워크 제약 사항을 정의하는 절대적인 규칙(Single Source of Truth)입니다. AI는 코드를 분석, 작성, 수정, 리팩토링하기 전에 반드시 이 문서의 모든 규칙을 적용해야 합니다. + +--- + +## 0. 🔄 Development Process (개발 프로세스) + +기능 개발 시 아래 순서를 반드시 따르십시오. 단계를 건너뛰지 마십시오. + +``` +1. 요구사항 정리 → 사용자와 대화하며 기능 요구사항을 구체화 +2. 요구사항 문서화 → 정리된 내용을 docs/spec/ 하위 명세 문서에 반영 +3. 구현 계획 → Plan Mode로 수정 대상 파일, 구현 방식, 영향 범위를 정리 +4. 사용자 승인 → 계획에 대해 사용자 동의를 받은 후 다음 단계 진행 +5. 구현 → 본 문서(rule.md)의 규칙에 따라 코드 작성 +6. 테스트 코드 작성 → 7번(Testing) 규칙에 따라 테스트 작성 +7. 통과 확인 → ./gradlew clean test 전체 통과 확인 +8. 커밋 → 사용자 승인 후 Conventional Commits 규격으로 커밋 +``` + +- **1~2단계를 건너뛰고 바로 코드를 작성하지 마십시오.** 명세가 먼저입니다. +- **4단계 승인 없이 5단계로 넘어가지 마십시오.** +- **7단계 테스트 통과 없이 8단계 커밋을 하지 마십시오.** +- 버그 수정, 단순 리팩토링 등 명세 변경이 없는 작업은 3단계(구현 계획)부터 시작할 수 있습니다. + +--- + +## 1. 🧠 AI Behavior & Interaction (AI 기본 동작 규칙) +- **Think Before Code (선 사고 후 작성):** 복잡한 비즈니스 로직, 아키텍처 설계, 쿼리 최적화 등을 수행할 때는 코드를 바로 출력하지 마십시오. 반드시 `` 태그를 사용하여 접근 방식과 구현 전략을 3~5줄로 먼저 요약하고, 사용자의 동의를 얻은 후 코드를 작성하십시오. +- **Language:** 코드 내의 클래스명, 메서드명, 변수명은 모두 명확한 **영어**로 작성합니다. 단, 사용자에게 설명하는 텍스트나 복잡한 비즈니스 로직을 설명하는 주석(Comment)은 **한국어**로 작성하십시오. +- **Minimal & Safe Changes:** 사용자가 지시한 기능과 관련된 코드만 수정하십시오. 코드 스타일을 맞춘다는 이유로 관련된 파일 전체를 맹목적으로 재작성(Rewrite)하거나 다른 메서드의 로직을 임의로 변경하지 마십시오. +- **Preserve Context:** 기존에 작성된 `TODO`, `FIXME`, 한국어 주석, 임시 주석 처리된 코드는 사용자의 명시적인 지시가 없는 한 절대 삭제하거나 수정하지 마십시오. + +--- + +## 2. 🏛️ Architecture & Code Style (아키텍처 및 스타일) +- **Layered Architecture:** `Controller` -> `Service` -> `Repository`의 단방향 의존성을 엄격히 유지하십시오. Controller가 Repository를 직접 호출하거나, Entity가 Controller 계층으로 넘어오는 것을 금지합니다. +- **Guard Clauses (Early Return):** 중첩된 `if-else` 문을 피하고, 예외 상황이나 조건 불만족 시 메서드 도입부에서 즉시 반환(Return)하거나 예외를 던지는 Guard Clause 패턴을 적극 사용하십시오. +- **Naming Conventions:** + - Class/Interface: `PascalCase` + - Method/Variable: `camelCase` + - Constant/Enum: `UPPER_SNAKE_CASE` + - 인터페이스 구현체는 `Impl` 접미사를 지양하고, 의도가 드러나는 명명(예: `FileUploader` -> `S3FileUploader`)을 사용하십시오. + +--- + +## 3. 🍃 Spring Boot & Framework Constraints (스프링 부트 제약) +- **Dependency Injection:** `@Autowired` (필드 주입) 사용을 절대 금지합니다. 불변성(Immutability) 보장을 위해 `final` 키워드와 함께 생성자 주입을 사용하십시오. (Lombok `@RequiredArgsConstructor` 활용) +- **Transaction Management:** - 조회(Read) 전용 서비스 메서드 클래스 레벨에는 `@Transactional(readOnly = true)`를 기본으로 적용하십시오. + - 데이터 생성/수정/삭제(CUD)가 일어나는 메서드에만 `@Transactional`을 오버라이드하여 명시하십시오. +- **DTO Mapping:** 계층 간 데이터 전달은 `Record` 클래스 또는 `DTO`를 사용하십시오. Entity 객체를 외부 API 응답으로 직접 직렬화(Serialization)하여 노출하는 것을 엄격히 금지합니다. +- **Global Exception Handling:** 개별 컨트롤러나 서비스에서 `try-catch`로 비즈니스 예외를 삼키지 마십시오. 예외가 발생하면 커스텀 예외(Custom Exception)를 던지고, `@RestControllerAdvice`에서 이를 가로채어 프로젝트의 표준 에러 응답 포맷(JSON)으로 일괄 처리하십시오. + +--- + +## 4. 🗄️ Database, JPA & ORM Optimization (데이터베이스 및 쿼리) +- **Lazy Loading Only:** JPA Entity의 모든 `@ManyToOne`, `@OneToOne` 연관관계는 반드시 `(fetch = FetchType.LAZY)`로 명시해야 합니다. (기본값이 EAGER이므로 생략 불가) +- **N+1 Problem Prevention:** 컬렉션(`@OneToMany`) 조회 시 N+1 문제가 발생하지 않도록, 상황에 맞게 `Fetch Join`, `EntityGraph`, 또는 하이버네이트 `@BatchSize`를 적용하십시오. 루프(Loop) 문 안에서 쿼리가 발생하는 코드는 작성하지 마십시오. +- **Auditing:** Entity 생성 및 수정 시간, 생성자/수정자 정보는 비즈니스 로직에서 수동으로 주입하지 말고, JPA Auditing(`@CreatedDate`, `@LastModifiedDate` 등)을 사용하여 자동화하십시오. +- **Pagination:** 대량의 데이터를 조회할 때는 메모리에 모두 올리는 `findAll()`을 금지합니다. 반드시 `Pageable`을 활용하여 페이징 처리하거나 커서 기반(Cursor-based) 페이지네이션을 구현하십시오. + +--- + +## 5. 🌐 API & RESTful Guidelines (API 설계 기준) +- **URI Naming:** REST API URI는 자원(Resource)을 나타내는 **복수형 명사**를 사용하십시오. (예: `/user` (X) -> `/users` (O)) URI에 동사(Action)를 포함하지 마십시오. (예: `/users/create` (X) -> `POST /users` (O)) +- **HTTP Methods:** 목적에 맞는 HTTP 메서드(GET, POST, PUT, PATCH, DELETE)를 정확히 사용하십시오. 자원의 부분 수정은 `PATCH`를 사용합니다. +- **Validation:** 사용자 입력값 검증은 비즈니스 로직(Service) 이전에 Controller 계층에서 `@Valid` 또는 `@Validated`와 `jakarta.validation.constraints` 어노테이션(`@NotNull`, `@NotBlank` 등)을 사용하여 우선 처리하십시오. + +--- + +## 6. 🛡️ Concurrency, Security & Logging (동시성, 보안, 로깅) +- **Concurrency Control:** 재고 차감, 동시 예약, 결제 등 동시성(Race Condition)이 예상되는 비즈니스 로직을 작성할 때는, 비관적 락(Pessimistic Lock), 낙관적 락(Optimistic Lock), 또는 분산 락(Distributed Lock) 중 적절한 전략을 주석으로 제안하고 적용하십시오. +- **Security:** 비밀번호 등 민감 정보는 절대 평문으로 저장하거나 로그에 남기지 마십시오. (Bcrypt 등 해시 함수 사용) +- **Logging Level:** - `ERROR`: 시스템 장애, 외부 API 호출 실패, 즉각적인 조치가 필요한 경우 + - `WARN`: 비즈니스 로직 예외 (예: 리소스 없음, 권한 부족 등 처리 가능한 에러) + - `INFO`: 주요 비즈니스 흐름의 시작/종료, 상태 변경 (예: "주문 완료: orderId=123") + - `DEBUG`: 개발 환경에서만 필요한 상세 파라미터나 쿼리 로그 + +--- + +## 7. 🧪 Testing (테스트 코드) + +### 기본 원칙 +- **프레임워크:** JUnit 5 + Mockito를 사용하십시오. +- **커버리지 목표: 70%** — 새 기능 구현 시 이 목표를 염두에 두고 테스트를 작성하십시오. +- 테스트 코드를 작성할 때는 `Given-When-Then` 패턴을 명확히 주석으로 구분하여 작성하십시오. +- 모든 테스트 메서드에는 `@DisplayName`을 **한국어**로 작성하십시오. 메서드명은 영어로 유지합니다. + ```java + @Test + @DisplayName("존재하지 않는 이벤트 조회 시 예외가 발생한다") + void getEvent_shouldThrowException_whenEventNotFound() { + // given + // when + // then + } + ``` + +### 테스트 작성 순서 +반드시 **단위 테스트 → 통합 테스트** 순서로 작성하십시오. 단위 테스트가 통과한 후에 통합 테스트를 작성합니다. + +### 계층별 테스트 전략 + +**[단위 테스트] - 먼저 작성** +- **Service 단위 테스트:** `@ExtendWith(MockitoExtension.class)`로 Repository를 모킹하여 비즈니스 로직 검증. 정상 케이스와 예외 케이스를 모두 작성하십시오. + +**[통합 테스트] - 단위 테스트 통과 후 작성** +- **Repository 통합 테스트:** `@DataJpaTest` + `@ActiveProfiles("test")`로 커스텀 쿼리(`@Query`, `@Lock`, `@EntityGraph`) 동작을 검증하십시오. +- **Controller 슬라이스 테스트:** `@WebMvcTest`로 요청/응답 형식, 유효성 검증(`@Valid`), HTTP 상태 코드를 검증하십시오. Service는 `@MockBean`으로 모킹합니다. + +**[인수 테스트] - 서버 코드 범위 밖** +- 인수 테스트(E2E)는 서버 코드에 작성하지 않습니다. k6 등 외부 도구로 배포 환경에서 검증합니다. + +### 테스트 데이터 +- 테스트 간 데이터 의존성을 만들지 마십시오. 각 테스트는 독립적으로 실행 가능해야 합니다. +- 테스트 프로필(`application-test.yml`)의 H2 인메모리 DB를 사용하십시오. +- 테스트 픽스처가 반복될 경우 `@BeforeEach`나 별도 헬퍼 메서드로 추출하되, 과도한 추상화는 지양하십시오. + +### 검증 기준 +- 새 기능 구현 시 최소한 Service 계층의 정상/예외 테스트는 반드시 포함하십시오. +- `./gradlew clean test` 전체 통과를 확인한 후에만 커밋 단계로 진행하십시오. +- **테스트 코드 수정 금지:** API 스펙이 변경되지 않은 한 기존 테스트 코드를 수정하지 마십시오. 테스트가 실패하면 테스트를 고치는 것이 아니라 구현 코드를 수정하여 스펙에 맞추십시오. + +--- + +## 8. 🔒 Security & Configuration (보안 및 환경 설정) + AI는 예제 코드를 작성할 때 시크릿 키나 비밀번호를 코드에 직접 하드코딩하는 실수를 자주 합니다. + +No Hardcoding Secrets: API 키, JWT 시크릿 키, 데이터베이스 비밀번호 등 민감한 정보는 절대로 Java 코드나 하드코딩된 문자열로 작성하지 마십시오. + +Environment Variables: 반드시 application.yml이나 application.properties를 통해 환경 변수로 분리하고, 코드에서는 @Value나 @ConfigurationProperties를 사용하여 주입받으십시오. + +CORS & Security: 보안 필터나 CORS 설정을 변경할 때는 와일드카드(*) 사용을 지양하고, 구체적인 Origin과 HTTP Method를 명시하도록 작성하십시오. + +## 9. ⏱️ Time & Type Safety (시간 처리 및 타입 안정성) + 시간 관련 버그와 NullPointerException(NPE)은 백엔드에서 가장 흔한 장애 원인입니다. AI에게 명확한 기준을 주어야 합니다. + +Timezone Handling: 현재 시간을 다룰 때 시스템 기본 시간대에 의존하는 LocalDateTime.now() 사용을 지양하십시오. 대신 타임존이 명확한 ZonedDateTime.now(ZoneId.of("UTC"))를 사용하거나, UTC 기준인 Instant.now()를 기본으로 사용하십시오. + +Null Safety: 메서드의 반환 값으로 null을 직접 반환하지 마십시오. 값이 없을 수 있는 경우 반드시 Optional을 사용하여 호출하는 쪽에서 Null 처리를 강제하도록 하십시오. 컬렉션의 경우 null 대신 빈 컬렉션(Collections.emptyList())을 반환하십시오. + +## 10. 🔌 External API & Resilience (외부 API 호출 및 안정성) + 외부 서버(결제, 알림 등)와 통신할 때 AI는 타임아웃을 빼먹는 경우가 많습니다. + +Timeout Mandatory: RestTemplate, WebClient, FeignClient 등을 사용하여 외부 API를 호출하는 코드를 작성할 때는 반드시 연결 타임아웃(Connect Timeout)과 읽기 타임아웃(Read Timeout)을 명시적으로 설정하십시오. + +Resilience: 외부 API 호출 실패 시 시스템 전체로 장애가 전파되지 않도록, 재시도(Retry) 로직이나 서킷 브레이커(Circuit Breaker), 혹은 최소한의 Fallback(기본값 반환) 로직을 함께 제안하십시오. + +## 11. 🚀 Load Testing (부하 테스트) + +부하 테스트는 k6를 사용하며, Docker 기반으로 실행합니다. + +- **스크립트 위치:** `k6/` 디렉토리에 관리하십시오. +- **실행:** `perf.sh` 래퍼 스크립트를 통해 실행하십시오. +- **결과 리포트:** `docs/reports/` 디렉토리에 작성하십시오. +- **가이드:** 스크립트 작성 형식, 환경변수, 실행 방법 등은 `docs/reports/load-test-guide.md`를 참고하십시오. + +### 스크립트 작성 규칙 +- 모든 스크립트에 JSDoc 주석으로 테스트 시나리오, 성공 기준, 실행 방법을 명시하십시오. +- 공통 로직은 `{도메인}-helpers.js`로 분리하고, 테스트 스크립트에서 import하십시오. +- 모든 요청에 `X-Test-Id`, `X-Perf-Test` 헤더를 포함하여 서버 로그 추적이 가능하도록 하십시오. +- `summaryTrendStats`에 avg, min, med, max, p(90), p(95), p(99), count를 포함하십시오. + +### 차단 목록 +외부 부작용이 있는 엔드포인트는 부하 테스트에서 반드시 차단하십시오: +- `/api/auth/email/send` (이메일 발송) +- `/api/auth/email/verify` (인증 상태 변경) +- `/api/host/s3/presigned-url` (S3 URL 생성) + +--- + +## 12. 📂 Documentation & File Organization (문서 관리 규칙) + +생성된 문서는 반드시 목적에 맞는 디렉토리에 배치하십시오. 루트나 docs/ 최상위에 무분별하게 파일을 생성하지 마십시오. + +``` +docs/ + spec/ → 기능 명세 문서 (*-spec.md) + reports/ → 테스트 결과, 성능 리포트, 부하 테스트 가이드 +k6/ → k6 부하 테스트 스크립트 +``` + +- **명세 문서(Spec):** API, ERD, 비즈니스 규칙, 도메인 상세 등 → `docs/spec/` 디렉토리에 `*-spec.md` 파일명으로 생성 +- **리포트(Report):** 부하 테스트 결과, 성능 측정 리포트, 테스트 가이드 → `docs/reports/` 디렉토리에 생성 +- **부하 테스트 스크립트:** k6 스크립트 → `k6/` 디렉토리에 생성 +- **인덱스:** `spec.md`(프로젝트 루트)가 명세 문서들의 진입점 역할을 합니다 +- 새 문서를 생성할 때는 해당 인덱스 파일에도 링크를 추가하십시오 + +--- + +## 13. 📝 Git & Commit Conventions (Git 협업 규칙) +Claude Code나 Cursor 같은 AI 에이전트는 커밋 메시지도 자동으로 작성하는 경우가 많습니다. 이때 팀의 규칙을 따르게 해야 합니다. + +Conventional Commits: 커밋 메시지를 작성하거나 제안할 때는 반드시 Conventional Commits 규격을 따르십시오. (예: feat:, fix:, docs:, refactor:, chore: 등) + +Detailed PR/Commit: 커밋 메시지의 본문(Body)에는 "무엇을(What)" 했는지 보다 "왜(Why)" 이 방식으로 구현/수정했는지를 명확한 한국어로 작성하십시오. \ No newline at end of file diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..b61d360 --- /dev/null +++ b/spec.md @@ -0,0 +1,10 @@ +# Form-PASS 기술 명세 + +프로젝트의 기능 명세를 다루는 문서 인덱스입니다. + +| 문서 | 설명 | +|------|------| +| [API 명세](docs/spec/api-spec.md) | 전체 엔드포인트, 요청/응답 형식, 상태 코드, 인증 방식 | +| [ERD](docs/spec/erd-spec.md) | 테이블 스키마, 관계, 제약조건, 인덱스, Enum 정의 | +| [비즈니스 규칙](docs/spec/business-rules-spec.md) | 예약 생성/취소/체크인 조건, 동시성 제어, 인증/인가 정책 | +| [도메인 상세](docs/spec/domain-spec.md) | 엔티티 클래스, DTO 구조, Repository 쿼리 메서드 |