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 extends BaseEntity> 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 extends Movie> 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 extends Schedule> 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 extends Schedule> 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 extends Seat> 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 extends Theater> 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'