diff --git a/CLAUDE.md b/CLAUDE.md index 1e6dcc9..358ede3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,67 +4,90 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Form PASS is an event reservation system built with Spring Boot 3.4.12 and Java 17. It allows hosts to manage events, schedules, and reservations with form validation and check-in functionality. +Form PASS is an event reservation system built with Spring Boot 3.4.12 and Java 17. Hosts create events with time-slot schedules and custom form questions; guests reserve tickets and check in via QR codes. Base package: `com.example.reservation_solution`. ## Build Commands ```bash -# Run all tests -./gradlew clean test - -# Build without tests -./gradlew clean build -x test - -# Run locally (requires MySQL on localhost:3306) -./gradlew bootRun - -# Run a specific test -./gradlew test --tests "TestClassName" - -# Build production JAR (produces ticket-form.jar) -./gradlew bootJar +./gradlew clean test # Run all tests +./gradlew clean build -x test # Build without tests +./gradlew bootRun # Run locally (default profile: dev) +./gradlew test --tests "TestClassName" # Run a specific test +./gradlew bootJar # Build production JAR (ticket-form.jar) ``` ## Architecture -**Layered Structure:** +**Domain-based package structure under `api/`:** ``` -controller/ → REST API endpoints (guest/ and host/ packages) -service/ → Business logic -repository/ → JPA data access with custom queries -domain/ → JPA entities -dto/ → Request/Response objects -global/ → Cross-cutting concerns (config, security, exception, mail, image, util) +api/ + auth/ → Host signup, login, JWT tokens, email verification + event/ → Event CRUD, schedules, form questions (guest + host controllers) + reservation/ → Guest reservation creation, lookup, cancellation + host/ → Host-only: dashboard stats, reservation list, QR/manual check-in +global/ + security/ → JwtProvider, JwtAuthenticationFilter, HostUserDetailsService + exception/ → BusinessException hierarchy + GlobalExceptionHandler + config/ → SecurityConfig, SwaggerConfig, AwsConfig + mail/ → Async email sending (Google SMTP) + image/ → S3 presigned URL generation + common/domain/ → BaseTimeEntity (createdAt/updatedAt auditing) + docs/ → Swagger @Operation annotation interfaces per endpoint + util/ → EncryptionUtils (AES for phone numbers), CodeGenerator ``` -**Key Domain Models:** -- `Host` → Event organizer account -- `Event` → Event definition with unique eventCode -- `EventSchedule` → Time slots with capacity (uses pessimistic locking) -- `Reservation` → Booking with QR token for check-in -- `FormQuestion` / `FormAnswer` → Custom form fields per event +Each domain package follows: `controller/ → service/ → repository/ → domain/ → dto/` + +**Key Domain Relationships:** +- `Host` → owns many `Event` (via eventId) +- `Event` → has `EventSchedule[]`, `FormQuestion[]`, `EventImage[]` +- `EventSchedule` → has `Reservation[]` (capacity-controlled with pessimistic locking) +- `Reservation` → has `FormAnswer[]`, a `qrToken` (UUID) for check-in, and `ReservationStatus` (CONFIRMED/CANCELLED) + +## Conventions + +- **Lombok** is used throughout (entities, DTOs). Use `@Getter`, `@NoArgsConstructor(access = PROTECTED)` on entities; `@Builder` on DTOs +- **Error messages are in Korean** (e.g., `"존재하지 않는 이벤트입니다"`) +- **Exception pattern**: Services throw `IllegalArgumentException` (400), `IllegalStateException` (409), `AccessDeniedException` (403), or `BusinessException` subclasses (`BadRequestException`, `ConflictException`, `ForbiddenException`, `NotFoundException`, `UnauthorizedException`). All handled by `GlobalExceptionHandler` +- **Host ownership validation**: Services verify `event.getHost().getEmail().equals(hostEmail)` before allowing mutations +- **Transactions**: Services use `@Transactional(readOnly = true)` at class level, `@Transactional` on write methods +- **Phone number encryption**: Guest phone numbers are encrypted via `EncryptionUtils` (AES-256) before storage and decrypted on read +- **Swagger docs**: Each controller method's `@Operation` annotation is defined in a separate interface in `global/docs/` (e.g., `EventControllerDocs`), which the controller implements +- **Entity creation**: Entities use static factory methods (`create(...)`) instead of public constructors + +## Concurrency Control + +- `EventSchedule.reservedCount` is protected by `@Lock(LockModeType.PESSIMISTIC_WRITE)` in `EventScheduleRepository` to prevent overbooking race conditions +- Duplicate reservation check: same phone number + same schedule combination is rejected ## Security -- JWT Bearer Token 인증 (Authorization 헤더), 30분 만료 -- Public endpoints: `/api/auth/**`, `/api/events/**`, `/api/reservations/**` -- Protected endpoints: `/api/host/**` (requires HOST role) -- Guest phone numbers are encrypted in database using `EncryptionUtils` +- JWT Bearer Token auth (Authorization header), 30-minute expiry, HS256 signing (jjwt 0.11.5) +- `JwtAuthenticationFilter` skips `/api/auth/**` paths +- Public: `/api/auth/**`, `/api/events/**`, `/api/reservations/**`, `/actuator/**`, Swagger UI +- Protected: `/api/host/**` requires `ROLE_HOST` +- Authenticated user's email is extracted from `SecurityContextHolder` principal in controllers +- CORS allowed origins: `localhost:3000`, `localhost:3001`, `form-pass.life`, `form-pass-client.vercel.app` ## Testing -- Test profile uses H2 in-memory database (see `application-test.yml`) +- Test profile (`application-test.yml`): H2 in-memory DB with `MODE=MySQL`, mock mail config, dedicated JWT secret - Tests run with: `./gradlew clean test` -- Swagger UI: `http://localhost:8080/swagger-ui.html` +- Currently only a context-load test exists (`ReservationSolutionApplicationTests`) -## Key Technical Details +## Profiles -- **Concurrency**: `EventSchedule` uses `@Lock(LockModeType.PESSIMISTIC_WRITE)` to prevent overselling -- **Email Verification**: Uses Caffeine cache for storing verification codes -- **File Uploads**: AWS S3 presigned URLs for direct client uploads -- **Async**: `@EnableAsync` enabled for background tasks like email sending +- `dev` (default, set in `application.yml`): Production RDS MySQL, Prometheus monitoring enabled +- `local`: localhost MySQL on port 3306, `ddl-auto: create`, debug logging +- `test`: H2 in-memory, used automatically in test runs ## CI/CD -- **CI**: Pull requests to main trigger `./gradlew clean test` -- **CD**: Push to main builds JAR and deploys to EC2 via SSH +- **CI**: PRs to `main` → `./gradlew clean test` + publish test results as PR comments +- **CD**: Push to `main` → build JAR (skip tests) → SCP to EC2 → run deploy script +- `application-dev.yml` is injected from GitHub Secrets (`APPLICATION_DEV_YML`) during CD + +## Key Dependencies + +- **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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..27dc1c6 --- /dev/null +++ b/README.md @@ -0,0 +1,373 @@ +# Form-PASS Server + +호스트를 위한 간편 예약 관리 솔루션의 백엔드 서버입니다. + +> 이벤트 생성 → 예약 폼 공유 → 선착순 예약 → QR 체크인까지 하나의 플로우로 처리합니다. + +**서비스 링크** : https://www.form-pass.life + +**프론트엔드** : https://github.com/LEEDONGH00N/form-pass-client + +--- + +## 기술 스택 + +| 분류 | 기술 | +|------|------| +| Language | Java 17 | +| Framework | Spring Boot 3.4.12 | +| ORM | Spring Data JPA (Hibernate) | +| Database | MySQL 8.0 (RDS), H2 (테스트) | +| Auth | Spring Security + JWT (jjwt 0.11.5) | +| Infra | AWS EC2, RDS, S3 | +| CI/CD | GitHub Actions | +| Monitoring | Spring Actuator + Micrometer Prometheus | +| API Docs | Springdoc OpenAPI 2.7.0 (Swagger UI) | +| Mail | Spring Mail (Google SMTP) | +| Cache | Caffeine (이메일 인증코드 TTL 관리) | +| Build | Gradle | + +--- + +## 인프라 구조 + +``` +[Client - Vercel] + │ + ▼ HTTPS +[EC2 Instance] + ├── Nginx (Reverse Proxy, SSL) + └── Spring Boot (ticket-form.jar) + ├── RDS MySQL (데이터 저장) + ├── S3 (이미지 저장) + └── Google SMTP (메일 발송) +``` + +--- + +## ERD + +``` +┌──────────┐ ┌──────────────┐ ┌─────────────────┐ +│ 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 │ +└──────────────┘ +``` + +**Enum 타입:** +- `Role` : HOST +- `ReservationStatus` : CONFIRMED, CANCELLED +- `QuestionType` : TEXT, CHECKBOX, RADIO + +--- + +## API 명세 + +### Auth (`/api/auth`) - Public + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/auth/signup` | 호스트 회원가입 | +| POST | `/api/auth/login` | 로그인 (JWT 발급) | +| POST | `/api/auth/email/send` | 이메일 인증코드 발송 | +| POST | `/api/auth/email/verify` | 인증코드 확인 | + +### Event - Guest (`/api/events`) - Public + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/events` | 공개 이벤트 목록 조회 | +| GET | `/api/events/{eventCode}` | 이벤트 상세 조회 (이벤트 코드) | + +### Event - Host (`/api/host/events`) - ROLE_HOST + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/host/events` | 이벤트 생성 | +| GET | `/api/host/events` | 내 이벤트 목록 조회 | +| GET | `/api/host/events/{eventId}` | 내 이벤트 상세 조회 | +| PUT | `/api/host/events/{eventId}` | 이벤트 수정 | +| PATCH | `/api/host/events/{eventId}/visibility` | 공개/비공개 전환 | + +### Reservation - Guest (`/api/reservations`) - Public + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/reservations` | 예약 생성 | +| GET | `/api/reservations/{id}` | 예약 상세 조회 | +| GET | `/api/reservations/qr/{qrToken}` | QR 토큰으로 예약 조회 | +| DELETE | `/api/reservations/{id}` | 예약 취소 | +| POST | `/api/reservations/lookup` | 이름+전화번호로 내 예약 조회 | + +### Reservation - Host (`/api/host`) - ROLE_HOST + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/host/events/{eventId}/dashboard` | 대시보드 통계 | +| GET | `/api/host/events/{eventId}/reservations` | 예약 목록 (페이징, 검색) | +| GET | `/api/host/reservations/{reservationId}` | 예약 상세 | +| PATCH | `/api/host/reservations/{reservationId}/cancel` | 예약 취소 | +| POST | `/api/host/checkin` | QR 체크인 | +| PATCH | `/api/host/reservations/{reservationId}/checkin` | 수동 체크인 | +| GET | `/api/host/events/{eventId}/schedules-status` | 스케줄별 현황 | + +### Image (`/api/host/s3`) - ROLE_HOST + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/api/host/s3/presigned-url` | S3 Presigned URL 발급 | + +### Monitoring - Public + +| Endpoint | 설명 | +|----------|------| +| `/health` | 서버 상태 확인 | +| `/actuator/prometheus` | Prometheus 메트릭 | +| `/swagger-ui/index.html` | API 문서 (Swagger UI) | + +--- + +## 핵심 기능 상세 + +### 동시성 제어 (선착순 예약) + +``` +요청 A ──▶ ┌─────────────────────────────────┐ + │ EventSchedule (scheduleId = 1) │ + │ PESSIMISTIC_WRITE Lock 획득 │ + │ reservedCount 검증 → 증가 │ + │ 예약 생성 → Lock 해제 │ +요청 B ──▶ └─────────────────────────────────┘ ──▶ Lock 대기 후 순차 처리 +``` + +- `EventScheduleRepository`에서 `@Lock(PESSIMISTIC_WRITE)`로 row-level 락 적용 +- `reservedCount >= maxCapacity` 시 `IllegalStateException` (409) +- 동일 스케줄 + 전화번호 중복 예약 거부 + +### QR 체크인 플로우 + +``` +예약 확정 → UUID 기반 qrToken 발급 + → 게스트가 QR 코드 제시 + → 호스트가 QR 스캔 (POST /api/host/checkin) + → 또는 수동 체크인 (PATCH /api/host/reservations/{id}/checkin) + → isCheckedIn = true 업데이트 +``` + +### 이미지 업로드 (Presigned URL) + +``` +1. 클라이언트 → POST /api/host/s3/presigned-url (fileName, contentType) +2. 서버 → S3 Presigned PUT URL + 최종 fileUrl 반환 +3. 클라이언트 → S3에 직접 PUT 업로드 +4. 클라이언트 → 이벤트 생성 시 fileUrl을 images 목록에 포함 +``` + +--- + +## 프로젝트 구조 + +``` +src/main/java/com/example/reservation_solution/ +├── api/ +│ ├── auth/ # 인증/회원 +│ │ ├── controller/ +│ │ ├── service/ +│ │ ├── repository/ +│ │ ├── domain/ # Host, Role +│ │ └── dto/ +│ ├── event/ # 이벤트 관리 +│ │ ├── controller/ # EventController(호스트), GuestEventController +│ │ ├── service/ +│ │ ├── repository/ +│ │ ├── domain/ # Event, EventSchedule, FormQuestion, EventImage +│ │ └── dto/ +│ ├── reservation/ # 예약 +│ │ ├── controller/ +│ │ ├── service/ +│ │ ├── repository/ +│ │ ├── domain/ # Reservation, FormAnswer, ReservationStatus +│ │ └── dto/ +│ └── host/ # 호스트 전용 (대시보드, 체크인) +│ ├── controller/ +│ ├── service/ +│ └── dto/ +└── global/ + ├── config/ # SecurityConfig, SwaggerConfig, AwsConfig + ├── security/ # JwtProvider, JwtAuthenticationFilter + ├── exception/ # BusinessException, GlobalExceptionHandler + ├── mail/ # GoogleMailService (비동기) + ├── image/ # S3Service, S3Controller + ├── common/domain/ # BaseTimeEntity (JPA Auditing) + ├── docs/ # Swagger @Operation 인터페이스 + └── util/ # EncryptionUtils, CodeGenerator +``` + +--- + +## 실행 방법 + +### 사전 조건 + +- Java 17+ +- MySQL 8.0 (local 프로필) 또는 H2 (test 프로필) +- Gradle 8+ + +### 로컬 실행 + +```bash +# 1. 프로젝트 클론 +git clone https://github.com/LEEDONGH00N/form-pass-server.git +cd form-pass-server + +# 2. local 프로필로 실행 (localhost MySQL 필요) +./gradlew bootRun -Dspring.profiles.active=local + +# 3. Swagger UI 확인 +open http://localhost:8080/swagger-ui/index.html +``` + +### 테스트 실행 + +```bash +# 전체 테스트 (H2 인메모리 DB 사용) +./gradlew clean test + +# 특정 테스트 클래스 실행 +./gradlew test --tests "ReservationSolutionApplicationTests" +``` + +### 빌드 + +```bash +# JAR 빌드 (테스트 제외) +./gradlew clean build -x test + +# 결과물: build/libs/ticket-form.jar +``` + +--- + +## 환경 설정 + +### 프로필 + +| 프로필 | DB | DDL | 용도 | +|--------|-----|-----|------| +| `dev` (기본) | AWS RDS MySQL | create | 운영 | +| `local` | localhost MySQL (3306) | create | 로컬 개발 | +| `test` | H2 In-Memory (MODE=MySQL) | create-drop | 테스트 | + +### 주요 설정값 + +```yaml +# JWT +jwt: + secret: {BASE64_ENCODED_SECRET} # 32자 이상 + expiration: 1800000 # 30분 + +# 암호화 +encryption: + secret-key: {AES_256_KEY} # 전화번호 암호화용 + +# AWS S3 +cloud: + aws: + s3: + bucket: form-pass-images + region: + static: ap-northeast-2 +``` + +--- + +## CI/CD + +### CI (Pull Request → main) + +``` +PR 생성 → GitHub Actions → ./gradlew clean test → 테스트 결과 PR 코멘트 +``` + +### CD (Push → main) + +``` +main 병합 → GitHub Actions → JAR 빌드 → SCP로 EC2 전송 → deploy 스크립트 실행 +``` + +**필요 Secrets:** +- `EC2_HOST`, `EC2_USERNAME`, `EC2_KEY` : EC2 접속 정보 +- `APPLICATION_DEV_YML` : 운영 환경 설정 파일 + +--- + +## 모니터링 + +| 엔드포인트 | 용도 | +|-----------|------| +| `/actuator/health` | 서버 상태 (DB, Disk 포함) | +| `/actuator/prometheus` | Prometheus 메트릭 수집 | + +**수집 메트릭:** HTTP 요청 응답시간 분포 (P95, P99), JVM 메모리/GC, Tomcat 스레드 풀 + +--- + +## 부하 테스트 + +k6를 사용하여 예약 생성 API의 동시성 안정성을 검증합니다. + +```bash +# Spike 테스트 (50 → 100 VU) +k6 run k6/reservation-test.js + +# Constant 테스트 (50 VU 고정) +k6 run k6/reservation-constant-test.js +``` + +### 테스트 결과 요약 + +| 시나리오 | VU | 총 요청 | 평균 응답 | p95 | 에러율 | 초과 예약 | +|----------|-----|--------|----------|-----|--------|----------| +| 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) diff --git a/docs/constant-test-report.md b/docs/constant-test-report.md new file mode 100644 index 0000000..230b865 --- /dev/null +++ b/docs/constant-test-report.md @@ -0,0 +1,168 @@ +# Form PASS Constant VU 부하 테스트 결과 보고서 + +**테스트 일시:** 2026-02-18 +**테스트 도구:** k6 +**테스트 대상:** 예약 생성 API (`POST /api/reservations`) + +--- + +## 1. 테스트 환경 + +| 항목 | 내용 | +|------|------| +| 서버 | AWS EC2 (운영 환경) | +| API 엔드포인트 | https://api.form-pass.life | +| 데이터베이스 | MySQL (RDS) | +| 테스트 클라이언트 | 로컬 머신 | + +--- + +## 2. 테스트 시나리오 + +### 2.1 부하 패턴 + +``` +VU(Virtual Users) + │ + 50 ┼────────────────────────────────────── + │ + 0 ┼──────────────────────────────────────▶ Time + 0 2m + Constant Load +``` + +| 항목 | 값 | +|------|-----| +| 동시 사용자 (VU) | 50 (고정) | +| 테스트 시간 | 2분 | +| Think Time | 0.5초 | + +### 2.2 테스트 목적 + +- **기준 성능(Baseline) 측정** +- 일정한 부하에서의 안정성 확인 +- 정확한 RPS 및 응답 시간 측정 + +### 2.3 성공 기준 (Thresholds) + +| 지표 | 기준 | 결과 | +|------|------|------| +| p(95) 응답 시간 | < 500ms | ❌ 567ms | +| 에러율 | < 1% | ✅ 0% | + +--- + +## 3. 테스트 결과 + +### 3.1 핵심 지표 + +| 지표 | 값 | 평가 | +|------|-----|------| +| **성공률** | 100% | ✅ | +| **에러율** | 0% | ✅ | +| **총 요청 수** | 8,152건 | - | +| **평균 RPS** | 67.3/s | ✅ | + +### 3.2 응답 시간 (http_req_duration) + +| 지표 | 값 | 평가 | +|------|-----|------| +| **평균 (avg)** | 236ms | ✅ 양호 | +| **중앙값 (med)** | 200ms | ✅ 양호 | +| **최소 (min)** | 26ms | ✅ | +| **최대 (max)** | 657ms | ✅ 양호 | +| **p(90)** | 539ms | ⚠️ | +| **p(95)** | 567ms | ⚠️ 기준 초과 | +| **p(99)** | 637ms | ⚠️ | + +### 3.3 상세 Trends + +| metric | avg | max | med | min | p90 | p95 | p99 | +|--------|-----|-----|-----|-----|-----|-----|-----| +| http_req_duration | 236ms | 657ms | 200ms | 26ms | 539ms | 567ms | 637ms | +| iteration_duration | 739ms | 1.4s | 701ms | 528ms | 1.04s | 1.07s | 1.14s | + +### 3.4 처리량 + +| 지표 | 값 | +|------|-----| +| **총 요청 수** | 8,152건 | +| **평균 RPS** | 67.3 req/s | +| **iterations** | 8,152 | + +### 3.5 네트워크 + +| 지표 | 값 | +|------|-----| +| 수신 (data_received) | 7.0 MB (58 kB/s) | +| 송신 (data_sent) | 2.3 MB (19 kB/s) | + +--- + +## 4. Stages 테스트와 비교 + +| 항목 | Stages (100 VU peak) | Constant (50 VU) | 차이 | +|------|---------------------|------------------|------| +| 평균 응답 (avg) | 706ms | **236ms** | 3배 개선 | +| p(95) | 1,000ms | **567ms** | 1.8배 개선 | +| 최대 응답 (max) | 4,000ms | **657ms** | 6배 개선 | +| RPS | 53/s | **67/s** | 26% 증가 | +| 성공률 | 100% | 100% | 동일 | + +### 분석 + +- **50 VU**: 안정적인 성능 유지 +- **100 VU**: 병목 발생, 응답 시간 급증 +- **병목 지점**: 50~100 VU 사이 + +--- + +## 5. 결론 + +### 5.1 성능 요약 + +| 항목 | 결과 | +|------|------| +| **최적 동시 사용자** | 50명 | +| **안정 RPS** | 67 req/s | +| **평균 응답 시간** | 236ms | +| **권장 p(95) 기준** | 600ms | + +### 5.2 성능 기준 충족 여부 + +| 기준 | 결과 | +|------|------| +| p(95) < 500ms | ❌ 미충족 (567ms) | +| p(95) < 600ms | ✅ 충족 | +| 에러율 < 1% | ✅ 충족 (0%) | +| 성공률 100% | ✅ 충족 | + +### 5.3 권장 사항 + +1. **p(95) 기준 조정**: 500ms → 600ms +2. **최대 동시 사용자 제한**: 50명 권장, 100명 한계 +3. **스케일링 필요 시점**: 동시 사용자 50명 초과 예상 시 + +--- + +## 6. 테스트 스크립트 + +```bash +# 실행 방법 +k6 run --out web-dashboard=host=0.0.0.0 k6/reservation-constant-test.js + +# 대시보드 확인 +http://localhost:5665 +``` + +--- + +## 부록: 테스트 전 데이터 초기화 + +```sql +DELETE FROM form_answers WHERE reservation_id IN ( + SELECT id FROM reservations WHERE event_schedule_id = 1 +); +DELETE FROM reservations WHERE event_schedule_id = 1; +UPDATE event_schedules SET reserved_count = 0, max_capacity = 100000 WHERE id = 1; +``` diff --git a/docs/load-test-report.md b/docs/load-test-report.md new file mode 100644 index 0000000..caa3284 --- /dev/null +++ b/docs/load-test-report.md @@ -0,0 +1,246 @@ +# Form PASS 부하 테스트 결과 보고서 + +**테스트 일시:** 2026-02-18 +**테스트 도구:** k6 +**테스트 대상:** 예약 생성 API (`POST /api/reservations`) + +--- + +## 1. 테스트 환경 + +| 항목 | 내용 | +|------|------| +| 서버 | AWS EC2 (운영 환경) | +| API 엔드포인트 | https://api.form-pass.life | +| 데이터베이스 | MySQL (RDS) | +| 테스트 클라이언트 | 로컬 머신 | + +--- + +## 2. 테스트 시나리오 + +### 2.1 부하 패턴 + +``` +VU(Virtual Users) + │ + 100 ┤ ┌────────────┐ + │ ╱ ╲ + 50 ┤ ┌───────────┘ ╲ + │ ╱ ╲ + 0 ┼────┴─────────────────────────────────┴────▶ Time + 0 30s 1m30s 2m 3m 3m30s 4m + Ramp Sustain Spike Peak Ramp + up up down +``` + +| 단계 | 시간 | VU 수 | 목적 | +|------|------|-------|------| +| Ramp-up | 0 ~ 30초 | 0 → 50 | 점진적 부하 증가 | +| Sustain | 30초 ~ 1분 30초 | 50 유지 | 안정성 확인 | +| Spike | 1분 30초 ~ 2분 | 50 → 100 | 급격한 부하 증가 대응 | +| Peak | 2분 ~ 3분 | 100 유지 | 최대 부하 내구성 | +| Ramp-down | 3분 ~ 3분 30초 | 100 → 0 | 정상 복구 확인 | + +### 2.2 테스트 요청 + +```json +POST /api/reservations +{ + "scheduleId": 1, + "guestName": "tester_1_0", + "guestPhoneNumber": "01012345678", + "ticketCount": 1, + "answers": [] +} +``` + +### 2.3 성공 기준 (Thresholds) + +| 지표 | 기준 | +|------|------| +| p(95) 응답 시간 | < 2,000ms | +| 에러율 | < 10% | + +--- + +## 3. 테스트 결과 + +### 3.1 응답 시간 (http_req_duration) + +| 지표 | 값 | 평가 | +|------|-----|------| +| **평균 (avg)** | 706ms | ⚠️ 높음 | +| **중앙값 (med)** | 248ms | ✅ 양호 | +| **최소 (min)** | 24ms | ✅ 양호 | +| **최대 (max)** | 4,000ms | ❌ 스파이크 | +| **p(90)** | 1,000ms | ⚠️ 경계 | +| **p(95)** | 1,000ms | ⚠️ 경계 | +| **p(99)** | 2,000ms | ⚠️ 높음 | + +### 3.2 상세 시간 분석 + +| 구간 | 평균 시간 | 비중 | +|------|----------|------| +| TCP 연결 (connecting) | 104µs | 0.01% | +| TLS 핸드셰이크 | 163µs | 0.02% | +| 요청 전송 (sending) | 28µs | < 0.01% | +| **서버 처리 (waiting)** | **706ms** | **99.9%** | +| 응답 수신 (receiving) | 75µs | 0.01% | + +> 💡 대부분의 시간이 **서버 처리(waiting)**에 소요됨 + +### 3.3 처리량 + +| 지표 | 값 | +|------|-----| +| **총 요청 수** | 11,200건 | +| **평균 RPS** | 53.41 req/s | +| **성공 건수** | 11,200건 | +| **성공률** | 100% ✅ | + +### 3.4 네트워크 + +| 지표 | 값 | +|------|-----| +| 수신 (data_received) | 9.74 MB (46.4 kB/s) | +| 송신 (data_sent) | 3.2 MB (15.2 kB/s) | +| 요청당 응답 크기 | ~870 bytes | + +### 3.5 상세 Trends + +| metric | avg | max | med | min | p90 | p95 | p99 | +|--------|-----|-----|-----|-----|-----|-----|-----| +| http_req_blocked | 282µs | 99ms | 6µs | 1µs | 10µs | 12µs | 98µs | +| http_req_connecting | 104µs | 44ms | 0ms | 0ms | 0ms | 0ms | 0ms | +| **http_req_duration** | **706ms** | **4s** | **248ms** | **24ms** | **1s** | **1s** | **2s** | +| http_req_receiving | 75µs | 3ms | 65µs | 10µs | 114µs | 138µs | 211µs | +| http_req_sending | 28µs | 4ms | 25µs | 3µs | 41µs | 48µs | 78µs | +| http_req_tls_handshaking | 163µs | 27ms | 0ms | 0ms | 0ms | 0ms | 0ms | +| http_req_waiting | 706ms | 4s | 248ms | 24ms | 1s | 1s | 2s | +| iteration_duration | 1s | 4s | 749ms | 526ms | 2s | 2s | 2s | + +--- + +## 4. VU별 성능 비교 + +| VU 수 | 평균 응답 시간 | p(95) | 상태 | +|-------|--------------|-------|------| +| 10 | 43ms | 78ms | ✅ 안정 | +| 50 | ~300ms | ~500ms | ⚠️ 보통 | +| 100 | 706ms | 1,000ms | ❌ 한계 도달 | + +--- + +## 5. 병목 분석 + +### 5.1 현상 + +- **중앙값(248ms)과 평균(706ms)의 큰 차이** + - 대부분의 요청은 빠르게 처리됨 + - 일부 요청이 매우 느려서 평균을 끌어올림 + +- **최대 응답 시간 4초** + - 100 VU 피크 시점에서 일부 요청 지연 심화 + +### 5.2 원인 (추정) + +1. **비관적 락 (Pessimistic Lock)** + - `EventSchedule` 엔티티에 `PESSIMISTIC_WRITE` 락 사용 + - 동시 예약 시 락 획득 대기 시간 증가 + +2. **DB 커넥션 풀 제한** + - HikariCP 기본 설정 (10개)으로 동시 처리 제한 + +3. **단일 스케줄 집중** + - 모든 요청이 scheduleId=1로 집중 + - 실제 운영에서는 분산될 가능성 있음 + +--- + +## 6. 결론 + +### 6.1 현재 상태 + +| 항목 | 결과 | +|------|------| +| **권장 동시 사용자** | 50명 이하 | +| **최대 허용 동시 사용자** | 100명 (성능 저하 감수) | +| **평균 RPS** | 53.41 req/s | +| **총 처리량** | 11,200건 / 4분 | + +### 6.2 성능 기준 충족 여부 + +| 기준 | 결과 | +|------|------| +| p(95) < 2,000ms | ✅ 충족 (1,000ms) | +| 에러율 < 10% | ✅ 충족 (0%) | +| 성공률 | ✅ 100% | + +--- + +## 7. 개선 권장 사항 + +### 7.1 단기 개선 (인프라) + +| 항목 | 현재 | 권장 | +|------|------|------| +| DB 커넥션 풀 | 10 | 20~30 | +| EC2 인스턴스 | t2.micro | t3.small 이상 | + +```yaml +# application.yml +spring: + datasource: + hikari: + maximum-pool-size: 30 + minimum-idle: 10 +``` + +### 7.2 중기 개선 (아키텍처) + +1. **낙관적 락 검토** + - 충돌이 적은 경우 성능 향상 가능 + - `@Version` 어노테이션 활용 + +2. **Redis 분산 락** + - DB 락 대신 Redis 기반 분산 락 도입 + - Redisson 라이브러리 활용 + +3. **읽기/쓰기 분리** + - 조회 API는 Read Replica 활용 + +### 7.3 장기 개선 (스케일링) + +1. **수평 확장** + - 로드밸런서 + 다중 인스턴스 구성 + +2. **메시지 큐 도입** + - 예약 요청을 큐에 넣고 순차 처리 + - 피크 시간 부하 분산 + +--- + +## 8. 테스트 스크립트 + +테스트에 사용된 k6 스크립트: `k6/reservation-test.js` + +```bash +# 실행 방법 +k6 run --out web-dashboard=host=0.0.0.0 k6/reservation-test.js + +# 대시보드 확인 +http://localhost:5665 +``` + +--- + +## 부록: 테스트 전 데이터 초기화 + +```sql +DELETE FROM form_answers WHERE reservation_id IN ( + SELECT id FROM reservations WHERE event_schedule_id = 1 +); +DELETE FROM reservations WHERE event_schedule_id = 1; +UPDATE event_schedules SET reserved_count = 0, max_capacity = 100000 WHERE id = 1; +``` diff --git a/k6/common.js b/k6/common.js new file mode 100644 index 0000000..bd679d5 --- /dev/null +++ b/k6/common.js @@ -0,0 +1,135 @@ +/** + * =========================================== + * Form PASS 범용 GET 엔드포인트 부하 테스터 + * =========================================== + * + * 여러 GET 엔드포인트에 대해 동시 부하를 생성합니다. + * DENYLIST로 외부 API 호출을 방지하고, + * X-Test-Id 헤더로 서버 로그 추적이 가능합니다. + * + * 환경변수: + * BASE_URL - API 서버 URL (기본: https://api.form-pass.life) + * ENDPOINTS - 테스트할 엔드포인트 CSV (예: "/api/events,/health") + * DENYLIST - 차단할 엔드포인트 CSV (매칭 시 즉시 fail) + * TEST_ID - 테스트 식별자 (X-Test-Id 헤더에 포함) + * WARMUP - "true"이면 warm-up 모드 (1 VU, 10s) + * TOKEN - Bearer 토큰 (인증 필요 시) + * + * 실행 예시: + * docker run --rm \ + * -e BASE_URL="https://api.form-pass.life" \ + * -e ENDPOINTS="/api/events,/health" \ + * -e DENYLIST="/api/auth/email/send" \ + * -e TEST_ID="exp-20260227-1430" \ + * -v "$(pwd)/k6:/scripts" \ + * grafana/k6 run /scripts/common.js + */ + +import http from 'k6/http'; +import { check, fail, sleep } from 'k6'; + +// ============================================ +// Configuration +// ============================================ +const BASE_URL = __ENV.BASE_URL || 'https://api.form-pass.life'; +const TEST_ID = __ENV.TEST_ID || 'unknown'; +const TOKEN = __ENV.TOKEN || ''; +const IS_WARMUP = (__ENV.WARMUP || 'false') === 'true'; + +const ENDPOINTS = (__ENV.ENDPOINTS || '/health') + .split(',') + .map((ep) => ep.trim()) + .filter((ep) => ep.length > 0); + +const DENYLIST = (__ENV.DENYLIST || '') + .split(',') + .map((ep) => ep.trim()) + .filter((ep) => ep.length > 0); + +// ============================================ +// Options +// ============================================ +export const options = IS_WARMUP + ? { + vus: 1, + duration: '10s', + thresholds: { + http_req_duration: ['p(95)<5000'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)', 'count'], + } + : { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 30 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<2000'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)', 'count'], + }; + +// ============================================ +// Setup +// ============================================ +export function setup() { + console.log('========================================'); + console.log(`Mode : ${IS_WARMUP ? 'WARM-UP (1 VU, 10s)' : 'MAIN (ramp-up stages)'}`); + console.log(`Test ID : ${TEST_ID}`); + console.log(`Base URL : ${BASE_URL}`); + console.log(`Endpoints : ${ENDPOINTS.join(', ')}`); + console.log(`Denylist : ${DENYLIST.length > 0 ? DENYLIST.join(', ') : '(none)'}`); + console.log(`Token : ${TOKEN ? '(set)' : '(not set)'}`); + console.log('========================================'); + + // DENYLIST 검증: 요청 전에 차단 대상이 ENDPOINTS에 포함되어 있는지 확인 + for (const ep of ENDPOINTS) { + for (const denied of DENYLIST) { + if (ep === denied || ep.startsWith(denied)) { + fail(`DENYLIST 위반: "${ep}" 는 차단된 엔드포인트입니다 (${denied})`); + } + } + } +} + +// ============================================ +// Main +// ============================================ +export default function () { + const headers = { + 'X-Test-Id': TEST_ID, + 'X-Perf-Test': 'true', + }; + + if (TOKEN) { + headers['Authorization'] = `Bearer ${TOKEN}`; + } + + for (const ep of ENDPOINTS) { + const url = `${BASE_URL}${ep}`; + const params = { + headers: headers, + tags: { name: ep }, + }; + + const response = http.get(url, params); + + check(response, { + [`${ep} status < 400`]: (r) => r.status < 400, + }); + } + + sleep(0.5); +} + +// ============================================ +// Teardown +// ============================================ +export function teardown() { + console.log('========================================'); + console.log('부하 테스트 완료'); + console.log(`Test ID: ${TEST_ID}`); + console.log('========================================'); +} diff --git a/k6/reservation-constant-test.js b/k6/reservation-constant-test.js new file mode 100644 index 0000000..e9021cb --- /dev/null +++ b/k6/reservation-constant-test.js @@ -0,0 +1,61 @@ +/** + * =========================================== + * Form PASS 예약 API 부하 테스트 (Constant VU) + * =========================================== + * + * [테스트 시나리오] + * 고정된 VU로 일정 시간 동안 부하 유지 + * → 정확한 기준 성능(Baseline) 측정 목적 + * + * [테스트 시간] 2분 + * [동시 사용자] 50 VU (고정) + * + * [성공 기준] + * - p(95) 응답 시간 < 500ms + * - 에러율 < 1% + * + * [실행 방법] + * $ ./perf.sh --script reservation-constant-test.js --warmup --dashboard + * + * [대시보드 확인] + * http://localhost:5665 + */ + +import { createReservation, BASE_URL, SCHEDULE_ID, TEST_ID } from './reservation-helpers.js'; + +// ============================================ +// Test Configuration +// ============================================ +export const options = { + vus: 50, + duration: '2m', + thresholds: { + http_req_duration: ['p(95)<500'], + error_rate: ['rate<0.01'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)', 'count'], +}; + +// ============================================ +// Main +// ============================================ +export default function () { + createReservation(); +} + +export function setup() { + console.log('========================================'); + console.log('Constant VU 부하 테스트 시작'); + console.log(`Test ID : ${TEST_ID}`); + console.log(`VU: ${options.vus}, Duration: ${options.duration}`); + console.log(`Target : ${BASE_URL}`); + console.log(`Schedule ID : ${SCHEDULE_ID}`); + console.log('========================================'); +} + +export function teardown() { + console.log('========================================'); + console.log('부하 테스트 완료'); + console.log(`Test ID: ${TEST_ID}`); + console.log('========================================'); +} diff --git a/k6/reservation-helpers.js b/k6/reservation-helpers.js new file mode 100644 index 0000000..aa840cf --- /dev/null +++ b/k6/reservation-helpers.js @@ -0,0 +1,113 @@ +/** + * =========================================== + * Form PASS 예약 테스트 공통 모듈 + * =========================================== + * + * 예약 API 전용 메트릭, 헬퍼 함수, 요청 로직을 제공합니다. + * reservation-test.js, reservation-constant-test.js에서 import하여 사용합니다. + * + * 환경변수: + * BASE_URL - API 서버 URL (기본: https://api.form-pass.life) + * SCHEDULE_ID - 테스트 대상 스케줄 ID (기본: 1) + * TEST_ID - 테스트 식별자 (X-Test-Id 헤더에 포함) + */ + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +// ============================================ +// Constants (환경변수에서 읽기) +// ============================================ +export const BASE_URL = __ENV.BASE_URL || 'https://api.form-pass.life'; +export const SCHEDULE_ID = parseInt(__ENV.SCHEDULE_ID || '1', 10); +export const TEST_ID = __ENV.TEST_ID || 'unknown'; + +// ============================================ +// Custom Metrics +// ============================================ +export const reservationSuccess = new Counter('reservation_success'); +export const reservationFailed = new Counter('reservation_failed'); +export const errorRate = new Rate('error_rate'); +export const reservationDuration = new Trend('reservation_duration'); + +// ============================================ +// Helper Functions +// ============================================ + +/** + * 고유한 전화번호 생성 (11자리: 010XXXXXXXX) + * VU ID + Iteration + Timestamp 조합으로 중복 방지 + */ +export function generateUniquePhone() { + const timestamp = Date.now() % 100000000; + const unique = (__VU * 1000000) + (__ITER * 1000) + (timestamp % 1000); + const num = unique % 100000000; + return `010${String(num).padStart(8, '0')}`; +} + +// ============================================ +// Main Test Logic +// ============================================ + +/** + * 예약 생성 요청을 보내고 결과를 검증합니다. + * 각 테스트 스크립트의 default function에서 호출하세요. + */ +export function createReservation() { + const phoneNumber = generateUniquePhone(); + + const payload = JSON.stringify({ + scheduleId: SCHEDULE_ID, + guestName: `tester_${__VU}_${__ITER}`, + guestPhoneNumber: phoneNumber, + ticketCount: 1, + answers: [], + }); + + const params = { + headers: { + 'Content-Type': 'application/json', + 'X-Test-Id': TEST_ID, + 'X-Perf-Test': 'true', + }, + tags: { name: '/api/reservations' }, + }; + + const startTime = Date.now(); + const response = http.post(`${BASE_URL}/api/reservations`, payload, params); + const duration = Date.now() - startTime; + + reservationDuration.add(duration); + + const success = check(response, { + 'status is 201': (r) => r.status === 201, + 'has reservation id': (r) => { + try { + const body = JSON.parse(r.body); + return body.id !== undefined; + } catch { + return false; + } + }, + 'has qrToken': (r) => { + try { + const body = JSON.parse(r.body); + return body.qrToken !== undefined; + } catch { + return false; + } + }, + }); + + if (success) { + reservationSuccess.add(1); + errorRate.add(0); + } else { + reservationFailed.add(1); + errorRate.add(1); + console.log(`[VU ${__VU}] Failed: ${response.status} - ${response.body}`); + } + + sleep(0.5); +} diff --git a/k6/reservation-test.js b/k6/reservation-test.js new file mode 100644 index 0000000..d565dfb --- /dev/null +++ b/k6/reservation-test.js @@ -0,0 +1,74 @@ +/** + * =========================================== + * Form PASS 예약 API 부하 테스트 (Ramp-up/Spike) + * =========================================== + * + * [테스트 시나리오] + * 1. Ramp-up: 30초 동안 0 → 50 VU 증가 + * 2. Sustain: 1분 동안 50 VU 유지 + * 3. Spike: 30초 동안 50 → 100 VU 증가 + * 4. Peak: 1분 동안 100 VU 유지 + * 5. Ramp-down: 30초 동안 100 → 0 VU 감소 + * + * [총 테스트 시간] 약 4분 + * + * [성공 기준] + * - p(95) 응답 시간 < 2초 + * - 에러율 < 10% + * + * [실행 방법] + * $ ./perf.sh --script reservation-test.js --warmup --dashboard + * + * [대시보드 확인] + * http://localhost:5665 + * + * [테스트 전 준비] + * 1. maxCapacity 충분히 설정 (10000 이상) + * 2. 기존 예약 데이터 삭제: + * DELETE FROM form_answers WHERE reservation_id IN (SELECT id FROM reservations WHERE event_schedule_id = 1); + * DELETE FROM reservations WHERE event_schedule_id = 1; + * UPDATE event_schedules SET reserved_count = 0 WHERE id = 1; + */ + +import { createReservation, BASE_URL, SCHEDULE_ID, TEST_ID } from './reservation-helpers.js'; + +// ============================================ +// Test Configuration +// ============================================ +export const options = { + stages: [ + { duration: '30s', target: 50 }, // Ramp-up: 50 VU까지 증가 + { duration: '1m', target: 50 }, // Sustain: 50 VU 유지 + { duration: '30s', target: 100 }, // Spike: 100 VU까지 증가 + { duration: '1m', target: 100 }, // Peak: 100 VU 유지 + { duration: '30s', target: 0 }, // Ramp-down: 0으로 감소 + ], + thresholds: { + http_req_duration: ['p(95)<2000'], + error_rate: ['rate<0.1'], + }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)', 'count'], +}; + +// ============================================ +// Main +// ============================================ +export default function () { + createReservation(); +} + +export function setup() { + console.log('========================================'); + console.log('Form PASS 예약 API 부하 테스트 시작 (Ramp-up/Spike)'); + console.log(`Test ID : ${TEST_ID}`); + console.log(`Target : ${BASE_URL}`); + console.log(`Schedule ID : ${SCHEDULE_ID}`); + console.log('========================================'); +} + +export function teardown() { + console.log('========================================'); + console.log('부하 테스트 완료'); + console.log(`Test ID: ${TEST_ID}`); + console.log('========================================'); +} diff --git a/perf.sh b/perf.sh new file mode 100755 index 0000000..2c91d90 --- /dev/null +++ b/perf.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# =========================================== +# Form PASS 부하테스트 실행 래퍼 +# =========================================== +# +# 사용 예시: +# ./perf.sh --endpoints "/api/events,/health" --warmup --dashboard +# ./perf.sh --script reservation-test.js --warmup --dashboard +# ./perf.sh --endpoints "/api/host/events" --token "$TOKEN" --dashboard +# +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" +ENDPOINTS="" +TOKEN="" +TEST_ID="" +WARMUP=false +DASHBOARD=false + +# ============================================ +# Parse Arguments +# ============================================ +while [[ $# -gt 0 ]]; do + case "$1" in + --script) + SCRIPT="$2"; shift 2 ;; + --base-url) + BASE_URL="$2"; shift 2 ;; + --endpoints) + ENDPOINTS="$2"; shift 2 ;; + --denylist) + DENYLIST="$2"; shift 2 ;; + --token) + TOKEN="$2"; shift 2 ;; + --test-id) + TEST_ID="$2"; shift 2 ;; + --warmup) + WARMUP=true; shift ;; + --dashboard) + DASHBOARD=true; shift ;; + -h|--help) + echo "Usage: ./perf.sh [OPTIONS]" + echo "" + echo "Options:" + echo " --script k6 스크립트 파일명 (기본: common.js)" + echo " --base-url API 서버 URL (기본: https://api.form-pass.life)" + echo " --endpoints 테스트할 GET 엔드포인트 CSV (common.js 전용)" + echo " --denylist 차단할 엔드포인트 CSV" + echo " --token Bearer 토큰" + echo " --test-id 테스트 ID (미지정 시 자동 생성)" + echo " --warmup warm-up 실행 후 main 실행 (2회 자동 실행)" + echo " --dashboard 웹 대시보드 활성화 (http://localhost:5665)" + echo " -h, --help 도움말 출력" + exit 0 ;; + *) + echo "Unknown option: $1" >&2 + exit 1 ;; + esac +done + +# ============================================ +# Auto-generate Test ID +# ============================================ +if [[ -z "$TEST_ID" ]]; then + TEST_ID="exp-$(date +%Y%m%d-%H%M)" +fi + +# ============================================ +# Docker arguments +# ============================================ +build_docker_args() { + local warmup_flag="$1" + local suffix="$2" + local current_test_id="${TEST_ID}-${suffix}" + + local args=( + "docker" "run" "--rm" + "-e" "BASE_URL=${BASE_URL}" + "-e" "TEST_ID=${current_test_id}" + "-e" "DENYLIST=${DENYLIST}" + ) + + if [[ -n "$ENDPOINTS" ]]; then + args+=("-e" "ENDPOINTS=${ENDPOINTS}") + fi + + if [[ -n "$TOKEN" ]]; then + args+=("-e" "TOKEN=${TOKEN}") + fi + + if [[ "$warmup_flag" == "true" ]]; then + args+=("-e" "WARMUP=true") + fi + + if [[ "$DASHBOARD" == true ]]; then + args+=("-p" "5665:5665") + fi + + args+=("-v" "$(pwd)/k6:/scripts") + args+=("grafana/k6" "run") + + if [[ "$DASHBOARD" == true ]]; then + args+=("--out" "web-dashboard=host=0.0.0.0") + fi + + args+=("/scripts/${SCRIPT}") + + echo "${args[@]}" +} + +# ============================================ +# Print Summary +# ============================================ +echo "========================================" +echo " Form PASS 부하테스트" +echo "========================================" +echo " Script : ${SCRIPT}" +echo " Base URL : ${BASE_URL}" +echo " Test ID : ${TEST_ID}" +echo " Warmup : ${WARMUP}" +echo " Dashboard : ${DASHBOARD}" +if [[ -n "$ENDPOINTS" ]]; then + echo " Endpoints : ${ENDPOINTS}" +fi +echo " Denylist : ${DENYLIST}" +echo " Token : ${TOKEN:+(set)}" +echo "========================================" +echo "" + +# ============================================ +# Execute +# ============================================ +if [[ "$WARMUP" == true ]]; then + echo "[1/2] Warm-up 실행 (Test ID: ${TEST_ID}-warmup)" + echo "----------------------------------------" + eval "$(build_docker_args true warmup)" + + echo "" + echo "Warm-up 완료. 3초 대기 후 main 실행..." + sleep 3 + echo "" + + echo "[2/2] Main 실행 (Test ID: ${TEST_ID}-main)" + echo "----------------------------------------" + eval "$(build_docker_args false main)" +else + echo "Main 실행 (Test ID: ${TEST_ID}-main)" + echo "----------------------------------------" + eval "$(build_docker_args false main)" +fi + +echo "" +echo "========================================" +echo " 테스트 완료: ${TEST_ID}" +echo "========================================" diff --git a/rule.md b/rule.md new file mode 100644 index 0000000..fc3eee4 --- /dev/null +++ b/rule.md @@ -0,0 +1,28 @@ +# 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