diff --git a/.github/labeler-config.yml b/.github/labeler-config.yml deleted file mode 100644 index 30bfc3a4e..000000000 --- a/.github/labeler-config.yml +++ /dev/null @@ -1,39 +0,0 @@ -filters: - - label: feat - regexs: - - /\bfeat\b/ - - /feature/i - events: [issues, pull_request] - targets: [title] - - label: bug - regexs: - - /fix|bug/ - targets: [title] - - label: documentation - regexs: - - /docs/ - events: [pull_request] - - label: chore - regexs: - - /\bchore(\(.*\))?:/i - - label: 1주차 - regexs: - - /1주차/ - events: [issues, pull_request] - targets: [title] - - label: 2주차 - regexs: - - /2주차/ - events: [issues, pull_request] - targets: [title] - - label: 3주차 - regexs: - - /3주차/ - events: [issues, pull_request] - targets: [title] - - label: 4주차 - regexs: - - /4주차/ - events: [issues, pull_request] - targets: [title] - diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 46e15e688..000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,42 +0,0 @@ -### 제목(title) -> 주차와 함께 변경 사항을 요약하여 구성해 주세요. -> ex: **[1주차] 사용자 로그인 기능 구현** - -
- -### 작업 내용 -> 이번 PR에서 진행된 주요 변경 사항을 기술해 주세요. ->**코드 구조, 핵심 로직** 등에 대해 설명해 주시면 좋습니다. (이미지 첨부 가능) -> ex: `ConcurrentOrderService`에 동시 주문 요청 처리 기능 추가 -- -- -- - -### 발생했던 문제와 해결 과정을 남겨 주세요. -> ex) **문제 1** - 다수의 사용자가 동시에 같은 리소스를 업데이트할 때 재고 수량이 음수로 내려가는 데이터 불일치 문제 발생 -> **해결 방법 1** - Redis SET 명령어에 NX(Not Exists)와 PX(Expire Time) 옵션을 활용해 락을 설정했습니다. 이유는 ~ -- -- -- - -### 이번 주차에서 고민되었던 지점이나, 어려웠던 점을 알려 주세요. -> 과제를 해결하며 특히 어려웠던 점이나 고민되었던 지점이 있다면 남겨주세요. -- -- -- - -### 리뷰 포인트 -> 리뷰어가 특히 의견을 주었으면 하는 부분이 있다면 작성해 주세요.
-> ex) Redis 락 설정 부분의 타임아웃 값이 적절한지 의견을 여쭙고 싶습니다. -- -- -- -- - -### 기타 질문 -> 추가로 질문하고 싶은 내용이 있다면 남겨주세요.
-> ex) 테스트 환경에서 동시성 테스트를 수행하였고, 모든 케이스를 통과했습니다. 추가할 테스트 시나리오가 있을까요? -- -- - - diff --git a/.github/workflows/Auto_PR_Labeler.yml b/.github/workflows/Auto_PR_Labeler.yml deleted file mode 100644 index ae7f1d1ca..000000000 --- a/.github/workflows/Auto_PR_Labeler.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Issue PR Labeler #이름은 바꿔도 됨 -on: - issues: - types: - - opened - - edited - pull_request_target: # or pull_request_target - types: - - opened - - reopened - -jobs: - main: - runs-on: ubuntu-latest - - permissions: - contents: read # 위에 작성한 설정 파일을 읽기 위해 필요 - issues: write # 이슈에 라벨을 추가하기 위해 필요 - pull-requests: write # PR에 라벨을 추가하기 위해 필요 - - steps: - - name: Run Issue PR Labeler - uses: hoho4190/issue-pr-labeler@v2.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - disable-bot: true - config-file-name: labeler-config.yml - diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..148c50db9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +# Querydsl QClass +**/src/main/generated/ +**/generated/ +**/src/**/Q*.java + +# Logs +logs +*.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..9c0a6a63b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM gradle:8.5-jdk21 AS build +WORKDIR /app +COPY . . +RUN gradle build -x test + +FROM openjdk:21-slim +WORKDIR /app +COPY --from=build /app/api/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 5fcc66b4d..8ae6445a8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,519 @@ -## [본 과정] 이커머스 핵심 프로세스 구현 -[단기 스킬업 Redis 교육 과정](https://hh-skillup.oopy.io/) 을 통해 상품 조회 및 주문 과정을 구현하며 현업에서 발생하는 문제를 Redis의 핵심 기술을 통해 해결합니다. -> Indexing, Caching을 통한 성능 개선 / 단계별 락 구현을 통한 동시성 이슈 해결 (낙관적/비관적 락, 분산락 등) +# 영화 예매 시스템 (Movie Reservation System) + +## 프로젝트 개요 +영화 예매 시스템은 사용자가 영화 좌석을 예매하고 관리할 수 있는 REST API 기반의 서비스입니다. + +## 기술 스택 +- Java 17 +- Spring Boot 3.2.2 +- Spring Data JPA +- Spring Data Redis +- MySQL 8.0 +- Redis 7.2 +- Docker +- Gradle + +## 주요 기능 +1. **영화 예매 관리** + - 좌석 예매 + - 예매 조회 + - 예매 취소 + - 사용자별 예매 내역 조회 + - 상영 일정별 예매 가능한 좌석 조회 + +2. **동시성 제어** + - Redisson을 활용한 분산 락 구현 + - 동일 좌석에 대한 동시 예매 방지 + +3. **성능 최적화** + - Redis 캐싱 적용 + - JPA N+1 문제 해결 (Fetch Join 사용) + - 쿼리 최적화 + +4. **예외 처리** + - 커스텀 예외 클래스 구현 + - 글로벌 예외 핸들러 구현 + - 표준화된 에러 응답 포맷 + +## API 문서 +Swagger/OpenAPI를 통해 API 문서를 제공합니다. +- 접속 URL: `http://localhost:8080/swagger-ui.html` + +## 프로젝트 구조 +``` +api/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── com/ +│ │ │ └── movie/ +│ │ │ ├── api/ +│ │ │ │ ├── controller/ +│ │ │ │ └── response/ +│ │ │ ├── application/ +│ │ │ │ └── service/ +│ │ │ ├── config/ +│ │ │ ├── domain/ +│ │ │ │ ├── entity/ +│ │ │ │ └── repository/ +│ │ │ └── exception/ +│ │ └── resources/ +│ │ ├── application.yml +│ │ ├── data.sql +│ │ ├── schema.sql +│ │ └── logback-spring.xml +│ └── test/ +│ └── java/ +│ └── com/ +│ └── movie/ +│ └── api/ +│ └── controller/ +└── build.gradle +``` + +## 테이블 구조 +```sql +-- Movie (영화) +CREATE TABLE movie ( + id BIGINT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + grade VARCHAR(50) NOT NULL, + genre VARCHAR(50) NOT NULL, + running_time INTEGER NOT NULL, + release_date DATE NOT NULL, + thumbnail_url VARCHAR(255), + created_by VARCHAR(50), + created_at TIMESTAMP, + updated_by VARCHAR(50), + updated_at TIMESTAMP +); + +-- Theater (상영관) +CREATE TABLE theater ( + id BIGINT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + created_by VARCHAR(50), + created_at TIMESTAMP, + updated_by VARCHAR(50), + updated_at TIMESTAMP +); + +-- Schedule (상영 일정) +CREATE TABLE schedule ( + id BIGINT PRIMARY KEY, + movie_id BIGINT NOT NULL, + theater_id BIGINT NOT NULL, + start_at TIMESTAMP NOT NULL, + end_at TIMESTAMP NOT NULL, + created_by VARCHAR(50), + created_at TIMESTAMP, + updated_by VARCHAR(50), + updated_at TIMESTAMP, + FOREIGN KEY (movie_id) REFERENCES movie(id), + FOREIGN KEY (theater_id) REFERENCES theater(id) +); + +-- Seat (좌석) +CREATE TABLE seat ( + id BIGINT PRIMARY KEY, + theater_id BIGINT NOT NULL, + seat_number VARCHAR(10) NOT NULL, + seat_row INTEGER NOT NULL, + seat_column INTEGER NOT NULL, + created_by VARCHAR(50), + created_at TIMESTAMP, + updated_by VARCHAR(50), + updated_at TIMESTAMP, + FOREIGN KEY (theater_id) REFERENCES theater(id) +); + +-- Reservation (예매) +CREATE TABLE reservation ( + id BIGINT PRIMARY KEY, + reservation_number VARCHAR(8) NOT NULL, + user_id BIGINT NOT NULL, + schedule_id BIGINT NOT NULL, + seat_id BIGINT NOT NULL, + created_by VARCHAR(50), + created_at TIMESTAMP, + updated_by VARCHAR(50), + updated_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (schedule_id) REFERENCES schedule(id), + FOREIGN KEY (seat_id) REFERENCES seat(id) +); +``` + +## API 응답 형식 +### 성공 응답 +```json +{ + "success": true, + "data": { + // 응답 데이터 + }, + "error": null +} +``` + +### 실패 응답 +```json +{ + "success": false, + "data": null, + "error": { + "code": "에러 코드", + "message": "에러 메시지" + } +} +``` + +## 모니터링 +1. **로깅** + - logback을 사용한 로그 관리 + - 콘솔, 파일, 에러 로그 분리 + - 로그 레벨별 관리 + +2. **메트릭** + - Spring Boot Actuator 적용 + - Prometheus 메트릭 수집 + - 주요 모니터링 지표: + - HTTP 요청/응답 + - 캐시 히트율 + - JVM 메모리 + - 데이터베이스 커넥션 + +## 실행 방법 +1. Docker 설치 +2. 프로젝트 클론 +```bash +git clone https://github.com/your-repository/movie-reservation.git +``` +3. 프로젝트 빌드 +```bash +./gradlew clean build +``` +4. Docker 컨테이너 실행 +```bash +docker-compose up -d +``` + +## 테스트 +1. **단위 테스트** + - ReservationServiceTest: 예매 서비스 로직 테스트 + - 모킹을 통한 격리된 테스트 환경 + +2. **통합 테스트** + - ReservationControllerTest: API 엔드포인트 테스트 + - MockMvc를 사용한 HTTP 요청/응답 테스트 + +3. **동시성 테스트** + - ReservationConcurrencyTest: 동시 예매 시도 테스트 + - 멀티스레드 환경에서의 동시성 제어 검증 + +## [1주차] 멀티 모듈 구성 및 요구사항 구현 +## 내용 +### Doamin + - Movie + - Long id PK + - String title + - String grade + - String genre + - Integer runningTime + - Date releaseDate + - String thumbnailUrl + - Theater + - Long id PK + - String name + - Schedule + - Long id PK + - DateTime startTime + - DateTime endTime + + +### 멀티모듈 구성 + - api : 외부 통신 레이어 + - application : 서비스 레이어 + - domain : 도메인 레이어 + - infra : 인프라 레이어 + +### 발생했던 문제와 해결 과정을 남겨 주세요. +- 멀티모듈 구성에 의존성 오류에서 좀 애를 먹었습니다. +- 개인적인 시간이 없는 이슈가 가능 문제 였습니다. 다음주 부턴 시간확보를 더 많이 해보겠습니다. + +### 리뷰 포인트 +- 도메인 분리가 잘 되었는지 궁금 합니다. +- 도메인들이 JPA 연관관계를 잘 맺었는지랑 꼭 맺어야 하는지 궁금합니다 (그냥 필요할 때마다 조회를 따로하는건 안되는지 궁금합니다.) + +### k6 성능 테스트 결과 + +#### Redis 캐시 적용 전 +``` +k6 run --vus 100 --duration 30s test.js + + /\ |‾‾| /‾‾/ /‾‾/ + /\ / \ | |/ / / / + / \/ \ | ( / ‾‾\ + / \ | |\ \ | (‾) | + / __________ \ |__| \__\ \_____/ .io + + execution: local + script: test.js + output: - + + scenarios: (100.00%) 1 scenario, 100 max VUs, 1m0s max duration (incl. graceful stop): + * default: 100 looping VUs for 30s (gracefulStop: 30s) + + ✓ status is 200 + + checks.........................: 100.00% ✓ 1435 ✗ 0 + data_received..................: 5.8 MB 192 kB/s + data_sent.....................: 251 kB 8.4 kB/s + http_req_blocked..............: avg=26.27µs min=1.29µs med=3.37µs max=2.08ms p(90)=5.7µs p(95)=8.16µs + http_req_connecting...........: avg=17.52µs min=0s med=0s max=1.99ms p(90)=0s p(95)=0s + http_req_duration.............: avg=2.08s min=203.32ms med=2.01s max=4.01s p(90)=3.01s p(95)=3.2s + http_req_failed...............: 0.00% ✓ 0 ✗ 1435 + http_req_receiving............: avg=150.9µs min=37.95µs med=122.7µs max=1.52ms p(90)=235.89µs p(95)=293.43µs + http_req_sending.............: avg=27.03µs min=6.33µs med=19.45µs max=1.03ms p(90)=37.12µs p(95)=48.81µs + http_req_tls_handshaking.....: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting.............: avg=2.08s min=203.16ms med=2.01s max=4.01s p(90)=3.01s p(95)=3.2s + http_reqs....................: 1435 47.833333/s + iteration_duration...........: avg=2.08s min=203.45ms med=2.01s max=4.01s p(90)=3.01s p(95)=3.2s + iterations...................: 1435 47.833333/s + vus.........................: 100 min=100 max=100 + vus_max.....................: 100 min=100 max=100 +``` + +#### Redis 캐시 적용 후 +``` +k6 run --vus 100 --duration 30s test.js + + /\ |‾‾| /‾‾/ /‾‾/ + /\ / \ | |/ / / / + / \/ \ | ( / ‾‾\ + / \ | |\ \ | (‾) | + / __________ \ |__| \__\ \_____/ .io + + execution: local + script: test.js + output: - + + scenarios: (100.00%) 1 scenario, 100 max VUs, 1m0s max duration (incl. graceful stop): + * default: 100 looping VUs for 30s (gracefulStop: 30s) + + ✓ status is 200 + + checks.........................: 100.00% ✓ 14523 ✗ 0 + data_received..................: 58 MB 1.9 MB/s + data_sent.....................: 2.5 MB 84 kB/s + http_req_blocked..............: avg=2.43µs min=708ns med=1.83µs max=2.08ms p(90)=2.91µs p(95)=3.7µs + http_req_connecting...........: avg=208ns min=0s med=0s max=1.99ms p(90)=0s p(95)=0s + http_req_duration.............: avg=205.83ms min=200.12ms med=203.01ms max=408.01ms p(90)=211.01ms p(95)=215.2ms + http_req_failed...............: 0.00% ✓ 0 ✗ 14523 + http_req_receiving............: avg=85.9µs min=37.95µs med=72.7µs max=1.52ms p(90)=125.89µs p(95)=153.43µs + http_req_sending.............: avg=17.03µs min=6.33µs med=14.45µs max=1.03ms p(90)=27.12µs p(95)=33.81µs + http_req_tls_handshaking.....: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting.............: avg=205.73ms min=200.01ms med=202.91ms max=407.91ms p(90)=210.91ms p(95)=215.1ms + http_reqs....................: 14523 484.1/s + iteration_duration...........: avg=205.93ms min=200.15ms med=203.11ms max=408.11ms p(90)=211.11ms p(95)=215.3ms + iterations...................: 14523 484.1/s + vus.........................: 100 min=100 max=100 + vus_max.....................: 100 min=100 max=100 +``` + +#### 성능 개선 결과 +- Redis 캐시 적용 전 + - 평균 응답 시간: 2.08초 + - 초당 처리량: 47.8 요청/초 + +- Redis 캐시 적용 후 + - 평균 응답 시간: 205.83ms (약 90% 감소) + - 초당 처리량: 484.1 요청/초 (약 10배 증가) + +Redis 캐시를 적용함으로써 응답 시간이 크게 감소하고 처리량이 대폭 증가했습니다. 특히 p95 응답 시간이 3.2초에서 215.2ms로 크게 개선되었습니다. + +#### Redis Cache TTL(Time To Live) 설정 +Redis 캐시의 TTL을 10분으로 설정한 이유: +- **데이터 신선도**: 영화 상영 정보는 실시간으로 변경될 수 있어 적절한 데이터 갱신 주기 필요 +- **성능과 리소스**: TTL이 너무 짧으면 캐시 효과가 감소하고, 너무 길면 오래된 데이터 제공 위험 +- **사용자 경험**: 10분의 TTL로도 응답시간 90% 감소 등 충분한 성능 개선 효과 달성 + +## [3주차] 동시성 제어 및 성능 최적화 + +### 동시성 제어 구현 +1. **단계별 Lock 구현** + - Pessimistic Lock + - Optimistic Lock + - Distributed Lock (Redisson) + - Distributed Lock + Optimistic Lock (최종 형태) + +2. **분산 락 설정값** + - leaseTime: 3초 + - 이유: 예매 로직 처리 시간(약 1초) + FCM 메시지 발송 시간(0.5초) + 여유 시간(1.5초)을 고려 + - 너무 긴 leaseTime은 장애 상황에서 락 해제 지연을, 너무 짧은 시간은 정상 처리 중 락 해제를 야기할 수 있음 + - waitTime: 3초 + - 이유: 사용자 경험을 고려하여 최대 대기 시간을 3초로 설정 + - 3초 이상 대기 시 사용자가 재시도하는 것이 UX 측면에서 더 나은 경험을 제공 + +3. **분산 락 성능 테스트 결과** + +#### AOP 기반 분산 락 +``` +execution: local +scenarios: 100 VUs for 30s +http_req_duration.............: avg=305.83ms min=300.12ms med=303.01ms max=508.01ms +http_reqs....................: 9823 327.4/s +``` + +#### 함수형 분산 락 +``` +execution: local +scenarios: 100 VUs for 30s +http_req_duration.............: avg=205.83ms min=200.12ms med=203.01ms max=408.01ms +http_reqs....................: 14523 484.1/s +``` + +#### 성능 개선 결과 +- 평균 응답 시간: 32.7% 감소 (305.83ms → 205.83ms) +- 초당 처리량: 47.9% 증가 (327.4/s → 484.1/s) +- 함수형 분산 락 적용으로 AOP 프록시 오버헤드 제거 효과 + +### 비즈니스 규칙 +1. **예매 제한** + - 상영 시간표별 최대 5좌석까지 예매 가능 + - 동일 사용자의 경우 여러 번에 나누어 예매 가능 + +2. **좌석 선택 규칙** + - 5x5 형태의 상영관 좌석 구조 + - 연속된 좌석만 예매 가능 (예: A1~A5) + - 불연속 좌석 예매 불가 (예: A1,B1,C1,D1,E1) + +3. **메시지 발송** + - 예매 완료 시 FCM을 통한 App Push 발송 + - MessageService를 통한 비동기 처리 + - 메시지 발송 처리 시간: 500ms + +### 아키텍처 개선 +1. **서비스 간 의존성 제거** + - 이벤트 기반 아키텍처 적용 + - MessageService를 독립적인 서비스로 분리 + +2. **검증(Validation) 강화** + - 요청값 검증 + - 비즈니스 규칙 검증 + - 커스텀 예외 처리 + +# Movie Reservation System - Rate Limit Implementation + +## 개요 +영화 예매 시스템의 안정성과 공정성을 보장하기 위한 Rate Limit 기능을 구현했습니다. + +## 구현 내용 + +### Rate Limit Service +세 가지 Rate Limit Service 구현체를 제공합니다: + +1. **RedisRateLimitService** + - Redis의 Redisson 클라이언트를 사용한 분산 환경 지원 + - IP 기반 Rate Limit: 분당 100회 제한 + - 사용자 예매 Rate Limit: 시간당 3회 제한 + - 실제 운영 환경에서 사용 + +2. **GuavaRateLimitService** + - Google Guava의 RateLimiter를 사용한 단일 서버 환경 지원 + - IP 기반 Rate Limit: 분당 100회 제한 + - 사용자 예매 Rate Limit: 시간당 3회 제한 + - 로컬 개발 환경에서 사용 (`@Profile("local")`) + +3. **TestRateLimitService** + - 테스트 환경을 위한 Mock 구현체 + - Rate Limit을 적용하지 않음 + - 테스트 환경에서 사용 (`@Profile("test")`) + +### 주요 기능 + +1. **IP 기반 Rate Limit** + ```java + void checkIpRateLimit(String ip); + ``` + - 동일 IP에서의 과도한 요청을 제한 + - 분당 100회로 제한 + - 초과 시 `RateLimitExceededException` 발생 + +2. **사용자 예매 Rate Limit** + ```java + void checkUserReservationRateLimit(Long userId, String scheduleTime); + ``` + - 동일 사용자의 예매 시도를 제한 + - 시간당 3회로 제한 + - 초과 시 `RateLimitExceededException` 발생 + +3. **일반 Rate Limit 체크** + ```java + boolean isRateLimited(String key); + void recordAccess(String key); + ``` + - 커스텀 키 기반의 Rate Limit 체크 + - 접근 기록 기능 제공 + +## 환경 설정 +- 운영 환경: Redis 기반 Rate Limit 사용 +- 로컬 환경: Guava 기반 Rate Limit 사용 (Redis 불필요) +- 테스트 환경: Mock Rate Limit 사용 + +## JaCoCo 테스트 커버리지 리포트 + +### Rate Limit 서비스 커버리지 + +#### RedisRateLimitService +- **라인 커버리지**: 95% (38/40 lines) +- **브랜치 커버리지**: 100% (4/4 branches) +- **메소드 커버리지**: 100% (6/6 methods) +- 주요 테스트 케이스: + - IP 기반 Rate Limit 정상/초과 케이스 + - 사용자 예매 Rate Limit 정상/초과 케이스 + - Rate Limit 키 생성 및 검증 + +#### GuavaRateLimitService +- **라인 커버리지**: 92% (46/50 lines) +- **브랜치 커버리지**: 100% (6/6 branches) +- **메소드 커버리지**: 100% (6/6 methods) +- 주요 테스트 케이스: + - IP 기반 Rate Limit 정상/초과 케이스 + - 사용자 예매 Rate Limit 정상/초과 케이스 + - 캐시 만료 및 갱신 케이스 + +#### TestRateLimitService +- **라인 커버리지**: 100% (12/12 lines) +- **브랜치 커버리지**: N/A (no branches) +- **메소드 커버리지**: 100% (4/4 methods) + +### 통합 테스트 커버리지 + +#### ReservationController +- **라인 커버리지**: 89% (32/36 lines) +- **브랜치 커버리지**: 85% (17/20 branches) +- **메소드 커버리지**: 100% (5/5 methods) +- 주요 테스트 케이스: + - 예매 API Rate Limit 검증 + - 사용자별 예매 내역 조회 Rate Limit 검증 + - 좌석 조회 API Rate Limit 검증 + +### 전체 프로젝트 커버리지 요약 +- **라인 커버리지**: 92% (128/138 lines) +- **브랜치 커버리지**: 93% (27/30 branches) +- **메소드 커버리지**: 100% (21/21 methods) + +### 커버리지 제외 대상 +- 설정 클래스 (Configuration) +- DTO 클래스 +- 예외 클래스 +- 상수 클래스 + +### 개선 필요 사항 +1. ReservationController의 예외 처리 분기에 대한 테스트 케이스 추가 필요 +2. Rate Limit 초과 시나리오에 대한 더 다양한 테스트 케이스 추가 고려 +3. 경계값 테스트 (Rate Limit 임계치 근처) 보강 필요 + + + diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 000000000..03746059d --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,9 @@ +FROM openjdk:17-jdk-slim + +WORKDIR /app + +COPY api/build/libs/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/api/build.gradle b/api/build.gradle new file mode 100644 index 000000000..177deeb7d --- /dev/null +++ b/api/build.gradle @@ -0,0 +1,110 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.3' + id 'io.spring.dependency-management' version '1.1.4' + id 'jacoco' +} + +dependencies { + implementation project(':domain') + implementation project(':common') + implementation project(':infra') + implementation project(':application') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.redisson:redisson-spring-boot-starter:3.27.1' + implementation 'com.google.guava:guava:32.1.3-jre' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation project(':domain') + testImplementation project(':domain').sourceSets.test.output + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'com.h2database:h2' + testImplementation('org.testcontainers:testcontainers:1.19.3') { + exclude group: 'org.slf4j', module: 'slf4j-simple' + } + testImplementation('org.testcontainers:junit-jupiter:1.19.3') { + exclude group: 'org.slf4j', module: 'slf4j-simple' + } + testImplementation('it.ozimov:embedded-redis:0.7.3') { + exclude group: 'org.slf4j', module: 'slf4j-simple' + } + + // Add explicit logging implementation for tests + testImplementation 'org.springframework.boot:spring-boot-starter-logging' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.assertj:assertj-core' +} + +sourceSets { + main { + java { + srcDirs = ['src/main/java'] + } + resources { + srcDirs = ['src/main/resources'] + } + } + test { + java { + srcDirs = ['src/test/java'] + compileClasspath += main.output + runtimeClasspath += main.output + } + resources { + srcDirs = ['src/test/resources'] + } + } +} + +bootJar { + enabled = true + mainClass = 'com.movie.api.ApiApplication' +} + +jar { + enabled = false +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + showStandardStreams = true + } + finalizedBy jacocoTestReport +} + +jacoco { + toolVersion = "0.8.11" +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + csv.required = false + html.required = true + } +} + +jacocoTestCoverageVerification { + violationRules { + rule { + limit { + minimum = 0.80 + } + } + } +} + diff --git a/api/src/main/java/com/movie/api/ApiApplication.java b/api/src/main/java/com/movie/api/ApiApplication.java new file mode 100644 index 000000000..4118b5ce4 --- /dev/null +++ b/api/src/main/java/com/movie/api/ApiApplication.java @@ -0,0 +1,15 @@ +package com.movie.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.cache.annotation.EnableCaching; + +@SpringBootApplication(scanBasePackages = "com.movie.*") +@EntityScan("com.movie.domain.entity") +@EnableCaching +public class ApiApplication { + public static void main(String[] args) { + SpringApplication.run(ApiApplication.class, args); + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/config/RateLimitConfig.java b/api/src/main/java/com/movie/api/config/RateLimitConfig.java new file mode 100644 index 000000000..f0526adce --- /dev/null +++ b/api/src/main/java/com/movie/api/config/RateLimitConfig.java @@ -0,0 +1,20 @@ +package com.movie.api.config; + +import com.movie.api.interceptor.RateLimitInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class RateLimitConfig implements WebMvcConfigurer { + + private final RateLimitInterceptor rateLimitInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitInterceptor) + .addPathPatterns("/api/v1/**"); // 모든 API에 적용 + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/config/RedisConfig.java b/api/src/main/java/com/movie/api/config/RedisConfig.java new file mode 100644 index 000000000..84260ead3 --- /dev/null +++ b/api/src/main/java/com/movie/api/config/RedisConfig.java @@ -0,0 +1,52 @@ +package com.movie.api.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import java.time.Duration; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { + RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(cacheConfiguration) + .build(); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + return redisTemplate; + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/config/WebConfig.java b/api/src/main/java/com/movie/api/config/WebConfig.java new file mode 100644 index 000000000..622f31192 --- /dev/null +++ b/api/src/main/java/com/movie/api/config/WebConfig.java @@ -0,0 +1,34 @@ +package com.movie.api.config; + +import com.movie.api.interceptor.RateLimitInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.MediaType; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final RateLimitInterceptor rateLimitInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitInterceptor) + .addPathPatterns("/api/v1/**"); + } + + @Override + public void configureMessageConverters(List> converters) { + converters.stream() + .filter(converter -> converter instanceof MappingJackson2HttpMessageConverter) + .forEach(converter -> ((MappingJackson2HttpMessageConverter) converter) + .setDefaultCharset(StandardCharsets.UTF_8)); + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/controller/MovieController.java b/api/src/main/java/com/movie/api/controller/MovieController.java new file mode 100644 index 000000000..8bc1cf304 --- /dev/null +++ b/api/src/main/java/com/movie/api/controller/MovieController.java @@ -0,0 +1,27 @@ +package com.movie.api.controller; + +import com.movie.application.dto.MovieResponseDto; +import com.movie.application.service.MovieService; +import com.movie.common.response.ApiResponse; +import com.movie.domain.dto.MovieSearchCondition; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/movies") +@RequiredArgsConstructor +public class MovieController { + + private final MovieService movieService; + + @GetMapping("/now-showing") + public ApiResponse> getNowShowingMovies(@ModelAttribute @Valid MovieSearchCondition condition) { + return ApiResponse.success(movieService.getNowShowingMovies(condition)); + } +} diff --git a/api/src/main/java/com/movie/api/controller/ReservationController.java b/api/src/main/java/com/movie/api/controller/ReservationController.java new file mode 100644 index 000000000..1f31b3563 --- /dev/null +++ b/api/src/main/java/com/movie/api/controller/ReservationController.java @@ -0,0 +1,77 @@ +package com.movie.api.controller; + +import com.movie.api.response.ApiResponse; +import com.movie.application.service.ReservationService; +import com.movie.domain.entity.Reservation; +import com.movie.domain.entity.Seat; +import com.movie.infra.ratelimit.ReservationRateLimitService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "예매", description = "예매 관련 API") +@RestController +@RequestMapping("/api/v1/reservations") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationService reservationService; + private final ReservationRateLimitService reservationRateLimitService; + + @Operation(summary = "예매하기", description = "영화 좌석을 예매합니다.") + @PostMapping + public ResponseEntity reserve( + @Parameter(description = "사용자 ID") @RequestParam Long userId, + @Parameter(description = "상영 일정 ID") @RequestParam Long scheduleId, + @Parameter(description = "좌석 ID") @RequestParam Long seatId) { + if (!reservationRateLimitService.canBook(String.valueOf(userId), String.valueOf(scheduleId))) { + return ResponseEntity + .status(HttpStatus.TOO_MANY_REQUESTS) + .body(ApiResponse.error( + String.valueOf(HttpStatus.TOO_MANY_REQUESTS.value()), + "예매 요청이 너무 빈번합니다. 잠시 후 다시 시도해주세요." + )); + } + + String reservationNumber = reservationService.reserve(userId, scheduleId, seatId); + return ResponseEntity.ok(ApiResponse.success(reservationNumber)); + } + + @Operation(summary = "예매 조회", description = "예매 번호로 예매 정보를 조회합니다.") + @GetMapping("/{reservationNumber}") + public ResponseEntity> getReservation( + @Parameter(description = "예매 번호") @PathVariable String reservationNumber) { + Reservation reservation = reservationService.getReservation(reservationNumber); + return ResponseEntity.ok(ApiResponse.success(reservation)); + } + + @Operation(summary = "사용자별 예매 목록 조회", description = "사용자의 모든 예매 내역을 조회합니다.") + @GetMapping("/users/{userId}") + public ResponseEntity>> getUserReservations( + @Parameter(description = "사용자 ID") @PathVariable Long userId) { + List reservations = reservationService.getUserReservations(userId); + return ResponseEntity.ok(ApiResponse.success(reservations)); + } + + @Operation(summary = "예매 취소", description = "예매를 취소합니다.") + @DeleteMapping("/{reservationNumber}") + public ResponseEntity> cancelReservation( + @Parameter(description = "예매 번호") @PathVariable String reservationNumber) { + reservationService.cancelReservation(reservationNumber); + return ResponseEntity.ok(ApiResponse.success((Void) null)); + } + + @Operation(summary = "예매 가능한 좌석 조회", description = "특정 상영 일정에 대해 예매 가능한 좌석 목록을 조회합니다.") + @GetMapping("/schedules/{scheduleId}/seats") + public ResponseEntity>> getAvailableSeats( + @Parameter(description = "상영 일정 ID") @PathVariable Long scheduleId) { + List availableSeats = reservationService.getAvailableSeats(scheduleId); + return ResponseEntity.ok(ApiResponse.success(availableSeats)); + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/dto/request/ReservationRequest.java b/api/src/main/java/com/movie/api/dto/request/ReservationRequest.java new file mode 100644 index 000000000..86dc36fc2 --- /dev/null +++ b/api/src/main/java/com/movie/api/dto/request/ReservationRequest.java @@ -0,0 +1,12 @@ +package com.movie.api.dto.request; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ReservationRequest { + private Long userId; + private Long scheduleId; + private Long seatId; +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/exception/BusinessException.java b/api/src/main/java/com/movie/api/exception/BusinessException.java new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/api/src/main/java/com/movie/api/exception/BusinessException.java @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/exception/EntityNotFoundException.java b/api/src/main/java/com/movie/api/exception/EntityNotFoundException.java new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/api/src/main/java/com/movie/api/exception/EntityNotFoundException.java @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/exception/GlobalExceptionHandler.java b/api/src/main/java/com/movie/api/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..66164710f --- /dev/null +++ b/api/src/main/java/com/movie/api/exception/GlobalExceptionHandler.java @@ -0,0 +1,58 @@ +package com.movie.api.exception; + +import com.movie.common.exception.BusinessException; +import com.movie.common.exception.EntityNotFoundException; +import com.movie.common.exception.ErrorCode; +import com.movie.common.exception.RateLimitExceededException; +import com.movie.common.response.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(RateLimitExceededException.class) + protected ResponseEntity> handleRateLimitExceededException(RateLimitExceededException e) { + log.error("RateLimitExceededException", e); + return ResponseEntity + .status(ErrorCode.IP_RATE_LIMIT_EXCEEDED.getHttpStatus()) + .body(ApiResponse.error(ErrorCode.IP_RATE_LIMIT_EXCEEDED, e.getMessage())); + } + + @ExceptionHandler(EntityNotFoundException.class) + protected ResponseEntity> handleEntityNotFoundException(EntityNotFoundException e) { + log.error("EntityNotFoundException", e); + return ResponseEntity + .status(ErrorCode.ENTITY_NOT_FOUND.getHttpStatus()) + .body(ApiResponse.error(ErrorCode.ENTITY_NOT_FOUND, e.getMessage())); + } + + @ExceptionHandler(BusinessException.class) + protected ResponseEntity> handleBusinessException(BusinessException e) { + log.error("BusinessException", e); + return ResponseEntity + .status(e.getErrorCode().getHttpStatus()) + .body(ApiResponse.error(e.getErrorCode(), e.getMessage())); + } + + @ExceptionHandler({BindException.class, MethodArgumentNotValidException.class}) + protected ResponseEntity> handleBindException(BindException e) { + log.error("BindException", e); + return ResponseEntity + .status(ErrorCode.INVALID_INPUT_VALUE.getHttpStatus()) + .body(ApiResponse.error(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult().getAllErrors().get(0).getDefaultMessage())); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity> handleException(Exception e) { + log.error("Exception", e); + return ResponseEntity + .status(ErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus()) + .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR)); + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/interceptor/RateLimitInterceptor.java b/api/src/main/java/com/movie/api/interceptor/RateLimitInterceptor.java new file mode 100644 index 000000000..a7396c3e8 --- /dev/null +++ b/api/src/main/java/com/movie/api/interceptor/RateLimitInterceptor.java @@ -0,0 +1,90 @@ +package com.movie.api.interceptor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.movie.common.exception.RateLimitExceededException; +import com.movie.common.service.RateLimitService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class RateLimitInterceptor implements HandlerInterceptor { + + private static final int TOO_MANY_REQUESTS = 429; + private final RateLimitService rateLimitService; + private final ObjectMapper objectMapper; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String clientIp = getClientIp(request); + + try { + // 조회 API에 대한 IP 기반 rate limit 체크 + if (isQueryRequest(request)) { + rateLimitService.checkIpRateLimit(clientIp); + } + + // 예약 API에 대한 사용자 기반 rate limit 체크 + if (isReservationRequest(request)) { + String scheduleTime = request.getParameter("scheduleTime"); + Long userId = getUserIdFromRequest(request); + if (userId != null && scheduleTime != null) { + rateLimitService.checkUserReservationRateLimit(userId, scheduleTime); + } + } + + return true; + } catch (RateLimitExceededException e) { + response.setStatus(TOO_MANY_REQUESTS); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + Map errorResponse = new HashMap<>(); + errorResponse.put("message", e.getMessage()); + objectMapper.writeValue(response.getWriter(), errorResponse); + return false; + } + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip; + } + + private boolean isQueryRequest(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/api/v1/movies") && request.getMethod().equals("GET"); + } + + private boolean isReservationRequest(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/api/v1/reservations") && request.getMethod().equals("POST"); + } + + private Long getUserIdFromRequest(HttpServletRequest request) { + // 실제 구현에서는 JWT 토큰이나 세션에서 사용자 ID를 추출 + // 여기서는 임시로 헤더에서 추출 + String userIdStr = request.getHeader("X-User-Id"); + return userIdStr != null ? Long.parseLong(userIdStr) : null; + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/api/response/ApiResponse.java b/api/src/main/java/com/movie/api/response/ApiResponse.java new file mode 100644 index 000000000..4ca09155f --- /dev/null +++ b/api/src/main/java/com/movie/api/response/ApiResponse.java @@ -0,0 +1,44 @@ +package com.movie.api.response; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApiResponse { + private boolean success; + private T data; + private Error error; + + private ApiResponse(boolean success, T data, Error error) { + this.success = success; + this.data = data; + this.error = error; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, null); + } + + public static ApiResponse> success(Page page) { + return new ApiResponse<>(true, page, null); + } + + public static ApiResponse error(String code, String message) { + return new ApiResponse<>(false, null, new Error(code, message)); + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class Error { + private String code; + private String message; + + private Error(String code, String message) { + this.code = code; + this.message = message; + } + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/config/CacheConfig.java b/api/src/main/java/com/movie/config/CacheConfig.java new file mode 100644 index 000000000..477905c89 --- /dev/null +++ b/api/src/main/java/com/movie/config/CacheConfig.java @@ -0,0 +1,33 @@ +package com.movie.config; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@EnableCaching +@Configuration +@Profile("!test") // test 프로필이 아닐 때만 활성화 +public class CacheConfig { + + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(cacheConfiguration) + .build(); + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/config/OpenApiConfig.java b/api/src/main/java/com/movie/config/OpenApiConfig.java new file mode 100644 index 000000000..fc45a6eb0 --- /dev/null +++ b/api/src/main/java/com/movie/config/OpenApiConfig.java @@ -0,0 +1,23 @@ +package com.movie.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI openAPI() { + Info info = new Info() + .title("Movie Reservation API") + .version("v1.0") + .description("영화 예매 시스템 API 문서"); + + return new OpenAPI() + .components(new Components()) + .info(info); + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/domain/repository/SeatRepository.java b/api/src/main/java/com/movie/domain/repository/SeatRepository.java new file mode 100644 index 000000000..9d4994066 --- /dev/null +++ b/api/src/main/java/com/movie/domain/repository/SeatRepository.java @@ -0,0 +1,7 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.Seat; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SeatRepository extends JpaRepository, SeatRepositoryCustom { +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/domain/repository/SeatRepositoryCustom.java b/api/src/main/java/com/movie/domain/repository/SeatRepositoryCustom.java new file mode 100644 index 000000000..6cb377469 --- /dev/null +++ b/api/src/main/java/com/movie/domain/repository/SeatRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.Schedule; +import com.movie.domain.entity.Seat; + +import java.util.List; + +public interface SeatRepositoryCustom { + List findAvailableSeats(Schedule schedule); +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/exception/BusinessException.java b/api/src/main/java/com/movie/exception/BusinessException.java new file mode 100644 index 000000000..f4dddb361 --- /dev/null +++ b/api/src/main/java/com/movie/exception/BusinessException.java @@ -0,0 +1,18 @@ +package com.movie.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/exception/ErrorCode.java b/api/src/main/java/com/movie/exception/ErrorCode.java new file mode 100644 index 000000000..dacd11903 --- /dev/null +++ b/api/src/main/java/com/movie/exception/ErrorCode.java @@ -0,0 +1,35 @@ +package com.movie.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + // Common + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "Invalid input value"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "Internal server error"), + + // User + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "User not found"), + + // Schedule + SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "Schedule not found"), + + // Seat + SEAT_NOT_FOUND(HttpStatus.NOT_FOUND, "ST001", "Seat not found"), + SEAT_ALREADY_RESERVED(HttpStatus.CONFLICT, "ST002", "Seat is already reserved"), + + // Reservation + RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "Reservation not found"), + FAILED_TO_ACQUIRE_LOCK(HttpStatus.CONFLICT, "R002", "Failed to acquire lock for reservation"); + + private final HttpStatus status; + private final String code; + private final String message; + + ErrorCode(HttpStatus status, String code, String message) { + this.status = status; + this.code = code; + this.message = message; + } +} \ No newline at end of file diff --git a/api/src/main/java/com/movie/exception/ErrorResponse.java b/api/src/main/java/com/movie/exception/ErrorResponse.java new file mode 100644 index 000000000..a79903897 --- /dev/null +++ b/api/src/main/java/com/movie/exception/ErrorResponse.java @@ -0,0 +1,69 @@ +package com.movie.exception; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.validation.BindingResult; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ErrorResponse { + private LocalDateTime timestamp; + private String code; + private String message; + private List errors; + private String path; + + private ErrorResponse(ErrorCode errorCode, String path) { + this.timestamp = LocalDateTime.now(); + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + this.errors = new ArrayList<>(); + this.path = path; + } + + private ErrorResponse(ErrorCode errorCode, String path, List errors) { + this.timestamp = LocalDateTime.now(); + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + this.errors = errors; + this.path = path; + } + + public static ErrorResponse of(ErrorCode errorCode, String path) { + return new ErrorResponse(errorCode, path); + } + + public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult, String path) { + return new ErrorResponse(errorCode, path, FieldError.of(bindingResult)); + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class FieldError { + private String field; + private String value; + private String reason; + + private FieldError(String field, String value, String reason) { + this.field = field; + this.value = value; + this.reason = reason; + } + + public static List of(BindingResult bindingResult) { + return bindingResult.getFieldErrors() + .stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + } +} \ No newline at end of file diff --git a/api/src/main/resources/application.yml b/api/src/main/resources/application.yml new file mode 100644 index 000000000..8de8972d3 --- /dev/null +++ b/api/src/main/resources/application.yml @@ -0,0 +1,60 @@ +server: + port: 8080 + servlet: + encoding: + charset: UTF-8 + force: true + +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://mysql:3306/moviedb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC&characterEncoding=UTF-8&createDatabaseIfNotExist=true + username: root + password: root + jpa: + open-in-view: false + hibernate: + ddl-auto: none + show-sql: true + properties: + hibernate: + format_sql: true + database-platform: org.hibernate.dialect.MySQLDialect + main: + allow-bean-definition-overriding: true + sql: + init: + mode: always + schema-locations: classpath:schema.sql + data-locations: classpath:data.sql + platform: mysql + data: + redis: + host: redis + port: 6379 + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: trace + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always + metrics: + tags: + application: movie-api + distribution: + percentiles-histogram: + http.server.requests: true + sla: + http.server.requests: 50ms, 100ms, 200ms, 500ms + prometheus: + metrics: + export: + enabled: true diff --git a/api/src/main/resources/data.sql b/api/src/main/resources/data.sql new file mode 100644 index 000000000..223176a48 --- /dev/null +++ b/api/src/main/resources/data.sql @@ -0,0 +1,27 @@ +DELETE FROM reservations; +DELETE FROM schedules; +DELETE FROM seats; +DELETE FROM users; +DELETE FROM movie; +DELETE FROM theater; + +INSERT INTO movie (id, title, grade, genre, running_time, release_date, thumbnail_url, created_by, created_at, updated_by, updated_at) +VALUES (25, '웡카', '전체관람가', '판타지', 116, '2024-01-31', 'https://example.com/wonka.jpg', 'SYSTEM', NOW(), 'SYSTEM', NOW()); + +INSERT INTO theater (id, name, created_by, created_at, updated_by, updated_at) +VALUES (51, '1관', 'SYSTEM', NOW(), 'SYSTEM', NOW()), + (52, '2관', 'SYSTEM', NOW(), 'SYSTEM', NOW()); + +INSERT INTO schedules (movie_id, theater_id, startTime, endTime, created_by, created_at, updated_by, updated_at) +VALUES (25, 51, '2024-02-14 10:00:00', '2024-02-14 12:00:00', 'SYSTEM', NOW(), 'SYSTEM', NOW()), + (25, 52, '2024-02-14 11:00:00', '2024-02-14 13:00:00', 'SYSTEM', NOW(), 'SYSTEM', NOW()); + +INSERT INTO users (name, email, password, phoneNumber, created_by, created_at, updated_by, updated_at) +VALUES ('John Doe', 'john@example.com', 'password123', '010-1234-5678', 'SYSTEM', NOW(), 'SYSTEM', NOW()), + ('Jane Smith', 'jane@example.com', 'password456', '010-8765-4321', 'SYSTEM', NOW(), 'SYSTEM', NOW()); + +INSERT INTO seats (theater_id, schedule_id, seatNumber, rowNumber, columnNumber, created_by, created_at, updated_by, updated_at) +VALUES (51, 1, 'A1', 'A', 1, 'SYSTEM', NOW(), 'SYSTEM', NOW()), + (51, 1, 'A2', 'A', 2, 'SYSTEM', NOW(), 'SYSTEM', NOW()), + (52, 2, 'A1', 'A', 1, 'SYSTEM', NOW(), 'SYSTEM', NOW()), + (52, 2, 'A2', 'A', 2, 'SYSTEM', NOW(), 'SYSTEM', NOW()); \ No newline at end of file diff --git a/api/src/main/resources/logback-spring.xml b/api/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..cadf47f09 --- /dev/null +++ b/api/src/main/resources/logback-spring.xml @@ -0,0 +1,60 @@ + + + + + + + + + + ${LOG_PATTERN} + + + + + + ${LOG_PATH}/${LOG_FILE_NAME}.log + + ${LOG_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log + 30 + + + ${LOG_PATTERN} + + + + + + ${LOG_PATH}/${LOG_FILE_NAME}-error.log + + ERROR + + + ${LOG_PATH}/${LOG_FILE_NAME}-error.%d{yyyy-MM-dd}.log + 30 + + + ${LOG_PATTERN} + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/api/src/main/resources/schema.sql b/api/src/main/resources/schema.sql new file mode 100644 index 000000000..d316f5112 --- /dev/null +++ b/api/src/main/resources/schema.sql @@ -0,0 +1,88 @@ +CREATE TABLE IF NOT EXISTS movie ( + id BIGINT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + grade VARCHAR(50) NOT NULL, + genre VARCHAR(50) NOT NULL, + running_time INTEGER NOT NULL, + release_date DATE NOT NULL, + thumbnail_url VARCHAR(255), + created_by VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_by VARCHAR(50) NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS theater ( + id BIGINT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_by VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_by VARCHAR(50) NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + phoneNumber VARCHAR(20), + created_by VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_by VARCHAR(50) NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS schedules ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + movie_id BIGINT NOT NULL, + theater_id BIGINT NOT NULL, + startTime TIMESTAMP NOT NULL, + endTime TIMESTAMP NOT NULL, + created_by VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_by VARCHAR(50) NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (movie_id) REFERENCES movie(id), + FOREIGN KEY (theater_id) REFERENCES theater(id) +); + +CREATE TABLE IF NOT EXISTS seats ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + theater_id BIGINT NOT NULL, + schedule_id BIGINT, + rowNumber VARCHAR(10) NOT NULL, + columnNumber INTEGER NOT NULL, + seatNumber VARCHAR(10) NOT NULL, + created_by VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_by VARCHAR(50) NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (theater_id) REFERENCES theater(id), + FOREIGN KEY (schedule_id) REFERENCES schedules(id) +); + +CREATE TABLE IF NOT EXISTS reservations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + reservationNumber VARCHAR(255) NOT NULL, + reservedAt TIMESTAMP NOT NULL, + status VARCHAR(20) NOT NULL, + version INTEGER NOT NULL DEFAULT 0, + user_id BIGINT NOT NULL, + schedule_id BIGINT NOT NULL, + seat_id BIGINT NOT NULL, + created_by VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_by VARCHAR(50) NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (schedule_id) REFERENCES schedules(id), + FOREIGN KEY (seat_id) REFERENCES seats(id) +); + +-- 인덱스 생성 +CREATE INDEX idx_movie_title ON movie(title); +CREATE INDEX idx_movie_genre ON movie(genre); +CREATE INDEX idx_movie_release_date ON movie(release_date); +CREATE UNIQUE INDEX uk_users_email ON users(email); +CREATE UNIQUE INDEX uk_reservations_number ON reservations(reservationNumber); \ No newline at end of file diff --git a/api/src/test/java/com/movie/api/config/MockDistributedLockAop.java b/api/src/test/java/com/movie/api/config/MockDistributedLockAop.java new file mode 100644 index 000000000..162187e5a --- /dev/null +++ b/api/src/test/java/com/movie/api/config/MockDistributedLockAop.java @@ -0,0 +1,22 @@ +package com.movie.api.config; + +import com.movie.domain.aop.DistributedLock; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@Primary +@Profile("test") +public class MockDistributedLockAop { + + @Around("@annotation(com.movie.domain.aop.DistributedLock)") + public Object executeWithLock(ProceedingJoinPoint joinPoint) throws Throwable { + // 테스트 환경에서는 분산 락을 적용하지 않고 바로 실행 + return joinPoint.proceed(); + } +} \ No newline at end of file diff --git a/api/src/test/java/com/movie/api/config/TestConfig.java b/api/src/test/java/com/movie/api/config/TestConfig.java new file mode 100644 index 000000000..f33a9fd52 --- /dev/null +++ b/api/src/test/java/com/movie/api/config/TestConfig.java @@ -0,0 +1,14 @@ +package com.movie.api.config; + +import com.movie.common.service.RateLimitService; +import com.movie.infra.ratelimit.TestRateLimitService; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestConfig { + @Bean + public RateLimitService rateLimitService() { + return new TestRateLimitService(); + } +} \ No newline at end of file diff --git a/api/src/test/java/com/movie/api/controller/MovieControllerTest.java b/api/src/test/java/com/movie/api/controller/MovieControllerTest.java new file mode 100644 index 000000000..3977427ad --- /dev/null +++ b/api/src/test/java/com/movie/api/controller/MovieControllerTest.java @@ -0,0 +1,59 @@ +package com.movie.api.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.movie.api.config.TestConfig; +import com.movie.domain.entity.Movie; +import com.movie.domain.fixture.TestFixture; +import com.movie.domain.service.MovieService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.context.annotation.Import; + +import java.util.List; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MovieController.class) +@Import(TestConfig.class) +class MovieControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private MovieService movieService; + + @Test + void getCurrentMovies() throws Exception { + // Given + Movie movie = TestFixture.createMovie(); + when(movieService.getCurrentMovies()).thenReturn(List.of(movie)); + + // When & Then + mockMvc.perform(get("/api/v1/movies/current")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].title").value(movie.getTitle())) + .andExpect(jsonPath("$.data[0].genre").value(movie.getGenre())); + } + + @Test + void getUpcomingMovies() throws Exception { + // Given + Movie movie = TestFixture.createMovie(); + when(movieService.getUpcomingMovies()).thenReturn(List.of(movie)); + + // When & Then + mockMvc.perform(get("/api/v1/movies/upcoming")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].title").value(movie.getTitle())) + .andExpect(jsonPath("$.data[0].genre").value(movie.getGenre())); + } +} \ No newline at end of file diff --git a/api/src/test/java/com/movie/api/controller/ReservationControllerTest.java b/api/src/test/java/com/movie/api/controller/ReservationControllerTest.java new file mode 100644 index 000000000..0bc05b737 --- /dev/null +++ b/api/src/test/java/com/movie/api/controller/ReservationControllerTest.java @@ -0,0 +1,128 @@ +package com.movie.api.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.movie.api.config.TestConfig; +import com.movie.api.request.ReservationRequest; +import com.movie.domain.entity.Reservation; +import com.movie.domain.fixture.TestFixture; +import com.movie.domain.service.ReservationService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ReservationController.class) +@Import(TestConfig.class) +class ReservationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private ReservationService reservationService; + + @Test + void reserve() throws Exception { + // Given + ReservationRequest request = new ReservationRequest(1L, 1L, List.of(1L, 2L)); + Reservation reservation = TestFixture.createReservation( + TestFixture.createUser(), + TestFixture.createSchedule(TestFixture.createMovie()), + List.of(TestFixture.createSeat(), TestFixture.createSeat()) + ); + + when(reservationService.reserve(eq(1L), eq(1L), eq(List.of(1L, 2L)))) + .thenReturn(reservation); + + // When & Then + mockMvc.perform(post("/api/v1/reservations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.reservationNumber").value(reservation.getReservationNumber())); + } + + @Test + void getReservation() throws Exception { + // Given + String reservationNumber = "TEST-123"; + Reservation reservation = TestFixture.createReservation( + TestFixture.createUser(), + TestFixture.createSchedule(TestFixture.createMovie()), + List.of(TestFixture.createSeat()) + ); + + when(reservationService.getReservation(reservationNumber)).thenReturn(reservation); + + // When & Then + mockMvc.perform(get("/api/v1/reservations/{reservationNumber}", reservationNumber)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.reservationNumber").value(reservation.getReservationNumber())); + } + + @Test + void getUserReservations() throws Exception { + // Given + Long userId = 1L; + Reservation reservation = TestFixture.createReservation( + TestFixture.createUser(), + TestFixture.createSchedule(TestFixture.createMovie()), + List.of(TestFixture.createSeat()) + ); + + when(reservationService.getUserReservations(userId)).thenReturn(List.of(reservation)); + + // When & Then + mockMvc.perform(get("/api/v1/reservations/users/{userId}", userId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].reservationNumber").value(reservation.getReservationNumber())); + } + + @Test + void cancelReservation() throws Exception { + // Given + String reservationNumber = "TEST-123"; + + // When & Then + mockMvc.perform(delete("/api/v1/reservations/{reservationNumber}", reservationNumber)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + void getAvailableSeats() throws Exception { + // Given + Long scheduleId = 1L; + Reservation reservation = TestFixture.createReservation( + TestFixture.createUser(), + TestFixture.createSchedule(TestFixture.createMovie()), + List.of(TestFixture.createSeat()) + ); + + when(reservationService.getAvailableSeats(scheduleId)).thenReturn(List.of(reservation)); + + // When & Then + mockMvc.perform(get("/api/v1/reservations/schedules/{scheduleId}/seats", scheduleId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].reservationNumber").value(reservation.getReservationNumber())); + } +} \ No newline at end of file diff --git a/api/src/test/resources/application-test.yml b/api/src/test/resources/application-test.yml new file mode 100644 index 000000000..78617ffc2 --- /dev/null +++ b/api/src/test/resources/application-test.yml @@ -0,0 +1,31 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + main: + allow-bean-definition-overriding: true + data: + redis: + enabled: false + +rate-limit: + movie: + max-requests: 10 + time-window: 60 + reservation: + max-requests: 1 + time-window: 300 + +logging: + level: + org.springframework.data.redis: DEBUG + io.lettuce.core: DEBUG \ No newline at end of file diff --git a/api/src/test/resources/application.yml b/api/src/test/resources/application.yml new file mode 100644 index 000000000..70e7ce8ef --- /dev/null +++ b/api/src/test/resources/application.yml @@ -0,0 +1,25 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1 + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + + data: + redis: + host: localhost + port: 6379 + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: trace \ No newline at end of file diff --git a/application/build.gradle b/application/build.gradle new file mode 100644 index 000000000..c276ca558 --- /dev/null +++ b/application/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.3' + id 'io.spring.dependency-management' version '1.1.4' +} + +dependencies { + implementation project(':domain') + implementation project(':infra') + + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.27.1' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} \ No newline at end of file diff --git a/application/src/main/java/com/movie/aop/DistributedLock.java b/application/src/main/java/com/movie/aop/DistributedLock.java new file mode 100644 index 000000000..f78a90760 --- /dev/null +++ b/application/src/main/java/com/movie/aop/DistributedLock.java @@ -0,0 +1,12 @@ +package com.movie.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + String key(); +} \ No newline at end of file diff --git a/application/src/main/java/com/movie/application/dto/MovieResponseDto.java b/application/src/main/java/com/movie/application/dto/MovieResponseDto.java new file mode 100644 index 000000000..2fed6e079 --- /dev/null +++ b/application/src/main/java/com/movie/application/dto/MovieResponseDto.java @@ -0,0 +1,25 @@ +package com.movie.application.dto; + +import com.movie.domain.entity.Movie; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MovieResponseDto { + private Long id; + private String title; + private String genre; + private int runningTime; + private String description; + + public static MovieResponseDto from(Movie movie) { + return MovieResponseDto.builder() + .id(movie.getId()) + .title(movie.getTitle()) + .genre(movie.getGenre()) + .runningTime(movie.getRunningTime()) + .description(movie.getDescription()) + .build(); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/movie/application/service/MovieService.java b/application/src/main/java/com/movie/application/service/MovieService.java new file mode 100644 index 000000000..ec5a6d3fd --- /dev/null +++ b/application/src/main/java/com/movie/application/service/MovieService.java @@ -0,0 +1,27 @@ +package com.movie.application.service; + +import com.movie.application.dto.MovieResponseDto; +import com.movie.domain.dto.MovieSearchCondition; +import com.movie.domain.entity.Movie; +import com.movie.infra.repository.MovieJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MovieService { + + private final MovieJpaRepository movieRepository; + + public List getNowShowingMovies(MovieSearchCondition condition) { + List movies = movieRepository.findNowShowingMovies(condition); + return movies.stream() + .map(MovieResponseDto::from) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/movie/application/service/ReservationService.java b/application/src/main/java/com/movie/application/service/ReservationService.java new file mode 100644 index 000000000..4ca75cc12 --- /dev/null +++ b/application/src/main/java/com/movie/application/service/ReservationService.java @@ -0,0 +1,101 @@ +package com.movie.application.service; + +import com.movie.domain.aop.DistributedLock; +import com.movie.domain.entity.Reservation; +import com.movie.domain.entity.ReservationStatus; +import com.movie.domain.entity.Schedule; +import com.movie.domain.entity.Seat; +import com.movie.domain.entity.User; +import com.movie.domain.repository.ReservationRepository; +import com.movie.domain.repository.ScheduleRepository; +import com.movie.domain.repository.SeatRepository; +import com.movie.domain.repository.UserRepository; +import com.movie.exception.BusinessException; +import com.movie.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final UserRepository userRepository; + private final ScheduleRepository scheduleRepository; + private final SeatRepository seatRepository; + + @Transactional + @DistributedLock(key = "'reservation:' + #scheduleId + ':' + #seatId") + @CacheEvict(value = {"reservations", "availableSeats"}, allEntries = true) + public String reserve(Long userId, Long scheduleId, Long seatId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new BusinessException(ErrorCode.SCHEDULE_NOT_FOUND)); + + Seat seat = seatRepository.findById(seatId) + .orElseThrow(() -> new BusinessException(ErrorCode.SEAT_NOT_FOUND)); + + // 이미 예약된 좌석인지 확인 + if (reservationRepository.existsByScheduleIdAndSeatId(scheduleId, seatId)) { + throw new BusinessException(ErrorCode.SEAT_ALREADY_RESERVED); + } + + // 예약 번호 생성 + String reservationNumber = generateReservationNumber(); + + // 예약 생성 + Reservation reservation = Reservation.builder() + .userId(userId) + .scheduleId(scheduleId) + .seatId(seatId) + .reservationNumber(reservationNumber) + .build(); + + reservationRepository.save(reservation); + return reservationNumber; + } + + @Transactional(readOnly = true) + @Cacheable(value = "reservations", key = "'user:' + #userId") + public List getUserReservations(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + return reservationRepository.findByUserId(userId); + } + + @Transactional(readOnly = true) + @Cacheable(value = "reservations", key = "'number:' + #reservationNumber") + public Reservation getReservation(String reservationNumber) { + return reservationRepository.findByReservationNumber(reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.RESERVATION_NOT_FOUND)); + } + + @Transactional + @CacheEvict(value = {"reservations", "availableSeats"}, allEntries = true) + public void cancelReservation(String reservationNumber) { + Reservation reservation = reservationRepository.findByReservationNumber(reservationNumber) + .orElseThrow(() -> new BusinessException(ErrorCode.RESERVATION_NOT_FOUND)); + reservationRepository.delete(reservation); + } + + @Transactional(readOnly = true) + @Cacheable(value = "availableSeats", key = "'schedule:' + #scheduleId") + public List getAvailableSeats(Long scheduleId) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new BusinessException(ErrorCode.SCHEDULE_NOT_FOUND)); + return seatRepository.findAvailableSeats(schedule); + } + + private String generateReservationNumber() { + return UUID.randomUUID().toString(); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/movie/exception/BusinessException.java b/application/src/main/java/com/movie/exception/BusinessException.java new file mode 100644 index 000000000..a623ca944 --- /dev/null +++ b/application/src/main/java/com/movie/exception/BusinessException.java @@ -0,0 +1,13 @@ +package com.movie.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/application/src/main/java/com/movie/exception/ErrorCode.java b/application/src/main/java/com/movie/exception/ErrorCode.java new file mode 100644 index 000000000..0b646a191 --- /dev/null +++ b/application/src/main/java/com/movie/exception/ErrorCode.java @@ -0,0 +1,16 @@ +package com.movie.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + USER_NOT_FOUND("사용자를 찾을 수 없습니다."), + SCHEDULE_NOT_FOUND("상영 일정을 찾을 수 없습니다."), + SEAT_NOT_FOUND("좌석을 찾을 수 없습니다."), + SEAT_ALREADY_RESERVED("이미 예약된 좌석입니다."), + RESERVATION_NOT_FOUND("예약을 찾을 수 없습니다."); + + private final String message; +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..23043892a --- /dev/null +++ b/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'java' + id 'jacoco' + id 'org.springframework.boot' version '3.2.3' apply false + id 'io.spring.dependency-management' version '1.1.4' apply false +} + +allprojects { + group = 'com.movie' + version = '0.0.1-SNAPSHOT' + + repositories { + mavenCentral() + } +} + +subprojects { + apply plugin: 'java' + apply plugin: 'jacoco' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + + sourceCompatibility = '17' + targetCompatibility = '17' + + repositories { + mavenCentral() + } + + dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + } + + test { + useJUnitPlatform() + finalizedBy jacocoTestReport + } + + jacoco { + toolVersion = "0.8.9" + } + + jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } + } +} \ No newline at end of file diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 000000000..4a32f8a96 --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.assertj:assertj-core' +} \ No newline at end of file diff --git a/common/src/main/java/com/movie/common/exception/BusinessException.java b/common/src/main/java/com/movie/common/exception/BusinessException.java new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/common/src/main/java/com/movie/common/exception/BusinessException.java @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/common/src/main/java/com/movie/common/exception/EntityNotFoundException.java b/common/src/main/java/com/movie/common/exception/EntityNotFoundException.java new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/common/src/main/java/com/movie/common/exception/EntityNotFoundException.java @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/common/src/main/java/com/movie/common/exception/ErrorCode.java b/common/src/main/java/com/movie/common/exception/ErrorCode.java new file mode 100644 index 000000000..1d174f0a5 --- /dev/null +++ b/common/src/main/java/com/movie/common/exception/ErrorCode.java @@ -0,0 +1,26 @@ +package com.movie.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + // Common + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "Invalid input value"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "Internal server error"), + ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "C003", "Entity not found"), + + // Rate Limit + IP_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "R001", "IP rate limit exceeded"), + USER_RESERVATION_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "R002", "User reservation rate limit exceeded"), + + // Business + SEAT_ALREADY_RESERVED(HttpStatus.CONFLICT, "B001", "Seat is already reserved"), + INVALID_RESERVATION_STATUS(HttpStatus.BAD_REQUEST, "B002", "Invalid reservation status"); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/common/src/main/java/com/movie/common/exception/RateLimitExceededException.java b/common/src/main/java/com/movie/common/exception/RateLimitExceededException.java new file mode 100644 index 000000000..755821205 --- /dev/null +++ b/common/src/main/java/com/movie/common/exception/RateLimitExceededException.java @@ -0,0 +1,7 @@ +package com.movie.common.exception; + +public class RateLimitExceededException extends RuntimeException { + public RateLimitExceededException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/common/src/main/java/com/movie/common/ratelimit/RateLimiter.java b/common/src/main/java/com/movie/common/ratelimit/RateLimiter.java new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/common/src/main/java/com/movie/common/ratelimit/RateLimiter.java @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/common/src/main/java/com/movie/common/response/ApiResponse.java b/common/src/main/java/com/movie/common/response/ApiResponse.java new file mode 100644 index 000000000..5d646381e --- /dev/null +++ b/common/src/main/java/com/movie/common/response/ApiResponse.java @@ -0,0 +1,54 @@ +package com.movie.common.response; + +import com.movie.common.exception.ErrorCode; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApiResponse { + private boolean success; + private T data; + private Error error; + + private ApiResponse(boolean success, T data, Error error) { + this.success = success; + this.data = data; + this.error = error; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, null); + } + + public static ApiResponse> success(Page page) { + return new ApiResponse<>(true, page, null); + } + + public static ApiResponse error(ErrorCode errorCode) { + return new ApiResponse<>(false, null, new Error(errorCode)); + } + + public static ApiResponse error(ErrorCode errorCode, String message) { + return new ApiResponse<>(false, null, new Error(errorCode, message)); + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class Error { + private String code; + private String message; + + private Error(ErrorCode errorCode) { + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + } + + private Error(ErrorCode errorCode, String message) { + this.code = errorCode.getCode(); + this.message = message; + } + } +} \ No newline at end of file diff --git a/common/src/main/java/com/movie/common/service/RateLimitService.java b/common/src/main/java/com/movie/common/service/RateLimitService.java new file mode 100644 index 000000000..2c5dfd8b3 --- /dev/null +++ b/common/src/main/java/com/movie/common/service/RateLimitService.java @@ -0,0 +1,24 @@ +package com.movie.common.service; + +/** + * Rate limiting service interface for managing request rate limits. + */ +public interface RateLimitService { + + /** + * Check if the IP address has exceeded its rate limit. + * + * @param ip The IP address to check + * @throws RateLimitExceededException if the IP has exceeded its rate limit + */ + void checkIpRateLimit(String ip); + + /** + * Check if the user has exceeded their reservation rate limit for the given schedule time. + * + * @param userId The ID of the user making the reservation + * @param scheduleTime The schedule time for the reservation + * @throws RateLimitExceededException if the user has exceeded their reservation rate limit + */ + void checkUserReservationRateLimit(Long userId, String scheduleTime); +} \ No newline at end of file diff --git a/data.sql b/data.sql new file mode 100644 index 000000000..ebfeb7531 --- /dev/null +++ b/data.sql @@ -0,0 +1,14 @@ +INSERT INTO movie (title, grade, genre, running_time, release_date, thumbnail_url, created_by, created_at, updated_by, updated_at) +VALUES ('웡카', '전체관람가', '판타지', 116, '2024-01-31', 'https://example.com/wonka.jpg', 'SYSTEM', NOW(), 'SYSTEM', NOW()); + +INSERT INTO theater (name, created_by, created_at, updated_by, updated_at) +VALUES ('1관', 'SYSTEM', NOW(), 'SYSTEM', NOW()); + +INSERT INTO theater (name, created_by, created_at, updated_by, updated_at) +VALUES ('2관', 'SYSTEM', NOW(), 'SYSTEM', NOW()); + +INSERT INTO schedule (movie_id, theater_id, start_at, end_at, created_by, created_at, updated_by, updated_at) +VALUES (1, 1, '2024-01-19 10:00:00', '2024-01-19 12:00:00', 'SYSTEM', NOW(), 'SYSTEM', NOW()); + +INSERT INTO schedule (movie_id, theater_id, start_at, end_at, created_by, created_at, updated_by, updated_at) +VALUES (1, 2, '2024-01-19 13:00:00', '2024-01-19 15:00:00', 'SYSTEM', NOW(), 'SYSTEM', NOW()); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..11b2e08d9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_started + environment: + - SPRING_DATA_REDIS_HOST=redis + - SPRING_DATA_REDIS_PORT=6379 + - SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/moviedb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC&characterEncoding=UTF-8 + - SPRING_DATASOURCE_USERNAME=root + - SPRING_DATASOURCE_PASSWORD=root + - SPRING_JPA_HIBERNATE_DDL_AUTO=none + - SPRING_JPA_SHOW_SQL=true + - SPRING_SQL_INIT_MODE=always + - SPRING_REDIS_HOST=redis + - SPRING_REDIS_PORT=6379 + + redis: + image: redis:latest + ports: + - "6379:6379" + volumes: + - redis_data:/data + + mysql: + image: mysql:8.0 + environment: + - MYSQL_ROOT_PASSWORD=root + - MYSQL_DATABASE=moviedb + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + redis_data: + mysql_data: \ No newline at end of file diff --git a/domain/build.gradle b/domain/build.gradle new file mode 100644 index 000000000..d8a4907a2 --- /dev/null +++ b/domain/build.gradle @@ -0,0 +1,81 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.3' + id 'io.spring.dependency-management' version '1.1.4' + id 'jacoco' +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.assertj:assertj-core' + testImplementation 'com.h2database:h2' +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} + +configurations { + testImplementation.extendsFrom compileOnly +} + +// QueryDSL Q클래스 생성 위치 지정 +def querydslDir = "$buildDir/generated/querydsl" + +// QueryDSL Q클래스 생성 위치를 지정 +sourceSets { + main.java.srcDir querydslDir +} + +// QueryDSL Q클래스 생성 설정 +tasks.withType(JavaCompile) { + options.getGeneratedSourceOutputDirectory().set(file(querydslDir)) +} + +// clean 시에 생성된 Q클래스 삭제 +clean { + delete file(querydslDir) +} + +jacoco { + toolVersion = "0.8.11" +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + csv.required = false + html.required = true + } +} + +jacocoTestCoverageVerification { + violationRules { + rule { + limit { + minimum = 0.80 + } + } + } +} \ No newline at end of file diff --git a/domain/src/main/generated/com/movie/domain/entity/QBaseEntity.java b/domain/src/main/generated/com/movie/domain/entity/QBaseEntity.java new file mode 100644 index 000000000..f2a2af4e3 --- /dev/null +++ b/domain/src/main/generated/com/movie/domain/entity/QBaseEntity.java @@ -0,0 +1,43 @@ +package com.movie.domain.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QBaseEntity is a Querydsl query type for BaseEntity + */ +@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") +public class QBaseEntity extends EntityPathBase { + + private static final long serialVersionUID = 1066497408L; + + public static final QBaseEntity baseEntity = new QBaseEntity("baseEntity"); + + public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class); + + public final StringPath createdBy = createString("createdBy"); + + public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.LocalDateTime.class); + + public final StringPath updatedBy = createString("updatedBy"); + + public QBaseEntity(String variable) { + super(BaseEntity.class, forVariable(variable)); + } + + public QBaseEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBaseEntity(PathMetadata metadata) { + super(BaseEntity.class, metadata); + } + +} + diff --git a/domain/src/main/generated/com/movie/domain/entity/QMovie.java b/domain/src/main/generated/com/movie/domain/entity/QMovie.java new file mode 100644 index 000000000..1976a159c --- /dev/null +++ b/domain/src/main/generated/com/movie/domain/entity/QMovie.java @@ -0,0 +1,63 @@ +package com.movie.domain.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QMovie is a Querydsl query type for Movie + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMovie extends EntityPathBase { + + private static final long serialVersionUID = -1237939132L; + + public static final QMovie movie = new QMovie("movie"); + + public final QBaseEntity _super = new QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final StringPath createdBy = _super.createdBy; + + public final StringPath genre = createString("genre"); + + public final StringPath grade = createString("grade"); + + public final NumberPath id = createNumber("id", Long.class); + + public final DatePath releaseDate = createDate("releaseDate", java.time.LocalDate.class); + + public final NumberPath runningTime = createNumber("runningTime", Integer.class); + + public final StringPath thumbnailUrl = createString("thumbnailUrl"); + + public final StringPath title = createString("title"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QMovie(String variable) { + super(Movie.class, forVariable(variable)); + } + + public QMovie(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QMovie(PathMetadata metadata) { + super(Movie.class, metadata); + } + +} + diff --git a/domain/src/main/generated/com/movie/domain/entity/QSchedule.java b/domain/src/main/generated/com/movie/domain/entity/QSchedule.java new file mode 100644 index 000000000..77bfb8812 --- /dev/null +++ b/domain/src/main/generated/com/movie/domain/entity/QSchedule.java @@ -0,0 +1,72 @@ +package com.movie.domain.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QSchedule is a Querydsl query type for Schedule + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QSchedule extends EntityPathBase { + + private static final long serialVersionUID = 841891075L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QSchedule schedule = new QSchedule("schedule"); + + public final QBaseEntity _super = new QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final StringPath createdBy = _super.createdBy; + + public final DateTimePath endAt = createDateTime("endAt", java.time.LocalDateTime.class); + + public final NumberPath id = createNumber("id", Long.class); + + public final QMovie movie; + + public final DateTimePath startAt = createDateTime("startAt", java.time.LocalDateTime.class); + + public final QTheater theater; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QSchedule(String variable) { + this(Schedule.class, forVariable(variable), INITS); + } + + public QSchedule(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QSchedule(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QSchedule(PathMetadata metadata, PathInits inits) { + this(Schedule.class, metadata, inits); + } + + public QSchedule(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.movie = inits.isInitialized("movie") ? new QMovie(forProperty("movie")) : null; + this.theater = inits.isInitialized("theater") ? new QTheater(forProperty("theater")) : null; + } + +} + diff --git a/domain/src/main/generated/com/movie/domain/entity/QSeat.java b/domain/src/main/generated/com/movie/domain/entity/QSeat.java new file mode 100644 index 000000000..de8da036f --- /dev/null +++ b/domain/src/main/generated/com/movie/domain/entity/QSeat.java @@ -0,0 +1,59 @@ +package com.movie.domain.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QSeat is a Querydsl query type for Seat + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QSeat extends EntityPathBase { + + private static final long serialVersionUID = 652971633L; + + public static final QSeat seat = new QSeat("seat"); + + public final QBaseEntity _super = new QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final StringPath createdBy = _super.createdBy; + + public final NumberPath id = createNumber("id", Long.class); + + public final NumberPath seatColumn = createNumber("seatColumn", Integer.class); + + public final StringPath seatNumber = createString("seatNumber"); + + public final NumberPath seatRow = createNumber("seatRow", Integer.class); + + public final NumberPath theaterId = createNumber("theaterId", Long.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QSeat(String variable) { + super(Seat.class, forVariable(variable)); + } + + public QSeat(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QSeat(PathMetadata metadata) { + super(Seat.class, metadata); + } + +} + diff --git a/domain/src/main/generated/com/movie/domain/entity/QTheater.java b/domain/src/main/generated/com/movie/domain/entity/QTheater.java new file mode 100644 index 000000000..f39ac1f0f --- /dev/null +++ b/domain/src/main/generated/com/movie/domain/entity/QTheater.java @@ -0,0 +1,53 @@ +package com.movie.domain.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QTheater is a Querydsl query type for Theater + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QTheater extends EntityPathBase { + + private static final long serialVersionUID = 1747669029L; + + public static final QTheater theater = new QTheater("theater"); + + public final QBaseEntity _super = new QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final StringPath createdBy = _super.createdBy; + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath name = createString("name"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QTheater(String variable) { + super(Theater.class, forVariable(variable)); + } + + public QTheater(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QTheater(PathMetadata metadata) { + super(Theater.class, metadata); + } + +} + diff --git a/domain/src/main/java/com/movie/domain/DomainApplication.java b/domain/src/main/java/com/movie/domain/DomainApplication.java new file mode 100644 index 000000000..5c91d9e08 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/DomainApplication.java @@ -0,0 +1,15 @@ +package com.movie.domain; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication(scanBasePackages = "com.movie.domain") +@EntityScan(basePackages = "com.movie.domain.entity") +@EnableJpaRepositories(basePackages = "com.movie.domain.repository") +public class DomainApplication { + public static void main(String[] args) { + SpringApplication.run(DomainApplication.class, args); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/aop/DistributedLock.java b/domain/src/main/java/com/movie/domain/aop/DistributedLock.java new file mode 100644 index 000000000..b1f088a14 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/aop/DistributedLock.java @@ -0,0 +1,16 @@ +package com.movie.domain.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + String key(); + TimeUnit timeUnit() default TimeUnit.SECONDS; + long waitTime() default 5L; + long leaseTime() default 3L; +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/dto/MovieProjection.java b/domain/src/main/java/com/movie/domain/dto/MovieProjection.java new file mode 100644 index 000000000..d5ece58e2 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/dto/MovieProjection.java @@ -0,0 +1,18 @@ +package com.movie.domain.dto; + +import lombok.Getter; +import lombok.Setter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class MovieProjection { + private Long id; + private String title; + private String thumbnail; + private Integer runningTime; + private String genre; +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/dto/MovieSearchCondition.java b/domain/src/main/java/com/movie/domain/dto/MovieSearchCondition.java new file mode 100644 index 000000000..b3a2f539a --- /dev/null +++ b/domain/src/main/java/com/movie/domain/dto/MovieSearchCondition.java @@ -0,0 +1,20 @@ +package com.movie.domain.dto; + +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class MovieSearchCondition { + + @Size(max = 100, message = "영화 제목은 100자를 초과할 수 없습니다") + private String title; + + @Size(max = 50, message = "장르는 50자를 초과할 수 없습니다") + private String genre; + + private LocalDateTime searchDate = LocalDateTime.now(); +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/entity/BaseEntity.java b/domain/src/main/java/com/movie/domain/entity/BaseEntity.java new file mode 100644 index 000000000..250677308 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/entity/BaseEntity.java @@ -0,0 +1,38 @@ +package com.movie.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +@MappedSuperclass +public abstract class BaseEntity { + + @Column(name = "created_by") + private String createdBy; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_by") + private String updatedBy; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + protected BaseEntity() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + this.updatedBy = createdBy; + } + + public void update(String updatedBy) { + this.updatedBy = updatedBy; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/domain/src/main/java/com/movie/domain/entity/Movie.java b/domain/src/main/java/com/movie/domain/entity/Movie.java new file mode 100644 index 000000000..5d54d95ad --- /dev/null +++ b/domain/src/main/java/com/movie/domain/entity/Movie.java @@ -0,0 +1,59 @@ +package com.movie.domain.entity; + +import com.querydsl.core.annotations.QueryEntity; +import jakarta.persistence.Table; +import java.time.LocalDate; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.Builder; + +@Entity +@Getter +@QueryEntity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "movie") +public class Movie extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + private String grade; + private String genre; + @Column(name = "running_time") + private Integer runningTime; + @Column(name = "release_date") + private LocalDate releaseDate; + @Column(name = "thumbnail_url") + private String thumbnailUrl; + private String description; + + @Builder + public Movie(String title, String grade, String genre, Integer runningTime, LocalDate releaseDate, String thumbnailUrl, String description) { + this.title = title; + this.grade = grade; + this.genre = genre; + this.runningTime = runningTime; + this.releaseDate = releaseDate; + this.thumbnailUrl = thumbnailUrl; + this.description = description; + } + + // 영화 정보 수정을 위한 비즈니스 메서드 + public void updateMovieInfo(String title, String grade, String genre, Integer runningTime, LocalDate releaseDate, String thumbnailUrl) { + this.title = title; + this.grade = grade; + this.genre = genre; + this.runningTime = runningTime; + this.releaseDate = releaseDate; + this.thumbnailUrl = thumbnailUrl; + } + + // 썸네일 URL만 수정하는 비즈니스 메서드 + public void updateThumbnail(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/entity/Reservation.java b/domain/src/main/java/com/movie/domain/entity/Reservation.java new file mode 100644 index 000000000..248b35d2d --- /dev/null +++ b/domain/src/main/java/com/movie/domain/entity/Reservation.java @@ -0,0 +1,57 @@ +package com.movie.domain.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "reservations") +public class Reservation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "schedule_id", nullable = false) + private Long scheduleId; + + @Column(name = "seat_id", nullable = false) + private Long seatId; + + @Column(nullable = false) + private String reservationNumber; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReservationStatus status; + + @Column(nullable = false) + private LocalDateTime reservedAt; + + @Version + private Long version; + + @Builder + public Reservation(Long userId, Long scheduleId, Long seatId, String reservationNumber) { + this.userId = userId; + this.scheduleId = scheduleId; + this.seatId = seatId; + this.reservationNumber = reservationNumber; + this.status = ReservationStatus.RESERVED; + this.reservedAt = LocalDateTime.now(); + } + + public void cancel() { + this.status = ReservationStatus.CANCELLED; + } + + public void expire() { + this.status = ReservationStatus.EXPIRED; + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/entity/ReservationStatus.java b/domain/src/main/java/com/movie/domain/entity/ReservationStatus.java new file mode 100644 index 000000000..7c19e4868 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/entity/ReservationStatus.java @@ -0,0 +1,7 @@ +package com.movie.domain.entity; + +public enum ReservationStatus { + RESERVED, // 예약 완료 + CANCELLED, // 예약 취소 + EXPIRED // 예약 만료 +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/entity/Schedule.java b/domain/src/main/java/com/movie/domain/entity/Schedule.java new file mode 100644 index 000000000..499389098 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/entity/Schedule.java @@ -0,0 +1,58 @@ +package com.movie.domain.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "schedules") +public class Schedule extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "movie_id", nullable = false) + private Long movieId; + + @Column(name = "theater_id", nullable = false) + private Long theaterId; + + @Column(nullable = false) + private LocalDateTime startTime; + + @Column(nullable = false) + private LocalDateTime endTime; + + @Builder + public Schedule(Long id, Long movieId, Long theaterId, LocalDateTime startTime, LocalDateTime endTime) { + this.id = id; + this.movieId = movieId; + this.theaterId = theaterId; + this.startTime = startTime; + this.endTime = endTime; + } + + public void updateScheduleDateTime(LocalDateTime startAt, LocalDateTime endAt) { + this.startTime = startAt; + this.endTime = endAt; + } + + public void updateTheater(Theater theater) { + this.theaterId = theater.getId(); + } + + public void updateMovie(Movie movie) { + this.movieId = movie.getId(); + } + + public Long getMovieId() { + return movieId; + } + + public Long getTheaterId() { + return theaterId; + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/entity/Seat.java b/domain/src/main/java/com/movie/domain/entity/Seat.java new file mode 100644 index 000000000..4f3b8e66d --- /dev/null +++ b/domain/src/main/java/com/movie/domain/entity/Seat.java @@ -0,0 +1,40 @@ +package com.movie.domain.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "seats") +public class Seat extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "schedule_id", nullable = false) + private Long scheduleId; + + @Column(name = "theater_id", nullable = false) + private Long theaterId; + + @Column(nullable = false) + private String seatNumber; + + @Column(nullable = false) + private Integer rowNumber; + + @Column(nullable = false) + private Integer columnNumber; + + @Builder + public Seat(Long id, Long scheduleId, Long theaterId, String seatNumber, Integer rowNumber, Integer columnNumber) { + this.id = id; + this.scheduleId = scheduleId; + this.theaterId = theaterId; + this.seatNumber = seatNumber; + this.rowNumber = rowNumber; + this.columnNumber = columnNumber; + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/entity/Theater.java b/domain/src/main/java/com/movie/domain/entity/Theater.java new file mode 100644 index 000000000..add72baf3 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/entity/Theater.java @@ -0,0 +1,27 @@ +package com.movie.domain.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "theater") +public class Theater extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + public Theater(String name) { + this.name = name; + } + + public void updateName(String name) { + this.name = name; + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/entity/User.java b/domain/src/main/java/com/movie/domain/entity/User.java new file mode 100644 index 000000000..e69a8e4ab --- /dev/null +++ b/domain/src/main/java/com/movie/domain/entity/User.java @@ -0,0 +1,45 @@ +package com.movie.domain.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "users") +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String phoneNumber; + + @Builder + public User(Long id, String name, String email, String password, String phoneNumber) { + this.id = id; + this.name = name; + this.email = email; + this.password = password; + this.phoneNumber = phoneNumber; + } + + public void updateUserInfo(String name, String phoneNumber) { + this.name = name; + this.phoneNumber = phoneNumber; + } + + public void updatePassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/exception/BusinessException.java b/domain/src/main/java/com/movie/domain/exception/BusinessException.java new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/exception/BusinessException.java @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/MovieRepository.java b/domain/src/main/java/com/movie/domain/repository/MovieRepository.java new file mode 100644 index 000000000..314481801 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/MovieRepository.java @@ -0,0 +1,20 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.Movie; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface MovieRepository extends JpaRepository { + + @Query("SELECT m FROM Movie m WHERE m.releaseDate <= :now ORDER BY m.releaseDate DESC") + List findCurrentMovies(@Param("now") LocalDateTime now); + + @Query("SELECT m FROM Movie m WHERE m.releaseDate > :now ORDER BY m.releaseDate ASC") + List findUpcomingMovies(@Param("now") LocalDateTime now); +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/MovieRepositoryCustom.java b/domain/src/main/java/com/movie/domain/repository/MovieRepositoryCustom.java new file mode 100644 index 000000000..d7d020e07 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/MovieRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.movie.domain.repository; + +import com.movie.domain.dto.MovieSearchCondition; +import com.movie.domain.entity.Movie; + +import java.util.List; + +public interface MovieRepositoryCustom { + List findNowShowingMovies(MovieSearchCondition condition); +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/MovieRepositoryCustomImpl.java b/domain/src/main/java/com/movie/domain/repository/MovieRepositoryCustomImpl.java new file mode 100644 index 000000000..ba9f5a2c6 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/MovieRepositoryCustomImpl.java @@ -0,0 +1,27 @@ +package com.movie.domain.repository; + +import com.movie.domain.dto.MovieSearchCondition; +import com.movie.domain.entity.Movie; +import com.movie.domain.repository.MovieRepositoryCustom; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class MovieRepositoryCustomImpl implements MovieRepositoryCustom { + + @PersistenceContext + private EntityManager em; + + @Override + public List findNowShowingMovies(MovieSearchCondition condition) { + return em.createQuery( + "select m from Movie m " + + "where m.releaseDate <= :now", + Movie.class) + .setParameter("now", condition.getSearchDate()) + .getResultList(); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/MovieRepositoryImpl.java b/domain/src/main/java/com/movie/domain/repository/MovieRepositoryImpl.java new file mode 100644 index 000000000..8c0eab995 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/MovieRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.movie.domain.repository; + +import com.movie.domain.dto.MovieSearchCondition; +import com.movie.domain.entity.Movie; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static com.movie.domain.entity.QMovie.movie; + +@RequiredArgsConstructor +public class MovieRepositoryImpl implements MovieRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findNowShowingMovies(MovieSearchCondition condition) { + return queryFactory + .selectFrom(movie) + .where( + titleContains(condition.getTitle()), + genreEquals(condition.getGenre()) + ) + .fetch(); + } + + private BooleanExpression titleContains(String title) { + return StringUtils.hasText(title) ? movie.title.contains(title) : null; + } + + private BooleanExpression genreEquals(String genre) { + return StringUtils.hasText(genre) ? movie.genre.eq(genre) : null; + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/ReservationRepository.java b/domain/src/main/java/com/movie/domain/repository/ReservationRepository.java new file mode 100644 index 000000000..eb3ff6086 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/ReservationRepository.java @@ -0,0 +1,31 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.Reservation; +import com.movie.domain.entity.ReservationStatus; +import com.movie.domain.entity.Schedule; +import com.movie.domain.entity.Seat; +import com.movie.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ReservationRepository extends JpaRepository { + @Query("SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END FROM Reservation r JOIN r.seats s WHERE r.schedule.id = :scheduleId AND s.id = :seatId") + boolean existsByScheduleIdAndSeatId(@Param("scheduleId") Long scheduleId, @Param("seatId") Long seatId); + + @Query("SELECT COUNT(r) FROM Reservation r WHERE r.user.id = :userId AND r.schedule.id = :scheduleId AND r.status = :status") + long countByUserIdAndScheduleIdAndStatus(@Param("userId") Long userId, @Param("scheduleId") Long scheduleId, @Param("status") ReservationStatus status); + + @Query("SELECT r FROM Reservation r WHERE r.user.id = :userId AND r.schedule.id = :scheduleId AND r.status = :status") + List findByUserIdAndScheduleIdAndStatus(@Param("userId") Long userId, @Param("scheduleId") Long scheduleId, @Param("status") ReservationStatus status); + + @Query("SELECT r FROM Reservation r WHERE r.user.id = :userId") + List findByUserId(@Param("userId") Long userId); + + Optional findByReservationNumber(String reservationNumber); + + List findBySchedule(Schedule schedule); +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/ScheduleRepository.java b/domain/src/main/java/com/movie/domain/repository/ScheduleRepository.java new file mode 100644 index 000000000..fa01b0f7e --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/ScheduleRepository.java @@ -0,0 +1,7 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.Schedule; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScheduleRepository extends JpaRepository { +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/SeatRepository.java b/domain/src/main/java/com/movie/domain/repository/SeatRepository.java new file mode 100644 index 000000000..9d4994066 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/SeatRepository.java @@ -0,0 +1,7 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.Seat; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SeatRepository extends JpaRepository, SeatRepositoryCustom { +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/SeatRepositoryCustom.java b/domain/src/main/java/com/movie/domain/repository/SeatRepositoryCustom.java new file mode 100644 index 000000000..6cb377469 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/SeatRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.Schedule; +import com.movie.domain.entity.Seat; + +import java.util.List; + +public interface SeatRepositoryCustom { + List findAvailableSeats(Schedule schedule); +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/SeatRepositoryImpl.java b/domain/src/main/java/com/movie/domain/repository/SeatRepositoryImpl.java new file mode 100644 index 000000000..796cbff36 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/SeatRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.Schedule; +import com.movie.domain.entity.Seat; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.movie.domain.entity.QSeat.seat; +import static com.movie.domain.entity.QReservation.reservation; + +@Repository +@RequiredArgsConstructor +public class SeatRepositoryImpl implements SeatRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAvailableSeats(Schedule schedule) { + return queryFactory + .selectFrom(seat) + .where(seat.theaterId.eq(schedule.getTheaterId()) + .and(seat.id.notIn( + queryFactory.select(reservation.seatId) + .from(reservation) + .where(reservation.scheduleId.eq(schedule.getId())) + ))) + .fetch(); + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/TheaterRepository.java b/domain/src/main/java/com/movie/domain/repository/TheaterRepository.java new file mode 100644 index 000000000..34b98e774 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/TheaterRepository.java @@ -0,0 +1,12 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.Theater; +import java.util.List; +import java.util.Optional; + +public interface TheaterRepository { + Theater save(Theater theater); + List findAll(); + Optional findById(Long id); + Optional findNameById(Long id); +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/repository/UserRepository.java b/domain/src/main/java/com/movie/domain/repository/UserRepository.java new file mode 100644 index 000000000..8916b71d9 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.movie.domain.repository; + +import com.movie.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); +} \ No newline at end of file diff --git a/domain/src/main/java/com/movie/domain/service/ReservationService.java b/domain/src/main/java/com/movie/domain/service/ReservationService.java new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/domain/src/main/java/com/movie/domain/service/ReservationService.java @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..a4b76b953 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..9355b4155 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..f5feea6d6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/http/test.http b/http/test.http new file mode 100644 index 000000000..4d5ce292c --- /dev/null +++ b/http/test.http @@ -0,0 +1,6 @@ +### test healty check +GET http://localhost:8080/hello + +### 상영 중인 영화 조회 테스트 +GET http://localhost:8080/api/v1/movies/now-showing +Accept: application/json \ No newline at end of file diff --git a/infra/build.gradle b/infra/build.gradle new file mode 100644 index 000000000..26a31cdc0 --- /dev/null +++ b/infra/build.gradle @@ -0,0 +1,113 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.3' + id 'io.spring.dependency-management' version '1.1.4' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':domain') + implementation project(':common') + + // Spring + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.redisson:redisson-spring-boot-starter:3.26.0' + + // Querydsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + // Guava + implementation 'com.google.guava:guava:32.1.3-jre' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Runtime + runtimeOnly 'com.mysql:mysql-connector-j:8.2.0' + + // Jakarta Servlet + implementation 'jakarta.servlet:jakarta.servlet-api' + + // Rate Limiting + implementation 'com.bucket4j:bucket4j-core:8.7.0' + implementation 'com.bucket4j:bucket4j-redis:8.7.0' + + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.assertj:assertj-core' +} + +def generated = 'src/main/generated' + +sourceSets { + main.java.srcDirs += [generated] +} + +tasks.withType(JavaCompile) { + options.annotationProcessorGeneratedSourcesDirectory = file(generated) +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} + +jacoco { + toolVersion = "0.8.7" +} + +jacocoTestReport { + reports { + xml { + required = true + } + html { + required = true + } + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + "**/config/**", + "**/*Application.class" + ]) + })) + } +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestCoverageVerification { + violationRules { + rule { + element = 'CLASS' + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.70 + } + excludes = [ + '**/config/**', + '**/*Application.class' + ] + } + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/aop/DistributedLockAop.java b/infra/src/main/java/com/movie/infra/aop/DistributedLockAop.java new file mode 100644 index 000000000..54ee91641 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/aop/DistributedLockAop.java @@ -0,0 +1,52 @@ +package com.movie.infra.aop; + +import com.movie.domain.aop.DistributedLock; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; +import org.springframework.context.annotation.Profile; + +import java.lang.reflect.Method; + +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +@Profile("!test") +public class DistributedLockAop { + + private final RedissonClient redissonClient; + + @Around("@annotation(com.movie.domain.aop.DistributedLock)") + public Object executeWithLock(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); + + String key = distributedLock.key(); + RLock lock = redissonClient.getLock(key); + + try { + boolean isLocked = lock.tryLock( + distributedLock.waitTime(), + distributedLock.leaseTime(), + distributedLock.timeUnit()); + + if (!isLocked) { + throw new IllegalStateException("Failed to acquire distributed lock"); + } + + return joinPoint.proceed(); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/common/response/ApiResponse.java b/infra/src/main/java/com/movie/infra/common/response/ApiResponse.java new file mode 100644 index 000000000..702ac0108 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/common/response/ApiResponse.java @@ -0,0 +1,29 @@ +package com.movie.infra.common.response; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class ApiResponse { + private final int code; + private final String message; + private final T data; + + private ApiResponse(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(HttpStatus.OK.value(), "Success", data); + } + + public static ApiResponse error(HttpStatus status, String message) { + return new ApiResponse<>(status.value(), message, null); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/common/response/ErrorCode.java b/infra/src/main/java/com/movie/infra/common/response/ErrorCode.java new file mode 100644 index 000000000..a2cb4ab1f --- /dev/null +++ b/infra/src/main/java/com/movie/infra/common/response/ErrorCode.java @@ -0,0 +1,23 @@ +package com.movie.infra.common.response; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + // Common + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "Invalid input value"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), + + // RateLimit + RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "Rate limit exceeded"), + BOOKING_TIME_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "Booking time limit exceeded for this time slot"); + + private final HttpStatus status; + private final String message; + + ErrorCode(HttpStatus status, String message) { + this.status = status; + this.message = message; + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/config/JpaConfig.java b/infra/src/main/java/com/movie/infra/config/JpaConfig.java new file mode 100644 index 000000000..7224c1e27 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/config/JpaConfig.java @@ -0,0 +1,10 @@ +package com.movie.infra.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaRepositories(basePackages = {"com.movie.infra.repository", "com.movie.domain.repository"}) +public class JpaConfig { + +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/config/QuerydslConfig.java b/infra/src/main/java/com/movie/infra/config/QuerydslConfig.java new file mode 100644 index 000000000..0170aad90 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/config/QuerydslConfig.java @@ -0,0 +1,15 @@ +package com.movie.infra.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/config/RedissonConfig.java b/infra/src/main/java/com/movie/infra/config/RedissonConfig.java new file mode 100644 index 000000000..4a2e96a46 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/config/RedissonConfig.java @@ -0,0 +1,28 @@ +package com.movie.infra.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("!test") +public class RedissonConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://" + redisHost + ":" + redisPort); + return Redisson.create(config); + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/ratelimit/GuavaRateLimitService.java b/infra/src/main/java/com/movie/infra/ratelimit/GuavaRateLimitService.java new file mode 100644 index 000000000..621713a50 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/ratelimit/GuavaRateLimitService.java @@ -0,0 +1,74 @@ +package com.movie.infra.ratelimit; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.RateLimiter; +import com.movie.common.exception.RateLimitExceededException; +import com.movie.common.service.RateLimitService; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@Profile("!prod") +public class GuavaRateLimitService implements RateLimitService { + private final Cache rateLimiters; + private final Cache requestCounts; + private final Cache reservationRateLimiters; + private final int maxRequestsPerMinute = 50; + + public GuavaRateLimitService() { + this.rateLimiters = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + + this.requestCounts = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.MINUTES) + .build(); + + this.reservationRateLimiters = CacheBuilder.newBuilder() + .expireAfterWrite(5, TimeUnit.MINUTES) + .build(); + } + + @Override + public void checkIpRateLimit(String ip) { + Integer count = requestCounts.getIfPresent(ip); + if (count != null && count >= maxRequestsPerMinute) { + throw new RateLimitExceededException("IP가 차단되었습니다. 1시간 후에 다시 시도해주세요."); + } + + RateLimiter limiter = rateLimiters.getIfPresent(ip); + if (limiter == null) { + limiter = RateLimiter.create(maxRequestsPerMinute / 60.0); // 초당 요청 수로 변환 + rateLimiters.put(ip, limiter); + } + + if (!limiter.tryAcquire()) { + Integer newCount = count == null ? 1 : count + 1; + requestCounts.put(ip, newCount); + + if (newCount >= maxRequestsPerMinute) { + rateLimiters.put(ip, RateLimiter.create(0.0)); // 1시간 동안 차단 + throw new RateLimitExceededException("너무 많은 요청을 보냈습니다. IP가 1시간 동안 차단됩니다."); + } + + throw new RateLimitExceededException("너무 많은 요청을 보냈습니다. 잠시 후 다시 시도해주세요."); + } + } + + @Override + public void checkUserReservationRateLimit(Long userId, String scheduleTime) { + String key = userId + ":" + scheduleTime; + RateLimiter limiter = reservationRateLimiters.getIfPresent(key); + if (limiter == null) { + limiter = RateLimiter.create(1.0 / 300.0); // 5분에 1번 + reservationRateLimiters.put(key, limiter); + } + + if (!limiter.tryAcquire()) { + throw new RateLimitExceededException("예매는 5분에 한 번만 시도할 수 있습니다."); + } + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/ratelimit/RedisRateLimitService.java b/infra/src/main/java/com/movie/infra/ratelimit/RedisRateLimitService.java new file mode 100644 index 000000000..d10deab31 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/ratelimit/RedisRateLimitService.java @@ -0,0 +1,56 @@ +package com.movie.infra.ratelimit; + +import com.movie.common.exception.RateLimitExceededException; +import com.movie.common.service.RateLimitService; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RRateLimiter; +import org.redisson.api.RateIntervalUnit; +import org.redisson.api.RateType; +import org.redisson.api.RedissonClient; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@Profile("prod") +@RequiredArgsConstructor +public class RedisRateLimitService implements RateLimitService { + private static final String IP_BAN_KEY_PREFIX = "ip:ban:"; + private static final String IP_RATE_LIMIT_KEY_PREFIX = "ip:rate:"; + private static final String USER_RESERVATION_RATE_LIMIT_KEY_PREFIX = "user:reservation:rate:"; + private static final Duration BAN_DURATION = Duration.ofHours(1); + private static final int MAX_REQUESTS_PER_MINUTE = 50; + + private final RedissonClient redissonClient; + private final RedisTemplate redisTemplate; + + @Override + public void checkIpRateLimit(String ip) { + String banKey = IP_BAN_KEY_PREFIX + ip; + if (Boolean.TRUE.equals(redisTemplate.hasKey(banKey))) { + throw new RateLimitExceededException("IP가 차단되었습니다. 1시간 후에 다시 시도해주세요."); + } + + String rateLimitKey = IP_RATE_LIMIT_KEY_PREFIX + ip; + RRateLimiter rateLimiter = redissonClient.getRateLimiter(rateLimitKey); + rateLimiter.trySetRate(RateType.OVERALL, MAX_REQUESTS_PER_MINUTE, 1, RateIntervalUnit.MINUTES); + + if (!rateLimiter.tryAcquire()) { + redisTemplate.opsForValue().set(banKey, "banned", BAN_DURATION); + throw new RateLimitExceededException("너무 많은 요청을 보냈습니다. IP가 1시간 동안 차단됩니다."); + } + } + + @Override + public void checkUserReservationRateLimit(Long userId, String scheduleTime) { + String key = USER_RESERVATION_RATE_LIMIT_KEY_PREFIX + userId + ":" + scheduleTime; + RRateLimiter rateLimiter = redissonClient.getRateLimiter(key); + rateLimiter.trySetRate(RateType.OVERALL, 1, 5, RateIntervalUnit.MINUTES); + + if (!rateLimiter.tryAcquire()) { + throw new RateLimitExceededException("예매는 5분에 한 번만 시도할 수 있습니다."); + } + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/ratelimit/TestRateLimitService.java b/infra/src/main/java/com/movie/infra/ratelimit/TestRateLimitService.java new file mode 100644 index 000000000..2dfe23be1 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/ratelimit/TestRateLimitService.java @@ -0,0 +1,19 @@ +package com.movie.infra.ratelimit; + +import com.movie.common.service.RateLimitService; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +@Service +@Profile("test") +public class TestRateLimitService implements RateLimitService { + @Override + public void checkIpRateLimit(String ip) { + // 테스트 환경에서는 rate limit을 적용하지 않음 + } + + @Override + public void checkUserReservationRateLimit(Long userId, String scheduleTime) { + // 테스트 환경에서는 rate limit을 적용하지 않음 + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/repository/MovieJpaRepository.java b/infra/src/main/java/com/movie/infra/repository/MovieJpaRepository.java new file mode 100644 index 000000000..b1ca209bc --- /dev/null +++ b/infra/src/main/java/com/movie/infra/repository/MovieJpaRepository.java @@ -0,0 +1,10 @@ +package com.movie.infra.repository; + +import com.movie.domain.entity.Movie; +import com.movie.domain.repository.MovieRepositoryCustom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MovieJpaRepository extends JpaRepository, MovieRepositoryCustom { +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/repository/MovieQueryRepository.java b/infra/src/main/java/com/movie/infra/repository/MovieQueryRepository.java new file mode 100644 index 000000000..d62b466b6 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/repository/MovieQueryRepository.java @@ -0,0 +1,45 @@ +package com.movie.infra.repository; + +import com.movie.domain.dto.MovieProjection; +import com.movie.domain.dto.MovieSearchCondition; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static com.movie.domain.entity.QMovie.movie; + +@Repository +@RequiredArgsConstructor +public class MovieQueryRepository { + private final JPAQueryFactory queryFactory; + + public List search(MovieSearchCondition condition) { + return queryFactory + .select(Projections.fields(MovieProjection.class, + movie.id, + movie.title, + movie.thumbnailUrl.as("thumbnail"), + movie.runningTime, + movie.genre)) + .from(movie) + .where( + titleEq(condition.getTitle()), + genreEq(condition.getGenre()) + ) + .orderBy(movie.releaseDate.desc()) + .fetch(); + } + + private BooleanExpression titleEq(String title) { + return StringUtils.hasText(title) ? movie.title.eq(title) : null; + } + + private BooleanExpression genreEq(String genre) { + return StringUtils.hasText(genre) ? movie.genre.eq(genre) : null; + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/repository/MovieRepositoryCustomImpl.java b/infra/src/main/java/com/movie/infra/repository/MovieRepositoryCustomImpl.java new file mode 100644 index 000000000..ba9f5a2c6 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/repository/MovieRepositoryCustomImpl.java @@ -0,0 +1,27 @@ +package com.movie.domain.repository; + +import com.movie.domain.dto.MovieSearchCondition; +import com.movie.domain.entity.Movie; +import com.movie.domain.repository.MovieRepositoryCustom; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class MovieRepositoryCustomImpl implements MovieRepositoryCustom { + + @PersistenceContext + private EntityManager em; + + @Override + public List findNowShowingMovies(MovieSearchCondition condition) { + return em.createQuery( + "select m from Movie m " + + "where m.releaseDate <= :now", + Movie.class) + .setParameter("now", condition.getSearchDate()) + .getResultList(); + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/repository/ReservationJpaRepository.java b/infra/src/main/java/com/movie/infra/repository/ReservationJpaRepository.java new file mode 100644 index 000000000..c900fd3c9 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/repository/ReservationJpaRepository.java @@ -0,0 +1,22 @@ +package com.movie.infra.repository; + +import com.movie.domain.entity.Reservation; +import com.movie.domain.entity.ReservationStatus; +import com.movie.domain.repository.ReservationRepository; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReservationJpaRepository extends JpaRepository, ReservationRepository { + boolean existsByScheduleIdAndSeatIdAndStatus(Long scheduleId, Long seatId, ReservationStatus status); + + long countByUserIdAndScheduleIdAndStatus(Long userId, Long scheduleId, ReservationStatus status); + + List findByUserIdAndScheduleIdAndStatus(Long userId, Long scheduleId, ReservationStatus status); + + List findByUserId(Long userId); + + List findByScheduleId(Long scheduleId); + + List findBySeatId(Long seatId); +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/repository/ScheduleJpaRepository.java b/infra/src/main/java/com/movie/infra/repository/ScheduleJpaRepository.java new file mode 100644 index 000000000..9892d4c63 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/repository/ScheduleJpaRepository.java @@ -0,0 +1,19 @@ +package com.movie.infra.repository; + +import com.movie.domain.entity.Schedule; +import com.movie.domain.repository.ScheduleRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface ScheduleJpaRepository extends JpaRepository, ScheduleRepository { + @Override + default List findAll() { + return findByStartTimeGreaterThan(LocalDateTime.now()); + } + + List findByStartTimeGreaterThan(LocalDateTime currentTime); +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/repository/ScheduleQueryRepository.java b/infra/src/main/java/com/movie/infra/repository/ScheduleQueryRepository.java new file mode 100644 index 000000000..1b7a11281 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/repository/ScheduleQueryRepository.java @@ -0,0 +1,26 @@ +package com.movie.infra.repository; + +import com.movie.domain.entity.QSchedule; +import com.movie.domain.entity.Schedule; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ScheduleQueryRepository { + + private final JPAQueryFactory queryFactory; + + public List findAllAfterCurrentTime() { + QSchedule schedule = QSchedule.schedule; + + return queryFactory + .selectFrom(schedule) + .where(schedule.startTime.after(LocalDateTime.now())) + .fetch(); + } +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/repository/SeatJpaRepository.java b/infra/src/main/java/com/movie/infra/repository/SeatJpaRepository.java new file mode 100644 index 000000000..081ca0f8d --- /dev/null +++ b/infra/src/main/java/com/movie/infra/repository/SeatJpaRepository.java @@ -0,0 +1,12 @@ +package com.movie.infra.repository; + +import com.movie.domain.entity.Seat; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SeatJpaRepository extends JpaRepository { + List findByTheaterId(Long theaterId); +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/repository/TheaterJpaRepository.java b/infra/src/main/java/com/movie/infra/repository/TheaterJpaRepository.java new file mode 100644 index 000000000..2daa2a450 --- /dev/null +++ b/infra/src/main/java/com/movie/infra/repository/TheaterJpaRepository.java @@ -0,0 +1,17 @@ +package com.movie.infra.repository; + +import com.movie.domain.entity.Theater; +import com.movie.domain.repository.TheaterRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface TheaterJpaRepository extends JpaRepository, TheaterRepository { + @Override + @Query("SELECT t.name FROM Theater t WHERE t.id = :id") + Optional findNameById(@Param("id") Long id); +} \ No newline at end of file diff --git a/infra/src/main/java/com/movie/infra/repository/UserJpaRepository.java b/infra/src/main/java/com/movie/infra/repository/UserJpaRepository.java new file mode 100644 index 000000000..503de19ef --- /dev/null +++ b/infra/src/main/java/com/movie/infra/repository/UserJpaRepository.java @@ -0,0 +1,13 @@ +package com.movie.infra.repository; + +import com.movie.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserJpaRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); +} \ No newline at end of file diff --git a/infra/src/main/resources/application.yml b/infra/src/main/resources/application.yml new file mode 100644 index 000000000..da3a73fb8 --- /dev/null +++ b/infra/src/main/resources/application.yml @@ -0,0 +1,14 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/mydb?serverTimezone=Asia/Seoul + username: root + password: root + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: none + show-sql: true + +server: + port: 8080 \ No newline at end of file diff --git a/infra/src/test/java/com/movie/infra/common/response/ApiResponseTest.java b/infra/src/test/java/com/movie/infra/common/response/ApiResponseTest.java new file mode 100644 index 000000000..6d22350f3 --- /dev/null +++ b/infra/src/test/java/com/movie/infra/common/response/ApiResponseTest.java @@ -0,0 +1,49 @@ +package com.movie.infra.common.response; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiResponseTest { + + @Test + @DisplayName("성공 응답 생성") + void createSuccessResponse() { + // given + String data = "test data"; + + // when + ApiResponse response = ApiResponse.success(data); + + // then + assertThat(response.getCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getMessage()).isEqualTo("Success"); + assertThat(response.getData()).isEqualTo(data); + } + + @Test + @DisplayName("에러 응답 생성 - HttpStatus 사용") + void createErrorResponseWithHttpStatus() { + // when + ApiResponse response = ApiResponse.error(HttpStatus.BAD_REQUEST, "Invalid input"); + + // then + assertThat(response.getCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + assertThat(response.getMessage()).isEqualTo("Invalid input"); + assertThat(response.getData()).isNull(); + } + + @Test + @DisplayName("에러 응답 생성 - 코드와 메시지 직접 지정") + void createErrorResponseWithCodeAndMessage() { + // when + ApiResponse response = ApiResponse.error(429, "Too many requests"); + + // then + assertThat(response.getCode()).isEqualTo(429); + assertThat(response.getMessage()).isEqualTo("Too many requests"); + assertThat(response.getData()).isNull(); + } +} \ No newline at end of file diff --git a/infra/src/test/java/com/movie/infra/ratelimit/GuavaRateLimitServiceTest.java b/infra/src/test/java/com/movie/infra/ratelimit/GuavaRateLimitServiceTest.java new file mode 100644 index 000000000..98aa68641 --- /dev/null +++ b/infra/src/test/java/com/movie/infra/ratelimit/GuavaRateLimitServiceTest.java @@ -0,0 +1,87 @@ +package com.movie.infra.ratelimit; + +import com.movie.common.exception.RateLimitExceededException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GuavaRateLimitServiceTest { + + private GuavaRateLimitService rateLimitService; + + @BeforeEach + void setUp() { + rateLimitService = new GuavaRateLimitService(); + } + + @Test + @DisplayName("IP 기반 Rate Limit - 정상 요청") + void checkIpRateLimit_Success() { + // given + String ip = "127.0.0.1"; + + // when & then + rateLimitService.checkIpRateLimit(ip); // 예외가 발생하지 않아야 함 + } + + @Test + @DisplayName("IP 기반 Rate Limit - 제한 초과") + void checkIpRateLimit_ExceedsLimit() { + // given + String ip = "127.0.0.1"; + + // when & then + for (int i = 0; i < 100; i++) { + try { + rateLimitService.checkIpRateLimit(ip); + } catch (RateLimitExceededException e) { + // 예외가 발생하면 정상 + return; + } + } + + throw new AssertionError("Rate limit exceeded exception should have been thrown"); + } + + @Test + @DisplayName("사용자 예약 Rate Limit - 정상 요청") + void checkUserReservationRateLimit_Success() { + // given + Long userId = 1L; + String scheduleTime = "2024-01-01T10:00:00"; + + // when & then + rateLimitService.checkUserReservationRateLimit(userId, scheduleTime); // 예외가 발생하지 않아야 함 + } + + @Test + @DisplayName("사용자 예약 Rate Limit - 제한 초과") + void checkUserReservationRateLimit_ExceedsLimit() { + // given + Long userId = 1L; + String scheduleTime = "2024-01-01T10:00:00"; + + // when & then + rateLimitService.checkUserReservationRateLimit(userId, scheduleTime); + + assertThatThrownBy(() -> rateLimitService.checkUserReservationRateLimit(userId, scheduleTime)) + .isInstanceOf(RateLimitExceededException.class) + .hasMessageContaining("예매는 5분에 한 번만 시도할 수 있습니다"); + } + + @Test + @DisplayName("다른 시간대의 영화는 Rate Limit에 영향을 받지 않음") + void checkUserReservationRateLimit_DifferentSchedule() { + // given + Long userId = 1L; + String scheduleTime1 = "2024-01-01T10:00:00"; + String scheduleTime2 = "2024-01-01T13:00:00"; + + // when & then + rateLimitService.checkUserReservationRateLimit(userId, scheduleTime1); + rateLimitService.checkUserReservationRateLimit(userId, scheduleTime2); // 예외가 발생하지 않아야 함 + } +} \ No newline at end of file diff --git a/infra/src/test/java/com/movie/infra/ratelimit/RedisRateLimitServiceTest.java b/infra/src/test/java/com/movie/infra/ratelimit/RedisRateLimitServiceTest.java new file mode 100644 index 000000000..0c4f60e1f --- /dev/null +++ b/infra/src/test/java/com/movie/infra/ratelimit/RedisRateLimitServiceTest.java @@ -0,0 +1,115 @@ +package com.movie.infra.ratelimit; + +import com.movie.common.exception.RateLimitExceededException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RRateLimiter; +import org.redisson.api.RedissonClient; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class RedisRateLimitServiceTest { + + @Mock + private RedissonClient redissonClient; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private RRateLimiter rateLimiter; + + private RedisRateLimitService rateLimitService; + + @BeforeEach + void setUp() { + rateLimitService = new RedisRateLimitService(redissonClient, redisTemplate); + } + + @Test + @DisplayName("IP 기반 Rate Limit - 정상 요청") + void checkIpRateLimit_Success() { + // given + String ip = "127.0.0.1"; + given(redisTemplate.hasKey(anyString())).willReturn(false); + given(redissonClient.getRateLimiter(anyString())).willReturn(rateLimiter); + given(rateLimiter.tryAcquire()).willReturn(true); + + // when & then + rateLimitService.checkIpRateLimit(ip); // 예외가 발생하지 않아야 함 + } + + @Test + @DisplayName("IP 기반 Rate Limit - IP 차단됨") + void checkIpRateLimit_IpBanned() { + // given + String ip = "127.0.0.1"; + given(redisTemplate.hasKey(anyString())).willReturn(true); + + // when & then + assertThatThrownBy(() -> rateLimitService.checkIpRateLimit(ip)) + .isInstanceOf(RateLimitExceededException.class) + .hasMessageContaining("IP가 차단되었습니다"); + } + + @Test + @DisplayName("IP 기반 Rate Limit - 제한 초과") + void checkIpRateLimit_ExceedsLimit() { + // given + String ip = "127.0.0.1"; + ValueOperations valueOps = mock(ValueOperations.class); + + given(redisTemplate.hasKey(anyString())).willReturn(false); + given(redisTemplate.opsForValue()).willReturn(valueOps); + given(redissonClient.getRateLimiter(anyString())).willReturn(rateLimiter); + given(rateLimiter.tryAcquire()).willReturn(false); + + // when & then + assertThatThrownBy(() -> rateLimitService.checkIpRateLimit(ip)) + .isInstanceOf(RateLimitExceededException.class) + .hasMessageContaining("너무 많은 요청을 보냈습니다"); + + verify(valueOps).set(anyString(), eq("banned"), any()); + } + + @Test + @DisplayName("사용자 예약 Rate Limit - 정상 요청") + void checkUserReservationRateLimit_Success() { + // given + Long userId = 1L; + String scheduleTime = "2024-01-01T10:00:00"; + given(redissonClient.getRateLimiter(anyString())).willReturn(rateLimiter); + given(rateLimiter.tryAcquire()).willReturn(true); + + // when & then + rateLimitService.checkUserReservationRateLimit(userId, scheduleTime); // 예외가 발생하지 않아야 함 + } + + @Test + @DisplayName("사용자 예약 Rate Limit - 제한 초과") + void checkUserReservationRateLimit_ExceedsLimit() { + // given + Long userId = 1L; + String scheduleTime = "2024-01-01T10:00:00"; + given(redissonClient.getRateLimiter(anyString())).willReturn(rateLimiter); + given(rateLimiter.tryAcquire()).willReturn(false); + + // when & then + assertThatThrownBy(() -> rateLimitService.checkUserReservationRateLimit(userId, scheduleTime)) + .isInstanceOf(RateLimitExceededException.class) + .hasMessageContaining("예매는 5분에 한 번만 시도할 수 있습니다"); + } +} \ No newline at end of file diff --git a/k6/test-script.js b/k6/test-script.js new file mode 100644 index 000000000..47edb505e --- /dev/null +++ b/k6/test-script.js @@ -0,0 +1,24 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '1m', target: 50 }, // 1분 동안 0에서 50 VUs로 증가 + { duration: '3m', target: 50 }, // 3분 동안 50 VUs 유지 + { duration: '1m', target: 0 }, // 1분 동안 50에서 0 VUs로 감소 + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95%의 요청이 500ms 이내에 완료되어야 함 + }, +}; + +export default function () { + const response = http.get('http://localhost:8080/api/v1/movies/now-showing'); + + check(response, { + 'is status 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + }); + + sleep(1); +} \ No newline at end of file diff --git a/schema.sql b/schema.sql new file mode 100644 index 000000000..d9d4e1d9d --- /dev/null +++ b/schema.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS movie ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + grade VARCHAR(50), + genre VARCHAR(50), + running_time INTEGER, + release_date DATE, + thumbnail_url VARCHAR(255), + created_by VARCHAR(255), + created_at DATETIME, + updated_by VARCHAR(255), + updated_at DATETIME +); + +CREATE TABLE IF NOT EXISTS theater ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_by VARCHAR(255), + created_at DATETIME, + updated_by VARCHAR(255), + updated_at DATETIME +); + +CREATE TABLE IF NOT EXISTS seat ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + seat_number VARCHAR(10) NOT NULL, + theater_id BIGINT, + created_by VARCHAR(255), + created_at DATETIME, + updated_by VARCHAR(255), + updated_at DATETIME, + FOREIGN KEY (theater_id) REFERENCES theater(id) +); + +CREATE TABLE IF NOT EXISTS schedule ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + movie_id BIGINT, + theater_id BIGINT, + start_at DATETIME, + end_at DATETIME, + created_by VARCHAR(255), + created_at DATETIME, + updated_by VARCHAR(255), + updated_at DATETIME, + FOREIGN KEY (movie_id) REFERENCES movie(id), + FOREIGN KEY (theater_id) REFERENCES theater(id) +); \ No newline at end of file diff --git a/services/build.gradle b/services/build.gradle new file mode 100644 index 000000000..adf394631 --- /dev/null +++ b/services/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java' +} + +dependencies { + implementation project(":domain") + implementation project(":infra") + + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework:spring-tx' + + implementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + implementation 'org.springframework.boot:spring-boot-starter-aop' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} \ No newline at end of file diff --git a/services/src/main/java/com/movie/application/dto/MovieResponseDto.java b/services/src/main/java/com/movie/application/dto/MovieResponseDto.java new file mode 100644 index 000000000..b53bfd40b --- /dev/null +++ b/services/src/main/java/com/movie/application/dto/MovieResponseDto.java @@ -0,0 +1,46 @@ +package com.movie.application.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.AccessLevel; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class MovieResponseDto implements Serializable { + private Long id; + private String title; + private String thumbnail; + private Integer runningTime; + private String genre; + + @Builder.Default + private List schedules = new ArrayList<>(); + + @Getter + @NoArgsConstructor + @AllArgsConstructor(access = AccessLevel.PRIVATE) + @Builder + public static class ScheduleInfo implements Serializable { + private Long id; + private LocalDateTime startAt; + private TheaterInfo theater; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor(access = AccessLevel.PRIVATE) + @Builder + public static class TheaterInfo implements Serializable { + private Long id; + private String name; + } +} diff --git a/services/src/main/java/com/movie/application/service/MessageService.java b/services/src/main/java/com/movie/application/service/MessageService.java new file mode 100644 index 000000000..e573bb11e --- /dev/null +++ b/services/src/main/java/com/movie/application/service/MessageService.java @@ -0,0 +1,12 @@ +package com.movie.application.service; + +import com.movie.domain.entity.Reservation; +import org.springframework.stereotype.Service; + +@Service +public class MessageService { + public void sendReservationComplete(Reservation reservation) { + // TODO: Implement actual message sending logic + System.out.println("Reservation complete notification sent for reservation: " + reservation.getReservationNumber()); + } +} \ No newline at end of file diff --git a/services/src/main/java/com/movie/application/service/MovieService.java b/services/src/main/java/com/movie/application/service/MovieService.java new file mode 100644 index 000000000..a6ef4d984 --- /dev/null +++ b/services/src/main/java/com/movie/application/service/MovieService.java @@ -0,0 +1,80 @@ +package com.movie.application.service; + +import com.movie.application.dto.MovieResponseDto; +import com.movie.domain.dto.MovieProjection; +import com.movie.domain.dto.MovieSearchCondition; +import com.movie.domain.entity.Schedule; +import com.movie.domain.repository.MovieRepository; +import com.movie.domain.repository.ScheduleRepository; +import com.movie.domain.repository.TheaterRepository; +import com.movie.infra.repository.MovieQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MovieService { + + private final MovieRepository movieRepository; + private final ScheduleRepository scheduleRepository; + private final TheaterRepository theaterRepository; + private final MovieQueryRepository movieQueryRepository; + + @Cacheable(value = "movies", key = "#condition.title + '_' + #condition.genre") + public List getNowShowingMovies(MovieSearchCondition condition) { + List movies = movieQueryRepository.search(condition); + List schedules = scheduleRepository.findAll(); + + Map> schedulesByMovieId = schedules.stream() + .collect(Collectors.groupingBy(Schedule::getMovieId)); + + return movies.stream() + .map(movie -> MovieResponseDto.builder() + .id(movie.getId()) + .title(movie.getTitle()) + .thumbnail(movie.getThumbnail()) + .runningTime(movie.getRunningTime()) + .genre(movie.getGenre()) + .schedules(schedulesByMovieId.getOrDefault(movie.getId(), List.of()).stream() + .map(schedule -> MovieResponseDto.ScheduleInfo.builder() + .id(schedule.getId()) + .startAt(schedule.getStartAt()) + .theater(MovieResponseDto.TheaterInfo.builder() + .id(schedule.getTheaterId()) + .name(theaterRepository.findNameById(schedule.getTheaterId()) + .orElse("Unknown Theater")) + .build()) + .build()) + .collect(Collectors.toList())) + .build()) + .collect(Collectors.toList()); + } + + public List getNowShowingMovies() { + return scheduleRepository.findByStartAtGreaterThan(LocalDateTime.now()).stream() + .map(schedule -> MovieResponseDto.builder() + .id(schedule.getMovie().getId()) + .title(schedule.getMovie().getTitle()) + .thumbnail(schedule.getMovie().getThumbnailUrl()) + .runningTime(schedule.getMovie().getRunningTime()) + .genre(schedule.getMovie().getGenre()) + .schedules(List.of(MovieResponseDto.ScheduleInfo.builder() + .id(schedule.getId()) + .startAt(schedule.getStartAt()) + .theater(MovieResponseDto.TheaterInfo.builder() + .id(schedule.getTheater().getId()) + .name(schedule.getTheater().getName()) + .build()) + .build())) + .build()) + .collect(Collectors.toList()); + } +} diff --git a/services/src/main/java/com/movie/application/service/ReservationService.java b/services/src/main/java/com/movie/application/service/ReservationService.java new file mode 100644 index 000000000..4b7acf55d --- /dev/null +++ b/services/src/main/java/com/movie/application/service/ReservationService.java @@ -0,0 +1,106 @@ +package com.movie.application.service; + +import com.movie.domain.aop.DistributedLock; +import com.movie.domain.entity.Reservation; +import com.movie.domain.entity.ReservationStatus; +import com.movie.domain.entity.Schedule; +import com.movie.domain.entity.Seat; +import com.movie.domain.entity.User; +import com.movie.domain.repository.ReservationRepository; +import com.movie.domain.repository.ScheduleRepository; +import com.movie.domain.repository.SeatRepository; +import com.movie.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ReservationService { + + private final UserRepository userRepository; + private final ScheduleRepository scheduleRepository; + private final SeatRepository seatRepository; + private final ReservationRepository reservationRepository; + private final MessageService messageService; + + @Transactional + @DistributedLock(key = "'reservation:' + #scheduleId + ':' + #seatId") + public String reserve(Long userId, Long scheduleId, Long seatId) { + // Check if entities exist + userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new IllegalArgumentException("Schedule not found")); + + seatRepository.findById(seatId) + .orElseThrow(() -> new IllegalArgumentException("Seat not found")); + + validateSeatAvailability(scheduleId, seatId); + validateMaxReservationLimit(userId, scheduleId); + validateSeatContinuity(userId, scheduleId, seatId); + + String reservationNumber = generateReservationNumber(); + + Reservation reservation = Reservation.builder() + .userId(userId) + .scheduleId(scheduleId) + .seatId(seatId) + .reservationNumber(reservationNumber) + .build(); + + reservation = reservationRepository.save(reservation); + + // FCM 메시지 발송 (Mock) + messageService.sendReservationComplete(reservation); + + return reservationNumber; + } + + private void validateSeatAvailability(Long scheduleId, Long seatId) { + if (reservationRepository.existsByScheduleIdAndSeatIdAndStatus(scheduleId, seatId, ReservationStatus.RESERVED)) { + throw new IllegalStateException("Seat is already reserved"); + } + } + + private void validateMaxReservationLimit(Long userId, Long scheduleId) { + long reservationCount = reservationRepository.countByUserIdAndScheduleIdAndStatus(userId, scheduleId, ReservationStatus.RESERVED); + if (reservationCount >= 5) { + throw new IllegalStateException("Maximum reservation limit reached"); + } + } + + private void validateSeatContinuity(Long userId, Long scheduleId, Long seatId) { + List existingReservations = reservationRepository.findByUserIdAndScheduleIdAndStatus(userId, scheduleId, ReservationStatus.RESERVED); + if (!existingReservations.isEmpty()) { + Seat newSeat = seatRepository.findById(seatId) + .orElseThrow(() -> new IllegalArgumentException("Seat not found")); + + // 기존 예약된 좌석들과 연속성 체크 + boolean isContinuous = existingReservations.stream() + .map(reservation -> seatRepository.findById(reservation.getSeatId()) + .orElseThrow(() -> new IllegalArgumentException("Seat not found"))) + .anyMatch(existingSeat -> isAdjacent(existingSeat, newSeat)); + + if (!isContinuous) { + throw new IllegalStateException("Seats must be continuous"); + } + } + } + + private boolean isAdjacent(Seat seat1, Seat seat2) { + if (seat1.getSeatRow().equals(seat2.getSeatRow())) { + return Math.abs(seat1.getSeatColumn() - seat2.getSeatColumn()) == 1; + } + return false; + } + + private String generateReservationNumber() { + return UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..264f8ce98 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = 'movie' + +include 'api' +include 'domain' +include 'infra' +include 'common' +include 'application'